mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-25 06:49:18 +01:00
Compare commits
90 Commits
v0.18.0
...
tonya/imag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53a5ed1f77 | ||
|
|
5d5ee8f555 | ||
|
|
4b13897839 | ||
|
|
6984b33389 | ||
|
|
9ff4b32db0 | ||
|
|
964d3264cc | ||
|
|
fca21a58f6 | ||
|
|
2fe41d4783 | ||
|
|
4c0af18dcb | ||
|
|
a68aafdfd7 | ||
|
|
9db6f51a43 | ||
|
|
fade1fbc21 | ||
|
|
cdabadb276 | ||
|
|
cd510a07e7 | ||
|
|
b50add5732 | ||
|
|
ca612a138f | ||
|
|
c275fa3e4a | ||
|
|
0ba6c08dda | ||
|
|
35b3e94b2f | ||
|
|
bf9ac0fb3b | ||
|
|
c5ae258757 | ||
|
|
9a1bd6bfc4 | ||
|
|
1c802feabe | ||
|
|
6b0c28df83 | ||
|
|
7937518ef0 | ||
|
|
406eca7709 | ||
|
|
e716fe54e1 | ||
|
|
64b4173d1d | ||
|
|
dcf16ba4c9 | ||
|
|
c00edce158 | ||
|
|
71d9b6605b | ||
|
|
8e9571c96a | ||
|
|
41ff4c4664 | ||
|
|
7279703d7c | ||
|
|
6b938a88cf | ||
|
|
8854a6835e | ||
|
|
51291e94b0 | ||
|
|
733d1c5f77 | ||
|
|
91fde1e3d7 | ||
|
|
46539547af | ||
|
|
858f242e52 | ||
|
|
1c7b6fef9e | ||
|
|
d5b062cd62 | ||
|
|
e216d46848 | ||
|
|
9ee6b7b72f | ||
|
|
a7c3c7041a | ||
|
|
92a0fa1193 | ||
|
|
46d217232a | ||
|
|
3b35612574 | ||
|
|
5e2e8b66d8 | ||
|
|
482aeac289 | ||
|
|
88bec687e3 | ||
|
|
537dedbbea | ||
|
|
ab777e4a35 | ||
|
|
9e2937a3d9 | ||
|
|
0344bbdabe | ||
|
|
3725c6d161 | ||
|
|
f2770584c7 | ||
|
|
de4a3ef7a9 | ||
|
|
341f0e9af9 | ||
|
|
e5be690e94 | ||
|
|
97eecac56f | ||
|
|
f28bb8886c | ||
|
|
b98c550ac3 | ||
|
|
1e89b06a2a | ||
|
|
585875aaf8 | ||
|
|
ada5cc8575 | ||
|
|
4f150f3c52 | ||
|
|
bde7f711de | ||
|
|
629b1139ba | ||
|
|
45c1c17154 | ||
|
|
61ded24e9b | ||
|
|
2da77b7b8c | ||
|
|
5b62911040 | ||
|
|
b9386a3db0 | ||
|
|
8e7bcaf389 | ||
|
|
7488209544 | ||
|
|
23771e1118 | ||
|
|
91cfadf834 | ||
|
|
88086a377b | ||
|
|
b082ab46b8 | ||
|
|
3baf1a5c92 | ||
|
|
6797fcb58f | ||
|
|
9fe509215d | ||
|
|
66654ab565 | ||
|
|
543c947d93 | ||
|
|
0e190af9c9 | ||
|
|
f2bde0be2f | ||
|
|
618f305f50 | ||
|
|
8b9b1bdad0 |
@@ -1,3 +1,3 @@
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:18-bullseye
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:22-bullseye
|
||||
|
||||
RUN sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
|
||||
|
||||
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
[support@sysadminemedia.com](mailto:support@sysadminemedia.com).
|
||||
[support@sysadminsmedia.com](mailto:support@sysadminsmedia.com).
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Node dependencies stage
|
||||
FROM public.ecr.aws/docker/library/node:18-alpine AS frontend-dependencies
|
||||
FROM public.ecr.aws/docker/library/node:lts-alpine AS frontend-dependencies
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm globally (caching layer)
|
||||
@@ -10,7 +10,7 @@ COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --shamefully-hoist
|
||||
|
||||
# Build Nuxt (frontend) stage
|
||||
FROM public.ecr.aws/docker/library/node:18-alpine AS frontend-builder
|
||||
FROM public.ecr.aws/docker/library/node:lts-alpine AS frontend-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm globally again (it can reuse the cache if not changed)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Node dependencies stage
|
||||
FROM public.ecr.aws/docker/library/node:18-alpine AS frontend-dependencies
|
||||
FROM public.ecr.aws/docker/library/node:lts-alpine AS frontend-dependencies
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm globally (caching layer)
|
||||
@@ -10,7 +10,7 @@ COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --shamefully-hoist
|
||||
|
||||
# Build Nuxt (frontend) stage
|
||||
FROM public.ecr.aws/docker/library/node:18-alpine AS frontend-builder
|
||||
FROM public.ecr.aws/docker/library/node:lts-alpine AS frontend-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm globally again (it can reuse the cache if not changed)
|
||||
|
||||
@@ -33,7 +33,7 @@ tasks:
|
||||
desc: Generates typescript types from swagger definition
|
||||
cmds:
|
||||
- |
|
||||
npx swagger-typescript-api \
|
||||
pnpm dlx swagger-typescript-api generate \
|
||||
--no-client \
|
||||
--modular \
|
||||
--path ./backend/app/api/static/docs/swagger.json \
|
||||
@@ -49,7 +49,8 @@ tasks:
|
||||
cmds:
|
||||
- task: swag
|
||||
- task: typescript-types
|
||||
- cp ./backend/app/api/static/docs/swagger.json docs/docs/api/openapi-2.0.json
|
||||
- cp ./backend/app/api/static/docs/swagger.json docs/en/api/openapi-2.0.json
|
||||
- cp ./backend/app/api/static/docs/swagger.yaml docs/en/api/openapi-2.0.yaml
|
||||
|
||||
go:run:
|
||||
env:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
version: 2
|
||||
|
||||
# This is an example .goreleaser.yml file with some sensible defaults.
|
||||
# Make sure to check the documentation at https://goreleaser.com
|
||||
before:
|
||||
@@ -23,8 +25,19 @@ builds:
|
||||
- goos: windows
|
||||
goarch: "386"
|
||||
|
||||
signs:
|
||||
- cmd: cosign
|
||||
stdin: "{{ .Env.COSIGN_PWD }}"
|
||||
args:
|
||||
- "sign-blob"
|
||||
- "--key=cosign.key"
|
||||
- "--output-signature=${signature}"
|
||||
- "${artifact}"
|
||||
- "--yes" # needed on cosign 2.0.0+
|
||||
artifacts: all
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
- formats: [ 'tar.gz' ]
|
||||
# this name template makes the OS and Arch compatible with the results of uname.
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_
|
||||
@@ -36,11 +49,16 @@ archives:
|
||||
# use zip for windows archives
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
formats: [ 'zip' ]
|
||||
|
||||
release:
|
||||
extra_files:
|
||||
- glob: dist/*.sig
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
version_template: "{{ incpatch .Version }}-next"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
|
||||
@@ -88,6 +88,9 @@ func (ctrl *V1Controller) HandleItemsGetAll() errchain.HandlerFunc {
|
||||
items, err := ctrl.repo.Items.QueryByGroup(ctx, ctx.GID, extractQuery(r))
|
||||
totalPrice := new(big.Int)
|
||||
for _, item := range items.Items {
|
||||
if !item.SoldTime.IsZero() { // Skip items with a non-null SoldDate
|
||||
continue
|
||||
}
|
||||
totalPrice.Add(totalPrice, big.NewInt(int64(item.PurchasePrice*100)))
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,11 @@ func (ctrl *V1Controller) GetLocationWithPrice(auth context.Context, gid uuid.UU
|
||||
}
|
||||
|
||||
for _, item := range items.Items {
|
||||
// Skip items with a non-zero SoldTime
|
||||
if !item.SoldTime.IsZero() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert item.Quantity to float64 for multiplication
|
||||
quantity := float64(item.Quantity)
|
||||
itemTotal := big.NewInt(int64(item.PurchasePrice * quantity * 100))
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/analytics"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -65,12 +66,16 @@ func validatePostgresSSLMode(sslMode string) bool {
|
||||
// @title Homebox API
|
||||
// @version 1.0
|
||||
// @description Track, Manage, and Organize your Things.
|
||||
// @contact.name Don't
|
||||
// @contact.name Homebox Team
|
||||
// @contact.url https://discord.homebox.software
|
||||
// @host demo.homebox.software
|
||||
// @schemes https http
|
||||
// @BasePath /api
|
||||
// @securityDefinitions.apikey Bearer
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description "Type 'Bearer TOKEN' to correctly set the API Key"
|
||||
// @externalDocs.url https://homebox.software/en/api
|
||||
|
||||
func main() {
|
||||
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
||||
@@ -89,6 +94,10 @@ func run(cfg *config.Config) error {
|
||||
app := new(cfg)
|
||||
app.setupLogger()
|
||||
|
||||
if cfg.Options.AllowAnalytics {
|
||||
analytics.Send(version, build())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Initialize Database & Repos
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ const docTemplate = `{
|
||||
"description": "{{escape .Description}}",
|
||||
"title": "{{.Title}}",
|
||||
"contact": {
|
||||
"name": "Don't"
|
||||
"name": "Homebox Team",
|
||||
"url": "https://discord.homebox.software"
|
||||
},
|
||||
"version": "{{.Version}}"
|
||||
},
|
||||
@@ -3262,9 +3263,9 @@ const docTemplate = `{
|
||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||
var SwaggerInfo = &swag.Spec{
|
||||
Version: "1.0",
|
||||
Host: "",
|
||||
Host: "demo.homebox.software",
|
||||
BasePath: "/api",
|
||||
Schemes: []string{},
|
||||
Schemes: []string{"https", "http"},
|
||||
Title: "Homebox API",
|
||||
Description: "Track, Manage, and Organize your Things.",
|
||||
InfoInstanceName: "swagger",
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
{
|
||||
"schemes": [
|
||||
"https",
|
||||
"http"
|
||||
],
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "Track, Manage, and Organize your Things.",
|
||||
"title": "Homebox API",
|
||||
"contact": {
|
||||
"name": "Don't"
|
||||
"name": "Homebox Team",
|
||||
"url": "https://discord.homebox.software"
|
||||
},
|
||||
"version": "1.0"
|
||||
},
|
||||
"host": "demo.homebox.software",
|
||||
"basePath": "/api",
|
||||
"paths": {
|
||||
"/v1/actions/ensure-asset-ids": {
|
||||
|
||||
@@ -772,9 +772,11 @@ definitions:
|
||||
fields:
|
||||
type: string
|
||||
type: object
|
||||
host: demo.homebox.software
|
||||
info:
|
||||
contact:
|
||||
name: Don't
|
||||
name: Homebox Team
|
||||
url: https://discord.homebox.software
|
||||
description: Track, Manage, and Organize your Things.
|
||||
title: Homebox API
|
||||
version: "1.0"
|
||||
@@ -2043,6 +2045,9 @@ paths:
|
||||
summary: Update Account
|
||||
tags:
|
||||
- User
|
||||
schemes:
|
||||
- https
|
||||
- http
|
||||
securityDefinitions:
|
||||
Bearer:
|
||||
description: '"Type ''Bearer TOKEN'' to correctly set the API Key"'
|
||||
|
||||
11
backend/cosign.key
Normal file
11
backend/cosign.key
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY-----
|
||||
eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjo2NTUzNiwiciI6
|
||||
OCwicCI6MX0sInNhbHQiOiJ3bmU3TTd2dndlL2FBS1piUEE2QktsdFNzMkhkSk9v
|
||||
eXlvOTNLMnByRXdJPSJ9LCJjaXBoZXIiOnsibmFtZSI6Im5hY2wvc2VjcmV0Ym94
|
||||
Iiwibm9uY2UiOiJoOWdIMHRsYk9zMnZIbVBTYk5zaGxBQU5TYUlkcVZoQiJ9LCJj
|
||||
aXBoZXJ0ZXh0IjoiTERiQk5ac3ZlVnRMbTlQdkRTa2t6bzRrWGExVGRTTEY5VzVO
|
||||
cGd6M05GNVJLRWlGRmJQRDJDYzhnTWNkRmkrTU8xd2FTUzFGWWdXU3BIdnI3QXZ3
|
||||
K0tUTXVWLzhSZ1pnOE9ieHNJY2xKSlZldHRLTzdzWXY2aWgxM09iZlVBV0lQcGpS
|
||||
ZUQ5UmE3WjJwbWd0SkpBdjl2dlk1RGNNeGRKcFFrOEY1UStLZytSbnhLRUd6Z1ZN
|
||||
MWUxdjF3UGhsOWhVRGRMSFVSTzE5Z0w3aFE9PSJ9
|
||||
-----END ENCRYPTED SIGSTORE PRIVATE KEY-----
|
||||
4
backend/cosign.pub
Normal file
4
backend/cosign.pub
Normal file
@@ -0,0 +1,4 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2DXKcerPznDayM+rMJ/25w+ubI8g
|
||||
e3ZTbm07VqLFz6uI2vXqN8X7/72dygtJlUw07FpR0oLXaSia0adaywz1JA==
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
github.com/olahol/melody v1.2.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/shirou/gopsutil/v4 v4.24.9
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/swaggo/http-swagger/v2 v2.0.2
|
||||
@@ -28,7 +29,16 @@ require (
|
||||
modernc.org/sqlite v1.36.0
|
||||
)
|
||||
|
||||
require github.com/zclconf/go-cty-yaml v1.1.0 // indirect
|
||||
require (
|
||||
github.com/ebitengine/purego v0.8.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
@@ -66,7 +76,7 @@ require (
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
||||
golang.org/x/image v0.23.0
|
||||
golang.org/x/mod v0.23.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/net v0.36.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
|
||||
@@ -21,6 +21,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
|
||||
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
@@ -31,6 +33,8 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
|
||||
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-openapi/inflect v0.21.0 h1:FoBjBTQEcbg2cJUWX6uwL9OyIW8eqc9k4KhN4lfbeYk=
|
||||
github.com/go-openapi/inflect v0.21.0/go.mod h1:INezMuUu7SJQc2AyR3WO0DqqYUJSj8Kb4hBd7WtjlAw=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
@@ -60,6 +64,7 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF0
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
@@ -86,6 +91,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
@@ -111,6 +118,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
@@ -118,6 +127,8 @@ github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUz
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/shirou/gopsutil/v4 v4.24.9 h1:KIV+/HaHD5ka5f570RZq+2SaeFsb/pq+fp2DGNWYoOI=
|
||||
github.com/shirou/gopsutil/v4 v4.24.9/go.mod h1:3fkaHNeYsUFCGZ8+9vZVWtbyM1k2eRnlL+bWO8Bxa/Q=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
@@ -128,12 +139,18 @@ github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSy
|
||||
github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
|
||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.2.5 h1:m+5BUIcbsaG2md76FIqI/oZULrAju8tsk47eOohovQ0=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.2.5/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ=
|
||||
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zclconf/go-cty v1.16.0 h1:xPKEhst+BW5D0wxebMZkxgapvOE/dw7bFTlgSc9nD6w=
|
||||
github.com/zclconf/go-cty v1.16.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
|
||||
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
|
||||
@@ -148,12 +165,16 @@ golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
|
||||
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
||||
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
@@ -161,6 +182,7 @@ golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
|
||||
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -2,14 +2,12 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
||||
"io"
|
||||
)
|
||||
|
||||
func (svc *ItemService) AttachmentPath(ctx context.Context, attachmentID uuid.UUID) (*ent.Document, error) {
|
||||
@@ -77,14 +75,19 @@ func (svc *ItemService) AttachmentDelete(ctx context.Context, gid, itemID, attac
|
||||
return err
|
||||
}
|
||||
|
||||
documentID := attachment.Edges.Document.GetID()
|
||||
|
||||
// Delete the attachment
|
||||
err = svc.repo.Attachments.Delete(ctx, attachmentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove File
|
||||
err = os.Remove(attachment.Edges.Document.Path)
|
||||
// Delete the document, this function also removes the file
|
||||
err = svc.repo.Docs.Delete(ctx, documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,6 @@ package runtime
|
||||
// The schema-stitching logic is generated in github.com/sysadminsmedia/homebox/backend/internal/data/ent/runtime.go
|
||||
|
||||
const (
|
||||
Version = "v0.14.1" // Version of ent codegen.
|
||||
Sum = "h1:fUERL506Pqr92EPHJqr8EYxbPioflJo6PudkrEA8a/s=" // Sum of ent codegen.
|
||||
Version = "v0.14.3" // Version of ent codegen.
|
||||
Sum = "h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=" // Sum of ent codegen.
|
||||
)
|
||||
|
||||
@@ -134,6 +134,9 @@ type (
|
||||
Labels []LabelSummary `json:"labels"`
|
||||
|
||||
ImageID *uuid.UUID `json:"imageId,omitempty"`
|
||||
|
||||
// Sale details
|
||||
SoldTime time.Time `json:"soldTime"`
|
||||
}
|
||||
|
||||
ItemOut struct {
|
||||
|
||||
68
backend/internal/sys/analytics/analytics.go
Normal file
68
backend/internal/sys/analytics/analytics.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Package analytics provides analytics function that sends data to a remote server.
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"github.com/shirou/gopsutil/v4/host"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type Data struct {
|
||||
Domain string `json:"domain"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Props map[string]interface{} `json:"props"`
|
||||
}
|
||||
|
||||
func Send(version, buildInfo string) {
|
||||
hostData, _ := host.Info()
|
||||
analytics := Data{
|
||||
Domain: "homebox.software",
|
||||
URL: "https://homebox.software/stats",
|
||||
Name: "stats",
|
||||
Props: map[string]interface{}{
|
||||
"version": version + "/" + buildInfo,
|
||||
"os": hostData.OS,
|
||||
"platform": hostData.Platform,
|
||||
"platform_family": hostData.PlatformFamily,
|
||||
"platform_version": hostData.PlatformVersion,
|
||||
"kernel_arch": hostData.KernelArch,
|
||||
"virt_type": hostData.VirtualizationSystem,
|
||||
},
|
||||
}
|
||||
jsonBody, err := json.Marshal(analytics)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to marshal analytics data")
|
||||
return
|
||||
}
|
||||
bodyReader := bytes.NewReader(jsonBody)
|
||||
req, err := http.NewRequest("POST", "https://a.sysadmins.zone/api/event", bodyReader)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to create analytics request")
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Homebox/"+version+"/"+buildInfo+" (https://homebox.software)")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to send analytics request")
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := res.Body.Close()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to close response body")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -35,6 +35,7 @@ type Options struct {
|
||||
AutoIncrementAssetID bool `yaml:"auto_increment_asset_id" conf:"default:true"`
|
||||
CurrencyConfig string `yaml:"currencies"`
|
||||
GithubReleaseCheck bool `yaml:"check_github_release" conf:"default:true"`
|
||||
AllowAnalytics bool `yaml:"allow_analytics" conf:"default:false"`
|
||||
}
|
||||
|
||||
type DebugConf struct {
|
||||
|
||||
@@ -56,7 +56,7 @@ export default defineConfig({
|
||||
],
|
||||
|
||||
footer: {
|
||||
message: 'HomeBox is an open-source project under the <a href="https://github.com/sysadminsmedia/homebox/blob/main/LICENSE">MIT license</a>',
|
||||
message: 'HomeBox is an open-source project under the <a href="https://github.com/sysadminsmedia/homebox/blob/main/LICENSE">AGPL License</a>',
|
||||
copyright: '© <a href="https://sysadminsmedia.com/">Sysadmins Media</a>, 2025',
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ export default [
|
||||
{text: 'Installation', link: '/en/installation'},
|
||||
{text: 'Configure', link: '/en/configure'},
|
||||
{text: 'Upgrade Guide', link: '/en/upgrade'},
|
||||
{text: 'Migration Guide', link: '/en/migration'},
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -25,7 +26,15 @@ export default [
|
||||
text: 'Contributing',
|
||||
items: [
|
||||
{text: 'Get Started', link: '/en/contribute/get-started'},
|
||||
{text: 'Switching to Shadcn-vue', link: '/en/contribute/shadcn'},
|
||||
{text: 'Bounty Program', link: '/en/contribute/bounty'}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'Analytics',
|
||||
items: [
|
||||
{text: 'Purpose & Data', link: '/en/analytics'},
|
||||
{text: 'Privacy Policy', link: '/en/analytics/privacy'},
|
||||
]
|
||||
}
|
||||
]
|
||||
14
docs/en/analytics/index.md
Normal file
14
docs/en/analytics/index.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
## What is This?
|
||||
We collect non-identifying information from users of Homebox that have opted in to analytics collection. By default users do not send us anything, however once opted in that data gets sent to our own Plausibe instance and the data below is live from that instance.
|
||||
|
||||
We make this data public so that everyone knows exactly what's being collected, and so that they can see the data we see as it helps us make some decisions.
|
||||
|
||||
## Current Analytics Collected
|
||||
|
||||
<iframe plausible-embed src="https://a.sysadmins.zone/share/homebox.software?auth=O2nQ-b8I0oo80RKJXx2Q7&embed=true&theme=system&goal=stats" scrolling="no" frameborder="0" loading="lazy" style="width: 1px; min-width: 100%; height: 100%; min-height: 1600px"></iframe>
|
||||
<div style="font-size: 14px; padding-bottom: 14px;">Stats powered by <a target="_blank" style="color: #4F46E5; text-decoration: underline;" href="https://plausible.io">Plausible Analytics</a> hosted on our own instance in the UK</div>
|
||||
<script async src="https://a.sysadmins.zone/js/embed.host.js"></script>
|
||||
68
docs/en/analytics/privacy.md
Normal file
68
docs/en/analytics/privacy.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# Homebox Privacy Policy
|
||||
|
||||
## Introduction
|
||||
|
||||
**Homebox** (and by extension; Sysadmins Media) respects the privacy of its users and is committed to protecting the data shared through our application. This privacy policy outlines the types of data collected from users who opt in to analytics, the purposes for which we collect and use this data, how it is stored, and the rights of our users under UK and US law. By opting in to data collection, you agree to the practices described in this policy.
|
||||
|
||||
# 1. Data Collected
|
||||
|
||||
With the user's consent (opt-in analytics only), Homebox collects **anonymized** data, including:
|
||||
|
||||
* Homebox application version
|
||||
* Operating System (OS) type and platform family
|
||||
* Platform version
|
||||
* Kernel architecture
|
||||
* Virtualization system used
|
||||
* General location data (country or region), as provided by our analytics tool, **Plausible**
|
||||
|
||||
Additionally, we collect default, anonymized data through Plausible, such as usage statistics, to understand how Homebox is used and to support its ongoing development.
|
||||
|
||||
# 2. Data Storage and Control
|
||||
|
||||
All data collected through Homebox's analytics are managed and stored in our self-hosted Plausible instance. No user data resides on third-party servers or is shared outside the control of Homebox and its administrative team (Sysadmins Media). The anonymized analytics generated from this data are publicly accessible, allowing users to review the data as we do, however Homebox will at no point share (or store) any analytical data that can be used to personally identify individual users of its systems.
|
||||
|
||||
# 3. Data Usage
|
||||
|
||||
We use the collected data exclusively to improve Homebox:
|
||||
|
||||
* Informing development focus based on popular platforms, architectures, and virtualization systems
|
||||
* Aiding in troubleshooting and diagnostic processes for better support and stability
|
||||
|
||||
All data collected is aggregated and anonymized to ensure individual users cannot be identified.
|
||||
|
||||
# 4. Data Retention
|
||||
|
||||
The information remains in our self-hosted Plausible instance as long as it remains useful for improving the application, there is no set retention period for the deletion of this data, however Homebox remains dedicated to transparency and openly shares the anonymized usage data collected by our systems publicly for our users to review.
|
||||
|
||||
# 5. User rights and Opt-In Consent
|
||||
|
||||
As Homebox is operated around the world, we conform to the relevant laws as applicable in any local jurisdiction. Homebox data is stored and processed in the US, with staff residing in US and UK locations.
|
||||
|
||||
Under both UK and US data protection laws, users have the following rights:
|
||||
|
||||
* **Right to opt-in**: Data collection only begins when a user explicitly chooses to share this information.
|
||||
* **Right to review**: Users can view the publicly accessible analytics to understand how their data contributes to Homebox.
|
||||
* **Right to withdraw consent**: Users may opt out at any time, stopping any further data collection from their device.
|
||||
|
||||
# 6. 3rd Parties
|
||||
|
||||
Homebox may use 3rd parties as part of providing the web services to operate. Currently this includes only Cloudflare, who handles cyber security services to our analytics endpoints and websites. You can view their privacy policy at https://www.cloudflare.com/privacypolicy/
|
||||
|
||||
# 7. Policy Changes
|
||||
|
||||
Any changes to this privacy policy will be communicated to users through Homebox's update channels (namely Discord or Reddit) **at a minimum** 7 full days prior to any change being conducted (unless mandated by law to do so otherwise).
|
||||
|
||||
Continued use of Homebox following updates will imply acceptance of the revised policy, and users are free to opt-out of analytics at any point without impact to their usage of Homebox software.
|
||||
|
||||
|
||||
### Contact Us
|
||||
|
||||
For any questions about this privacy policy or your data, please contact the team through our official channels:
|
||||
|
||||
* Discord: https://discord.homebox.software/
|
||||
* Reddit Modmail: r/Homebox
|
||||
* Github: https://git.homebox.software/
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
layout: page
|
||||
sidebar: false
|
||||
---
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useData } from 'vitepress';
|
||||
|
||||
const elementScript = document.createElement('script');
|
||||
elementScript.src = 'https://unpkg.com/@stoplight/elements/web-components.min.js';
|
||||
document.head.appendChild(elementScript);
|
||||
|
||||
const elementStyle = document.createElement('link');
|
||||
elementStyle.rel = 'stylesheet';
|
||||
elementStyle.href = 'https://unpkg.com/@stoplight/elements/styles.min.css';
|
||||
document.head.appendChild(elementStyle);
|
||||
|
||||
const { isDark } = useData();
|
||||
let theme = 'light';
|
||||
if (isDark.value) {
|
||||
theme = 'dark';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.TryItPanel {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<elements-api
|
||||
apiDescriptionUrl="https://cdn.jsdelivr.net/gh/sysadminsmedia/homebox@main/docs/docs/api/openapi-2.0.json"
|
||||
router="hash"
|
||||
layout="responsive"
|
||||
hideSchemas="true"
|
||||
:data-theme="theme"
|
||||
/>
|
||||
61
docs/en/api/index.md
Normal file
61
docs/en/api/index.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
layout: page
|
||||
sidebar: false
|
||||
---
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useData } from 'vitepress';
|
||||
|
||||
// Reactive key for re-rendering the elements-api component
|
||||
const componentKey = ref(0);
|
||||
|
||||
// Set BaseURL
|
||||
const BaseURL = "https://demo.homebox.software/api";
|
||||
|
||||
// Access dark mode setting from VitePress
|
||||
const { isDark } = useData();
|
||||
const theme = ref(isDark.value ? 'dark' : 'light');
|
||||
|
||||
// Watch for changes to the dark mode value and force a re-render when it changes
|
||||
watch(isDark, (newVal) => {
|
||||
theme.value = newVal ? 'dark' : 'light';
|
||||
// Increment key to force a refresh of the Stoplight component and its CSS
|
||||
componentKey.value++;
|
||||
});
|
||||
|
||||
// Use a native hashchange listener (as before) to refresh on navigation changes
|
||||
const handleHashChange = () => {
|
||||
componentKey.value++;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('hashchange', handleHashChange);
|
||||
});
|
||||
|
||||
// Append the Stoplight Elements script and stylesheet
|
||||
const elementScript = document.createElement('script');
|
||||
elementScript.src = 'https://unpkg.com/@stoplight/elements/web-components.min.js';
|
||||
document.head.appendChild(elementScript);
|
||||
|
||||
const elementStyle = document.createElement('link');
|
||||
elementStyle.rel = 'stylesheet';
|
||||
elementStyle.href = 'https://unpkg.com/@stoplight/elements/styles.min.css';
|
||||
document.head.appendChild(elementStyle);
|
||||
</script>
|
||||
|
||||
<client-only>
|
||||
<elements-api
|
||||
:key="componentKey"
|
||||
apiDescriptionUrl="https://raw.githubusercontent.com/sysadminsmedia/homebox/refs/heads/main/docs/en/api/openapi-2.0.json"
|
||||
router="hash"
|
||||
layout="responsive"
|
||||
hideSchemas="true"
|
||||
hideTryIt="true"
|
||||
:data-theme="theme"
|
||||
:tryItBaseUrl="BaseURL"
|
||||
/>
|
||||
</client-only>
|
||||
@@ -1,13 +1,19 @@
|
||||
{
|
||||
"schemes": [
|
||||
"https",
|
||||
"http"
|
||||
],
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "Track, Manage, and Organize your Things.",
|
||||
"title": "Homebox API",
|
||||
"contact": {
|
||||
"name": "Don't"
|
||||
"name": "Homebox Team",
|
||||
"url": "https://discord.homebox.software"
|
||||
},
|
||||
"version": "1.0"
|
||||
},
|
||||
"host": "demo.homebox.software",
|
||||
"basePath": "/api",
|
||||
"paths": {
|
||||
"/v1/actions/ensure-asset-ids": {
|
||||
2057
docs/en/api/openapi-2.0.yaml
Normal file
2057
docs/en/api/openapi-2.0.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ aside: false
|
||||
| HBOX_OPTIONS_ALLOW_REGISTRATION | true | allow users to register themselves |
|
||||
| HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID | true | auto-increments the asset_id field for new items |
|
||||
| HBOX_OPTIONS_CURRENCY_CONFIG | | json configuration file containing additional currencie |
|
||||
| HBOX_OPTIONS_ALLOW_ANALYTICS | false | Allows the homebox team to view extremely basic information about the system that your running on. This helps make decisions regarding builds and other general decisions. |
|
||||
| HBOX_WEB_MAX_UPLOAD | 10 | maximum file upload size supported in MB |
|
||||
| HBOX_WEB_READ_TIMEOUT | 10s | Read timeout of HTTP sever |
|
||||
| HBOX_WEB_WRITE_TIMEOUT | 10s | Write timeout of HTTP server |
|
||||
@@ -29,7 +30,7 @@ aside: false
|
||||
| HBOX_MAILER_FROM | | email from address to use |
|
||||
| HBOX_SWAGGER_HOST | 7745 | swagger host to use, if not set swagger will be disabled |
|
||||
| HBOX_SWAGGER_SCHEMA | `http` | swagger schema to use, can be one of: `http`, `https` |
|
||||
| HBOX_DATABASE_TYPE | sqlite3 | sets the correct database type (`sqlite3` or `postgres`) |
|
||||
| HBOX_DATABASE_DRIVER | sqlite3 | sets the correct database type (`sqlite3` or `postgres`) |
|
||||
| HBOX_DATABASE_SQLITE_PATH | ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1 | sets the directory path for Sqlite |
|
||||
| HBOX_DATABASE_HOST | | sets the hostname for a postgres database |
|
||||
| HBOX_DATABASE_PORT | | sets the port for a postgres database |
|
||||
@@ -86,7 +87,7 @@ OPTIONS
|
||||
--demo/$HBOX_DEMO <bool>
|
||||
--debug-enabled/$HBOX_DEBUG_ENABLED <bool> (default: false)
|
||||
--debug-port/$HBOX_DEBUG_PORT <string> (default: 4000)
|
||||
--database-type/$HBOX_DATABASE_TYPE <string> (default: sqlite3)
|
||||
--database-driver/$HBOX_DATABASE_DRIVER <string> (default: sqlite3)
|
||||
--database-sqlite-path/$HBOX_DATABASE_SQLITE_PATH <string> (default: ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1)
|
||||
--database-host/$HBOX_DATABASE_HOST <string>
|
||||
--database-port/$HBOX_DATABASE_PORT <string>
|
||||
@@ -98,6 +99,7 @@ OPTIONS
|
||||
--options-auto-increment-asset-id/$HBOX_OPTIONS_AUTO_INCREMENT_ASSET_ID <bool> (default: true)
|
||||
--options-currency-config/$HBOX_OPTIONS_CURRENCY_CONFIG <string>
|
||||
--options-check-github-release/$HBOX_OPTIONS_CHECK_GITHUB_RELEASE <bool> (default: true)
|
||||
--options-allow-analytics/$HBOX_OPTIONS_ALLOW_ANALYTICS <bool> (default: false)
|
||||
--label-maker-width/$HBOX_LABEL_MAKER_WIDTH <int> (default: 526)
|
||||
--label-maker-height/$HBOX_LABEL_MAKER_HEIGHT <int> (default: 200)
|
||||
--label-maker-padding/$HBOX_LABEL_MAKER_PADDING <int> (default: 32)
|
||||
|
||||
@@ -52,9 +52,9 @@ When modifying components, follow these best practices:
|
||||
During the migration process, you can test without DaisyUI using these commands:
|
||||
|
||||
```bash
|
||||
DISABLE_DAISYUI=true; task ui:dev
|
||||
export DISABLE_DAISYUI=true; task ui:dev
|
||||
```
|
||||
or
|
||||
```bash
|
||||
DISABLE_DAISYUI=true; task ui:fix
|
||||
export DISABLE_DAISYUI=true; task ui:fix
|
||||
```
|
||||
|
||||
@@ -14,7 +14,7 @@ hero:
|
||||
link: /en/quick-start
|
||||
- theme: alt
|
||||
text: Tips and Tricks
|
||||
link: /en/tips-tricks
|
||||
link: /en/user-guide/tips-tricks
|
||||
- theme: alt
|
||||
text: Try It Out
|
||||
link: https://demo.homebox.software
|
||||
|
||||
@@ -32,6 +32,7 @@ $ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--publish 3100:7745 \
|
||||
--env TZ=Europe/Bucharest \
|
||||
--env HBOX_OPTIONS_ALLOW_ANALYTICS=false \
|
||||
--volume /path/to/data/folder/:/data \
|
||||
ghcr.io/sysadminsmedia/homebox:latest
|
||||
# ghcr.io/sysadminsmedia/homebox:latest-rootless
|
||||
@@ -52,6 +53,8 @@ services:
|
||||
- HBOX_LOG_LEVEL=info
|
||||
- HBOX_LOG_FORMAT=text
|
||||
- HBOX_WEB_MAX_FILE_UPLOAD=10
|
||||
# Please consider allowing analytics to help us improve Homebox (basic computer information, no personal data)
|
||||
- HBOX_OPTIONS_ALLOW_ANALYTICS=false
|
||||
volumes:
|
||||
- homebox-data:/data/
|
||||
ports:
|
||||
|
||||
74
docs/en/migration.md
Normal file
74
docs/en/migration.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Migration Guide
|
||||
|
||||
This guide will help you migrate from the original version of Homebox ([https://github.com/hay-kot/homebox](https://github.com/hay-kot/homebox)) to our actively maintained fork.
|
||||
|
||||
## Why Migrate?
|
||||
|
||||
Migrating to our fork ensures you benefit from:
|
||||
|
||||
- **Active Development**: The original Homebox has been archived and is no longer maintained, while our fork receives regular updates and bug fixes.
|
||||
- **Community Support**: Get help and advice on our [Discord server](https://discord.homebox.software) or [GitHub](https://git.homebox.software).
|
||||
- **Improved Features**: Enjoy enhancements and optimizations that make Homebox even better.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting the migration, ensure you have:
|
||||
|
||||
- A working installation of `hay-kot/homebox`.
|
||||
- Docker and Docker Compose installed on your server (this guide assumes Docker is being used).
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### 1. Stop the Original Homebox Instance
|
||||
|
||||
To avoid conflicts during migration, shut down your existing `hay-kot/homebox` instance:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### 2. Backup Your Data
|
||||
|
||||
**This step is critical!** Before proceeding, create a backup of your data to ensure nothing is lost.
|
||||
|
||||
> [!WARNING]
|
||||
> **Don't skip this step!** Backing up your data is the most important part of the migration process.
|
||||
|
||||
Locate the `data` folder used by your current Homebox installation and copy its contents to a safe location on your server. If you are using a data volume, follow the [instructions on Docker's website](https://docs.docker.com/engine/storage/volumes/#back-up-restore-or-migrate-data-volumes).
|
||||
### 3. Update the Docker Compose File
|
||||
|
||||
Modify your `docker-compose.yml` file to point to the new Homebox fork:
|
||||
|
||||
- Replace:
|
||||
`ghcr.io/hay-kot/homebox:latest`
|
||||
**With:**
|
||||
`ghcr.io/sysadminsmedia/homebox:latest`
|
||||
|
||||
- If you're using the rootless image, replace:
|
||||
`ghcr.io/hay-kot/homebox:latest-rootless`
|
||||
**With:**
|
||||
`ghcr.io/sysadminsmedia/homebox:latest-rootless`
|
||||
|
||||
- Update the environment variable:
|
||||
- If you're using `HBOX_STORAGE_SQLITE_URL`, change it to `HBOX_DATABASE_SQLITE_PATH`.
|
||||
- If you're using `HBOX_WEB_READ_TIMEOUT`, `HBOX_WEB_WRITE_TIMEOUT`, or `HBOX_IDLE_TIMEOUT`, add an `s` for seconds or `m` for minutes to the end of the integers.
|
||||
|
||||
### 4. Start the New Homebox Instance
|
||||
|
||||
Launch the new version of Homebox with the following command:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Once the service is running, access the web interface and verify:
|
||||
|
||||
- All your data has been successfully migrated.
|
||||
- The service is functioning as expected.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you run into any issues during the migration process, don't hesitate to reach out for help:
|
||||
|
||||
- **Discord**: [Join our community](https://discord.homebox.software) for real-time support.
|
||||
- **GitHub**: [Open an issue or discussion](https://git.homebox.software) for technical assistance.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Quick Start
|
||||
|
||||
> [!TIP]
|
||||
> If you're currently running the original version of Homebox ([https://github.com/hay-kot/homebox](https://github.com/hay-kot/homebox)) switching is easy, just follow the instructions in the [Migration Guide](./migration) to switch to the new version.
|
||||
1. Install Homebox either by using [the latest Docker image](./installation#docker), or by downloading the correct executable for your Operating System from the [Releases](https://github.com/sysadminsmedia/homebox/releases). (See [Installation](./installation) for more details)
|
||||
|
||||
2. Browse to `http://SERVER_IP:3100` (if Using Docker) or `http://SERVER_IP:7745` (if installed locally) to access the included web User Interface.
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<Html :lang="locale" :data-theme="theme || 'homebox'" />
|
||||
<Link rel="icon" type="image/svg" href="/favicon.svg"></Link>
|
||||
<Link rel="apple-touch-icon" href="/apple-touch-icon.png" size="180x180" />
|
||||
<Link rel="mask-icon" href="/mask-icon.svg" color="#5b7f67" />
|
||||
<Meta name="theme-color" content="#5b7f67" />
|
||||
<Link rel="manifest" href="/manifest.webmanifest" />
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<DialogProvider>
|
||||
<ClientOnly>
|
||||
<Toaster class="pointer-events-auto" />
|
||||
</ClientOnly>
|
||||
|
||||
<NuxtLayout>
|
||||
<Html :lang="locale" :data-theme="theme || 'homebox'" />
|
||||
<Link rel="icon" type="image/svg" href="/favicon.svg"></Link>
|
||||
<Link rel="apple-touch-icon" href="/apple-touch-icon.png" size="180x180" />
|
||||
<Link rel="mask-icon" href="/mask-icon.svg" color="#5b7f67" />
|
||||
<Meta name="theme-color" content="#5b7f67" />
|
||||
<Link rel="manifest" href="/manifest.webmanifest" />
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</DialogProvider>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { DialogProvider } from "@/components/ui/dialog-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "default",
|
||||
"typescript": true,
|
||||
"tsConfigPath": "tsconfig.json",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "assets/css/main.css",
|
||||
@@ -10,7 +9,6 @@
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"framework": "nuxt",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
|
||||
43
frontend/components/App/CreateModal.vue
Normal file
43
frontend/components/App/CreateModal.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<Dialog v-if="isDesktop" :dialog-id="dialogId">
|
||||
<DialogScrollContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<slot />
|
||||
|
||||
<DialogFooter>
|
||||
<span class="flex items-center gap-1 text-sm">
|
||||
Use <Shortcut size="sm" :keys="['Shift']" /> + <Shortcut size="sm" :keys="['Enter']" /> to create and add
|
||||
another.
|
||||
</span>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
|
||||
<Drawer v-else :dialog-id="dialogId">
|
||||
<DrawerContent class="max-h-[80%]">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{{ title }}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
|
||||
<div class="m-2 overflow-y-auto">
|
||||
<slot />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMediaQuery } from "@vueuse/core";
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
defineProps<{
|
||||
dialogId: string;
|
||||
title: string;
|
||||
}>();
|
||||
</script>
|
||||
@@ -46,6 +46,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import MdiUpload from "~icons/mdi/upload";
|
||||
type Props = {
|
||||
modelValue: boolean;
|
||||
@@ -60,7 +61,6 @@
|
||||
const dialog = useVModel(props, "modelValue", emit);
|
||||
|
||||
const api = useUserApi();
|
||||
const toast = useNotifier();
|
||||
|
||||
const importCsv = ref<File | null>(null);
|
||||
const importLoading = ref(false);
|
||||
|
||||
@@ -1,41 +1,86 @@
|
||||
<template>
|
||||
<BaseModal v-model="modal">
|
||||
<template #title>🎉 {{ $t("components.app.outdated.new_version_available") }} 🎉</template>
|
||||
<div class="p-4">
|
||||
<p>{{ $t("components.app.outdated.current_version") }}: {{ current }}</p>
|
||||
<p>{{ $t("components.app.outdated.latest_version") }}: {{ latest }}</p>
|
||||
<p>
|
||||
<a href="https://github.com/sysadminsmedia/homebox/releases" target="_blank" rel="noopener" class="link">
|
||||
{{ $t("components.app.outdated.new_version_available_link") }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-warning" @click="hide">
|
||||
{{ $t("components.app.outdated.dismiss") }}
|
||||
</button>
|
||||
</BaseModal>
|
||||
<AlertDialog v-model:open="open">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>🎉 {{ $t("components.app.outdated.new_version_available") }} 🎉</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<p>{{ $t("components.app.outdated.current_version") }}: {{ current }}</p>
|
||||
<p>{{ $t("components.app.outdated.latest_version") }}: {{ latest }}</p>
|
||||
<p>
|
||||
<a href="https://github.com/sysadminsmedia/homebox/releases" target="_blank" rel="noopener" class="link">
|
||||
{{ $t("components.app.outdated.new_version_available_link") }}
|
||||
</a>
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction @click="hide">{{ $t("components.app.outdated.dismiss") }}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
current: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
latest: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
import { lt } from "semver";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogAction,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { useDialog } from "~/components/ui/dialog-provider";
|
||||
|
||||
const modal = useVModel(props, "modelValue");
|
||||
const props = defineProps<{
|
||||
status: {
|
||||
build: {
|
||||
version: string;
|
||||
};
|
||||
latest: {
|
||||
version: string;
|
||||
};
|
||||
};
|
||||
}>();
|
||||
|
||||
const latest = computed(() => props.status.latest.version);
|
||||
const current = computed(() => props.status.build.version);
|
||||
|
||||
const isDev = computed(() => import.meta.dev || !current.value?.includes("."));
|
||||
const isOutdated = computed(() => current.value && latest.value && lt(current.value, latest.value));
|
||||
const hasHiddenLatest = computed(() => localStorage.getItem("latestVersion") === latest.value);
|
||||
|
||||
const displayOutdatedWarning = computed(() => Boolean(!isDev.value && !hasHiddenLatest.value && isOutdated.value));
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
watch(
|
||||
displayOutdatedWarning,
|
||||
displayOutdatedWarning => {
|
||||
if (displayOutdatedWarning) {
|
||||
open.value = true;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const hide = () => {
|
||||
modal.value = false;
|
||||
localStorage.setItem("latestVersion", props.latest);
|
||||
open.value = false;
|
||||
localStorage.setItem("latestVersion", latest.value);
|
||||
};
|
||||
|
||||
const { addAlert, removeAlert } = useDialog();
|
||||
|
||||
watch(
|
||||
open,
|
||||
val => {
|
||||
if (val) {
|
||||
addAlert("new-version-modal");
|
||||
} else {
|
||||
removeAlert("new-version-modal");
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
81
frontend/components/App/QuickMenuModal.vue
Normal file
81
frontend/components/App/QuickMenuModal.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandSeparator,
|
||||
} from "~/components/ui/command";
|
||||
import { Shortcut } from "~/components/ui/shortcut";
|
||||
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||
|
||||
export type QuickMenuAction =
|
||||
| { text: string; href: string; type: "navigate" }
|
||||
| { text: string; dialogId: string; shortcut: string; type: "create" };
|
||||
|
||||
const props = defineProps({
|
||||
actions: {
|
||||
type: Array as PropType<QuickMenuAction[]>,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { closeDialog, openDialog } = useDialog();
|
||||
|
||||
useDialogHotkey("quick-menu", { code: "Backquote", ctrl: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommandDialog dialog-id="quick-menu">
|
||||
<CommandInput
|
||||
:placeholder="t('components.quick_menu.shortcut_hint')"
|
||||
@keydown="
|
||||
(e: KeyboardEvent) => {
|
||||
const item = props.actions.filter(item => 'shortcut' in item).find(item => item.shortcut === e.key);
|
||||
if (item) {
|
||||
openDialog(item.dialogId);
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ t("components.quick_menu.no_results") }}</CommandEmpty>
|
||||
<CommandGroup :heading="t('global.create')">
|
||||
<CommandItem
|
||||
v-for="(create, i) in props.actions.filter(item => item.type === 'create')"
|
||||
:key="`$global.create_${i + 1}`"
|
||||
:value="create.text"
|
||||
@select="
|
||||
() => {
|
||||
openDialog(create.dialogId);
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ create.text }}
|
||||
<Shortcut v-if="'shortcut' in create" class="ml-auto" size="sm" :keys="[create.shortcut]" />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup :heading="t('global.navigate')">
|
||||
<CommandItem
|
||||
v-for="(navigate, i) in props.actions.filter(item => item.type === 'navigate')"
|
||||
:key="navigate.text"
|
||||
:value="`global.navigate_${i + 1}`"
|
||||
@select="
|
||||
() => {
|
||||
closeDialog('quick-menu');
|
||||
navigateTo(navigate.href);
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ navigate.text }}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</template>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<div class="fixed right-2 top-2 z-[9999] w-[300px]">
|
||||
<TransitionGroup name="notify" tag="div">
|
||||
<div
|
||||
v-for="(notify, index) in notifications.slice(0, 4)"
|
||||
:key="notify.id"
|
||||
class="my-2 w-[300px] rounded-md p-3 text-sm text-white"
|
||||
:class="{
|
||||
'bg-primary': notify.type === 'info',
|
||||
'bg-red-600': notify.type === 'error',
|
||||
'bg-green-600': notify.type === 'success',
|
||||
}"
|
||||
@click="dropNotification(index)"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<template v-if="notify.type == 'success'">
|
||||
<MdiCheckboxMarkedCircle class="size-5" />
|
||||
</template>
|
||||
<template v-if="notify.type == 'info'">
|
||||
<MdiInformationSlabCircle class="size-5" />
|
||||
</template>
|
||||
|
||||
<template v-if="notify.type == 'error'">
|
||||
<MdiAlert class="size-5" />
|
||||
</template>
|
||||
{{ notify.message }}
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MdiCheckboxMarkedCircle from "~icons/mdi/checkbox-marked-circle";
|
||||
import MdiInformationSlabCircle from "~icons/mdi/information-slab-circle";
|
||||
import MdiAlert from "~icons/mdi/alert";
|
||||
|
||||
import { useNotifications } from "@/composables/use-notifier";
|
||||
|
||||
const { notifications, dropNotification } = useNotifications();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notify-move,
|
||||
.notify-enter-active,
|
||||
.notify-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
.notify-enter-from,
|
||||
.notify-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
.notify-leave-active {
|
||||
position: absolute;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
</style>
|
||||
@@ -47,6 +47,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import MdiClose from "~icons/mdi/close";
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
@@ -101,7 +102,6 @@
|
||||
}
|
||||
|
||||
const api = useUserApi();
|
||||
const toast = useNotifier();
|
||||
|
||||
async function createAndAdd(name: string) {
|
||||
const { error, data } = await api.labels.create({
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<FormTextField v-model="value" placeholder="Password" :label="label" :type="inputType"> </FormTextField>
|
||||
<button
|
||||
type="button"
|
||||
class="tooltip absolute right-3 top-11 mb-3 ml-1 mt-auto inline-flex justify-center p-1"
|
||||
class="tooltip absolute right-3 top-6 mb-3 ml-1 mt-auto inline-flex justify-center p-1"
|
||||
data-tip="Toggle Password Show"
|
||||
@click="toggle()"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div v-if="!inline" class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
<div v-if="!inline" class="flex w-full flex-col gap-1.5">
|
||||
<Label :for="id" class="flex w-full px-1">
|
||||
<span>{{ label }}</span>
|
||||
<span class="grow"></span>
|
||||
<span
|
||||
:class="{
|
||||
'text-red-600':
|
||||
@@ -11,12 +12,13 @@
|
||||
>
|
||||
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea ref="el" v-model="value" class="textarea textarea-bordered h-28 w-full" :placeholder="placeholder" />
|
||||
</Label>
|
||||
<Textarea :id="id" v-model="value" :placeholder="placeholder" class="min-h-[112px] w-full resize-none" />
|
||||
</div>
|
||||
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
<Label :for="id" class="flex w-full px-1 py-2">
|
||||
<span>{{ label }}</span>
|
||||
<span class="grow"></span>
|
||||
<span
|
||||
:class="{
|
||||
'text-red-600':
|
||||
@@ -26,32 +28,23 @@
|
||||
>
|
||||
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
ref="el"
|
||||
v-model="value"
|
||||
class="textarea textarea-bordered col-span-3 mt-3 h-28 w-full"
|
||||
auto-grow
|
||||
:placeholder="placeholder"
|
||||
auto-height
|
||||
/>
|
||||
</Label>
|
||||
<Textarea :id="id" v-model="value" autosize :placeholder="placeholder" class="col-span-3 mt-2 w-full resize-none" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String],
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "text",
|
||||
required: true,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
@@ -73,17 +66,6 @@
|
||||
},
|
||||
});
|
||||
|
||||
const el = ref();
|
||||
function setHeight() {
|
||||
el.value.style.height = "auto";
|
||||
el.value.style.height = el.value.scrollHeight + 5 + "px";
|
||||
}
|
||||
|
||||
onUpdated(() => {
|
||||
if (props.inline) {
|
||||
setHeight();
|
||||
}
|
||||
});
|
||||
|
||||
const value = useVModel(props, "modelValue", emit);
|
||||
const id = useId();
|
||||
const value = useVModel(props, "modelValue");
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div v-if="!inline" class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text"> {{ label }} </span>
|
||||
<div v-if="!inline" class="flex w-full flex-col gap-1.5">
|
||||
<Label :for="id" class="flex w-full px-1">
|
||||
<span> {{ label }} </span>
|
||||
<span class="grow"></span>
|
||||
<span
|
||||
:class="{
|
||||
'text-red-600':
|
||||
@@ -11,19 +12,21 @@
|
||||
>
|
||||
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
</Label>
|
||||
<Input
|
||||
:id="id"
|
||||
ref="input"
|
||||
v-model="value"
|
||||
:placeholder="placeholder"
|
||||
:type="type"
|
||||
:required="required"
|
||||
class="input input-bordered w-full"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
|
||||
<label class="label">
|
||||
<span class="label-text"> {{ label }} </span>
|
||||
<Label class="flex w-full px-1 py-2" :for="id">
|
||||
<span> {{ label }} </span>
|
||||
<span class="grow"></span>
|
||||
<span
|
||||
:class="{
|
||||
'text-red-600':
|
||||
@@ -33,18 +36,21 @@
|
||||
>
|
||||
{{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
</Label>
|
||||
<Input
|
||||
:id="id"
|
||||
v-model="value"
|
||||
:placeholder="placeholder"
|
||||
:type="type"
|
||||
:required="required"
|
||||
class="input input-bordered col-span-3 mt-2 w-full"
|
||||
class="col-span-3 mt-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Input } from "~/components/ui/input";
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
@@ -86,6 +92,8 @@
|
||||
},
|
||||
});
|
||||
|
||||
const id = useId();
|
||||
|
||||
const input = ref<HTMLElement | null>(null);
|
||||
|
||||
whenever(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<BaseModal v-model="modal">
|
||||
<template #title> {{ $t("components.item.create_modal.title") }} </template>
|
||||
<form @submit.prevent="create()">
|
||||
<BaseModal dialog-id="create-item" :title="$t('components.item.create_modal.title')">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="create()">
|
||||
<LocationSelector v-model="form.location" />
|
||||
<FormTextField
|
||||
ref="nameInput"
|
||||
@@ -17,78 +16,50 @@
|
||||
:label="$t('components.item.create_modal.item_description')"
|
||||
:max-length="1000"
|
||||
/>
|
||||
<FormMultiselect v-model="form.labels" :label="$t('global.labels')" :items="labels ?? []" />
|
||||
|
||||
<div class="modal-action mb-6">
|
||||
<div>
|
||||
<label for="photo" class="btn">{{ $t("components.item.create_modal.photo_button") }}</label>
|
||||
<input
|
||||
id="photo"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/avif,image/webp"
|
||||
multiple
|
||||
@change="previewImage"
|
||||
/>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<div>
|
||||
<BaseButton class="rounded-r-none" :loading="loading" type="submit">
|
||||
<template #icon>
|
||||
<MdiPackageVariant class="swap-off size-5" />
|
||||
<MdiPackageVariantClosed class="swap-on size-5" />
|
||||
</template>
|
||||
<LabelSelector v-model="form.labels" :labels="labels ?? []" />
|
||||
<PhotoUploader :initial-photos="form.photos" @update:photos="photos => (form.photos = photos)" />
|
||||
<div class="mt-4 flex flex-row-reverse">
|
||||
<ButtonGroup>
|
||||
<Button :disabled="loading" type="submit" class="group">
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center transition-transform duration-300 group-hover:rotate-[360deg]"
|
||||
>
|
||||
<MdiPackageVariant class="size-5 group-hover:hidden" />
|
||||
<MdiPackageVariantClosed class="hidden size-5 group-hover:block" />
|
||||
</div>
|
||||
</div>
|
||||
{{ $t("global.create") }}
|
||||
</BaseButton>
|
||||
<div class="dropdown dropdown-top">
|
||||
<label tabindex="0" class="btn rounded-l-none rounded-r-xl">
|
||||
<MdiChevronDown class="size-5" name="mdi-chevron-down" />
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu rounded-box right-0 w-64 bg-base-100 p-2 shadow">
|
||||
<li>
|
||||
<button type="button" @click="create(false)">{{ $t("global.create_and_add") }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- photo preview area is AFTER the create button, to avoid pushing the button below the screen on small displays -->
|
||||
<div class="border-t border-gray-300 p-4">
|
||||
<div v-for="(photo, index) in form.photos" :key="index">
|
||||
<p class="mb-0" style="overflow-wrap: anywhere">File name: {{ photo.photoName }}</p>
|
||||
<img
|
||||
:src="photo.fileBase64"
|
||||
class="w-full rounded-t border-gray-300 object-fill shadow-sm"
|
||||
alt="Uploaded Photo"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
<Button variant="outline" :disabled="loading" type="button" @click="create(false)">
|
||||
{{ $t("global.create_and_add") }}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</form>
|
||||
<p class="mt-4 text-center text-sm">
|
||||
use <kbd class="kbd kbd-xs">Shift</kbd> + <kbd class="kbd kbd-xs"> Enter </kbd> to create and add another
|
||||
</p>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ItemCreate, LabelOut, LocationOut, PhotoPreview } from "~~/lib/api/types/data-contracts";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import { Button, ButtonGroup } from "~/components/ui/button";
|
||||
import BaseModal from "@/components/App/CreateModal.vue";
|
||||
import type { ItemCreate, LocationOut } from "~~/lib/api/types/data-contracts";
|
||||
import { useLabelStore } from "~~/stores/labels";
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
import MdiPackageVariant from "~icons/mdi/package-variant";
|
||||
import MdiPackageVariantClosed from "~icons/mdi/package-variant-closed";
|
||||
import MdiChevronDown from "~icons/mdi/chevron-down";
|
||||
import { AttachmentTypes } from "~~/lib/api/types/non-generated";
|
||||
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||
import LabelSelector from "~/components/Label/Selector.vue";
|
||||
import type { PhotoPreview } from "~/components/Item/ImageUpload.vue";
|
||||
import PhotoUploader from "~/components/Item/ImageUpload.vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const { activeDialog, closeDialog } = useDialog();
|
||||
|
||||
useDialogHotkey("create-item", { code: "Digit1", shift: true });
|
||||
|
||||
const api = useUserApi();
|
||||
const toast = useNotifier();
|
||||
|
||||
const locationsStore = useLocationStore();
|
||||
const locations = computed(() => locationsStore.allLocations);
|
||||
@@ -114,62 +85,39 @@
|
||||
|
||||
const nameInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const modal = useVModel(props, "modelValue");
|
||||
const loading = ref(false);
|
||||
const focused = ref(false);
|
||||
const form = reactive({
|
||||
location: locations.value && locations.value.length > 0 ? locations.value[0] : ({} as LocationOut),
|
||||
name: "",
|
||||
description: "",
|
||||
color: "", // Future!
|
||||
labels: [] as LabelOut[],
|
||||
color: "",
|
||||
labels: [] as string[],
|
||||
photos: [] as PhotoPreview[],
|
||||
});
|
||||
|
||||
const { shift } = useMagicKeys();
|
||||
|
||||
function previewImage(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
// We support uploading multiple files at once, so build up the list of files to preview and upload
|
||||
if (input.files && input.files.length > 0) {
|
||||
for (const file of input.files) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
form.photos.push({ photoName: file.name, fileBase64: e.target?.result as string, file });
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => modal.value,
|
||||
open => {
|
||||
if (open) {
|
||||
useTimeoutFn(() => {
|
||||
focused.value = true;
|
||||
}, 50);
|
||||
|
||||
() => activeDialog.value,
|
||||
active => {
|
||||
if (active === "create-item") {
|
||||
if (locationId.value) {
|
||||
const found = locations.value.find(l => l.id === locationId.value);
|
||||
if (found) {
|
||||
form.location = found;
|
||||
}
|
||||
}
|
||||
|
||||
if (labelId.value) {
|
||||
form.labels = labels.value.filter(l => l.id === labelId.value);
|
||||
form.labels = labels.value.filter(l => l.id === labelId.value).map(l => l.id);
|
||||
}
|
||||
} else {
|
||||
focused.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function create(close = true) {
|
||||
if (!form.location) {
|
||||
if (!form.location?.id) {
|
||||
toast.error("Please select a location.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -180,20 +128,18 @@
|
||||
|
||||
loading.value = true;
|
||||
|
||||
if (shift.value) {
|
||||
close = false;
|
||||
}
|
||||
if (shift.value) close = false;
|
||||
|
||||
const out: ItemCreate = {
|
||||
parentId: null,
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
locationId: form.location.id as string,
|
||||
labelIds: form.labels.map(l => l.id) as string[],
|
||||
labelIds: form.labels,
|
||||
};
|
||||
|
||||
const { error, data } = await api.items.create(out);
|
||||
loading.value = false;
|
||||
|
||||
if (error) {
|
||||
loading.value = false;
|
||||
toast.error("Couldn't create item");
|
||||
@@ -202,30 +148,40 @@
|
||||
|
||||
toast.success("Item created");
|
||||
|
||||
// If the photo was provided, upload it
|
||||
// NOTE: This is not transactional. It's entirely possible for some of the photos to successfully upload and the rest to fail, which will result in missing photos
|
||||
for (const photo of form.photos) {
|
||||
const { error } = await api.items.attachments.add(data.id, photo.file, photo.photoName, AttachmentTypes.Photo);
|
||||
if (form.photos.length > 0) {
|
||||
toast.info(`Uploading ${form.photos.length} photo(s)...`);
|
||||
let uploadError = false;
|
||||
for (const photo of form.photos) {
|
||||
const { error: attachError } = await api.items.attachments.add(
|
||||
data.id,
|
||||
photo.file,
|
||||
photo.photoName,
|
||||
AttachmentTypes.Photo
|
||||
);
|
||||
|
||||
if (error) {
|
||||
loading.value = false;
|
||||
toast.error("Failed to upload Photo " + photo.photoName);
|
||||
return;
|
||||
if (attachError) {
|
||||
uploadError = true;
|
||||
toast.error(`Failed to upload Photo: ${photo.photoName}`);
|
||||
console.error(attachError);
|
||||
}
|
||||
}
|
||||
if (uploadError) {
|
||||
toast.warning("Some photos failed to upload.");
|
||||
} else {
|
||||
toast.success("All photos uploaded successfully.");
|
||||
}
|
||||
|
||||
toast.success("Photo uploaded");
|
||||
}
|
||||
|
||||
// Reset
|
||||
form.name = "";
|
||||
form.description = "";
|
||||
form.color = "";
|
||||
form.photos = [];
|
||||
form.labels = [];
|
||||
focused.value = false;
|
||||
loading.value = false;
|
||||
|
||||
if (close) {
|
||||
modal.value = false;
|
||||
closeDialog("create-item");
|
||||
navigateTo(`/item/${data.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
216
frontend/components/Item/ImageUpload.vue
Normal file
216
frontend/components/Item/ImageUpload.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { Cropper } from "vue-advanced-cropper";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import MdiDelete from "~icons/mdi/delete";
|
||||
import MdiRotateLeft from "~icons/mdi/rotate-left";
|
||||
import MdiRotateRight from "~icons/mdi/rotate-right";
|
||||
import MdiFlipHorizontal from "~icons/mdi/flip-horizontal";
|
||||
import MdiFlipVertical from "~icons/mdi/flip-vertical";
|
||||
// import MdiStarOutline from "~icons/mdi/star-outline";
|
||||
// import MdiStar from "~icons/mdi/star";
|
||||
|
||||
import "vue-advanced-cropper/dist/style.css";
|
||||
|
||||
export type PhotoPreview = {
|
||||
photoName: string;
|
||||
file: File;
|
||||
fileBase64: string;
|
||||
primary: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<{ initialPhotos: PhotoPreview[] }>();
|
||||
const emits = defineEmits<{
|
||||
(e: "update:photos", photos: PhotoPreview[]): void;
|
||||
}>();
|
||||
|
||||
const photos = ref<PhotoPreview[]>(props.initialPhotos);
|
||||
const croppers = ref<(InstanceType<typeof Cropper> | null)[]>([]);
|
||||
|
||||
onMounted(() => {
|
||||
croppers.value = Array(photos.value.length).fill(null);
|
||||
});
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
for (const file of input.files) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const photo = {
|
||||
photoName: file.name,
|
||||
fileBase64: e.target?.result as string,
|
||||
file,
|
||||
primary: photos.value.length === 0,
|
||||
};
|
||||
photos.value.push(photo);
|
||||
emits("update:photos", photos.value);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function deleteImage(index: number) {
|
||||
photos.value.splice(index, 1);
|
||||
croppers.value.splice(index, 1);
|
||||
emits("update:photos", photos.value);
|
||||
}
|
||||
|
||||
// function setPrimary(index: number) {
|
||||
// const primary = photos.value.findIndex(p => p.primary);
|
||||
|
||||
// if (primary !== -1) photos.value[primary].primary = false;
|
||||
// if (primary !== index) photos.value[index].primary = true;
|
||||
|
||||
// toast.error("Currently this does not do anything, the first photo will always be primary");
|
||||
// }
|
||||
|
||||
const setSize = (index: number) => {
|
||||
const cropper = croppers.value[index];
|
||||
const img = new Image();
|
||||
img.src = photos.value[index].fileBase64;
|
||||
img.onload = () => {
|
||||
// get the image size
|
||||
cropper?.setCoordinates({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex w-full flex-col gap-1.5">
|
||||
<Label for="image-create-photo" class="flex w-full px-1">
|
||||
{{ $t("components.item.create_modal.item_photo") }}
|
||||
</Label>
|
||||
<div class="relative inline-block">
|
||||
<Button type="button" variant="outline" class="w-full" aria-hidden="true">
|
||||
{{ $t("components.item.create_modal.upload_photos") }}
|
||||
</Button>
|
||||
<Input
|
||||
id="image-create-photo"
|
||||
class="absolute left-0 top-0 size-full cursor-pointer opacity-0"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/avif,image/webp;capture=camera"
|
||||
multiple
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="photos.length > 0" class="mt-4 border-t border-gray-300">
|
||||
<div v-for="(photo, index) in photos" :key="index">
|
||||
<div class="mt-8 w-full">
|
||||
<cropper
|
||||
ref="croppers"
|
||||
:src="photo.fileBase64"
|
||||
alt="Uploaded Photo"
|
||||
background-class="image-cropper-bg"
|
||||
class="image-cropper"
|
||||
@ready="
|
||||
() => {
|
||||
setSize(index);
|
||||
}
|
||||
"
|
||||
/>
|
||||
<!-- class="w-full rounded border-gray-300 object-fill shadow-sm" -->
|
||||
</div>
|
||||
<div class="mt-2 flex justify-center gap-2">
|
||||
<TooltipProvider class="flex gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button size="icon" type="button" variant="outline" @click.prevent="croppers[index]?.rotate(-90)">
|
||||
<MdiRotateLeft />
|
||||
<div class="sr-only">Rotate left</div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Rotate left</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button size="icon" type="button" variant="outline" @click.prevent="croppers[index]?.flip(true, false)">
|
||||
<MdiFlipHorizontal />
|
||||
<div class="sr-only">Flip horizontal</div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Flip horizontal</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button size="icon" type="button" variant="destructive" @click.prevent="deleteImage(index)">
|
||||
<MdiDelete />
|
||||
<div class="sr-only">Delete photo</div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete photo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<!-- TODO: re-enable when we have a way to set primary photos -->
|
||||
<!-- <Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
:variant="photo.primary ? 'default' : 'outline'"
|
||||
@click.prevent="setPrimary(index)"
|
||||
>
|
||||
<MdiStar v-if="photo.primary" />
|
||||
<MdiStarOutline v-else />
|
||||
<div class="sr-only">Set as {{ photo.primary ? "non" : "" }} primary photo</div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Set as {{ photo.primary ? "non" : "" }} primary photo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip> -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button size="icon" type="button" variant="outline" @click.prevent="croppers[index]?.flip(false, true)">
|
||||
<MdiFlipVertical />
|
||||
<div class="sr-only">Flip vertical</div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Flip vertical</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button size="icon" type="button" variant="outline" @click.prevent="croppers[index]?.rotate(90)">
|
||||
<MdiRotateRight />
|
||||
<div class="sr-only">Rotate right</div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Rotate right</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<p class="mt-1 text-center text-sm" style="overflow-wrap: anywhere">{{ photo.photoName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.image-cropper {
|
||||
width: 462px;
|
||||
}
|
||||
|
||||
.image-cropper-bg {
|
||||
background-color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -249,13 +249,17 @@
|
||||
return 0;
|
||||
}
|
||||
|
||||
const aLower = extractSortable(a, sortByProperty.value);
|
||||
const bLower = extractSortable(b, sortByProperty.value);
|
||||
const aVal = extractSortable(a, sortByProperty.value);
|
||||
const bVal = extractSortable(b, sortByProperty.value);
|
||||
|
||||
if (aLower < bLower) {
|
||||
if (typeof aVal === "string" && typeof bVal === "string") {
|
||||
return aVal.localeCompare(bVal, undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
if (aVal < bVal) {
|
||||
return -1;
|
||||
}
|
||||
if (aLower > bLower) {
|
||||
if (aVal > bVal) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -37,6 +37,6 @@
|
||||
<MdiArrowRight class="swap-on mr-2" />
|
||||
<MdiTagOutline class="swap-off mr-2" />
|
||||
</label>
|
||||
{{ label.name }}
|
||||
{{ label.name.length > 20 ? `${label.name.substring(0, 20)}...` : label.name }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<template>
|
||||
<BaseModal v-model="modal">
|
||||
<template #title>{{ $t("components.label.create_modal.title") }}</template>
|
||||
<form @submit.prevent="create()">
|
||||
<BaseModal dialog-id="create-label" :title="$t('components.label.create_modal.title')">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="create()">
|
||||
<FormTextField
|
||||
ref="locationNameRef"
|
||||
v-model="form.name"
|
||||
:trigger-focus="focused"
|
||||
:autofocus="true"
|
||||
:label="$t('components.label.create_modal.label_name')"
|
||||
:max-length="255"
|
||||
:max-length="50"
|
||||
:min-length="1"
|
||||
/>
|
||||
<FormTextArea
|
||||
@@ -16,38 +14,27 @@
|
||||
:label="$t('components.label.create_modal.label_description')"
|
||||
:max-length="255"
|
||||
/>
|
||||
<div class="modal-action">
|
||||
<div class="flex justify-center">
|
||||
<BaseButton class="rounded-r-none" :loading="loading" type="submit"> {{ $t("global.create") }} </BaseButton>
|
||||
<div class="dropdown dropdown-top">
|
||||
<label tabindex="0" class="btn rounded-l-none rounded-r-xl">
|
||||
<MdiChevronDown class="size-5" />
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu rounded-box right-0 w-64 bg-base-100 p-2 shadow">
|
||||
<li>
|
||||
<button type="button" @click="create(false)">{{ $t("global.create_and_add") }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-row-reverse">
|
||||
<ButtonGroup>
|
||||
<Button :disabled="loading" type="submit">{{ $t("global.create") }}</Button>
|
||||
<Button variant="outline" :disabled="loading" type="button" @click="create(false)">
|
||||
{{ $t("global.create_and_add") }}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</form>
|
||||
<p class="mt-4 text-center text-sm">
|
||||
use <kbd class="kbd kbd-xs">Shift</kbd> + <kbd class="kbd kbd-xs"> Enter </kbd> to create and add another
|
||||
</p>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MdiChevronDown from "~icons/mdi/chevron-down";
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import BaseModal from "@/components/App/CreateModal.vue";
|
||||
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||
|
||||
const { closeDialog } = useDialog();
|
||||
|
||||
useDialogHotkey("create-label", { code: "Digit2", shift: true });
|
||||
|
||||
const modal = useVModel(props, "modelValue");
|
||||
const loading = ref(false);
|
||||
const focused = ref(false);
|
||||
const form = reactive({
|
||||
@@ -64,20 +51,7 @@
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => modal.value,
|
||||
open => {
|
||||
if (open)
|
||||
useTimeoutFn(() => {
|
||||
focused.value = true;
|
||||
}, 50);
|
||||
else focused.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
const api = useUserApi();
|
||||
const toast = useNotifier();
|
||||
|
||||
const { shift } = useMagicKeys();
|
||||
|
||||
async function create(close = true) {
|
||||
@@ -85,13 +59,17 @@
|
||||
toast.error("Already creating a label");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
|
||||
if (shift.value) {
|
||||
close = false;
|
||||
if (form.name.length > 50) {
|
||||
toast.error("Label name must not be longer than 50 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
if (shift.value) close = false;
|
||||
|
||||
const { error, data } = await api.labels.create(form);
|
||||
|
||||
if (error) {
|
||||
toast.error("Couldn't create label");
|
||||
loading.value = false;
|
||||
@@ -102,7 +80,7 @@
|
||||
reset();
|
||||
|
||||
if (close) {
|
||||
modal.value = false;
|
||||
closeDialog("create-label");
|
||||
navigateTo(`/label/${data.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
149
frontend/components/Label/Selector.vue
Normal file
149
frontend/components/Label/Selector.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label :for="id" class="px-1">
|
||||
{{ $t("global.labels") }}
|
||||
</Label>
|
||||
|
||||
<TagsInput
|
||||
v-model="modelValue"
|
||||
class="w-full gap-0 px-0"
|
||||
:display-value="v => shortenedLabels.find(l => l.id === v)?.name ?? 'Loading...'"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 px-3">
|
||||
<TagsInputItem v-for="item in modelValue" :key="item" :value="item">
|
||||
<TagsInputItemText />
|
||||
<TagsInputItemDelete />
|
||||
</TagsInputItem>
|
||||
</div>
|
||||
|
||||
<ComboboxRoot v-model="modelValue" v-model:open="open" class="w-full" :ignore-filter="true">
|
||||
<ComboboxAnchor as-child>
|
||||
<ComboboxInput v-model="searchTerm" :placeholder="$t('components.label.selector.select_labels')" as-child>
|
||||
<TagsInputInput
|
||||
:id="id"
|
||||
class="w-full px-3"
|
||||
:class="modelValue.length > 0 ? 'mt-2' : ''"
|
||||
@focus="open = true"
|
||||
/>
|
||||
</ComboboxInput>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent :side-offset="4" position="popper" class="z-50">
|
||||
<CommandList
|
||||
position="popper"
|
||||
class="mt-2 w-[--reka-popper-anchor-width] rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
>
|
||||
<CommandEmpty />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="label in filteredLabels"
|
||||
:key="label.value"
|
||||
:value="label.value"
|
||||
@select.prevent="
|
||||
ev => {
|
||||
if (typeof ev.detail.value === 'string') {
|
||||
if (ev.detail.value === 'create-item') {
|
||||
void createAndAdd(searchTerm);
|
||||
} else {
|
||||
if (!modelValue.includes(ev.detail.value)) {
|
||||
modelValue = [...modelValue, ev.detail.value];
|
||||
}
|
||||
}
|
||||
searchTerm = '';
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ label.label }}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
</TagsInput>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ComboboxAnchor, ComboboxContent, ComboboxInput, ComboboxPortal, ComboboxRoot } from "reka-ui";
|
||||
import { computed, ref } from "vue";
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import { CommandEmpty, CommandGroup, CommandItem, CommandList } from "~/components/ui/command";
|
||||
import {
|
||||
TagsInput,
|
||||
TagsInputInput,
|
||||
TagsInputItem,
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText,
|
||||
} from "@/components/ui/tags-input";
|
||||
import type { LabelOut } from "~/lib/api/types/data-contracts";
|
||||
|
||||
const id = useId();
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as () => string[],
|
||||
default: null,
|
||||
},
|
||||
labels: {
|
||||
type: Array as () => LabelOut[],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emit);
|
||||
|
||||
const open = ref(false);
|
||||
const searchTerm = ref("");
|
||||
|
||||
const shortenedLabels = computed(() => {
|
||||
return props.labels.map(l => ({
|
||||
...l,
|
||||
name: l.name.length > 20 ? `${l.name.substring(0, 20)}...` : l.name,
|
||||
}));
|
||||
});
|
||||
|
||||
const filteredLabels = computed(() => {
|
||||
const filtered = fuzzysort
|
||||
.go(searchTerm.value, shortenedLabels.value, { key: "name", all: true })
|
||||
.map(l => ({
|
||||
value: l.obj.id,
|
||||
label: l.obj.name,
|
||||
}))
|
||||
.filter(i => !modelValue.value.includes(i.value));
|
||||
|
||||
if (searchTerm.value.trim() !== "") {
|
||||
filtered.push({ value: "create-item", label: `Create ${searchTerm.value}` });
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const createAndAdd = async (name: string) => {
|
||||
if (name.length > 50) {
|
||||
toast.error("Label name must not be longer than 50 characters");
|
||||
return;
|
||||
}
|
||||
const { error, data } = await api.labels.create({
|
||||
name,
|
||||
color: "", // Future!
|
||||
description: "",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error("Couldn't create label");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Label created");
|
||||
|
||||
modelValue.value = [...modelValue.value, data.id];
|
||||
};
|
||||
|
||||
// TODO: when reka-ui 2 is release use hook to set cursor to end when label is added with click
|
||||
</script>
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<BaseModal v-model="modal">
|
||||
<template #title>{{ $t("components.location.create_modal.title") }}</template>
|
||||
<form @submit.prevent="create()">
|
||||
<BaseModal dialog-id="create-location" :title="$t('components.location.create_modal.title')">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="create()">
|
||||
<LocationSelector v-model="form.parent" />
|
||||
<FormTextField
|
||||
ref="locationNameRef"
|
||||
@@ -18,39 +17,29 @@
|
||||
:label="$t('components.location.create_modal.location_description')"
|
||||
:max-length="1000"
|
||||
/>
|
||||
<div class="modal-action">
|
||||
<div class="flex justify-center">
|
||||
<BaseButton class="rounded-r-none" type="submit" :loading="loading">{{ $t("global.create") }}</BaseButton>
|
||||
<div class="dropdown dropdown-top">
|
||||
<label tabindex="0" class="btn rounded-l-none rounded-r-xl">
|
||||
<MdiChevronDown class="size-5" />
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu rounded-box right-0 w-64 bg-base-100 p-2 shadow">
|
||||
<li>
|
||||
<button type="button" @click="create(false)">{{ $t("global.create_and_add") }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-row-reverse">
|
||||
<ButtonGroup>
|
||||
<Button :disabled="loading" type="submit">{{ $t("global.create") }}</Button>
|
||||
<Button variant="outline" :disabled="loading" type="button" @click="create(false)">{{
|
||||
$t("global.create_and_add")
|
||||
}}</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</form>
|
||||
<p class="mt-4 text-center text-sm">
|
||||
use <kbd class="kbd kbd-xs">Shift</kbd> + <kbd class="kbd kbd-xs"> Enter </kbd> to create and add another
|
||||
</p>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import { Button, ButtonGroup } from "~/components/ui/button";
|
||||
import BaseModal from "@/components/App/CreateModal.vue";
|
||||
import type { LocationSummary } from "~~/lib/api/types/data-contracts";
|
||||
import MdiChevronDown from "~icons/mdi/chevron-down";
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
import { useDialog, useDialogHotkey } from "~/components/ui/dialog-provider";
|
||||
|
||||
const { activeDialog, closeDialog } = useDialog();
|
||||
|
||||
useDialogHotkey("create-location", { code: "Digit3", shift: true });
|
||||
|
||||
const modal = useVModel(props, "modelValue");
|
||||
const loading = ref(false);
|
||||
const focused = ref(false);
|
||||
const form = reactive({
|
||||
@@ -60,12 +49,12 @@
|
||||
});
|
||||
|
||||
watch(
|
||||
() => modal.value,
|
||||
open => {
|
||||
if (open) {
|
||||
useTimeoutFn(() => {
|
||||
focused.value = true;
|
||||
}, 50);
|
||||
() => activeDialog.value,
|
||||
active => {
|
||||
if (active === "create-location") {
|
||||
// useTimeoutFn(() => {
|
||||
// focused.value = true;
|
||||
// }, 50);
|
||||
|
||||
if (locationId.value) {
|
||||
const found = locations.value.find(l => l.id === locationId.value);
|
||||
@@ -74,7 +63,7 @@
|
||||
}
|
||||
}
|
||||
} else {
|
||||
focused.value = false;
|
||||
// focused.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -88,7 +77,6 @@
|
||||
}
|
||||
|
||||
const api = useUserApi();
|
||||
const toast = useNotifier();
|
||||
|
||||
const locationsStore = useLocationStore();
|
||||
const locations = computed(() => locationsStore.allLocations);
|
||||
@@ -111,9 +99,7 @@
|
||||
}
|
||||
loading.value = true;
|
||||
|
||||
if (shift.value) {
|
||||
close = false;
|
||||
}
|
||||
if (shift.value) close = false;
|
||||
|
||||
const { data, error } = await api.locations.create({
|
||||
name: form.name,
|
||||
@@ -132,7 +118,7 @@
|
||||
reset();
|
||||
|
||||
if (close) {
|
||||
modal.value = false;
|
||||
closeDialog("create-location");
|
||||
navigateTo(`/location/${data.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
63
frontend/components/Location/LegacySelector.vue
Normal file
63
frontend/components/Location/LegacySelector.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<FormAutocomplete2
|
||||
v-if="locations"
|
||||
v-model="value"
|
||||
:items="locations"
|
||||
display="name"
|
||||
:label="$t('components.location.selector.parent_location')"
|
||||
>
|
||||
<template #display="{ item, selected, active }">
|
||||
<div>
|
||||
<div class="flex w-full">
|
||||
{{ cast(item.value).name }}
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="['absolute inset-y-0 right-0 flex items-center pr-4', active ? 'text-white' : 'text-primary']"
|
||||
>
|
||||
<MdiCheck class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="cast(item.value).name != cast(item.value).treeString" class="mt-1 text-xs">
|
||||
{{ cast(item.value).treeString }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FormAutocomplete2>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FlatTreeItem } from "~~/composables/use-location-helpers";
|
||||
import { useFlatLocations } from "~~/composables/use-location-helpers";
|
||||
import type { LocationSummary } from "~~/lib/api/types/data-contracts";
|
||||
import MdiCheck from "~icons/mdi/check";
|
||||
|
||||
type Props = {
|
||||
modelValue?: LocationSummary | null;
|
||||
};
|
||||
|
||||
// Cast the type of the item to a FlatTreeItem so we can get type "safety" in the template
|
||||
// Note that this does not actually change the type of the item, it just tells the compiler
|
||||
// that the type is FlatTreeItem. We must keep this in sync with the type of the items
|
||||
function cast(value: any): FlatTreeItem {
|
||||
return value as FlatTreeItem;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const value = useVModel(props, "modelValue");
|
||||
|
||||
const locations = useFlatLocations();
|
||||
const form = ref({
|
||||
parent: null as LocationSummary | null,
|
||||
search: "",
|
||||
});
|
||||
|
||||
// Whenever parent goes from value to null reset search
|
||||
watch(
|
||||
() => value.value,
|
||||
() => {
|
||||
if (!value.value) {
|
||||
form.value.search = "";
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -1,62 +1,95 @@
|
||||
<template>
|
||||
<FormAutocomplete2
|
||||
v-if="locations"
|
||||
v-model="value"
|
||||
:items="locations"
|
||||
display="name"
|
||||
:label="$t('components.location.selector.parent_location')"
|
||||
>
|
||||
<template #display="{ item, selected, active }">
|
||||
<div>
|
||||
<div class="flex w-full">
|
||||
{{ cast(item.value).name }}
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="['absolute inset-y-0 right-0 flex items-center pr-4', active ? 'text-white' : 'text-primary']"
|
||||
>
|
||||
<MdiCheck class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="cast(item.value).name != cast(item.value).treeString" class="mt-1 text-xs">
|
||||
{{ cast(item.value).treeString }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FormAutocomplete2>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label :for="id" class="px-1">
|
||||
{{ $t("components.location.selector.parent_location") }}
|
||||
</Label>
|
||||
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button :id="id" variant="outline" role="combobox" :aria-expanded="open" class="w-full justify-between">
|
||||
{{ value && value.name ? value.name : $t("components.location.selector.select_location") }}
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[--reka-popper-anchor-width] p-0">
|
||||
<Command :ignore-filter="true">
|
||||
<CommandInput
|
||||
v-model="search"
|
||||
:placeholder="$t('components.location.selector.search_location')"
|
||||
:display-value="_ => ''"
|
||||
/>
|
||||
<CommandEmpty>{{ $t("components.location.selector.no_location_found") }}</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="location in filteredLocations"
|
||||
:key="location.id"
|
||||
:value="location.id"
|
||||
@select="selectLocation(location as unknown as LocationSummary)"
|
||||
>
|
||||
<Check :class="cn('mr-2 h-4 w-4', value?.id === location.id ? 'opacity-100' : 'opacity-0')" />
|
||||
<div>
|
||||
<div class="flex w-full">
|
||||
{{ location.name }}
|
||||
</div>
|
||||
<div v-if="location.name !== location.treeString" class="mt-1 text-xs text-muted-foreground">
|
||||
{{ location.treeString }}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FlatTreeItem } from "~~/composables/use-location-helpers";
|
||||
import { useFlatLocations } from "~~/composables/use-location-helpers";
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronsUpDown } from "lucide-vue-next";
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { LocationSummary } from "~~/lib/api/types/data-contracts";
|
||||
import MdiCheck from "~icons/mdi/check";
|
||||
import { useFlatLocations } from "~~/composables/use-location-helpers";
|
||||
|
||||
type Props = {
|
||||
modelValue?: LocationSummary | null;
|
||||
};
|
||||
|
||||
// Cast the type of the item to a FlatTreeItem so we can get type "safety" in the template
|
||||
// Note that this does not actually change the type of the item, it just tells the compiler
|
||||
// that the type is FlatTreeItem. We must keep this in sync with the type of the items
|
||||
function cast(value: any): FlatTreeItem {
|
||||
return value as FlatTreeItem;
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const open = ref(false);
|
||||
const search = ref("");
|
||||
const id = useId();
|
||||
const locations = useFlatLocations();
|
||||
const value = useVModel(props, "modelValue", emit);
|
||||
|
||||
function selectLocation(location: LocationSummary) {
|
||||
if (value.value?.id !== location.id) {
|
||||
value.value = location;
|
||||
} else {
|
||||
value.value = null;
|
||||
}
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const value = useVModel(props, "modelValue");
|
||||
const filteredLocations = computed(() => {
|
||||
const filtered = fuzzysort.go(search.value, locations.value, { key: "name", all: true }).map(i => i.obj);
|
||||
|
||||
const locations = useFlatLocations();
|
||||
const form = ref({
|
||||
parent: null as LocationSummary | null,
|
||||
search: "",
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// Whenever parent goes from value to null reset search
|
||||
// Reset search when value is cleared
|
||||
watch(
|
||||
() => value.value,
|
||||
() => {
|
||||
if (!value.value) {
|
||||
form.value.search = "";
|
||||
search.value = "";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<FormTextField v-model="entry.name" autofocus :label="$t('maintenance.modal.entry_name')" />
|
||||
<DatePicker v-model="entry.completedDate" :label="$t('maintenance.modal.completed_date')" />
|
||||
<DatePicker v-model="entry.scheduledDate" :label="$t('maintenance.modal.scheduled_date')" />
|
||||
<FormTextArea v-model="entry.description" :label="$t('maintenance.modal.notes')" />
|
||||
<FormTextField v-model="entry.cost" autofocus :label="$t('maintenance.modal.cost')" />
|
||||
<FormTextArea v-model="entry.description" :label="$t('maintenance.modal.notes')" class="pt-2" />
|
||||
<FormTextField v-model="entry.cost" autofocus :label="$t('maintenance.modal.cost')" class="pt-2" />
|
||||
<div class="flex justify-end py-2">
|
||||
<BaseButton type="submit" class="ml-2 mt-2">
|
||||
<template #icon>
|
||||
@@ -23,13 +23,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import type { MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
import MdiPost from "~icons/mdi/post";
|
||||
import DatePicker from "~~/components/Form/DatePicker.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const api = useUserApi();
|
||||
const toast = useNotifier();
|
||||
|
||||
const emit = defineEmits(["changed"]);
|
||||
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
<template>
|
||||
<BaseModal v-model="isRevealed" readonly @cancel="cancel(false)">
|
||||
<template #title> {{ $t("global.confirm") }} </template>
|
||||
<div>
|
||||
<p>{{ text }}</p>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<BaseButton type="submit" @click="confirm(true)"> {{ $t("global.confirm") }} </BaseButton>
|
||||
</div>
|
||||
</BaseModal>
|
||||
<AlertDialog :open="isRevealed">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ $t("global.confirm") }}</AlertDialogTitle>
|
||||
<AlertDialogDescription> {{ text }} </AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel @click="cancel(false)">
|
||||
{{ $t("global.cancel") }}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction @click="confirm(true)">
|
||||
{{ $t("global.confirm") }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDialog } from "./ui/dialog-provider";
|
||||
|
||||
const { text, isRevealed, confirm, cancel } = useConfirm();
|
||||
const { addAlert, removeAlert } = useDialog();
|
||||
|
||||
watch(
|
||||
isRevealed,
|
||||
val => {
|
||||
if (val) {
|
||||
addAlert("confirm-modal");
|
||||
} else {
|
||||
removeAlert("confirm-modal");
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<p class="text-sm">
|
||||
{{ $t("components.global.copy_text.learn_more") }}
|
||||
<a
|
||||
href="https://homebox.software/en/tips-tricks.html#copy-to-clipboard"
|
||||
href="https://homebox.software/en/user-guide/tips-tricks.html#copy-to-clipboard"
|
||||
class="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { route } from "../../lib/api/base";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import MdiPrinterPos from "~icons/mdi/printer-pos";
|
||||
import MdiFileDownload from "~icons/mdi/file-download";
|
||||
|
||||
@@ -9,7 +10,6 @@
|
||||
}>();
|
||||
|
||||
const pubApi = usePublicApi();
|
||||
const toast = useNotifier();
|
||||
|
||||
const { data: status } = useAsyncData(async () => {
|
||||
const { data, error } = await pubApi.status();
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
<template>
|
||||
<Combobox v-model="selectedAction" :nullable="true">
|
||||
<ComboboxInput
|
||||
ref="inputBox"
|
||||
class="input input-bordered mt-2 w-full"
|
||||
@input="inputValue = $event.target.value"
|
||||
></ComboboxInput>
|
||||
<ComboboxOptions
|
||||
class="card dropdown-content absolute max-h-48 w-full overflow-y-scroll rounded-lg border border-base-300 bg-base-100"
|
||||
:unmount="false"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="(action, idx) in filteredActions"
|
||||
:key="idx"
|
||||
v-slot="{ active }"
|
||||
:value="action"
|
||||
as="template"
|
||||
>
|
||||
<button
|
||||
class="flex w-full rounded-lg px-3 py-1.5 text-left transition-colors"
|
||||
:class="{ 'bg-primary text-primary-content': active }"
|
||||
>
|
||||
{{ action.text }}
|
||||
|
||||
<kbd
|
||||
v-if="action.shortcut"
|
||||
class="kbd kbd-sm ml-auto"
|
||||
:class="{ 'border-primary-content bg-primary': active }"
|
||||
>
|
||||
{{ action.shortcut }}
|
||||
</kbd>
|
||||
</button>
|
||||
</ComboboxOption>
|
||||
<div
|
||||
v-if="filteredActions.length == 0"
|
||||
class="w-full rounded-lg p-3 text-left transition-colors hover:bg-base-300"
|
||||
>
|
||||
No actions found.
|
||||
</div>
|
||||
</ComboboxOptions>
|
||||
<ComboboxButton ref="inputBoxButton"></ComboboxButton>
|
||||
</Combobox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption, ComboboxButton } from "@headlessui/vue";
|
||||
|
||||
type ExposedProps = {
|
||||
focused: boolean;
|
||||
revealActions: () => void;
|
||||
};
|
||||
|
||||
type QuickMenuAction = {
|
||||
text: string;
|
||||
action: () => void;
|
||||
// A character that invokes this action instantly if pressed
|
||||
shortcut?: string;
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object as PropType<QuickMenuAction>,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
actions: {
|
||||
type: Array as PropType<QuickMenuAction[]>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "actionSelected"]);
|
||||
const selectedAction = ref(null);
|
||||
|
||||
const inputValue = ref("");
|
||||
const inputBox = ref();
|
||||
const inputBoxButton = ref();
|
||||
const { focused: inputBoxFocused } = useFocus(inputBox);
|
||||
|
||||
const revealActions = () => {
|
||||
unrefElement(inputBoxButton).click();
|
||||
};
|
||||
|
||||
watch(inputBoxFocused, val => {
|
||||
if (val) revealActions();
|
||||
else inputValue.value = "";
|
||||
});
|
||||
|
||||
watch(inputValue, (val, oldVal) => {
|
||||
if (!oldVal) {
|
||||
const action = props.actions?.find(v => v.shortcut === val);
|
||||
if (action) {
|
||||
emit("actionSelected", action);
|
||||
inputBoxFocused.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(selectedAction, val => {
|
||||
if (val) {
|
||||
emit("actionSelected", val);
|
||||
selectedAction.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
const filteredActions = computed(() => {
|
||||
const searchTerm = inputValue.value.toLowerCase();
|
||||
return (props.actions || []).filter(action => {
|
||||
return action.text.toLowerCase().includes(searchTerm) || action.shortcut?.includes(searchTerm);
|
||||
});
|
||||
});
|
||||
|
||||
defineExpose({ focused: inputBoxFocused, revealActions });
|
||||
|
||||
export type { QuickMenuAction, ExposedProps };
|
||||
</script>
|
||||
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
v-model="modal"
|
||||
:show-close-button="false"
|
||||
:click-outside-to-close="true"
|
||||
:modal-top="true"
|
||||
:class="{ 'self-start': true }"
|
||||
>
|
||||
<div class="relative">
|
||||
<span class="text-neutral-400">{{ $t("components.quick_menu.shortcut_hint") }}</span>
|
||||
<QuickMenuInput ref="inputBox" :actions="props.actions || []" @action-selected="invokeAction"></QuickMenuInput>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ExposedProps as QuickMenuInputData, QuickMenuAction } from "./Input.vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
actions: {
|
||||
type: Array as PropType<QuickMenuAction[]>,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const modal = useVModel(props, "modelValue");
|
||||
|
||||
const inputBox = ref<QuickMenuInputData>({ focused: false, revealActions: () => {} });
|
||||
|
||||
const onModalOpen = useTimeoutFn(() => {
|
||||
inputBox.value.focused = true;
|
||||
}, 50).start;
|
||||
|
||||
const onModalClose = () => {
|
||||
inputBox.value.focused = false;
|
||||
};
|
||||
|
||||
watch(modal, () => (modal.value ? onModalOpen : onModalClose)());
|
||||
|
||||
onStartTyping(() => {
|
||||
inputBox.value.focused = true;
|
||||
});
|
||||
|
||||
function invokeAction(action: QuickMenuAction) {
|
||||
modal.value = false;
|
||||
useTimeoutFn(action.action, 100).start();
|
||||
}
|
||||
</script>
|
||||
14
frontend/components/ui/alert-dialog/AlertDialog.vue
Normal file
14
frontend/components/ui/alert-dialog/AlertDialog.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { type AlertDialogEmits, type AlertDialogProps, AlertDialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
20
frontend/components/ui/alert-dialog/AlertDialogAction.vue
Normal file
20
frontend/components/ui/alert-dialog/AlertDialogAction.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
||||
27
frontend/components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
27
frontend/components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
||||
42
frontend/components/ui/alert-dialog/AlertDialogContent.vue
Normal file
42
frontend/components/ui/alert-dialog/AlertDialogContent.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
AlertDialogContent,
|
||||
type AlertDialogContentEmits,
|
||||
type AlertDialogContentProps,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<AlertDialogContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
AlertDialogDescription,
|
||||
type AlertDialogDescriptionProps,
|
||||
} from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
</template>
|
||||
21
frontend/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
21
frontend/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
16
frontend/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
16
frontend/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
22
frontend/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
22
frontend/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AlertDialogTitle, type AlertDialogTitleProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
</template>
|
||||
11
frontend/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
11
frontend/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertDialogTrigger, type AlertDialogTriggerProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<AlertDialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTrigger v-bind="props">
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
</template>
|
||||
9
frontend/components/ui/alert-dialog/index.ts
Normal file
9
frontend/components/ui/alert-dialog/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as AlertDialog } from './AlertDialog.vue'
|
||||
export { default as AlertDialogAction } from './AlertDialogAction.vue'
|
||||
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'
|
||||
export { default as AlertDialogContent } from './AlertDialogContent.vue'
|
||||
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'
|
||||
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'
|
||||
export { default as AlertDialogHeader } from './AlertDialogHeader.vue'
|
||||
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'
|
||||
export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue'
|
||||
16
frontend/components/ui/badge/Badge.vue
Normal file
16
frontend/components/ui/badge/Badge.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type BadgeVariants, badgeVariants } from '.'
|
||||
|
||||
const props = defineProps<{
|
||||
variant?: BadgeVariants['variant']
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(badgeVariants({ variant }), props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
25
frontend/components/ui/badge/index.ts
Normal file
25
frontend/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
export { default as Badge } from './Badge.vue'
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { Primitive, type PrimitiveProps } from "radix-vue";
|
||||
import { Primitive, type PrimitiveProps } from "reka-ui";
|
||||
import { type ButtonVariants, buttonVariants } from ".";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
||||
22
frontend/components/ui/button/ButtonGroup.vue
Normal file
22
frontend/components/ui/button/ButtonGroup.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex rounded-lg',
|
||||
'[&>*]:rounded-none',
|
||||
'[&>*:first-child]:rounded-l-lg',
|
||||
'[&>*:last-child]:rounded-r-lg',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
export { default as Button } from "./Button.vue";
|
||||
export { default as ButtonGroup } from "./ButtonGroup.vue";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
|
||||
30
frontend/components/ui/command/Command.vue
Normal file
30
frontend/components/ui/command/Command.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComboboxRootEmits, ComboboxRootProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ComboboxRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<ComboboxRootProps & { class?: HTMLAttributes['class'] }>(), {
|
||||
open: true,
|
||||
modelValue: '',
|
||||
})
|
||||
|
||||
const emits = defineEmits<ComboboxRootEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxRoot
|
||||
v-bind="forwarded"
|
||||
:class="cn('flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxRoot>
|
||||
</template>
|
||||
21
frontend/components/ui/command/CommandDialog.vue
Normal file
21
frontend/components/ui/command/CommandDialog.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { useForwardPropsEmits } from 'reka-ui'
|
||||
import Command from './Command.vue'
|
||||
|
||||
const props = defineProps<DialogRootProps & { dialogId: string }>();
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-bind="forwarded">
|
||||
<DialogContent class="overflow-hidden p-0 shadow-lg">
|
||||
<Command class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
<slot />
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
20
frontend/components/ui/command/CommandEmpty.vue
Normal file
20
frontend/components/ui/command/CommandEmpty.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComboboxEmptyProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ComboboxEmpty } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<ComboboxEmptyProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxEmpty v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)">
|
||||
<slot />
|
||||
</ComboboxEmpty>
|
||||
</template>
|
||||
29
frontend/components/ui/command/CommandGroup.vue
Normal file
29
frontend/components/ui/command/CommandGroup.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComboboxGroupProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ComboboxGroup, ComboboxLabel } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<ComboboxGroupProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
heading?: string
|
||||
}>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxGroup
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', props.class)"
|
||||
>
|
||||
<ComboboxLabel v-if="heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{{ heading }}
|
||||
</ComboboxLabel>
|
||||
<slot />
|
||||
</ComboboxGroup>
|
||||
</template>
|
||||
33
frontend/components/ui/command/CommandInput.vue
Normal file
33
frontend/components/ui/command/CommandInput.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Search } from 'lucide-vue-next'
|
||||
import { ComboboxInput, type ComboboxInputProps, useForwardProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<ComboboxInputProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center border-b px-3" cmdk-input-wrapper>
|
||||
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<ComboboxInput
|
||||
v-bind="{ ...forwardedProps, ...$attrs }"
|
||||
auto-focus
|
||||
:class="cn('flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
26
frontend/components/ui/command/CommandItem.vue
Normal file
26
frontend/components/ui/command/CommandItem.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComboboxItemEmits, ComboboxItemProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ComboboxItem, useForwardPropsEmits } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<ComboboxItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<ComboboxItemEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxItem
|
||||
v-bind="forwarded"
|
||||
:class="cn('relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxItem>
|
||||
</template>
|
||||
25
frontend/components/ui/command/CommandList.vue
Normal file
25
frontend/components/ui/command/CommandList.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComboboxContentEmits, ComboboxContentProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ComboboxContent, useForwardPropsEmits } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<ComboboxContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<ComboboxContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxContent v-bind="forwarded" :class="cn('max-h-[300px] overflow-y-auto overflow-x-hidden', props.class)">
|
||||
<div role="presentation">
|
||||
<slot />
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</template>
|
||||
23
frontend/components/ui/command/CommandSeparator.vue
Normal file
23
frontend/components/ui/command/CommandSeparator.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComboboxSeparatorProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ComboboxSeparator } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<ComboboxSeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxSeparator
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('-mx-1 h-px bg-border', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ComboboxSeparator>
|
||||
</template>
|
||||
14
frontend/components/ui/command/CommandShortcut.vue
Normal file
14
frontend/components/ui/command/CommandShortcut.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
9
frontend/components/ui/command/index.ts
Normal file
9
frontend/components/ui/command/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as Command } from './Command.vue'
|
||||
export { default as CommandDialog } from './CommandDialog.vue'
|
||||
export { default as CommandEmpty } from './CommandEmpty.vue'
|
||||
export { default as CommandGroup } from './CommandGroup.vue'
|
||||
export { default as CommandInput } from './CommandInput.vue'
|
||||
export { default as CommandItem } from './CommandItem.vue'
|
||||
export { default as CommandList } from './CommandList.vue'
|
||||
export { default as CommandSeparator } from './CommandSeparator.vue'
|
||||
export { default as CommandShortcut } from './CommandShortcut.vue'
|
||||
48
frontend/components/ui/dialog-provider/DialogProvider.vue
Normal file
48
frontend/components/ui/dialog-provider/DialogProvider.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<!-- DialogProvider.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from "vue";
|
||||
import { provideDialogContext } from "./utils";
|
||||
|
||||
const activeDialog = ref<string | null>(null);
|
||||
const activeAlerts = reactive<string[]>([]);
|
||||
|
||||
const openDialog = (dialogId: string) => {
|
||||
if (activeAlerts.length > 0) return;
|
||||
activeDialog.value = dialogId;
|
||||
};
|
||||
|
||||
const closeDialog = (dialogId?: string) => {
|
||||
if (dialogId) {
|
||||
if (activeDialog.value === dialogId) {
|
||||
activeDialog.value = null;
|
||||
}
|
||||
} else {
|
||||
activeDialog.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const addAlert = (alertId: string) => {
|
||||
activeAlerts.push(alertId);
|
||||
};
|
||||
|
||||
const removeAlert = (alertId: string) => {
|
||||
const index = activeAlerts.indexOf(alertId);
|
||||
if (index !== -1) {
|
||||
activeAlerts.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Provide context to child components
|
||||
provideDialogContext({
|
||||
activeDialog: computed(() => activeDialog.value),
|
||||
openDialog,
|
||||
closeDialog,
|
||||
activeAlerts: computed(() => activeAlerts),
|
||||
addAlert,
|
||||
removeAlert,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
2
frontend/components/ui/dialog-provider/index.ts
Normal file
2
frontend/components/ui/dialog-provider/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useDialog, useDialogHotkey } from "./utils";
|
||||
export { default as DialogProvider } from "./DialogProvider.vue";
|
||||
56
frontend/components/ui/dialog-provider/utils.ts
Normal file
56
frontend/components/ui/dialog-provider/utils.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ComputedRef } from "vue";
|
||||
import { createContext } from "reka-ui";
|
||||
import { useMagicKeys, useActiveElement } from "@vueuse/core";
|
||||
|
||||
export const [useDialog, provideDialogContext] = createContext<{
|
||||
activeDialog: ComputedRef<string | null>;
|
||||
activeAlerts: ComputedRef<string[]>;
|
||||
openDialog: (dialogId: string) => void;
|
||||
closeDialog: (dialogId?: string) => void;
|
||||
addAlert: (alertId: string) => void;
|
||||
removeAlert: (alertId: string) => void;
|
||||
}>("DialogProvider");
|
||||
|
||||
export const useDialogHotkey = (
|
||||
dialogId: string,
|
||||
key: {
|
||||
shift?: boolean;
|
||||
ctrl?: boolean;
|
||||
code: string;
|
||||
}
|
||||
) => {
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const activeElement = useActiveElement();
|
||||
|
||||
const notUsingInput = computed(
|
||||
() => activeElement.value?.tagName !== "INPUT" && activeElement.value?.tagName !== "TEXTAREA"
|
||||
);
|
||||
|
||||
useMagicKeys({
|
||||
passive: false,
|
||||
onEventFired: event => {
|
||||
// console.log({
|
||||
// event,
|
||||
// notUsingInput: notUsingInput.value,
|
||||
// eventType: event.type,
|
||||
// keyCode: event.code,
|
||||
// matchingKeyCode: key.code === event.code,
|
||||
// shift: event.shiftKey,
|
||||
// matchingShift: key.shift === undefined || event.shiftKey === key.shift,
|
||||
// ctrl: event.ctrlKey,
|
||||
// matchingCtrl: key.ctrl === undefined || event.ctrlKey === key.ctrl,
|
||||
// });
|
||||
if (
|
||||
notUsingInput.value &&
|
||||
event.type === "keydown" &&
|
||||
event.code === key.code &&
|
||||
(key.shift === undefined || event.shiftKey === key.shift) &&
|
||||
(key.ctrl === undefined || event.ctrlKey === key.ctrl)
|
||||
) {
|
||||
openDialog(dialogId);
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
22
frontend/components/ui/dialog/Dialog.vue
Normal file
22
frontend/components/ui/dialog/Dialog.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from "reka-ui";
|
||||
import { useDialog } from "../dialog-provider/utils";
|
||||
|
||||
const props = defineProps<DialogRootProps & { dialogId: string }>();
|
||||
const emits = defineEmits<DialogRootEmits>();
|
||||
|
||||
const { closeDialog, activeDialog } = useDialog();
|
||||
|
||||
const isOpen = computed(() => activeDialog.value === props.dialogId);
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (!open) closeDialog(props.dialogId);
|
||||
};
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-bind="forwarded" :open="isOpen" @update:open="onOpenChange">
|
||||
<slot />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
11
frontend/components/ui/dialog/DialogClose.vue
Normal file
11
frontend/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogClose, type DialogCloseProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose v-bind="props">
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
50
frontend/components/ui/dialog/DialogContent.vue
Normal file
50
frontend/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
type DialogContentEmits,
|
||||
type DialogContentProps,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
24
frontend/components/ui/dialog/DialogDescription.vue
Normal file
24
frontend/components/ui/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { DialogDescription, type DialogDescriptionProps, useForwardProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
19
frontend/components/ui/dialog/DialogFooter.vue
Normal file
19
frontend/components/ui/dialog/DialogFooter.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
16
frontend/components/ui/dialog/DialogHeader.vue
Normal file
16
frontend/components/ui/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
59
frontend/components/ui/dialog/DialogScrollContent.vue
Normal file
59
frontend/components/ui/dialog/DialogScrollContent.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
type DialogContentEmits,
|
||||
type DialogContentProps,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
@pointer-down-outside="(event: CustomEvent<{ originalEvent: PointerEvent }>) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-3 right-3 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
29
frontend/components/ui/dialog/DialogTitle.vue
Normal file
29
frontend/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { DialogTitle, type DialogTitleProps, useForwardProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
11
frontend/components/ui/dialog/DialogTrigger.vue
Normal file
11
frontend/components/ui/dialog/DialogTrigger.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogTrigger, type DialogTriggerProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger v-bind="props">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
9
frontend/components/ui/dialog/index.ts
Normal file
9
frontend/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as Dialog } from './Dialog.vue'
|
||||
export { default as DialogClose } from './DialogClose.vue'
|
||||
export { default as DialogContent } from './DialogContent.vue'
|
||||
export { default as DialogDescription } from './DialogDescription.vue'
|
||||
export { default as DialogFooter } from './DialogFooter.vue'
|
||||
export { default as DialogHeader } from './DialogHeader.vue'
|
||||
export { default as DialogScrollContent } from './DialogScrollContent.vue'
|
||||
export { default as DialogTitle } from './DialogTitle.vue'
|
||||
export { default as DialogTrigger } from './DialogTrigger.vue'
|
||||
27
frontend/components/ui/drawer/Drawer.vue
Normal file
27
frontend/components/ui/drawer/Drawer.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DrawerRootEmits, DrawerRootProps } from "vaul-vue";
|
||||
import { useForwardPropsEmits } from "reka-ui";
|
||||
import { DrawerRoot } from "vaul-vue";
|
||||
import { useDialog } from "../dialog-provider/utils";
|
||||
|
||||
const props = withDefaults(defineProps<DrawerRootProps & { dialogId: string }>(), {
|
||||
shouldScaleBackground: true,
|
||||
}) as DrawerRootProps & { dialogId: string };
|
||||
|
||||
const emits = defineEmits<DrawerRootEmits>();
|
||||
|
||||
const { closeDialog, activeDialog } = useDialog();
|
||||
|
||||
const isOpen = computed(() => activeDialog.value === props.dialogId);
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (!open) closeDialog(props.dialogId);
|
||||
};
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DrawerRoot v-bind="forwarded" :open="isOpen" @update:open="onOpenChange">
|
||||
<slot />
|
||||
</DrawerRoot>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user