Compare commits
5 Commits
master
..
b692fabed5
| Author | SHA1 | Date | |
|---|---|---|---|
| b692fabed5 | |||
| 830e411d92 | |||
| b7995b7e4a | |||
| f043d38eb2 | |||
| 780f85de59 |
@@ -1,36 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# Tanabata File Manager — environment variables
|
|
||||||
# Copy to .env and fill in the values.
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Server
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
LISTEN_ADDR=:8080
|
|
||||||
JWT_SECRET=change-me-to-a-random-32-byte-secret
|
|
||||||
JWT_ACCESS_TTL=15m
|
|
||||||
JWT_REFRESH_TTL=720h
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Database
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
DATABASE_URL=postgres://tanabata:password@localhost:5432/tanabata?sslmode=disable
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Storage
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
FILES_PATH=/data/files
|
|
||||||
THUMBS_CACHE_PATH=/data/thumbs
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Thumbnails
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
THUMB_WIDTH=160
|
|
||||||
THUMB_HEIGHT=160
|
|
||||||
PREVIEW_WIDTH=1920
|
|
||||||
PREVIEW_HEIGHT=1080
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Import
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
IMPORT_PATH=/data/import
|
|
||||||
@@ -52,7 +52,4 @@ npm run generate:types # regenerate API types from openapi.yaml
|
|||||||
- TypeScript: strict mode, named exports
|
- TypeScript: strict mode, named exports
|
||||||
- SQL: snake_case, all migrations via goose
|
- SQL: snake_case, all migrations via goose
|
||||||
- API errors: { code, message, details? }
|
- API errors: { code, message, details? }
|
||||||
- Git: conventional commits with scope — `type(scope): message`
|
- Git: conventional commits (feat:, fix:, docs:, refactor:)
|
||||||
- `(backend)` for Go backend code
|
|
||||||
- `(frontend)` for SvelteKit/TypeScript code
|
|
||||||
- `(project)` for root-level files (.gitignore, docs/reference, structure)
|
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/stdlib"
|
|
||||||
"github.com/pressly/goose/v3"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/config"
|
|
||||||
"tanabata/backend/internal/db/postgres"
|
|
||||||
"tanabata/backend/internal/handler"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
"tanabata/backend/internal/storage"
|
|
||||||
"tanabata/backend/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to load config", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := postgres.NewPool(context.Background(), cfg.DatabaseURL)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to connect to database", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
slog.Info("database connected")
|
|
||||||
|
|
||||||
migDB := stdlib.OpenDBFromPool(pool)
|
|
||||||
goose.SetBaseFS(migrations.FS)
|
|
||||||
if err := goose.SetDialect("postgres"); err != nil {
|
|
||||||
slog.Error("goose dialect error", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := goose.Up(migDB, "."); err != nil {
|
|
||||||
slog.Error("migrations failed", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
migDB.Close()
|
|
||||||
slog.Info("migrations applied")
|
|
||||||
|
|
||||||
// Storage
|
|
||||||
diskStorage, err := storage.NewDiskStorage(
|
|
||||||
cfg.FilesPath,
|
|
||||||
cfg.ThumbsCachePath,
|
|
||||||
cfg.ThumbWidth, cfg.ThumbHeight,
|
|
||||||
cfg.PreviewWidth, cfg.PreviewHeight,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to initialise storage", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Repositories
|
|
||||||
userRepo := postgres.NewUserRepo(pool)
|
|
||||||
sessionRepo := postgres.NewSessionRepo(pool)
|
|
||||||
fileRepo := postgres.NewFileRepo(pool)
|
|
||||||
mimeRepo := postgres.NewMimeRepo(pool)
|
|
||||||
aclRepo := postgres.NewACLRepo(pool)
|
|
||||||
auditRepo := postgres.NewAuditRepo(pool)
|
|
||||||
tagRepo := postgres.NewTagRepo(pool)
|
|
||||||
tagRuleRepo := postgres.NewTagRuleRepo(pool)
|
|
||||||
categoryRepo := postgres.NewCategoryRepo(pool)
|
|
||||||
poolRepo := postgres.NewPoolRepo(pool)
|
|
||||||
transactor := postgres.NewTransactor(pool)
|
|
||||||
|
|
||||||
// Services
|
|
||||||
authSvc := service.NewAuthService(
|
|
||||||
userRepo,
|
|
||||||
sessionRepo,
|
|
||||||
cfg.JWTSecret,
|
|
||||||
cfg.JWTAccessTTL,
|
|
||||||
cfg.JWTRefreshTTL,
|
|
||||||
)
|
|
||||||
aclSvc := service.NewACLService(aclRepo)
|
|
||||||
auditSvc := service.NewAuditService(auditRepo)
|
|
||||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
|
||||||
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
|
|
||||||
poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc)
|
|
||||||
fileSvc := service.NewFileService(
|
|
||||||
fileRepo,
|
|
||||||
mimeRepo,
|
|
||||||
diskStorage,
|
|
||||||
aclSvc,
|
|
||||||
auditSvc,
|
|
||||||
tagSvc,
|
|
||||||
transactor,
|
|
||||||
cfg.ImportPath,
|
|
||||||
)
|
|
||||||
userSvc := service.NewUserService(userRepo, auditSvc)
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
|
||||||
authHandler := handler.NewAuthHandler(authSvc)
|
|
||||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
|
||||||
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
|
||||||
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
|
||||||
poolHandler := handler.NewPoolHandler(poolSvc)
|
|
||||||
userHandler := handler.NewUserHandler(userSvc)
|
|
||||||
aclHandler := handler.NewACLHandler(aclSvc)
|
|
||||||
auditHandler := handler.NewAuditHandler(auditSvc)
|
|
||||||
|
|
||||||
r := handler.NewRouter(
|
|
||||||
authMiddleware, authHandler,
|
|
||||||
fileHandler, tagHandler, categoryHandler, poolHandler,
|
|
||||||
userHandler, aclHandler, auditHandler,
|
|
||||||
)
|
|
||||||
|
|
||||||
slog.Info("starting server", "addr", cfg.ListenAddr)
|
|
||||||
if err := r.Run(cfg.ListenAddr); err != nil {
|
|
||||||
slog.Error("server error", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-101
@@ -1,104 +1,5 @@
|
|||||||
module tanabata/backend
|
module tanabata/backend
|
||||||
|
|
||||||
go 1.26
|
go 1.21
|
||||||
|
|
||||||
toolchain go1.26.1
|
require github.com/google/uuid v1.6.0
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/disintegration/imaging v1.6.2
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12
|
|
||||||
github.com/gin-gonic/gin v1.9.1
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
|
||||||
github.com/google/uuid v1.6.0
|
|
||||||
github.com/jackc/pgx/v5 v5.5.5
|
|
||||||
github.com/joho/godotenv v1.5.1
|
|
||||||
github.com/pressly/goose/v3 v3.21.1
|
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
|
||||||
github.com/stretchr/testify v1.11.1
|
|
||||||
github.com/testcontainers/testcontainers-go v0.41.0
|
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0
|
|
||||||
golang.org/x/crypto v0.48.0
|
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
|
||||||
github.com/containerd/errdefs v1.0.0 // indirect
|
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
|
||||||
github.com/containerd/platforms v0.2.1 // indirect
|
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
|
||||||
github.com/docker/docker v28.5.2+incompatible // indirect
|
|
||||||
github.com/docker/go-connections v0.6.0 // indirect
|
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
|
||||||
github.com/ebitengine/purego v0.10.0 // indirect
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
|
||||||
github.com/go-playground/validator/v10 v10.22.0 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
|
||||||
github.com/klauspost/compress v1.18.2 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
|
||||||
github.com/magiconair/properties v1.8.10 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
|
||||||
github.com/moby/go-archive v0.2.0 // indirect
|
|
||||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
|
||||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
|
||||||
github.com/moby/sys/user v0.4.0 // indirect
|
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
|
||||||
github.com/moby/term v0.5.2 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
|
||||||
github.com/morikuni/aec v1.0.0 // indirect
|
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
|
||||||
github.com/shirou/gopsutil/v4 v4.26.2 // indirect
|
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
|
||||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
|
||||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
|
||||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
|
||||||
golang.org/x/net v0.49.0 // indirect
|
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
|
||||||
golang.org/x/text v0.34.0 // indirect
|
|
||||||
google.golang.org/grpc v1.79.1 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
|
||||||
|
|||||||
-273
@@ -1,275 +1,2 @@
|
|||||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
|
||||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
|
||||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
|
||||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
|
||||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
|
||||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
|
||||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
|
||||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
|
||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
|
||||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
|
||||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
|
||||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
|
||||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
|
||||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
|
||||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
|
||||||
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.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
|
||||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
|
||||||
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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
|
||||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
|
||||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
|
||||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
|
||||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
|
||||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
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/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
|
||||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
|
||||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
|
||||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
|
||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
|
||||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
|
||||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
|
||||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
|
||||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
|
||||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
|
||||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
|
||||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
|
||||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
|
||||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
|
||||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
|
||||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
|
||||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
|
||||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
|
||||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
|
||||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
|
||||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
|
||||||
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-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
|
||||||
github.com/pressly/goose/v3 v3.21.1 h1:5SSAKKWej8LVVzNLuT6KIvP1eFDuPvxa+B6H0w78buQ=
|
|
||||||
github.com/pressly/goose/v3 v3.21.1/go.mod h1:sqthmzV8PitchEkjecFJII//l43dLOCzfWh8pHEe+vE=
|
|
||||||
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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
|
||||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
|
||||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais=
|
|
||||||
github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI=
|
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0 h1:AOtFXssrDlLm84A2sTTR/AhvJiYbrIuCO59d+Ro9Tb0=
|
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0/go.mod h1:k2a09UKhgSp6vNpliIY0QSgm4Hi7GXVTzWvWgUemu/8=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
|
||||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
|
||||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
|
||||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
|
||||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
|
||||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
|
|
||||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
|
||||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
|
||||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
|
||||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
|
||||||
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
|
||||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
|
||||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
|
||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|
||||||
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-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
|
||||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
|
||||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
|
||||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
|
||||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
|
||||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
|
||||||
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
|
|
||||||
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
|
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
|
||||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
|
||||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
|
||||||
modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4=
|
|
||||||
modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
|
|
||||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
|
||||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config holds all application configuration loaded from environment variables.
|
|
||||||
type Config struct {
|
|
||||||
// Server
|
|
||||||
ListenAddr string
|
|
||||||
JWTSecret string
|
|
||||||
JWTAccessTTL time.Duration
|
|
||||||
JWTRefreshTTL time.Duration
|
|
||||||
|
|
||||||
// Database
|
|
||||||
DatabaseURL string
|
|
||||||
|
|
||||||
// Storage
|
|
||||||
FilesPath string
|
|
||||||
ThumbsCachePath string
|
|
||||||
|
|
||||||
// Thumbnails
|
|
||||||
ThumbWidth int
|
|
||||||
ThumbHeight int
|
|
||||||
PreviewWidth int
|
|
||||||
PreviewHeight int
|
|
||||||
|
|
||||||
// Import
|
|
||||||
ImportPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load reads a .env file (if present) then loads all configuration from
|
|
||||||
// environment variables. Returns an error listing every missing or invalid var.
|
|
||||||
func Load() (*Config, error) {
|
|
||||||
// Non-fatal: .env may not exist in production.
|
|
||||||
_ = godotenv.Load()
|
|
||||||
|
|
||||||
var errs []error
|
|
||||||
|
|
||||||
requireStr := func(key string) string {
|
|
||||||
v := os.Getenv(key)
|
|
||||||
if v == "" {
|
|
||||||
errs = append(errs, fmt.Errorf("%s is required", key))
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultStr := func(key, def string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
parseDuration := func(key, def string) time.Duration {
|
|
||||||
raw := defaultStr(key, def)
|
|
||||||
d, err := time.ParseDuration(raw)
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("%s: invalid duration %q: %w", key, raw, err))
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
parseInt := func(key string, def int) int {
|
|
||||||
raw := os.Getenv(key)
|
|
||||||
if raw == "" {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(raw)
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("%s: invalid integer %q: %w", key, raw, err))
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &Config{
|
|
||||||
ListenAddr: defaultStr("LISTEN_ADDR", ":8080"),
|
|
||||||
JWTSecret: requireStr("JWT_SECRET"),
|
|
||||||
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
|
|
||||||
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
|
|
||||||
|
|
||||||
DatabaseURL: requireStr("DATABASE_URL"),
|
|
||||||
|
|
||||||
FilesPath: requireStr("FILES_PATH"),
|
|
||||||
ThumbsCachePath: requireStr("THUMBS_CACHE_PATH"),
|
|
||||||
|
|
||||||
ThumbWidth: parseInt("THUMB_WIDTH", 160),
|
|
||||||
ThumbHeight: parseInt("THUMB_HEIGHT", 160),
|
|
||||||
PreviewWidth: parseInt("PREVIEW_WIDTH", 1920),
|
|
||||||
PreviewHeight: parseInt("PREVIEW_HEIGHT", 1080),
|
|
||||||
|
|
||||||
ImportPath: requireStr("IMPORT_PATH"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(errs) > 0 {
|
|
||||||
return nil, errors.Join(errs...)
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// Package db provides shared helpers used by all database adapters.
|
|
||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
)
|
|
||||||
|
|
||||||
// txKey is the context key used to store an active transaction.
|
|
||||||
type txKey struct{}
|
|
||||||
|
|
||||||
// TxFromContext returns the pgx.Tx stored in ctx by the Transactor, along
|
|
||||||
// with a boolean indicating whether a transaction is active.
|
|
||||||
func TxFromContext(ctx context.Context) (pgx.Tx, bool) {
|
|
||||||
tx, ok := ctx.Value(txKey{}).(pgx.Tx)
|
|
||||||
return tx, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContextWithTx returns a copy of ctx that carries tx.
|
|
||||||
// Called by the Transactor before invoking the user function.
|
|
||||||
func ContextWithTx(ctx context.Context, tx pgx.Tx) context.Context {
|
|
||||||
return context.WithValue(ctx, txKey{}, tx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Querier is the common query interface satisfied by both *pgxpool.Pool and
|
|
||||||
// pgx.Tx, allowing repo helpers to work with either.
|
|
||||||
type Querier interface {
|
|
||||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
|
||||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
|
||||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScanRow executes a single-row query against q and scans the result using
|
|
||||||
// scan. It wraps pgx.ErrNoRows so callers can detect missing rows without
|
|
||||||
// importing pgx directly.
|
|
||||||
func ScanRow[T any](ctx context.Context, q Querier, sql string, args []any, scan func(pgx.Row) (T, error)) (T, error) {
|
|
||||||
row := q.QueryRow(ctx, sql, args...)
|
|
||||||
val, err := scan(row)
|
|
||||||
if err != nil {
|
|
||||||
var zero T
|
|
||||||
if err == pgx.ErrNoRows {
|
|
||||||
return zero, fmt.Errorf("%w", pgx.ErrNoRows)
|
|
||||||
}
|
|
||||||
return zero, fmt.Errorf("ScanRow: %w", err)
|
|
||||||
}
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClampLimit enforces the [1, max] range on limit, returning def when limit
|
|
||||||
// is zero or negative.
|
|
||||||
func ClampLimit(limit, def, max int) int {
|
|
||||||
if limit <= 0 {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
if limit > max {
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
return limit
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClampOffset returns 0 for negative offsets.
|
|
||||||
func ClampOffset(offset int) int {
|
|
||||||
if offset < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return offset
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
type permissionRow struct {
|
|
||||||
UserID int16 `db:"user_id"`
|
|
||||||
UserName string `db:"user_name"`
|
|
||||||
ObjectTypeID int16 `db:"object_type_id"`
|
|
||||||
ObjectID uuid.UUID `db:"object_id"`
|
|
||||||
CanView bool `db:"can_view"`
|
|
||||||
CanEdit bool `db:"can_edit"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPermission(r permissionRow) domain.Permission {
|
|
||||||
return domain.Permission{
|
|
||||||
UserID: r.UserID,
|
|
||||||
UserName: r.UserName,
|
|
||||||
ObjectTypeID: r.ObjectTypeID,
|
|
||||||
ObjectID: r.ObjectID,
|
|
||||||
CanView: r.CanView,
|
|
||||||
CanEdit: r.CanEdit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ACLRepo implements port.ACLRepo using PostgreSQL.
|
|
||||||
type ACLRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewACLRepo creates an ACLRepo backed by pool.
|
|
||||||
func NewACLRepo(pool *pgxpool.Pool) *ACLRepo {
|
|
||||||
return &ACLRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.ACLRepo = (*ACLRepo)(nil)
|
|
||||||
|
|
||||||
func (r *ACLRepo) List(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT p.user_id, u.name AS user_name, p.object_type_id, p.object_id,
|
|
||||||
p.can_view, p.can_edit
|
|
||||||
FROM acl.permissions p
|
|
||||||
JOIN core.users u ON u.id = p.user_id
|
|
||||||
WHERE p.object_type_id = $1 AND p.object_id = $2
|
|
||||||
ORDER BY u.name`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, objectTypeID, objectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ACLRepo.List: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[permissionRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ACLRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
perms := make([]domain.Permission, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
perms[i] = toPermission(row)
|
|
||||||
}
|
|
||||||
return perms, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ACLRepo) Get(ctx context.Context, userID int16, objectTypeID int16, objectID uuid.UUID) (*domain.Permission, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT p.user_id, u.name AS user_name, p.object_type_id, p.object_id,
|
|
||||||
p.can_view, p.can_edit
|
|
||||||
FROM acl.permissions p
|
|
||||||
JOIN core.users u ON u.id = p.user_id
|
|
||||||
WHERE p.user_id = $1 AND p.object_type_id = $2 AND p.object_id = $3`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, userID, objectTypeID, objectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ACLRepo.Get: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[permissionRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("ACLRepo.Get scan: %w", err)
|
|
||||||
}
|
|
||||||
p := toPermission(row)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ACLRepo) Set(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error {
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
|
|
||||||
const del = `DELETE FROM acl.permissions WHERE object_type_id = $1 AND object_id = $2`
|
|
||||||
if _, err := q.Exec(ctx, del, objectTypeID, objectID); err != nil {
|
|
||||||
return fmt.Errorf("ACLRepo.Set delete: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(perms) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const ins = `
|
|
||||||
INSERT INTO acl.permissions (user_id, object_type_id, object_id, can_view, can_edit)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)`
|
|
||||||
for _, p := range perms {
|
|
||||||
if _, err := q.Exec(ctx, ins, p.UserID, objectTypeID, objectID, p.CanView, p.CanEdit); err != nil {
|
|
||||||
return fmt.Errorf("ACLRepo.Set insert: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db"
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// auditRowWithTotal matches the columns returned by the audit log SELECT.
|
|
||||||
// object_type is nullable (LEFT JOIN), object_id and details are nullable columns.
|
|
||||||
type auditRowWithTotal struct {
|
|
||||||
ID int64 `db:"id"`
|
|
||||||
UserID int16 `db:"user_id"`
|
|
||||||
UserName string `db:"user_name"`
|
|
||||||
Action string `db:"action"`
|
|
||||||
ObjectType *string `db:"object_type"`
|
|
||||||
ObjectID *uuid.UUID `db:"object_id"`
|
|
||||||
Details json.RawMessage `db:"details"`
|
|
||||||
PerformedAt time.Time `db:"performed_at"`
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toAuditEntry(r auditRowWithTotal) domain.AuditEntry {
|
|
||||||
return domain.AuditEntry{
|
|
||||||
ID: r.ID,
|
|
||||||
UserID: r.UserID,
|
|
||||||
UserName: r.UserName,
|
|
||||||
Action: r.Action,
|
|
||||||
ObjectType: r.ObjectType,
|
|
||||||
ObjectID: r.ObjectID,
|
|
||||||
Details: r.Details,
|
|
||||||
PerformedAt: r.PerformedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditRepo implements port.AuditRepo using PostgreSQL.
|
|
||||||
type AuditRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuditRepo creates an AuditRepo backed by pool.
|
|
||||||
func NewAuditRepo(pool *pgxpool.Pool) *AuditRepo {
|
|
||||||
return &AuditRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.AuditRepo = (*AuditRepo)(nil)
|
|
||||||
|
|
||||||
// Log inserts one audit record. action_type_id and object_type_id are resolved
|
|
||||||
// from the reference tables inside the INSERT via subqueries.
|
|
||||||
func (r *AuditRepo) Log(ctx context.Context, entry domain.AuditEntry) error {
|
|
||||||
const sql = `
|
|
||||||
INSERT INTO activity.audit_log
|
|
||||||
(user_id, action_type_id, object_type_id, object_id, details)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
(SELECT id FROM activity.action_types WHERE name = $2),
|
|
||||||
CASE WHEN $3::text IS NOT NULL
|
|
||||||
THEN (SELECT id FROM core.object_types WHERE name = $3)
|
|
||||||
ELSE NULL END,
|
|
||||||
$4,
|
|
||||||
$5
|
|
||||||
)`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
_, err := q.Exec(ctx, sql,
|
|
||||||
entry.UserID,
|
|
||||||
entry.Action,
|
|
||||||
entry.ObjectType,
|
|
||||||
entry.ObjectID,
|
|
||||||
entry.Details,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("AuditRepo.Log: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns a filtered, offset-paginated page of audit log entries ordered
|
|
||||||
// newest-first.
|
|
||||||
func (r *AuditRepo) List(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error) {
|
|
||||||
var conds []string
|
|
||||||
args := make([]any, 0, 8)
|
|
||||||
n := 1
|
|
||||||
|
|
||||||
if filter.UserID != nil {
|
|
||||||
conds = append(conds, fmt.Sprintf("a.user_id = $%d", n))
|
|
||||||
args = append(args, *filter.UserID)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.Action != "" {
|
|
||||||
conds = append(conds, fmt.Sprintf("at.name = $%d", n))
|
|
||||||
args = append(args, filter.Action)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.ObjectType != "" {
|
|
||||||
conds = append(conds, fmt.Sprintf("ot.name = $%d", n))
|
|
||||||
args = append(args, filter.ObjectType)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.ObjectID != nil {
|
|
||||||
conds = append(conds, fmt.Sprintf("a.object_id = $%d", n))
|
|
||||||
args = append(args, *filter.ObjectID)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.From != nil {
|
|
||||||
conds = append(conds, fmt.Sprintf("a.performed_at >= $%d", n))
|
|
||||||
args = append(args, *filter.From)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if filter.To != nil {
|
|
||||||
conds = append(conds, fmt.Sprintf("a.performed_at <= $%d", n))
|
|
||||||
args = append(args, *filter.To)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
|
|
||||||
where := ""
|
|
||||||
if len(conds) > 0 {
|
|
||||||
where = "WHERE " + strings.Join(conds, " AND ")
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := db.ClampLimit(filter.Limit, 50, 200)
|
|
||||||
offset := db.ClampOffset(filter.Offset)
|
|
||||||
args = append(args, limit, offset)
|
|
||||||
|
|
||||||
sql := fmt.Sprintf(`
|
|
||||||
SELECT a.id, a.user_id, u.name AS user_name,
|
|
||||||
at.name AS action,
|
|
||||||
ot.name AS object_type,
|
|
||||||
a.object_id, a.details,
|
|
||||||
a.performed_at,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM activity.audit_log a
|
|
||||||
JOIN core.users u ON u.id = a.user_id
|
|
||||||
JOIN activity.action_types at ON at.id = a.action_type_id
|
|
||||||
LEFT JOIN core.object_types ot ON ot.id = a.object_type_id
|
|
||||||
%s
|
|
||||||
ORDER BY a.performed_at DESC
|
|
||||||
LIMIT $%d OFFSET $%d`, where, n, n+1)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("AuditRepo.List: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[auditRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("AuditRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
page := &domain.AuditPage{Offset: offset, Limit: limit}
|
|
||||||
if len(collected) > 0 {
|
|
||||||
page.Total = collected[0].Total
|
|
||||||
}
|
|
||||||
page.Items = make([]domain.AuditEntry, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
page.Items[i] = toAuditEntry(row)
|
|
||||||
}
|
|
||||||
return page, nil
|
|
||||||
}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Row struct
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type categoryRow struct {
|
|
||||||
ID uuid.UUID `db:"id"`
|
|
||||||
Name string `db:"name"`
|
|
||||||
Notes *string `db:"notes"`
|
|
||||||
Color *string `db:"color"`
|
|
||||||
Metadata []byte `db:"metadata"`
|
|
||||||
CreatorID int16 `db:"creator_id"`
|
|
||||||
CreatorName string `db:"creator_name"`
|
|
||||||
IsPublic bool `db:"is_public"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type categoryRowWithTotal struct {
|
|
||||||
categoryRow
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Converter
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func toCategory(r categoryRow) domain.Category {
|
|
||||||
c := domain.Category{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Notes: r.Notes,
|
|
||||||
Color: r.Color,
|
|
||||||
CreatorID: r.CreatorID,
|
|
||||||
CreatorName: r.CreatorName,
|
|
||||||
IsPublic: r.IsPublic,
|
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
|
||||||
}
|
|
||||||
if len(r.Metadata) > 0 && string(r.Metadata) != "null" {
|
|
||||||
c.Metadata = json.RawMessage(r.Metadata)
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared SQL
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const categorySelectFrom = `
|
|
||||||
SELECT
|
|
||||||
c.id,
|
|
||||||
c.name,
|
|
||||||
c.notes,
|
|
||||||
c.color,
|
|
||||||
c.metadata,
|
|
||||||
c.creator_id,
|
|
||||||
u.name AS creator_name,
|
|
||||||
c.is_public
|
|
||||||
FROM data.categories c
|
|
||||||
JOIN core.users u ON u.id = c.creator_id`
|
|
||||||
|
|
||||||
func categorySortColumn(s string) string {
|
|
||||||
if s == "name" {
|
|
||||||
return "c.name"
|
|
||||||
}
|
|
||||||
return "c.id" // "created"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CategoryRepo — implements port.CategoryRepo
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// CategoryRepo handles category CRUD using PostgreSQL.
|
|
||||||
type CategoryRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.CategoryRepo = (*CategoryRepo)(nil)
|
|
||||||
|
|
||||||
// NewCategoryRepo creates a CategoryRepo backed by pool.
|
|
||||||
func NewCategoryRepo(pool *pgxpool.Pool) *CategoryRepo {
|
|
||||||
return &CategoryRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// List
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *CategoryRepo) List(ctx context.Context, params port.OffsetParams) (*domain.CategoryOffsetPage, error) {
|
|
||||||
order := "ASC"
|
|
||||||
if strings.ToLower(params.Order) == "desc" {
|
|
||||||
order = "DESC"
|
|
||||||
}
|
|
||||||
sortCol := categorySortColumn(params.Sort)
|
|
||||||
|
|
||||||
args := []any{}
|
|
||||||
n := 1
|
|
||||||
var conditions []string
|
|
||||||
|
|
||||||
if params.Search != "" {
|
|
||||||
conditions = append(conditions, fmt.Sprintf("lower(c.name) LIKE lower($%d)", n))
|
|
||||||
args = append(args, "%"+params.Search+"%")
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
|
|
||||||
where := ""
|
|
||||||
if len(conditions) > 0 {
|
|
||||||
where = "WHERE " + strings.Join(conditions, " AND ")
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := params.Limit
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
offset := params.Offset
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
c.id, c.name, c.notes, c.color, c.metadata,
|
|
||||||
c.creator_id, u.name AS creator_name, c.is_public,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM data.categories c
|
|
||||||
JOIN core.users u ON u.id = c.creator_id
|
|
||||||
%s
|
|
||||||
ORDER BY %s %s NULLS LAST, c.id ASC
|
|
||||||
LIMIT $%d OFFSET $%d`, where, sortCol, order, n, n+1)
|
|
||||||
|
|
||||||
args = append(args, limit, offset)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.List query: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[categoryRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]domain.Category, len(collected))
|
|
||||||
total := 0
|
|
||||||
for i, row := range collected {
|
|
||||||
items[i] = toCategory(row.categoryRow)
|
|
||||||
total = row.Total
|
|
||||||
}
|
|
||||||
return &domain.CategoryOffsetPage{
|
|
||||||
Items: items,
|
|
||||||
Total: total,
|
|
||||||
Offset: offset,
|
|
||||||
Limit: limit,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GetByID
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *CategoryRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Category, error) {
|
|
||||||
const query = categorySelectFrom + `
|
|
||||||
WHERE c.id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.GetByID: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[categoryRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.GetByID scan: %w", err)
|
|
||||||
}
|
|
||||||
c := toCategory(row)
|
|
||||||
return &c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Create
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *CategoryRepo) Create(ctx context.Context, c *domain.Category) (*domain.Category, error) {
|
|
||||||
const query = `
|
|
||||||
WITH ins AS (
|
|
||||||
INSERT INTO data.categories (name, notes, color, metadata, creator_id, is_public)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING *
|
|
||||||
)
|
|
||||||
SELECT ins.id, ins.name, ins.notes, ins.color, ins.metadata,
|
|
||||||
ins.creator_id, u.name AS creator_name, ins.is_public
|
|
||||||
FROM ins
|
|
||||||
JOIN core.users u ON u.id = ins.creator_id`
|
|
||||||
|
|
||||||
var meta any
|
|
||||||
if len(c.Metadata) > 0 {
|
|
||||||
meta = c.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query,
|
|
||||||
c.Name, c.Notes, c.Color, meta, c.CreatorID, c.IsPublic)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[categoryRow])
|
|
||||||
if err != nil {
|
|
||||||
if isPgUniqueViolation(err) {
|
|
||||||
return nil, domain.ErrConflict
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
created := toCategory(row)
|
|
||||||
return &created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Update
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Update replaces all mutable fields. The caller must merge current values
|
|
||||||
// with the patch before calling (read-then-write semantics).
|
|
||||||
func (r *CategoryRepo) Update(ctx context.Context, id uuid.UUID, c *domain.Category) (*domain.Category, error) {
|
|
||||||
const query = `
|
|
||||||
WITH upd AS (
|
|
||||||
UPDATE data.categories SET
|
|
||||||
name = $2,
|
|
||||||
notes = $3,
|
|
||||||
color = $4,
|
|
||||||
metadata = COALESCE($5, metadata),
|
|
||||||
is_public = $6
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING *
|
|
||||||
)
|
|
||||||
SELECT upd.id, upd.name, upd.notes, upd.color, upd.metadata,
|
|
||||||
upd.creator_id, u.name AS creator_name, upd.is_public
|
|
||||||
FROM upd
|
|
||||||
JOIN core.users u ON u.id = upd.creator_id`
|
|
||||||
|
|
||||||
var meta any
|
|
||||||
if len(c.Metadata) > 0 {
|
|
||||||
meta = c.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query,
|
|
||||||
id, c.Name, c.Notes, c.Color, meta, c.IsPublic)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.Update: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[categoryRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
if isPgUniqueViolation(err) {
|
|
||||||
return nil, domain.ErrConflict
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.Update scan: %w", err)
|
|
||||||
}
|
|
||||||
updated := toCategory(row)
|
|
||||||
return &updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Delete
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *CategoryRepo) Delete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
const query = `DELETE FROM data.categories WHERE id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
ct, err := q.Exec(ctx, query, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("CategoryRepo.Delete: %w", err)
|
|
||||||
}
|
|
||||||
if ct.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,796 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db"
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Row structs
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type fileRow struct {
|
|
||||||
ID uuid.UUID `db:"id"`
|
|
||||||
OriginalName *string `db:"original_name"`
|
|
||||||
MIMEType string `db:"mime_type"`
|
|
||||||
MIMEExtension string `db:"mime_extension"`
|
|
||||||
ContentDatetime time.Time `db:"content_datetime"`
|
|
||||||
Notes *string `db:"notes"`
|
|
||||||
Metadata json.RawMessage `db:"metadata"`
|
|
||||||
EXIF json.RawMessage `db:"exif"`
|
|
||||||
PHash *int64 `db:"phash"`
|
|
||||||
CreatorID int16 `db:"creator_id"`
|
|
||||||
CreatorName string `db:"creator_name"`
|
|
||||||
IsPublic bool `db:"is_public"`
|
|
||||||
IsDeleted bool `db:"is_deleted"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileTagRow is used for both single-file and batch tag loading.
|
|
||||||
// file_id is always selected so the same struct works for both cases.
|
|
||||||
type fileTagRow struct {
|
|
||||||
FileID uuid.UUID `db:"file_id"`
|
|
||||||
ID uuid.UUID `db:"id"`
|
|
||||||
Name string `db:"name"`
|
|
||||||
Notes *string `db:"notes"`
|
|
||||||
Color *string `db:"color"`
|
|
||||||
CategoryID *uuid.UUID `db:"category_id"`
|
|
||||||
CategoryName *string `db:"category_name"`
|
|
||||||
CategoryColor *string `db:"category_color"`
|
|
||||||
Metadata json.RawMessage `db:"metadata"`
|
|
||||||
CreatorID int16 `db:"creator_id"`
|
|
||||||
CreatorName string `db:"creator_name"`
|
|
||||||
IsPublic bool `db:"is_public"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// anchorValRow holds the sort-column values fetched for an anchor file.
|
|
||||||
type anchorValRow struct {
|
|
||||||
ContentDatetime time.Time `db:"content_datetime"`
|
|
||||||
OriginalName string `db:"original_name"` // COALESCE(original_name,'') applied in SQL
|
|
||||||
MIMEType string `db:"mime_type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Converters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func toFile(r fileRow) domain.File {
|
|
||||||
return domain.File{
|
|
||||||
ID: r.ID,
|
|
||||||
OriginalName: r.OriginalName,
|
|
||||||
MIMEType: r.MIMEType,
|
|
||||||
MIMEExtension: r.MIMEExtension,
|
|
||||||
ContentDatetime: r.ContentDatetime,
|
|
||||||
Notes: r.Notes,
|
|
||||||
Metadata: r.Metadata,
|
|
||||||
EXIF: r.EXIF,
|
|
||||||
PHash: r.PHash,
|
|
||||||
CreatorID: r.CreatorID,
|
|
||||||
CreatorName: r.CreatorName,
|
|
||||||
IsPublic: r.IsPublic,
|
|
||||||
IsDeleted: r.IsDeleted,
|
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toTagFromFileTag(r fileTagRow) domain.Tag {
|
|
||||||
return domain.Tag{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Notes: r.Notes,
|
|
||||||
Color: r.Color,
|
|
||||||
CategoryID: r.CategoryID,
|
|
||||||
CategoryName: r.CategoryName,
|
|
||||||
CategoryColor: r.CategoryColor,
|
|
||||||
Metadata: r.Metadata,
|
|
||||||
CreatorID: r.CreatorID,
|
|
||||||
CreatorName: r.CreatorName,
|
|
||||||
IsPublic: r.IsPublic,
|
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Cursor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type fileCursor struct {
|
|
||||||
Sort string `json:"s"` // canonical sort name
|
|
||||||
Order string `json:"o"` // "ASC" or "DESC"
|
|
||||||
ID string `json:"id"` // UUID of the boundary file
|
|
||||||
Val string `json:"v"` // sort column value; empty for "created" (id IS the key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func encodeCursor(c fileCursor) string {
|
|
||||||
b, _ := json.Marshal(c)
|
|
||||||
return base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeCursor(s string) (fileCursor, error) {
|
|
||||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
|
||||||
if err != nil {
|
|
||||||
return fileCursor{}, fmt.Errorf("cursor: invalid encoding")
|
|
||||||
}
|
|
||||||
var c fileCursor
|
|
||||||
if err := json.Unmarshal(b, &c); err != nil {
|
|
||||||
return fileCursor{}, fmt.Errorf("cursor: invalid format")
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// makeCursor builds a fileCursor from a boundary row and the current sort/order.
|
|
||||||
func makeCursor(r fileRow, sort, order string) fileCursor {
|
|
||||||
var val string
|
|
||||||
switch sort {
|
|
||||||
case "content_datetime":
|
|
||||||
val = r.ContentDatetime.UTC().Format(time.RFC3339Nano)
|
|
||||||
case "original_name":
|
|
||||||
if r.OriginalName != nil {
|
|
||||||
val = *r.OriginalName
|
|
||||||
}
|
|
||||||
case "mime":
|
|
||||||
val = r.MIMEType
|
|
||||||
// "created": val is empty; f.id is the sort key.
|
|
||||||
}
|
|
||||||
return fileCursor{Sort: sort, Order: order, ID: r.ID.String(), Val: val}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sort helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func normSort(s string) string {
|
|
||||||
switch s {
|
|
||||||
case "content_datetime", "original_name", "mime":
|
|
||||||
return s
|
|
||||||
default:
|
|
||||||
return "created"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normOrder(o string) string {
|
|
||||||
if strings.EqualFold(o, "asc") {
|
|
||||||
return "ASC"
|
|
||||||
}
|
|
||||||
return "DESC"
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildKeysetCond returns a keyset WHERE fragment and an ORDER BY fragment.
|
|
||||||
//
|
|
||||||
// - forward=true: items after the cursor in the sort order (standard next-page)
|
|
||||||
// - forward=false: items before the cursor (previous-page); ORDER BY is reversed,
|
|
||||||
// caller must reverse the result slice after fetching
|
|
||||||
// - incl=true: include the cursor file itself (anchor case; uses ≤ / ≥)
|
|
||||||
//
|
|
||||||
// All user values are bound as parameters — no SQL injection possible.
|
|
||||||
func buildKeysetCond(
|
|
||||||
sort, order string,
|
|
||||||
forward, incl bool,
|
|
||||||
cursorID uuid.UUID, cursorVal string,
|
|
||||||
n int, args []any,
|
|
||||||
) (where, orderBy string, nextN int, outArgs []any) {
|
|
||||||
// goDown=true → want smaller values → primary comparison is "<".
|
|
||||||
// Applies for DESC+forward and ASC+backward.
|
|
||||||
goDown := (order == "DESC") == forward
|
|
||||||
|
|
||||||
var op, idOp string
|
|
||||||
if goDown {
|
|
||||||
op = "<"
|
|
||||||
if incl {
|
|
||||||
idOp = "<="
|
|
||||||
} else {
|
|
||||||
idOp = "<"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
op = ">"
|
|
||||||
if incl {
|
|
||||||
idOp = ">="
|
|
||||||
} else {
|
|
||||||
idOp = ">"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Effective ORDER BY direction: reversed for backward so the DB returns
|
|
||||||
// the closest items first (the ones we keep after trimming the extra).
|
|
||||||
dir := order
|
|
||||||
if !forward {
|
|
||||||
if order == "DESC" {
|
|
||||||
dir = "ASC"
|
|
||||||
} else {
|
|
||||||
dir = "DESC"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch sort {
|
|
||||||
case "created":
|
|
||||||
// Single-column keyset: f.id (UUID v7, so ordering = chronological).
|
|
||||||
where = fmt.Sprintf("f.id %s $%d", idOp, n)
|
|
||||||
orderBy = fmt.Sprintf("f.id %s", dir)
|
|
||||||
outArgs = append(args, cursorID)
|
|
||||||
n++
|
|
||||||
|
|
||||||
case "content_datetime":
|
|
||||||
// Two-column keyset: (content_datetime, id).
|
|
||||||
// $n is referenced twice in the SQL (< and =) but passed once in args —
|
|
||||||
// PostgreSQL extended protocol allows multiple references to $N.
|
|
||||||
t, _ := time.Parse(time.RFC3339Nano, cursorVal)
|
|
||||||
where = fmt.Sprintf(
|
|
||||||
"(f.content_datetime %s $%d OR (f.content_datetime = $%d AND f.id %s $%d))",
|
|
||||||
op, n, n, idOp, n+1)
|
|
||||||
orderBy = fmt.Sprintf("f.content_datetime %s, f.id %s", dir, dir)
|
|
||||||
outArgs = append(args, t, cursorID)
|
|
||||||
n += 2
|
|
||||||
|
|
||||||
case "original_name":
|
|
||||||
// COALESCE treats NULL names as '' for stable pagination.
|
|
||||||
where = fmt.Sprintf(
|
|
||||||
"(COALESCE(f.original_name,'') %s $%d OR (COALESCE(f.original_name,'') = $%d AND f.id %s $%d))",
|
|
||||||
op, n, n, idOp, n+1)
|
|
||||||
orderBy = fmt.Sprintf("COALESCE(f.original_name,'') %s, f.id %s", dir, dir)
|
|
||||||
outArgs = append(args, cursorVal, cursorID)
|
|
||||||
n += 2
|
|
||||||
|
|
||||||
default: // "mime"
|
|
||||||
where = fmt.Sprintf(
|
|
||||||
"(mt.name %s $%d OR (mt.name = $%d AND f.id %s $%d))",
|
|
||||||
op, n, n, idOp, n+1)
|
|
||||||
orderBy = fmt.Sprintf("mt.name %s, f.id %s", dir, dir)
|
|
||||||
outArgs = append(args, cursorVal, cursorID)
|
|
||||||
n += 2
|
|
||||||
}
|
|
||||||
|
|
||||||
nextN = n
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// defaultOrderBy returns the natural ORDER BY for the first page (no cursor).
|
|
||||||
func defaultOrderBy(sort, order string) string {
|
|
||||||
switch sort {
|
|
||||||
case "created":
|
|
||||||
return fmt.Sprintf("f.id %s", order)
|
|
||||||
case "content_datetime":
|
|
||||||
return fmt.Sprintf("f.content_datetime %s, f.id %s", order, order)
|
|
||||||
case "original_name":
|
|
||||||
return fmt.Sprintf("COALESCE(f.original_name,'') %s, f.id %s", order, order)
|
|
||||||
default: // "mime"
|
|
||||||
return fmt.Sprintf("mt.name %s, f.id %s", order, order)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// FileRepo
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// FileRepo implements port.FileRepo using PostgreSQL.
|
|
||||||
type FileRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileRepo creates a FileRepo backed by pool.
|
|
||||||
func NewFileRepo(pool *pgxpool.Pool) *FileRepo {
|
|
||||||
return &FileRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.FileRepo = (*FileRepo)(nil)
|
|
||||||
|
|
||||||
// fileSelectCTE is the SELECT appended after a CTE named "r" that exposes
|
|
||||||
// all file columns (including mime_id). Used by Create, Update, and Restore
|
|
||||||
// to get the full denormalized record in a single round-trip.
|
|
||||||
const fileSelectCTE = `
|
|
||||||
SELECT r.id, r.original_name,
|
|
||||||
mt.name AS mime_type, mt.extension AS mime_extension,
|
|
||||||
r.content_datetime, r.notes, r.metadata, r.exif, r.phash,
|
|
||||||
r.creator_id, u.name AS creator_name,
|
|
||||||
r.is_public, r.is_deleted
|
|
||||||
FROM r
|
|
||||||
JOIN core.mime_types mt ON mt.id = r.mime_id
|
|
||||||
JOIN core.users u ON u.id = r.creator_id`
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Create
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Create inserts a new file record using the ID already set on f.
|
|
||||||
// The MIME type is resolved from f.MIMEType (name string) via a subquery.
|
|
||||||
func (r *FileRepo) Create(ctx context.Context, f *domain.File) (*domain.File, error) {
|
|
||||||
const sqlStr = `
|
|
||||||
WITH r AS (
|
|
||||||
INSERT INTO data.files
|
|
||||||
(id, original_name, mime_id, content_datetime, notes, metadata, exif, phash, creator_id, is_public)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
$2,
|
|
||||||
(SELECT id FROM core.mime_types WHERE name = $3),
|
|
||||||
$4, $5, $6, $7, $8, $9, $10
|
|
||||||
)
|
|
||||||
RETURNING id, original_name, mime_id, content_datetime, notes,
|
|
||||||
metadata, exif, phash, creator_id, is_public, is_deleted
|
|
||||||
)` + fileSelectCTE
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr,
|
|
||||||
f.ID, f.OriginalName, f.MIMEType, f.ContentDatetime,
|
|
||||||
f.Notes, f.Metadata, f.EXIF, f.PHash,
|
|
||||||
f.CreatorID, f.IsPublic,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
created := toFile(row)
|
|
||||||
return &created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GetByID
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *FileRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
|
||||||
const sqlStr = `
|
|
||||||
SELECT f.id, f.original_name,
|
|
||||||
mt.name AS mime_type, mt.extension AS mime_extension,
|
|
||||||
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
|
|
||||||
f.creator_id, u.name AS creator_name,
|
|
||||||
f.is_public, f.is_deleted
|
|
||||||
FROM data.files f
|
|
||||||
JOIN core.mime_types mt ON mt.id = f.mime_id
|
|
||||||
JOIN core.users u ON u.id = f.creator_id
|
|
||||||
WHERE f.id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.GetByID: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("FileRepo.GetByID scan: %w", err)
|
|
||||||
}
|
|
||||||
f := toFile(row)
|
|
||||||
tags, err := r.ListTags(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
f.Tags = tags
|
|
||||||
return &f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Update
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Update applies editable metadata fields. MIME type and EXIF are immutable.
|
|
||||||
func (r *FileRepo) Update(ctx context.Context, id uuid.UUID, f *domain.File) (*domain.File, error) {
|
|
||||||
const sqlStr = `
|
|
||||||
WITH r AS (
|
|
||||||
UPDATE data.files
|
|
||||||
SET original_name = $2,
|
|
||||||
content_datetime = $3,
|
|
||||||
notes = $4,
|
|
||||||
metadata = $5,
|
|
||||||
is_public = $6
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, original_name, mime_id, content_datetime, notes,
|
|
||||||
metadata, exif, phash, creator_id, is_public, is_deleted
|
|
||||||
)` + fileSelectCTE
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr,
|
|
||||||
id, f.OriginalName, f.ContentDatetime,
|
|
||||||
f.Notes, f.Metadata, f.IsPublic,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.Update: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("FileRepo.Update scan: %w", err)
|
|
||||||
}
|
|
||||||
updated := toFile(row)
|
|
||||||
tags, err := r.ListTags(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
updated.Tags = tags
|
|
||||||
return &updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// SoftDelete / Restore / DeletePermanent
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// SoftDelete moves a file to trash (is_deleted = true). Returns ErrNotFound
|
|
||||||
// if the file does not exist or is already in trash.
|
|
||||||
func (r *FileRepo) SoftDelete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
const sqlStr = `UPDATE data.files SET is_deleted = true WHERE id = $1 AND is_deleted = false`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
tag, err := q.Exec(ctx, sqlStr, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("FileRepo.SoftDelete: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore moves a file out of trash (is_deleted = false). Returns ErrNotFound
|
|
||||||
// if the file does not exist or is not in trash.
|
|
||||||
func (r *FileRepo) Restore(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
|
||||||
const sqlStr = `
|
|
||||||
WITH r AS (
|
|
||||||
UPDATE data.files
|
|
||||||
SET is_deleted = false
|
|
||||||
WHERE id = $1 AND is_deleted = true
|
|
||||||
RETURNING id, original_name, mime_id, content_datetime, notes,
|
|
||||||
metadata, exif, phash, creator_id, is_public, is_deleted
|
|
||||||
)` + fileSelectCTE
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.Restore: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[fileRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("FileRepo.Restore scan: %w", err)
|
|
||||||
}
|
|
||||||
restored := toFile(row)
|
|
||||||
tags, err := r.ListTags(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
restored.Tags = tags
|
|
||||||
return &restored, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeletePermanent removes a file record permanently. Only allowed when the
|
|
||||||
// file is already in trash (is_deleted = true).
|
|
||||||
func (r *FileRepo) DeletePermanent(ctx context.Context, id uuid.UUID) error {
|
|
||||||
const sqlStr = `DELETE FROM data.files WHERE id = $1 AND is_deleted = true`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
tag, err := q.Exec(ctx, sqlStr, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("FileRepo.DeletePermanent: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ListTags / SetTags
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ListTags returns all tags assigned to a file, ordered by tag name.
|
|
||||||
func (r *FileRepo) ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
|
|
||||||
m, err := r.loadTagsBatch(ctx, []uuid.UUID{fileID})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return m[fileID], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetTags replaces all tags on a file (full replace semantics).
|
|
||||||
func (r *FileRepo) SetTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error {
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
const del = `DELETE FROM data.file_tag WHERE file_id = $1`
|
|
||||||
if _, err := q.Exec(ctx, del, fileID); err != nil {
|
|
||||||
return fmt.Errorf("FileRepo.SetTags delete: %w", err)
|
|
||||||
}
|
|
||||||
if len(tagIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
const ins = `INSERT INTO data.file_tag (file_id, tag_id) VALUES ($1, $2)`
|
|
||||||
for _, tagID := range tagIDs {
|
|
||||||
if _, err := q.Exec(ctx, ins, fileID, tagID); err != nil {
|
|
||||||
return fmt.Errorf("FileRepo.SetTags insert: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// List
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// List returns a cursor-paginated page of files.
|
|
||||||
//
|
|
||||||
// Pagination is keyset-based for stable performance on large tables.
|
|
||||||
// Cursor encodes the sort position; the caller provides direction.
|
|
||||||
// Anchor mode centres the result around a specific file UUID.
|
|
||||||
func (r *FileRepo) List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error) {
|
|
||||||
sort := normSort(params.Sort)
|
|
||||||
order := normOrder(params.Order)
|
|
||||||
forward := params.Direction != "backward"
|
|
||||||
limit := db.ClampLimit(params.Limit, 50, 200)
|
|
||||||
|
|
||||||
// --- resolve cursor / anchor ---
|
|
||||||
var (
|
|
||||||
cursorID uuid.UUID
|
|
||||||
cursorVal string
|
|
||||||
hasCursor bool
|
|
||||||
isAnchor bool
|
|
||||||
)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case params.Cursor != "":
|
|
||||||
cur, err := decodeCursor(params.Cursor)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
|
|
||||||
}
|
|
||||||
id, err := uuid.Parse(cur.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrValidation
|
|
||||||
}
|
|
||||||
// Lock in the sort/order encoded in the cursor so changing query
|
|
||||||
// parameters mid-session doesn't corrupt pagination.
|
|
||||||
sort = normSort(cur.Sort)
|
|
||||||
order = normOrder(cur.Order)
|
|
||||||
cursorID = id
|
|
||||||
cursorVal = cur.Val
|
|
||||||
hasCursor = true
|
|
||||||
|
|
||||||
case params.Anchor != nil:
|
|
||||||
av, err := r.fetchAnchorVals(ctx, *params.Anchor)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cursorID = *params.Anchor
|
|
||||||
switch sort {
|
|
||||||
case "content_datetime":
|
|
||||||
cursorVal = av.ContentDatetime.UTC().Format(time.RFC3339Nano)
|
|
||||||
case "original_name":
|
|
||||||
cursorVal = av.OriginalName
|
|
||||||
case "mime":
|
|
||||||
cursorVal = av.MIMEType
|
|
||||||
// "created": cursorVal stays ""; cursorID is the sort key.
|
|
||||||
}
|
|
||||||
hasCursor = true
|
|
||||||
isAnchor = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without a cursor there is no meaningful "backward" direction.
|
|
||||||
if !hasCursor {
|
|
||||||
forward = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- build WHERE and ORDER BY ---
|
|
||||||
var conds []string
|
|
||||||
args := make([]any, 0, 8)
|
|
||||||
n := 1
|
|
||||||
|
|
||||||
conds = append(conds, fmt.Sprintf("f.is_deleted = $%d", n))
|
|
||||||
args = append(args, params.Trash)
|
|
||||||
n++
|
|
||||||
|
|
||||||
if params.Search != "" {
|
|
||||||
conds = append(conds, fmt.Sprintf("f.original_name ILIKE $%d", n))
|
|
||||||
args = append(args, "%"+params.Search+"%")
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
|
|
||||||
if params.Filter != "" {
|
|
||||||
filterSQL, nextN, filterArgs, err := ParseFilter(params.Filter, n)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
|
|
||||||
}
|
|
||||||
if filterSQL != "" {
|
|
||||||
conds = append(conds, filterSQL)
|
|
||||||
n = nextN
|
|
||||||
args = append(args, filterArgs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var orderBy string
|
|
||||||
if hasCursor {
|
|
||||||
ksWhere, ksOrder, nextN, ksArgs := buildKeysetCond(
|
|
||||||
sort, order, forward, isAnchor, cursorID, cursorVal, n, args)
|
|
||||||
conds = append(conds, ksWhere)
|
|
||||||
n = nextN
|
|
||||||
args = ksArgs
|
|
||||||
orderBy = ksOrder
|
|
||||||
} else {
|
|
||||||
orderBy = defaultOrderBy(sort, order)
|
|
||||||
}
|
|
||||||
|
|
||||||
where := ""
|
|
||||||
if len(conds) > 0 {
|
|
||||||
where = "WHERE " + strings.Join(conds, " AND ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch one extra row to detect whether more items exist beyond this page.
|
|
||||||
args = append(args, limit+1)
|
|
||||||
sqlStr := fmt.Sprintf(`
|
|
||||||
SELECT f.id, f.original_name,
|
|
||||||
mt.name AS mime_type, mt.extension AS mime_extension,
|
|
||||||
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
|
|
||||||
f.creator_id, u.name AS creator_name,
|
|
||||||
f.is_public, f.is_deleted
|
|
||||||
FROM data.files f
|
|
||||||
JOIN core.mime_types mt ON mt.id = f.mime_id
|
|
||||||
JOIN core.users u ON u.id = f.creator_id
|
|
||||||
%s
|
|
||||||
ORDER BY %s
|
|
||||||
LIMIT $%d`, where, orderBy, n)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.List: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[fileRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- trim extra row and reverse for backward ---
|
|
||||||
hasMore := len(collected) > limit
|
|
||||||
if hasMore {
|
|
||||||
collected = collected[:limit]
|
|
||||||
}
|
|
||||||
if !forward {
|
|
||||||
// Results were fetched in reversed ORDER BY; invert to restore the
|
|
||||||
// natural sort order expected by the caller.
|
|
||||||
for i, j := 0, len(collected)-1; i < j; i, j = i+1, j-1 {
|
|
||||||
collected[i], collected[j] = collected[j], collected[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- assemble page ---
|
|
||||||
page := &domain.FilePage{
|
|
||||||
Items: make([]domain.File, len(collected)),
|
|
||||||
}
|
|
||||||
for i, row := range collected {
|
|
||||||
page.Items[i] = toFile(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- set next/prev cursors ---
|
|
||||||
// next_cursor: navigate further in the forward direction.
|
|
||||||
// prev_cursor: navigate further in the backward direction.
|
|
||||||
if len(collected) > 0 {
|
|
||||||
firstCur := encodeCursor(makeCursor(collected[0], sort, order))
|
|
||||||
lastCur := encodeCursor(makeCursor(collected[len(collected)-1], sort, order))
|
|
||||||
|
|
||||||
if forward {
|
|
||||||
// We only know a prev page exists if we arrived via cursor.
|
|
||||||
if hasCursor {
|
|
||||||
page.PrevCursor = &firstCur
|
|
||||||
}
|
|
||||||
if hasMore {
|
|
||||||
page.NextCursor = &lastCur
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Backward: last item (after reversal) is closest to original cursor.
|
|
||||||
if hasCursor {
|
|
||||||
page.NextCursor = &lastCur
|
|
||||||
}
|
|
||||||
if hasMore {
|
|
||||||
page.PrevCursor = &firstCur
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- batch-load tags ---
|
|
||||||
if len(page.Items) > 0 {
|
|
||||||
fileIDs := make([]uuid.UUID, len(page.Items))
|
|
||||||
for i, f := range page.Items {
|
|
||||||
fileIDs[i] = f.ID
|
|
||||||
}
|
|
||||||
tagMap, err := r.loadTagsBatch(ctx, fileIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for i, f := range page.Items {
|
|
||||||
page.Items[i].Tags = tagMap[f.ID] // nil becomes []domain.Tag{} via loadTagsBatch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return page, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Internal helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// fetchAnchorVals returns the sort-column values for the given file.
|
|
||||||
// Used to set up a cursor when the caller provides an anchor UUID.
|
|
||||||
func (r *FileRepo) fetchAnchorVals(ctx context.Context, fileID uuid.UUID) (*anchorValRow, error) {
|
|
||||||
const sqlStr = `
|
|
||||||
SELECT f.content_datetime,
|
|
||||||
COALESCE(f.original_name, '') AS original_name,
|
|
||||||
mt.name AS mime_type
|
|
||||||
FROM data.files f
|
|
||||||
JOIN core.mime_types mt ON mt.id = f.mime_id
|
|
||||||
WHERE f.id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.fetchAnchorVals: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[anchorValRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("FileRepo.fetchAnchorVals scan: %w", err)
|
|
||||||
}
|
|
||||||
return &row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadTagsBatch fetches tags for multiple files in a single query and returns
|
|
||||||
// them as a map keyed by file ID. Every requested file ID appears as a key
|
|
||||||
// (with an empty slice if the file has no tags).
|
|
||||||
func (r *FileRepo) loadTagsBatch(ctx context.Context, fileIDs []uuid.UUID) (map[uuid.UUID][]domain.Tag, error) {
|
|
||||||
if len(fileIDs) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a parameterised IN list. The max page size is 200, so at most 200
|
|
||||||
// placeholders — well within PostgreSQL's limits.
|
|
||||||
placeholders := make([]string, len(fileIDs))
|
|
||||||
args := make([]any, len(fileIDs))
|
|
||||||
for i, id := range fileIDs {
|
|
||||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
||||||
args[i] = id
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlStr := fmt.Sprintf(`
|
|
||||||
SELECT ft.file_id,
|
|
||||||
t.id, t.name, t.notes, t.color,
|
|
||||||
t.category_id,
|
|
||||||
c.name AS category_name,
|
|
||||||
c.color AS category_color,
|
|
||||||
t.metadata, t.creator_id, u.name AS creator_name, t.is_public
|
|
||||||
FROM data.file_tag ft
|
|
||||||
JOIN data.tags t ON t.id = ft.tag_id
|
|
||||||
JOIN core.users u ON u.id = t.creator_id
|
|
||||||
LEFT JOIN data.categories c ON c.id = t.category_id
|
|
||||||
WHERE ft.file_id IN (%s)
|
|
||||||
ORDER BY ft.file_id, t.name`, strings.Join(placeholders, ","))
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.loadTagsBatch: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[fileTagRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileRepo.loadTagsBatch scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make(map[uuid.UUID][]domain.Tag, len(fileIDs))
|
|
||||||
for _, fid := range fileIDs {
|
|
||||||
result[fid] = []domain.Tag{} // guarantee every key has a non-nil slice
|
|
||||||
}
|
|
||||||
for _, row := range collected {
|
|
||||||
result[row.FileID] = append(result[row.FileID], toTagFromFileTag(row))
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Token types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type filterTokenKind int
|
|
||||||
|
|
||||||
const (
|
|
||||||
ftkAnd filterTokenKind = iota
|
|
||||||
ftkOr
|
|
||||||
ftkNot
|
|
||||||
ftkLParen
|
|
||||||
ftkRParen
|
|
||||||
ftkTag // t=<uuid>
|
|
||||||
ftkMimeExact // m=<int>
|
|
||||||
ftkMimeLike // m~<pattern>
|
|
||||||
)
|
|
||||||
|
|
||||||
type filterToken struct {
|
|
||||||
kind filterTokenKind
|
|
||||||
tagID uuid.UUID // ftkTag
|
|
||||||
untagged bool // ftkTag with zero UUID → "file has no tags"
|
|
||||||
mimeID int16 // ftkMimeExact
|
|
||||||
pattern string // ftkMimeLike
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// AST nodes
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// filterNode produces a parameterized SQL fragment.
|
|
||||||
// n is the index of the next available positional parameter ($n).
|
|
||||||
// Returns the fragment, the updated n, and the extended args slice.
|
|
||||||
type filterNode interface {
|
|
||||||
toSQL(n int, args []any) (string, int, []any)
|
|
||||||
}
|
|
||||||
|
|
||||||
type andNode struct{ left, right filterNode }
|
|
||||||
type orNode struct{ left, right filterNode }
|
|
||||||
type notNode struct{ child filterNode }
|
|
||||||
type leafNode struct{ tok filterToken }
|
|
||||||
|
|
||||||
func (a *andNode) toSQL(n int, args []any) (string, int, []any) {
|
|
||||||
ls, n, args := a.left.toSQL(n, args)
|
|
||||||
rs, n, args := a.right.toSQL(n, args)
|
|
||||||
return "(" + ls + " AND " + rs + ")", n, args
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *orNode) toSQL(n int, args []any) (string, int, []any) {
|
|
||||||
ls, n, args := o.left.toSQL(n, args)
|
|
||||||
rs, n, args := o.right.toSQL(n, args)
|
|
||||||
return "(" + ls + " OR " + rs + ")", n, args
|
|
||||||
}
|
|
||||||
|
|
||||||
func (no *notNode) toSQL(n int, args []any) (string, int, []any) {
|
|
||||||
cs, n, args := no.child.toSQL(n, args)
|
|
||||||
return "(NOT " + cs + ")", n, args
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *leafNode) toSQL(n int, args []any) (string, int, []any) {
|
|
||||||
switch l.tok.kind {
|
|
||||||
case ftkTag:
|
|
||||||
if l.tok.untagged {
|
|
||||||
return "NOT EXISTS (SELECT 1 FROM data.file_tag ft WHERE ft.file_id = f.id)", n, args
|
|
||||||
}
|
|
||||||
s := fmt.Sprintf(
|
|
||||||
"EXISTS (SELECT 1 FROM data.file_tag ft WHERE ft.file_id = f.id AND ft.tag_id = $%d)", n)
|
|
||||||
return s, n + 1, append(args, l.tok.tagID)
|
|
||||||
case ftkMimeExact:
|
|
||||||
return fmt.Sprintf("f.mime_id = $%d", n), n + 1, append(args, l.tok.mimeID)
|
|
||||||
case ftkMimeLike:
|
|
||||||
// mt alias comes from the JOIN in the main file query (always present).
|
|
||||||
return fmt.Sprintf("mt.name LIKE $%d", n), n + 1, append(args, l.tok.pattern)
|
|
||||||
}
|
|
||||||
panic("filterNode.toSQL: unknown leaf kind")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Lexer
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// lexFilter tokenises the DSL string {a,b,c,...} into filterTokens.
|
|
||||||
func lexFilter(dsl string) ([]filterToken, error) {
|
|
||||||
dsl = strings.TrimSpace(dsl)
|
|
||||||
if !strings.HasPrefix(dsl, "{") || !strings.HasSuffix(dsl, "}") {
|
|
||||||
return nil, fmt.Errorf("filter DSL must be wrapped in braces: {…}")
|
|
||||||
}
|
|
||||||
inner := strings.TrimSpace(dsl[1 : len(dsl)-1])
|
|
||||||
if inner == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(inner, ",")
|
|
||||||
tokens := make([]filterToken, 0, len(parts))
|
|
||||||
|
|
||||||
for _, raw := range parts {
|
|
||||||
p := strings.TrimSpace(raw)
|
|
||||||
switch {
|
|
||||||
case p == "&":
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkAnd})
|
|
||||||
case p == "|":
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkOr})
|
|
||||||
case p == "!":
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkNot})
|
|
||||||
case p == "(":
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkLParen})
|
|
||||||
case p == ")":
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkRParen})
|
|
||||||
case strings.HasPrefix(p, "t="):
|
|
||||||
id, err := uuid.Parse(p[2:])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("filter: invalid tag UUID %q", p[2:])
|
|
||||||
}
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkTag, tagID: id, untagged: id == uuid.Nil})
|
|
||||||
case strings.HasPrefix(p, "m="):
|
|
||||||
v, err := strconv.ParseInt(p[2:], 10, 16)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("filter: invalid MIME ID %q", p[2:])
|
|
||||||
}
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkMimeExact, mimeID: int16(v)})
|
|
||||||
case strings.HasPrefix(p, "m~"):
|
|
||||||
// The pattern value is passed as a query parameter, so no SQL injection risk.
|
|
||||||
tokens = append(tokens, filterToken{kind: ftkMimeLike, pattern: p[2:]})
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("filter: unknown token %q", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tokens, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Recursive-descent parser
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type filterParser struct {
|
|
||||||
tokens []filterToken
|
|
||||||
pos int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *filterParser) peek() (filterToken, bool) {
|
|
||||||
if p.pos >= len(p.tokens) {
|
|
||||||
return filterToken{}, false
|
|
||||||
}
|
|
||||||
return p.tokens[p.pos], true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *filterParser) next() filterToken {
|
|
||||||
t := p.tokens[p.pos]
|
|
||||||
p.pos++
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grammar (standard NOT > AND > OR precedence):
|
|
||||||
//
|
|
||||||
// expr := or_expr
|
|
||||||
// or_expr := and_expr ('|' and_expr)*
|
|
||||||
// and_expr := not_expr ('&' not_expr)*
|
|
||||||
// not_expr := '!' not_expr | atom
|
|
||||||
// atom := '(' expr ')' | leaf
|
|
||||||
|
|
||||||
func (p *filterParser) parseExpr() (filterNode, error) { return p.parseOr() }
|
|
||||||
|
|
||||||
func (p *filterParser) parseOr() (filterNode, error) {
|
|
||||||
left, err := p.parseAnd()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
t, ok := p.peek()
|
|
||||||
if !ok || t.kind != ftkOr {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
p.next()
|
|
||||||
right, err := p.parseAnd()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
left = &orNode{left, right}
|
|
||||||
}
|
|
||||||
return left, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *filterParser) parseAnd() (filterNode, error) {
|
|
||||||
left, err := p.parseNot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
t, ok := p.peek()
|
|
||||||
if !ok || t.kind != ftkAnd {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
p.next()
|
|
||||||
right, err := p.parseNot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
left = &andNode{left, right}
|
|
||||||
}
|
|
||||||
return left, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *filterParser) parseNot() (filterNode, error) {
|
|
||||||
t, ok := p.peek()
|
|
||||||
if ok && t.kind == ftkNot {
|
|
||||||
p.next()
|
|
||||||
child, err := p.parseNot() // right-recursive to allow !!x
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ¬Node{child}, nil
|
|
||||||
}
|
|
||||||
return p.parseAtom()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *filterParser) parseAtom() (filterNode, error) {
|
|
||||||
t, ok := p.peek()
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("filter: unexpected end of expression")
|
|
||||||
}
|
|
||||||
if t.kind == ftkLParen {
|
|
||||||
p.next()
|
|
||||||
expr, err := p.parseExpr()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rp, ok := p.peek()
|
|
||||||
if !ok || rp.kind != ftkRParen {
|
|
||||||
return nil, fmt.Errorf("filter: expected ')'")
|
|
||||||
}
|
|
||||||
p.next()
|
|
||||||
return expr, nil
|
|
||||||
}
|
|
||||||
switch t.kind {
|
|
||||||
case ftkTag, ftkMimeExact, ftkMimeLike:
|
|
||||||
p.next()
|
|
||||||
return &leafNode{t}, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("filter: unexpected token at position %d", p.pos)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public entry point
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ParseFilter parses a filter DSL string into a parameterized SQL fragment.
|
|
||||||
//
|
|
||||||
// argStart is the 1-based index for the first $N placeholder; this lets the
|
|
||||||
// caller interleave filter parameters with other query parameters.
|
|
||||||
//
|
|
||||||
// Returns ("", argStart, nil, nil) for an empty or trivial DSL.
|
|
||||||
// SQL injection is structurally impossible: every user-supplied value is
|
|
||||||
// bound as a query parameter ($N), never interpolated into the SQL string.
|
|
||||||
func ParseFilter(dsl string, argStart int) (sql string, nextN int, args []any, err error) {
|
|
||||||
dsl = strings.TrimSpace(dsl)
|
|
||||||
if dsl == "" || dsl == "{}" {
|
|
||||||
return "", argStart, nil, nil
|
|
||||||
}
|
|
||||||
toks, err := lexFilter(dsl)
|
|
||||||
if err != nil {
|
|
||||||
return "", argStart, nil, err
|
|
||||||
}
|
|
||||||
if len(toks) == 0 {
|
|
||||||
return "", argStart, nil, nil
|
|
||||||
}
|
|
||||||
p := &filterParser{tokens: toks}
|
|
||||||
node, err := p.parseExpr()
|
|
||||||
if err != nil {
|
|
||||||
return "", argStart, nil, err
|
|
||||||
}
|
|
||||||
if p.pos != len(p.tokens) {
|
|
||||||
return "", argStart, nil, fmt.Errorf("filter: trailing tokens at position %d", p.pos)
|
|
||||||
}
|
|
||||||
sql, nextN, args = node.toSQL(argStart, nil)
|
|
||||||
return sql, nextN, args, nil
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mimeRow struct {
|
|
||||||
ID int16 `db:"id"`
|
|
||||||
Name string `db:"name"`
|
|
||||||
Extension string `db:"extension"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toMIMEType(r mimeRow) domain.MIMEType {
|
|
||||||
return domain.MIMEType{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Extension: r.Extension,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MimeRepo implements port.MimeRepo using PostgreSQL.
|
|
||||||
type MimeRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMimeRepo creates a MimeRepo backed by pool.
|
|
||||||
func NewMimeRepo(pool *pgxpool.Pool) *MimeRepo {
|
|
||||||
return &MimeRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.MimeRepo = (*MimeRepo)(nil)
|
|
||||||
|
|
||||||
func (r *MimeRepo) List(ctx context.Context) ([]domain.MIMEType, error) {
|
|
||||||
const sql = `SELECT id, name, extension FROM core.mime_types ORDER BY name`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("MimeRepo.List: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[mimeRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("MimeRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]domain.MIMEType, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
result[i] = toMIMEType(row)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *MimeRepo) GetByID(ctx context.Context, id int16) (*domain.MIMEType, error) {
|
|
||||||
const sql = `SELECT id, name, extension FROM core.mime_types WHERE id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("MimeRepo.GetByID: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[mimeRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("MimeRepo.GetByID scan: %w", err)
|
|
||||||
}
|
|
||||||
m := toMIMEType(row)
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *MimeRepo) GetByName(ctx context.Context, name string) (*domain.MIMEType, error) {
|
|
||||||
const sql = `SELECT id, name, extension FROM core.mime_types WHERE name = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("MimeRepo.GetByName: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[mimeRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrUnsupportedMIME
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("MimeRepo.GetByName scan: %w", err)
|
|
||||||
}
|
|
||||||
m := toMIMEType(row)
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
@@ -1,665 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db"
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Row structs
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type poolRow struct {
|
|
||||||
ID uuid.UUID `db:"id"`
|
|
||||||
Name string `db:"name"`
|
|
||||||
Notes *string `db:"notes"`
|
|
||||||
Metadata []byte `db:"metadata"`
|
|
||||||
CreatorID int16 `db:"creator_id"`
|
|
||||||
CreatorName string `db:"creator_name"`
|
|
||||||
IsPublic bool `db:"is_public"`
|
|
||||||
FileCount int `db:"file_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type poolRowWithTotal struct {
|
|
||||||
poolRow
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// poolFileRow is a flat struct combining all file columns plus pool position.
|
|
||||||
type poolFileRow struct {
|
|
||||||
ID uuid.UUID `db:"id"`
|
|
||||||
OriginalName *string `db:"original_name"`
|
|
||||||
MIMEType string `db:"mime_type"`
|
|
||||||
MIMEExtension string `db:"mime_extension"`
|
|
||||||
ContentDatetime time.Time `db:"content_datetime"`
|
|
||||||
Notes *string `db:"notes"`
|
|
||||||
Metadata json.RawMessage `db:"metadata"`
|
|
||||||
EXIF json.RawMessage `db:"exif"`
|
|
||||||
PHash *int64 `db:"phash"`
|
|
||||||
CreatorID int16 `db:"creator_id"`
|
|
||||||
CreatorName string `db:"creator_name"`
|
|
||||||
IsPublic bool `db:"is_public"`
|
|
||||||
IsDeleted bool `db:"is_deleted"`
|
|
||||||
Position int `db:"position"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Converters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func toPool(r poolRow) domain.Pool {
|
|
||||||
p := domain.Pool{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Notes: r.Notes,
|
|
||||||
CreatorID: r.CreatorID,
|
|
||||||
CreatorName: r.CreatorName,
|
|
||||||
IsPublic: r.IsPublic,
|
|
||||||
FileCount: r.FileCount,
|
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
|
||||||
}
|
|
||||||
if len(r.Metadata) > 0 && string(r.Metadata) != "null" {
|
|
||||||
p.Metadata = json.RawMessage(r.Metadata)
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPoolFile(r poolFileRow) domain.PoolFile {
|
|
||||||
return domain.PoolFile{
|
|
||||||
File: domain.File{
|
|
||||||
ID: r.ID,
|
|
||||||
OriginalName: r.OriginalName,
|
|
||||||
MIMEType: r.MIMEType,
|
|
||||||
MIMEExtension: r.MIMEExtension,
|
|
||||||
ContentDatetime: r.ContentDatetime,
|
|
||||||
Notes: r.Notes,
|
|
||||||
Metadata: r.Metadata,
|
|
||||||
EXIF: r.EXIF,
|
|
||||||
PHash: r.PHash,
|
|
||||||
CreatorID: r.CreatorID,
|
|
||||||
CreatorName: r.CreatorName,
|
|
||||||
IsPublic: r.IsPublic,
|
|
||||||
IsDeleted: r.IsDeleted,
|
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
|
||||||
},
|
|
||||||
Position: r.Position,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Cursor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type poolFileCursor struct {
|
|
||||||
Position int `json:"p"`
|
|
||||||
FileID string `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func encodePoolCursor(c poolFileCursor) string {
|
|
||||||
b, _ := json.Marshal(c)
|
|
||||||
return base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodePoolCursor(s string) (poolFileCursor, error) {
|
|
||||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
|
||||||
if err != nil {
|
|
||||||
return poolFileCursor{}, fmt.Errorf("cursor: invalid encoding")
|
|
||||||
}
|
|
||||||
var c poolFileCursor
|
|
||||||
if err := json.Unmarshal(b, &c); err != nil {
|
|
||||||
return poolFileCursor{}, fmt.Errorf("cursor: invalid format")
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared SQL
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// poolCountSubquery computes per-pool file counts.
|
|
||||||
const poolCountSubquery = `(SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool GROUP BY pool_id)`
|
|
||||||
|
|
||||||
const poolSelectFrom = `
|
|
||||||
SELECT p.id, p.name, p.notes, p.metadata,
|
|
||||||
p.creator_id, u.name AS creator_name, p.is_public,
|
|
||||||
COALESCE(fc.cnt, 0) AS file_count
|
|
||||||
FROM data.pools p
|
|
||||||
JOIN core.users u ON u.id = p.creator_id
|
|
||||||
LEFT JOIN ` + poolCountSubquery + ` fc ON fc.pool_id = p.id`
|
|
||||||
|
|
||||||
func poolSortColumn(s string) string {
|
|
||||||
if s == "name" {
|
|
||||||
return "p.name"
|
|
||||||
}
|
|
||||||
return "p.id" // "created"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PoolRepo
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// PoolRepo implements port.PoolRepo using PostgreSQL.
|
|
||||||
type PoolRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.PoolRepo = (*PoolRepo)(nil)
|
|
||||||
|
|
||||||
// NewPoolRepo creates a PoolRepo backed by pool.
|
|
||||||
func NewPoolRepo(pool *pgxpool.Pool) *PoolRepo {
|
|
||||||
return &PoolRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// List
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *PoolRepo) List(ctx context.Context, params port.OffsetParams) (*domain.PoolOffsetPage, error) {
|
|
||||||
order := "ASC"
|
|
||||||
if strings.ToLower(params.Order) == "desc" {
|
|
||||||
order = "DESC"
|
|
||||||
}
|
|
||||||
sortCol := poolSortColumn(params.Sort)
|
|
||||||
|
|
||||||
args := []any{}
|
|
||||||
n := 1
|
|
||||||
var conditions []string
|
|
||||||
|
|
||||||
if params.Search != "" {
|
|
||||||
conditions = append(conditions, fmt.Sprintf("lower(p.name) LIKE lower($%d)", n))
|
|
||||||
args = append(args, "%"+params.Search+"%")
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
|
|
||||||
where := ""
|
|
||||||
if len(conditions) > 0 {
|
|
||||||
where = "WHERE " + strings.Join(conditions, " AND ")
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := params.Limit
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
offset := params.Offset
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
|
||||||
SELECT p.id, p.name, p.notes, p.metadata,
|
|
||||||
p.creator_id, u.name AS creator_name, p.is_public,
|
|
||||||
COALESCE(fc.cnt, 0) AS file_count,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM data.pools p
|
|
||||||
JOIN core.users u ON u.id = p.creator_id
|
|
||||||
LEFT JOIN %s fc ON fc.pool_id = p.id
|
|
||||||
%s
|
|
||||||
ORDER BY %s %s NULLS LAST, p.id ASC
|
|
||||||
LIMIT $%d OFFSET $%d`, poolCountSubquery, where, sortCol, order, n, n+1)
|
|
||||||
|
|
||||||
args = append(args, limit, offset)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.List query: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[poolRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]domain.Pool, len(collected))
|
|
||||||
total := 0
|
|
||||||
for i, row := range collected {
|
|
||||||
items[i] = toPool(row.poolRow)
|
|
||||||
total = row.Total
|
|
||||||
}
|
|
||||||
return &domain.PoolOffsetPage{
|
|
||||||
Items: items,
|
|
||||||
Total: total,
|
|
||||||
Offset: offset,
|
|
||||||
Limit: limit,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GetByID
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *PoolRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Pool, error) {
|
|
||||||
query := poolSelectFrom + `
|
|
||||||
WHERE p.id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.GetByID: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[poolRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("PoolRepo.GetByID scan: %w", err)
|
|
||||||
}
|
|
||||||
p := toPool(row)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Create
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *PoolRepo) Create(ctx context.Context, p *domain.Pool) (*domain.Pool, error) {
|
|
||||||
const query = `
|
|
||||||
WITH ins AS (
|
|
||||||
INSERT INTO data.pools (name, notes, metadata, creator_id, is_public)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
RETURNING *
|
|
||||||
)
|
|
||||||
SELECT ins.id, ins.name, ins.notes, ins.metadata,
|
|
||||||
ins.creator_id, u.name AS creator_name, ins.is_public,
|
|
||||||
0 AS file_count
|
|
||||||
FROM ins
|
|
||||||
JOIN core.users u ON u.id = ins.creator_id`
|
|
||||||
|
|
||||||
var meta any
|
|
||||||
if len(p.Metadata) > 0 {
|
|
||||||
meta = p.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, p.Name, p.Notes, meta, p.CreatorID, p.IsPublic)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[poolRow])
|
|
||||||
if err != nil {
|
|
||||||
if isPgUniqueViolation(err) {
|
|
||||||
return nil, domain.ErrConflict
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("PoolRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
created := toPool(row)
|
|
||||||
return &created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Update
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *PoolRepo) Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*domain.Pool, error) {
|
|
||||||
const query = `
|
|
||||||
WITH upd AS (
|
|
||||||
UPDATE data.pools SET
|
|
||||||
name = $2,
|
|
||||||
notes = $3,
|
|
||||||
metadata = COALESCE($4, metadata),
|
|
||||||
is_public = $5
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING *
|
|
||||||
)
|
|
||||||
SELECT upd.id, upd.name, upd.notes, upd.metadata,
|
|
||||||
upd.creator_id, u.name AS creator_name, upd.is_public,
|
|
||||||
COALESCE(fc.cnt, 0) AS file_count
|
|
||||||
FROM upd
|
|
||||||
JOIN core.users u ON u.id = upd.creator_id
|
|
||||||
LEFT JOIN (SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool WHERE pool_id = $1 GROUP BY pool_id) fc
|
|
||||||
ON fc.pool_id = upd.id`
|
|
||||||
|
|
||||||
var meta any
|
|
||||||
if len(p.Metadata) > 0 {
|
|
||||||
meta = p.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, id, p.Name, p.Notes, meta, p.IsPublic)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.Update: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[poolRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
if isPgUniqueViolation(err) {
|
|
||||||
return nil, domain.ErrConflict
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("PoolRepo.Update scan: %w", err)
|
|
||||||
}
|
|
||||||
updated := toPool(row)
|
|
||||||
return &updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Delete
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *PoolRepo) Delete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
const query = `DELETE FROM data.pools WHERE id = $1`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
ct, err := q.Exec(ctx, query, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.Delete: %w", err)
|
|
||||||
}
|
|
||||||
if ct.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ListFiles
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// fileSelectForPool is the column list for pool file queries (without position).
|
|
||||||
const fileSelectForPool = `
|
|
||||||
f.id, f.original_name,
|
|
||||||
mt.name AS mime_type, mt.extension AS mime_extension,
|
|
||||||
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
|
|
||||||
f.creator_id, u.name AS creator_name,
|
|
||||||
f.is_public, f.is_deleted`
|
|
||||||
|
|
||||||
func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) {
|
|
||||||
limit := db.ClampLimit(params.Limit, 50, 200)
|
|
||||||
|
|
||||||
args := []any{poolID}
|
|
||||||
n := 2
|
|
||||||
var conds []string
|
|
||||||
|
|
||||||
conds = append(conds, "fp.pool_id = $1")
|
|
||||||
conds = append(conds, "f.is_deleted = false")
|
|
||||||
|
|
||||||
if params.Filter != "" {
|
|
||||||
filterSQL, nextN, filterArgs, err := ParseFilter(params.Filter, n)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
|
|
||||||
}
|
|
||||||
if filterSQL != "" {
|
|
||||||
conds = append(conds, filterSQL)
|
|
||||||
n = nextN
|
|
||||||
args = append(args, filterArgs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor condition.
|
|
||||||
var orderBy string
|
|
||||||
if params.Cursor != "" {
|
|
||||||
cur, err := decodePoolCursor(params.Cursor)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
|
|
||||||
}
|
|
||||||
fileID, err := uuid.Parse(cur.FileID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrValidation
|
|
||||||
}
|
|
||||||
conds = append(conds, fmt.Sprintf(
|
|
||||||
"(fp.position > $%d OR (fp.position = $%d AND fp.file_id > $%d))",
|
|
||||||
n, n, n+1))
|
|
||||||
args = append(args, cur.Position, fileID)
|
|
||||||
n += 2
|
|
||||||
}
|
|
||||||
orderBy = "fp.position ASC, fp.file_id ASC"
|
|
||||||
|
|
||||||
where := "WHERE " + strings.Join(conds, " AND ")
|
|
||||||
args = append(args, limit+1)
|
|
||||||
|
|
||||||
sqlStr := fmt.Sprintf(`
|
|
||||||
SELECT %s, fp.position
|
|
||||||
FROM data.file_pool fp
|
|
||||||
JOIN data.files f ON f.id = fp.file_id
|
|
||||||
JOIN core.mime_types mt ON mt.id = f.mime_id
|
|
||||||
JOIN core.users u ON u.id = f.creator_id
|
|
||||||
%s
|
|
||||||
ORDER BY %s
|
|
||||||
LIMIT $%d`, fileSelectForPool, where, orderBy, n)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.ListFiles query: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[poolFileRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.ListFiles scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hasMore := len(collected) > limit
|
|
||||||
if hasMore {
|
|
||||||
collected = collected[:limit]
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]domain.PoolFile, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
items[i] = toPoolFile(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
page := &domain.PoolFilePage{Items: items}
|
|
||||||
|
|
||||||
if hasMore && len(collected) > 0 {
|
|
||||||
last := collected[len(collected)-1]
|
|
||||||
cur := encodePoolCursor(poolFileCursor{
|
|
||||||
Position: last.Position,
|
|
||||||
FileID: last.ID.String(),
|
|
||||||
})
|
|
||||||
page.NextCursor = &cur
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch-load tags.
|
|
||||||
if len(items) > 0 {
|
|
||||||
fileIDs := make([]uuid.UUID, len(items))
|
|
||||||
for i, pf := range items {
|
|
||||||
fileIDs[i] = pf.File.ID
|
|
||||||
}
|
|
||||||
tagMap, err := r.loadPoolTagsBatch(ctx, fileIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for i, pf := range items {
|
|
||||||
page.Items[i].File.Tags = tagMap[pf.File.ID]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return page, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadPoolTagsBatch re-uses the same pattern as FileRepo.loadTagsBatch.
|
|
||||||
func (r *PoolRepo) loadPoolTagsBatch(ctx context.Context, fileIDs []uuid.UUID) (map[uuid.UUID][]domain.Tag, error) {
|
|
||||||
if len(fileIDs) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
placeholders := make([]string, len(fileIDs))
|
|
||||||
args := make([]any, len(fileIDs))
|
|
||||||
for i, id := range fileIDs {
|
|
||||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
||||||
args[i] = id
|
|
||||||
}
|
|
||||||
sqlStr := fmt.Sprintf(`
|
|
||||||
SELECT ft.file_id,
|
|
||||||
t.id, t.name, t.notes, t.color,
|
|
||||||
t.category_id,
|
|
||||||
c.name AS category_name,
|
|
||||||
c.color AS category_color,
|
|
||||||
t.metadata, t.creator_id, u.name AS creator_name, t.is_public
|
|
||||||
FROM data.file_tag ft
|
|
||||||
JOIN data.tags t ON t.id = ft.tag_id
|
|
||||||
JOIN core.users u ON u.id = t.creator_id
|
|
||||||
LEFT JOIN data.categories c ON c.id = t.category_id
|
|
||||||
WHERE ft.file_id IN (%s)
|
|
||||||
ORDER BY ft.file_id, t.name`, strings.Join(placeholders, ","))
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.loadPoolTagsBatch: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[fileTagRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.loadPoolTagsBatch scan: %w", err)
|
|
||||||
}
|
|
||||||
result := make(map[uuid.UUID][]domain.Tag, len(fileIDs))
|
|
||||||
for _, fid := range fileIDs {
|
|
||||||
result[fid] = []domain.Tag{}
|
|
||||||
}
|
|
||||||
for _, row := range collected {
|
|
||||||
result[row.FileID] = append(result[row.FileID], toTagFromFileTag(row))
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// AddFiles
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// AddFiles inserts files into the pool. When position is nil, files are
|
|
||||||
// appended after the last existing file (MAX(position) + 1000 * i).
|
|
||||||
// When position is provided (0-indexed), files are inserted at that index
|
|
||||||
// and all pool positions are reassigned in one shot.
|
|
||||||
func (r *PoolRepo) AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error {
|
|
||||||
if len(fileIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
|
|
||||||
if position == nil {
|
|
||||||
// Append: get current max position, then bulk-insert.
|
|
||||||
var maxPos int
|
|
||||||
row := q.QueryRow(ctx, `SELECT COALESCE(MAX(position), 0) FROM data.file_pool WHERE pool_id = $1`, poolID)
|
|
||||||
if err := row.Scan(&maxPos); err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.AddFiles maxPos: %w", err)
|
|
||||||
}
|
|
||||||
const ins = `INSERT INTO data.file_pool (file_id, pool_id, position) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`
|
|
||||||
for i, fid := range fileIDs {
|
|
||||||
if _, err := q.Exec(ctx, ins, fid, poolID, maxPos+1000*(i+1)); err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.AddFiles insert: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Positional insert: rebuild the full ordered list and reassign.
|
|
||||||
return r.insertAtPosition(ctx, q, poolID, fileIDs, *position)
|
|
||||||
}
|
|
||||||
|
|
||||||
// insertAtPosition fetches the current ordered file list, splices in the new
|
|
||||||
// IDs at index pos (0-indexed, clamped), then does a full position reassign.
|
|
||||||
func (r *PoolRepo) insertAtPosition(ctx context.Context, q db.Querier, poolID uuid.UUID, newIDs []uuid.UUID, pos int) error {
|
|
||||||
// 1. Fetch current order.
|
|
||||||
rows, err := q.Query(ctx, `SELECT file_id FROM data.file_pool WHERE pool_id = $1 ORDER BY position ASC, file_id ASC`, poolID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.insertAtPosition fetch: %w", err)
|
|
||||||
}
|
|
||||||
var current []uuid.UUID
|
|
||||||
for rows.Next() {
|
|
||||||
var fid uuid.UUID
|
|
||||||
if err := rows.Scan(&fid); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return fmt.Errorf("PoolRepo.insertAtPosition scan: %w", err)
|
|
||||||
}
|
|
||||||
current = append(current, fid)
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.insertAtPosition rows: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Build new ordered list, skipping already-present IDs from newIDs.
|
|
||||||
present := make(map[uuid.UUID]bool, len(current))
|
|
||||||
for _, fid := range current {
|
|
||||||
present[fid] = true
|
|
||||||
}
|
|
||||||
toAdd := make([]uuid.UUID, 0, len(newIDs))
|
|
||||||
for _, fid := range newIDs {
|
|
||||||
if !present[fid] {
|
|
||||||
toAdd = append(toAdd, fid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(toAdd) == 0 {
|
|
||||||
return nil // all already present
|
|
||||||
}
|
|
||||||
|
|
||||||
if pos < 0 {
|
|
||||||
pos = 0
|
|
||||||
}
|
|
||||||
if pos > len(current) {
|
|
||||||
pos = len(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
ordered := make([]uuid.UUID, 0, len(current)+len(toAdd))
|
|
||||||
ordered = append(ordered, current[:pos]...)
|
|
||||||
ordered = append(ordered, toAdd...)
|
|
||||||
ordered = append(ordered, current[pos:]...)
|
|
||||||
|
|
||||||
// 3. Full replace.
|
|
||||||
return r.reassignPositions(ctx, q, poolID, ordered)
|
|
||||||
}
|
|
||||||
|
|
||||||
// reassignPositions does a DELETE + bulk INSERT for the pool with positions
|
|
||||||
// 1000, 2000, 3000, ...
|
|
||||||
func (r *PoolRepo) reassignPositions(ctx context.Context, q db.Querier, poolID uuid.UUID, ordered []uuid.UUID) error {
|
|
||||||
if _, err := q.Exec(ctx, `DELETE FROM data.file_pool WHERE pool_id = $1`, poolID); err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.reassignPositions delete: %w", err)
|
|
||||||
}
|
|
||||||
if len(ordered) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
const ins = `INSERT INTO data.file_pool (file_id, pool_id, position) VALUES ($1, $2, $3)`
|
|
||||||
for i, fid := range ordered {
|
|
||||||
if _, err := q.Exec(ctx, ins, fid, poolID, 1000*(i+1)); err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.reassignPositions insert: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// RemoveFiles
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *PoolRepo) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
|
|
||||||
if len(fileIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
placeholders := make([]string, len(fileIDs))
|
|
||||||
args := make([]any, len(fileIDs)+1)
|
|
||||||
args[0] = poolID
|
|
||||||
for i, fid := range fileIDs {
|
|
||||||
placeholders[i] = fmt.Sprintf("$%d", i+2)
|
|
||||||
args[i+1] = fid
|
|
||||||
}
|
|
||||||
query := fmt.Sprintf(
|
|
||||||
`DELETE FROM data.file_pool WHERE pool_id = $1 AND file_id IN (%s)`,
|
|
||||||
strings.Join(placeholders, ","))
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
if _, err := q.Exec(ctx, query, args...); err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.RemoveFiles: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Reorder
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Reorder replaces the full ordered sequence with positions 1000, 2000, …
|
|
||||||
// Only file IDs already in the pool are allowed; unknown IDs are silently
|
|
||||||
// skipped to avoid integrity violations.
|
|
||||||
func (r *PoolRepo) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
return r.reassignPositions(ctx, q, poolID, fileIDs)
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
// Package postgres provides the PostgreSQL implementations of the port interfaces.
|
|
||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewPool creates and validates a *pgxpool.Pool from the given connection URL.
|
|
||||||
// The pool is ready to use; the caller is responsible for closing it.
|
|
||||||
func NewPool(ctx context.Context, url string) (*pgxpool.Pool, error) {
|
|
||||||
pool, err := pgxpool.New(ctx, url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("pgxpool.New: %w", err)
|
|
||||||
}
|
|
||||||
if err := pool.Ping(ctx); err != nil {
|
|
||||||
pool.Close()
|
|
||||||
return nil, fmt.Errorf("postgres ping: %w", err)
|
|
||||||
}
|
|
||||||
return pool, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transactor implements port.Transactor using a pgxpool.Pool.
|
|
||||||
type Transactor struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTransactor creates a Transactor backed by pool.
|
|
||||||
func NewTransactor(pool *pgxpool.Pool) *Transactor {
|
|
||||||
return &Transactor{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithTx begins a transaction, stores it in ctx, and calls fn. If fn returns
|
|
||||||
// an error the transaction is rolled back; otherwise it is committed.
|
|
||||||
func (t *Transactor) WithTx(ctx context.Context, fn func(ctx context.Context) error) error {
|
|
||||||
tx, err := t.pool.BeginTx(ctx, pgx.TxOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("begin tx: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
txCtx := db.ContextWithTx(ctx, tx)
|
|
||||||
|
|
||||||
if err := fn(txCtx); err != nil {
|
|
||||||
_ = tx.Rollback(ctx)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
return fmt.Errorf("commit tx: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// connOrTx returns the pgx.Tx stored in ctx by WithTx, or the pool itself when
|
|
||||||
// no transaction is active. The returned value satisfies db.Querier and can be
|
|
||||||
// used directly for queries and commands.
|
|
||||||
func connOrTx(ctx context.Context, pool *pgxpool.Pool) db.Querier {
|
|
||||||
if tx, ok := db.TxFromContext(ctx); ok {
|
|
||||||
return tx
|
|
||||||
}
|
|
||||||
return pool
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// sessionRow matches the columns stored in activity.sessions.
|
|
||||||
// IsCurrent is a service-layer concern and is not stored in the database.
|
|
||||||
type sessionRow struct {
|
|
||||||
ID int `db:"id"`
|
|
||||||
TokenHash string `db:"token_hash"`
|
|
||||||
UserID int16 `db:"user_id"`
|
|
||||||
UserAgent string `db:"user_agent"`
|
|
||||||
StartedAt time.Time `db:"started_at"`
|
|
||||||
ExpiresAt *time.Time `db:"expires_at"`
|
|
||||||
LastActivity time.Time `db:"last_activity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionRowWithTotal extends sessionRow with a window-function count for ListByUser.
|
|
||||||
type sessionRowWithTotal struct {
|
|
||||||
sessionRow
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toSession(r sessionRow) domain.Session {
|
|
||||||
return domain.Session{
|
|
||||||
ID: r.ID,
|
|
||||||
TokenHash: r.TokenHash,
|
|
||||||
UserID: r.UserID,
|
|
||||||
UserAgent: r.UserAgent,
|
|
||||||
StartedAt: r.StartedAt,
|
|
||||||
ExpiresAt: r.ExpiresAt,
|
|
||||||
LastActivity: r.LastActivity,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionRepo implements port.SessionRepo using PostgreSQL.
|
|
||||||
type SessionRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSessionRepo creates a SessionRepo backed by pool.
|
|
||||||
func NewSessionRepo(pool *pgxpool.Pool) *SessionRepo {
|
|
||||||
return &SessionRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.SessionRepo = (*SessionRepo)(nil)
|
|
||||||
|
|
||||||
func (r *SessionRepo) Create(ctx context.Context, s *domain.Session) (*domain.Session, error) {
|
|
||||||
const sql = `
|
|
||||||
INSERT INTO activity.sessions (token_hash, user_id, user_agent, expires_at)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, token_hash, user_id, user_agent, started_at, expires_at, last_activity`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, s.TokenHash, s.UserID, s.UserAgent, s.ExpiresAt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[sessionRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
created := toSession(row)
|
|
||||||
return &created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity
|
|
||||||
FROM activity.sessions
|
|
||||||
WHERE token_hash = $1 AND is_active = true`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, hash)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.GetByTokenHash: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[sessionRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("SessionRepo.GetByTokenHash scan: %w", err)
|
|
||||||
}
|
|
||||||
s := toSession(row)
|
|
||||||
return &s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) ListByUser(ctx context.Context, userID int16) (*domain.SessionList, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM activity.sessions
|
|
||||||
WHERE user_id = $1 AND is_active = true
|
|
||||||
ORDER BY started_at DESC`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.ListByUser: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[sessionRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SessionRepo.ListByUser scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
list := &domain.SessionList{}
|
|
||||||
if len(collected) > 0 {
|
|
||||||
list.Total = collected[0].Total
|
|
||||||
}
|
|
||||||
list.Items = make([]domain.Session, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
list.Items[i] = toSession(row.sessionRow)
|
|
||||||
}
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) UpdateLastActivity(ctx context.Context, id int, t time.Time) error {
|
|
||||||
const sql = `UPDATE activity.sessions SET last_activity = $2 WHERE id = $1`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
tag, err := q.Exec(ctx, sql, id, t)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("SessionRepo.UpdateLastActivity: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) Delete(ctx context.Context, id int) error {
|
|
||||||
const sql = `UPDATE activity.sessions SET is_active = false WHERE id = $1 AND is_active = true`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
tag, err := q.Exec(ctx, sql, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("SessionRepo.Delete: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *SessionRepo) DeleteByUserID(ctx context.Context, userID int16) error {
|
|
||||||
const sql = `UPDATE activity.sessions SET is_active = false WHERE user_id = $1 AND is_active = true`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
_, err := q.Exec(ctx, sql, userID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("SessionRepo.DeleteByUserID: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,634 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Row structs — use pgx-scannable types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type tagRow struct {
|
|
||||||
ID uuid.UUID `db:"id"`
|
|
||||||
Name string `db:"name"`
|
|
||||||
Notes *string `db:"notes"`
|
|
||||||
Color *string `db:"color"`
|
|
||||||
CategoryID *uuid.UUID `db:"category_id"`
|
|
||||||
CategoryName *string `db:"category_name"`
|
|
||||||
CategoryColor *string `db:"category_color"`
|
|
||||||
Metadata []byte `db:"metadata"`
|
|
||||||
CreatorID int16 `db:"creator_id"`
|
|
||||||
CreatorName string `db:"creator_name"`
|
|
||||||
IsPublic bool `db:"is_public"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type tagRowWithTotal struct {
|
|
||||||
tagRow
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type tagRuleRow struct {
|
|
||||||
WhenTagID uuid.UUID `db:"when_tag_id"`
|
|
||||||
ThenTagID uuid.UUID `db:"then_tag_id"`
|
|
||||||
ThenTagName string `db:"then_tag_name"`
|
|
||||||
IsActive bool `db:"is_active"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Converters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func toTag(r tagRow) domain.Tag {
|
|
||||||
t := domain.Tag{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Notes: r.Notes,
|
|
||||||
Color: r.Color,
|
|
||||||
CategoryID: r.CategoryID,
|
|
||||||
CategoryName: r.CategoryName,
|
|
||||||
CategoryColor: r.CategoryColor,
|
|
||||||
CreatorID: r.CreatorID,
|
|
||||||
CreatorName: r.CreatorName,
|
|
||||||
IsPublic: r.IsPublic,
|
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
|
||||||
}
|
|
||||||
if len(r.Metadata) > 0 && string(r.Metadata) != "null" {
|
|
||||||
t.Metadata = json.RawMessage(r.Metadata)
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
func toTagRule(r tagRuleRow) domain.TagRule {
|
|
||||||
return domain.TagRule{
|
|
||||||
WhenTagID: r.WhenTagID,
|
|
||||||
ThenTagID: r.ThenTagID,
|
|
||||||
ThenTagName: r.ThenTagName,
|
|
||||||
IsActive: r.IsActive,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared SQL fragments
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const tagSelectFrom = `
|
|
||||||
SELECT
|
|
||||||
t.id,
|
|
||||||
t.name,
|
|
||||||
t.notes,
|
|
||||||
t.color,
|
|
||||||
t.category_id,
|
|
||||||
c.name AS category_name,
|
|
||||||
c.color AS category_color,
|
|
||||||
t.metadata,
|
|
||||||
t.creator_id,
|
|
||||||
u.name AS creator_name,
|
|
||||||
t.is_public
|
|
||||||
FROM data.tags t
|
|
||||||
LEFT JOIN data.categories c ON c.id = t.category_id
|
|
||||||
JOIN core.users u ON u.id = t.creator_id`
|
|
||||||
|
|
||||||
func tagSortColumn(s string) string {
|
|
||||||
switch s {
|
|
||||||
case "name":
|
|
||||||
return "t.name"
|
|
||||||
case "color":
|
|
||||||
return "t.color"
|
|
||||||
case "category_name":
|
|
||||||
return "c.name"
|
|
||||||
default: // "created"
|
|
||||||
return "t.id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isPgUniqueViolation reports whether err is a PostgreSQL unique-constraint error.
|
|
||||||
func isPgUniqueViolation(err error) bool {
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// TagRepo — implements port.TagRepo
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// TagRepo handles tag CRUD and file–tag relations.
|
|
||||||
type TagRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.TagRepo = (*TagRepo)(nil)
|
|
||||||
|
|
||||||
// NewTagRepo creates a TagRepo backed by pool.
|
|
||||||
func NewTagRepo(pool *pgxpool.Pool) *TagRepo {
|
|
||||||
return &TagRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// List / ListByCategory
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *TagRepo) List(ctx context.Context, params port.OffsetParams) (*domain.TagOffsetPage, error) {
|
|
||||||
return r.listTags(ctx, params, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TagRepo) ListByCategory(ctx context.Context, categoryID uuid.UUID, params port.OffsetParams) (*domain.TagOffsetPage, error) {
|
|
||||||
return r.listTags(ctx, params, &categoryID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TagRepo) listTags(ctx context.Context, params port.OffsetParams, categoryID *uuid.UUID) (*domain.TagOffsetPage, error) {
|
|
||||||
order := "ASC"
|
|
||||||
if strings.ToLower(params.Order) == "desc" {
|
|
||||||
order = "DESC"
|
|
||||||
}
|
|
||||||
sortCol := tagSortColumn(params.Sort)
|
|
||||||
|
|
||||||
args := []any{}
|
|
||||||
n := 1
|
|
||||||
var conditions []string
|
|
||||||
|
|
||||||
if params.Search != "" {
|
|
||||||
conditions = append(conditions, fmt.Sprintf("lower(t.name) LIKE lower($%d)", n))
|
|
||||||
args = append(args, "%"+params.Search+"%")
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
if categoryID != nil {
|
|
||||||
conditions = append(conditions, fmt.Sprintf("t.category_id = $%d", n))
|
|
||||||
args = append(args, *categoryID)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
|
|
||||||
where := ""
|
|
||||||
if len(conditions) > 0 {
|
|
||||||
where = "WHERE " + strings.Join(conditions, " AND ")
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := params.Limit
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
offset := params.Offset
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
t.id, t.name, t.notes, t.color,
|
|
||||||
t.category_id,
|
|
||||||
c.name AS category_name,
|
|
||||||
c.color AS category_color,
|
|
||||||
t.metadata, t.creator_id,
|
|
||||||
u.name AS creator_name,
|
|
||||||
t.is_public,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM data.tags t
|
|
||||||
LEFT JOIN data.categories c ON c.id = t.category_id
|
|
||||||
JOIN core.users u ON u.id = t.creator_id
|
|
||||||
%s
|
|
||||||
ORDER BY %s %s NULLS LAST, t.id ASC
|
|
||||||
LIMIT $%d OFFSET $%d`, where, sortCol, order, n, n+1)
|
|
||||||
|
|
||||||
args = append(args, limit, offset)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TagRepo.List query: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[tagRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TagRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]domain.Tag, len(collected))
|
|
||||||
total := 0
|
|
||||||
for i, row := range collected {
|
|
||||||
items[i] = toTag(row.tagRow)
|
|
||||||
total = row.Total
|
|
||||||
}
|
|
||||||
return &domain.TagOffsetPage{
|
|
||||||
Items: items,
|
|
||||||
Total: total,
|
|
||||||
Offset: offset,
|
|
||||||
Limit: limit,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GetByID
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *TagRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Tag, error) {
|
|
||||||
const query = tagSelectFrom + `
|
|
||||||
WHERE t.id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TagRepo.GetByID: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[tagRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("TagRepo.GetByID scan: %w", err)
|
|
||||||
}
|
|
||||||
t := toTag(row)
|
|
||||||
return &t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Create
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *TagRepo) Create(ctx context.Context, t *domain.Tag) (*domain.Tag, error) {
|
|
||||||
const query = `
|
|
||||||
WITH ins AS (
|
|
||||||
INSERT INTO data.tags (name, notes, color, category_id, metadata, creator_id, is_public)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING *
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
ins.id, ins.name, ins.notes, ins.color,
|
|
||||||
ins.category_id,
|
|
||||||
c.name AS category_name,
|
|
||||||
c.color AS category_color,
|
|
||||||
ins.metadata, ins.creator_id,
|
|
||||||
u.name AS creator_name,
|
|
||||||
ins.is_public
|
|
||||||
FROM ins
|
|
||||||
LEFT JOIN data.categories c ON c.id = ins.category_id
|
|
||||||
JOIN core.users u ON u.id = ins.creator_id`
|
|
||||||
|
|
||||||
var meta any
|
|
||||||
if len(t.Metadata) > 0 {
|
|
||||||
meta = t.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query,
|
|
||||||
t.Name, t.Notes, t.Color, t.CategoryID, meta, t.CreatorID, t.IsPublic)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TagRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[tagRow])
|
|
||||||
if err != nil {
|
|
||||||
if isPgUniqueViolation(err) {
|
|
||||||
return nil, domain.ErrConflict
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("TagRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
created := toTag(row)
|
|
||||||
return &created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Update
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Update replaces all mutable fields. The caller must merge current values with
|
|
||||||
// the patch (read-then-write) before calling this.
|
|
||||||
func (r *TagRepo) Update(ctx context.Context, id uuid.UUID, t *domain.Tag) (*domain.Tag, error) {
|
|
||||||
const query = `
|
|
||||||
WITH upd AS (
|
|
||||||
UPDATE data.tags SET
|
|
||||||
name = $2,
|
|
||||||
notes = $3,
|
|
||||||
color = $4,
|
|
||||||
category_id = $5,
|
|
||||||
metadata = COALESCE($6, metadata),
|
|
||||||
is_public = $7
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING *
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
upd.id, upd.name, upd.notes, upd.color,
|
|
||||||
upd.category_id,
|
|
||||||
c.name AS category_name,
|
|
||||||
c.color AS category_color,
|
|
||||||
upd.metadata, upd.creator_id,
|
|
||||||
u.name AS creator_name,
|
|
||||||
upd.is_public
|
|
||||||
FROM upd
|
|
||||||
LEFT JOIN data.categories c ON c.id = upd.category_id
|
|
||||||
JOIN core.users u ON u.id = upd.creator_id`
|
|
||||||
|
|
||||||
var meta any
|
|
||||||
if len(t.Metadata) > 0 {
|
|
||||||
meta = t.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query,
|
|
||||||
id, t.Name, t.Notes, t.Color, t.CategoryID, meta, t.IsPublic)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TagRepo.Update: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[tagRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
if isPgUniqueViolation(err) {
|
|
||||||
return nil, domain.ErrConflict
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("TagRepo.Update scan: %w", err)
|
|
||||||
}
|
|
||||||
updated := toTag(row)
|
|
||||||
return &updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Delete
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *TagRepo) Delete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
const query = `DELETE FROM data.tags WHERE id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
ct, err := q.Exec(ctx, query, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("TagRepo.Delete: %w", err)
|
|
||||||
}
|
|
||||||
if ct.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// File–tag operations
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *TagRepo) ListByFile(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
|
|
||||||
const query = tagSelectFrom + `
|
|
||||||
JOIN data.file_tag ft ON ft.tag_id = t.id
|
|
||||||
WHERE ft.file_id = $1
|
|
||||||
ORDER BY t.name`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TagRepo.ListByFile: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[tagRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TagRepo.ListByFile scan: %w", err)
|
|
||||||
}
|
|
||||||
tags := make([]domain.Tag, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
tags[i] = toTag(row)
|
|
||||||
}
|
|
||||||
return tags, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TagRepo) AddFileTag(ctx context.Context, fileID, tagID uuid.UUID) error {
|
|
||||||
const query = `
|
|
||||||
INSERT INTO data.file_tag (file_id, tag_id) VALUES ($1, $2)
|
|
||||||
ON CONFLICT DO NOTHING`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
if _, err := q.Exec(ctx, query, fileID, tagID); err != nil {
|
|
||||||
return fmt.Errorf("TagRepo.AddFileTag: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TagRepo) RemoveFileTag(ctx context.Context, fileID, tagID uuid.UUID) error {
|
|
||||||
const query = `DELETE FROM data.file_tag WHERE file_id = $1 AND tag_id = $2`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
if _, err := q.Exec(ctx, query, fileID, tagID); err != nil {
|
|
||||||
return fmt.Errorf("TagRepo.RemoveFileTag: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TagRepo) SetFileTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error {
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
|
|
||||||
if _, err := q.Exec(ctx,
|
|
||||||
`DELETE FROM data.file_tag WHERE file_id = $1`, fileID); err != nil {
|
|
||||||
return fmt.Errorf("TagRepo.SetFileTags delete: %w", err)
|
|
||||||
}
|
|
||||||
if len(tagIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
placeholders := make([]string, len(tagIDs))
|
|
||||||
args := []any{fileID}
|
|
||||||
for i, tagID := range tagIDs {
|
|
||||||
placeholders[i] = fmt.Sprintf("($1, $%d)", i+2)
|
|
||||||
args = append(args, tagID)
|
|
||||||
}
|
|
||||||
ins := `INSERT INTO data.file_tag (file_id, tag_id) VALUES ` +
|
|
||||||
strings.Join(placeholders, ", ") + ` ON CONFLICT DO NOTHING`
|
|
||||||
|
|
||||||
if _, err := q.Exec(ctx, ins, args...); err != nil {
|
|
||||||
return fmt.Errorf("TagRepo.SetFileTags insert: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TagRepo) CommonTagsForFiles(ctx context.Context, fileIDs []uuid.UUID) ([]domain.Tag, error) {
|
|
||||||
if len(fileIDs) == 0 {
|
|
||||||
return []domain.Tag{}, nil
|
|
||||||
}
|
|
||||||
return r.queryTagsByPresence(ctx, fileIDs, "=")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TagRepo) PartialTagsForFiles(ctx context.Context, fileIDs []uuid.UUID) ([]domain.Tag, error) {
|
|
||||||
if len(fileIDs) == 0 {
|
|
||||||
return []domain.Tag{}, nil
|
|
||||||
}
|
|
||||||
return r.queryTagsByPresence(ctx, fileIDs, "<")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TagRepo) queryTagsByPresence(ctx context.Context, fileIDs []uuid.UUID, op string) ([]domain.Tag, error) {
|
|
||||||
placeholders := make([]string, len(fileIDs))
|
|
||||||
args := make([]any, len(fileIDs)+1)
|
|
||||||
for i, id := range fileIDs {
|
|
||||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
||||||
args[i] = id
|
|
||||||
}
|
|
||||||
args[len(fileIDs)] = len(fileIDs)
|
|
||||||
n := len(fileIDs) + 1
|
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
t.id, t.name, t.notes, t.color,
|
|
||||||
t.category_id,
|
|
||||||
c.name AS category_name,
|
|
||||||
c.color AS category_color,
|
|
||||||
t.metadata, t.creator_id,
|
|
||||||
u.name AS creator_name,
|
|
||||||
t.is_public
|
|
||||||
FROM data.tags t
|
|
||||||
JOIN data.file_tag ft ON ft.tag_id = t.id
|
|
||||||
LEFT JOIN data.categories c ON c.id = t.category_id
|
|
||||||
JOIN core.users u ON u.id = t.creator_id
|
|
||||||
WHERE ft.file_id IN (%s)
|
|
||||||
GROUP BY t.id, c.id, u.id
|
|
||||||
HAVING COUNT(DISTINCT ft.file_id) %s $%d
|
|
||||||
ORDER BY t.name`,
|
|
||||||
strings.Join(placeholders, ", "), op, n)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TagRepo.queryTagsByPresence: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[tagRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TagRepo.queryTagsByPresence scan: %w", err)
|
|
||||||
}
|
|
||||||
tags := make([]domain.Tag, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
tags[i] = toTag(row)
|
|
||||||
}
|
|
||||||
return tags, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// TagRuleRepo — implements port.TagRuleRepo (separate type to avoid method collision)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// TagRuleRepo handles tag-rule CRUD.
|
|
||||||
type TagRuleRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.TagRuleRepo = (*TagRuleRepo)(nil)
|
|
||||||
|
|
||||||
// NewTagRuleRepo creates a TagRuleRepo backed by pool.
|
|
||||||
func NewTagRuleRepo(pool *pgxpool.Pool) *TagRuleRepo {
|
|
||||||
return &TagRuleRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TagRuleRepo) ListByTag(ctx context.Context, tagID uuid.UUID) ([]domain.TagRule, error) {
|
|
||||||
const query = `
|
|
||||||
SELECT
|
|
||||||
tr.when_tag_id,
|
|
||||||
tr.then_tag_id,
|
|
||||||
t.name AS then_tag_name,
|
|
||||||
tr.is_active
|
|
||||||
FROM data.tag_rules tr
|
|
||||||
JOIN data.tags t ON t.id = tr.then_tag_id
|
|
||||||
WHERE tr.when_tag_id = $1
|
|
||||||
ORDER BY t.name`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, tagID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TagRuleRepo.ListByTag: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[tagRuleRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TagRuleRepo.ListByTag scan: %w", err)
|
|
||||||
}
|
|
||||||
rules := make([]domain.TagRule, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
rules[i] = toTagRule(row)
|
|
||||||
}
|
|
||||||
return rules, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TagRuleRepo) Create(ctx context.Context, rule domain.TagRule) (*domain.TagRule, error) {
|
|
||||||
const query = `
|
|
||||||
WITH ins AS (
|
|
||||||
INSERT INTO data.tag_rules (when_tag_id, then_tag_id, is_active)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
RETURNING *
|
|
||||||
)
|
|
||||||
SELECT ins.when_tag_id, ins.then_tag_id, t.name AS then_tag_name, ins.is_active
|
|
||||||
FROM ins
|
|
||||||
JOIN data.tags t ON t.id = ins.then_tag_id`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, rule.WhenTagID, rule.ThenTagID, rule.IsActive)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("TagRuleRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[tagRuleRow])
|
|
||||||
if err != nil {
|
|
||||||
if isPgUniqueViolation(err) {
|
|
||||||
return nil, domain.ErrConflict
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("TagRuleRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
result := toTagRule(row)
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TagRuleRepo) SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active, applyToExisting bool) error {
|
|
||||||
const updateQuery = `
|
|
||||||
UPDATE data.tag_rules SET is_active = $3
|
|
||||||
WHERE when_tag_id = $1 AND then_tag_id = $2`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
ct, err := q.Exec(ctx, updateQuery, whenTagID, thenTagID, active)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("TagRuleRepo.SetActive: %w", err)
|
|
||||||
}
|
|
||||||
if ct.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if !active || !applyToExisting {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retroactively apply the full transitive expansion of thenTagID to all
|
|
||||||
// files that already carry whenTagID. The recursive CTE walks active rules
|
|
||||||
// starting from thenTagID (mirrors the Go expandTagSet BFS).
|
|
||||||
const retroQuery = `
|
|
||||||
WITH RECURSIVE expansion(tag_id) AS (
|
|
||||||
SELECT $2::uuid
|
|
||||||
UNION
|
|
||||||
SELECT r.then_tag_id
|
|
||||||
FROM data.tag_rules r
|
|
||||||
JOIN expansion e ON r.when_tag_id = e.tag_id
|
|
||||||
WHERE r.is_active = true
|
|
||||||
)
|
|
||||||
INSERT INTO data.file_tag (file_id, tag_id)
|
|
||||||
SELECT ft.file_id, e.tag_id
|
|
||||||
FROM data.file_tag ft
|
|
||||||
CROSS JOIN expansion e
|
|
||||||
WHERE ft.tag_id = $1
|
|
||||||
ON CONFLICT DO NOTHING`
|
|
||||||
|
|
||||||
if _, err := q.Exec(ctx, retroQuery, whenTagID, thenTagID); err != nil {
|
|
||||||
return fmt.Errorf("TagRuleRepo.SetActive retroactive apply: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TagRuleRepo) Delete(ctx context.Context, whenTagID, thenTagID uuid.UUID) error {
|
|
||||||
const query = `
|
|
||||||
DELETE FROM data.tag_rules
|
|
||||||
WHERE when_tag_id = $1 AND then_tag_id = $2`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
ct, err := q.Exec(ctx, query, whenTagID, thenTagID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("TagRuleRepo.Delete: %w", err)
|
|
||||||
}
|
|
||||||
if ct.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db"
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// userRow matches the columns returned by every user SELECT.
|
|
||||||
type userRow struct {
|
|
||||||
ID int16 `db:"id"`
|
|
||||||
Name string `db:"name"`
|
|
||||||
Password string `db:"password"`
|
|
||||||
IsAdmin bool `db:"is_admin"`
|
|
||||||
CanCreate bool `db:"can_create"`
|
|
||||||
IsBlocked bool `db:"is_blocked"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// userRowWithTotal extends userRow with a window-function total for List.
|
|
||||||
type userRowWithTotal struct {
|
|
||||||
userRow
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toUser(r userRow) domain.User {
|
|
||||||
return domain.User{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Password: r.Password,
|
|
||||||
IsAdmin: r.IsAdmin,
|
|
||||||
CanCreate: r.CanCreate,
|
|
||||||
IsBlocked: r.IsBlocked,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// userSortColumn whitelists valid sort keys to prevent SQL injection.
|
|
||||||
var userSortColumn = map[string]string{
|
|
||||||
"name": "name",
|
|
||||||
"id": "id",
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserRepo implements port.UserRepo using PostgreSQL.
|
|
||||||
type UserRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUserRepo creates a UserRepo backed by pool.
|
|
||||||
func NewUserRepo(pool *pgxpool.Pool) *UserRepo {
|
|
||||||
return &UserRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.UserRepo = (*UserRepo)(nil)
|
|
||||||
|
|
||||||
func (r *UserRepo) List(ctx context.Context, params port.OffsetParams) (*domain.UserPage, error) {
|
|
||||||
col, ok := userSortColumn[params.Sort]
|
|
||||||
if !ok {
|
|
||||||
col = "id"
|
|
||||||
}
|
|
||||||
ord := "ASC"
|
|
||||||
if params.Order == "desc" {
|
|
||||||
ord = "DESC"
|
|
||||||
}
|
|
||||||
limit := db.ClampLimit(params.Limit, 50, 200)
|
|
||||||
offset := db.ClampOffset(params.Offset)
|
|
||||||
|
|
||||||
sql := fmt.Sprintf(`
|
|
||||||
SELECT id, name, password, is_admin, can_create, is_blocked,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM core.users
|
|
||||||
ORDER BY %s %s
|
|
||||||
LIMIT $1 OFFSET $2`, col, ord)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.List: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[userRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
page := &domain.UserPage{Offset: offset, Limit: limit}
|
|
||||||
if len(collected) > 0 {
|
|
||||||
page.Total = collected[0].Total
|
|
||||||
}
|
|
||||||
page.Items = make([]domain.User, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
page.Items[i] = toUser(row.userRow)
|
|
||||||
}
|
|
||||||
return page, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) GetByID(ctx context.Context, id int16) (*domain.User, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, name, password, is_admin, can_create, is_blocked
|
|
||||||
FROM core.users WHERE id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.GetByID: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("UserRepo.GetByID scan: %w", err)
|
|
||||||
}
|
|
||||||
u := toUser(row)
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) GetByName(ctx context.Context, name string) (*domain.User, error) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, name, password, is_admin, can_create, is_blocked
|
|
||||||
FROM core.users WHERE name = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.GetByName: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("UserRepo.GetByName scan: %w", err)
|
|
||||||
}
|
|
||||||
u := toUser(row)
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) Create(ctx context.Context, u *domain.User) (*domain.User, error) {
|
|
||||||
const sql = `
|
|
||||||
INSERT INTO core.users (name, password, is_admin, can_create)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, name, password, is_admin, can_create, is_blocked`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, u.Name, u.Password, u.IsAdmin, u.CanCreate)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
created := toUser(row)
|
|
||||||
return &created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) Update(ctx context.Context, id int16, u *domain.User) (*domain.User, error) {
|
|
||||||
const sql = `
|
|
||||||
UPDATE core.users
|
|
||||||
SET name = $2, password = $3, is_admin = $4, can_create = $5, is_blocked = $6
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, name, password, is_admin, can_create, is_blocked`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sql, id, u.Name, u.Password, u.IsAdmin, u.CanCreate, u.IsBlocked)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserRepo.Update: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("UserRepo.Update scan: %w", err)
|
|
||||||
}
|
|
||||||
updated := toUser(row)
|
|
||||||
return &updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *UserRepo) Delete(ctx context.Context, id int16) error {
|
|
||||||
const sql = `DELETE FROM core.users WHERE id = $1`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
tag, err := q.Exec(ctx, sql, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("UserRepo.Delete: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -17,13 +17,5 @@ type Category struct {
|
|||||||
CreatorID int16
|
CreatorID int16
|
||||||
CreatorName string // denormalized
|
CreatorName string // denormalized
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
CreatedAt time.Time // extracted from UUID v7
|
||||||
}
|
|
||||||
|
|
||||||
// CategoryOffsetPage is an offset-based page of categories.
|
|
||||||
type CategoryOffsetPage struct {
|
|
||||||
Items []Category
|
|
||||||
Total int
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,24 +9,16 @@ const userKey ctxKey = iota
|
|||||||
type contextUser struct {
|
type contextUser struct {
|
||||||
ID int16
|
ID int16
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
SessionID int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithUser stores user identity and current session ID in ctx.
|
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context {
|
||||||
func WithUser(ctx context.Context, userID int16, isAdmin bool, sessionID int) context.Context {
|
return context.WithValue(ctx, userKey, contextUser{ID: userID, IsAdmin: isAdmin})
|
||||||
return context.WithValue(ctx, userKey, contextUser{
|
|
||||||
ID: userID,
|
|
||||||
IsAdmin: isAdmin,
|
|
||||||
SessionID: sessionID,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserFromContext retrieves user identity from ctx.
|
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) {
|
||||||
// Returns zero values if no user is stored.
|
|
||||||
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool, sessionID int) {
|
|
||||||
u, ok := ctx.Value(userKey).(contextUser)
|
u, ok := ctx.Value(userKey).(contextUser)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, false, 0
|
return 0, false
|
||||||
}
|
}
|
||||||
return u.ID, u.IsAdmin, u.SessionID
|
return u.ID, u.IsAdmin
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
// DomainError is a typed domain error with a stable machine-readable code.
|
import "errors"
|
||||||
// Handlers map these codes to HTTP status codes.
|
|
||||||
type DomainError struct {
|
|
||||||
code string
|
|
||||||
message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *DomainError) Error() string { return e.message }
|
// Sentinel domain errors. Handlers map these to HTTP status codes.
|
||||||
func (e *DomainError) Code() string { return e.code }
|
|
||||||
|
|
||||||
// Sentinel domain errors. Use errors.Is(err, domain.ErrNotFound) for matching.
|
|
||||||
var (
|
var (
|
||||||
ErrNotFound = &DomainError{"not_found", "not found"}
|
ErrNotFound = errors.New("not found")
|
||||||
ErrForbidden = &DomainError{"forbidden", "forbidden"}
|
ErrForbidden = errors.New("forbidden")
|
||||||
ErrUnauthorized = &DomainError{"unauthorized", "unauthorized"}
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
ErrConflict = &DomainError{"conflict", "conflict"}
|
ErrConflict = errors.New("conflict")
|
||||||
ErrValidation = &DomainError{"validation_error", "validation error"}
|
ErrValidation = errors.New("validation error")
|
||||||
ErrUnsupportedMIME = &DomainError{"unsupported_mime", "unsupported MIME type"}
|
ErrUnsupportedMIME = errors.New("unsupported MIME type")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,26 +29,21 @@ type File struct {
|
|||||||
CreatorName string // denormalized from core.users
|
CreatorName string // denormalized from core.users
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
IsDeleted bool
|
IsDeleted bool
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
CreatedAt time.Time // extracted from UUID v7
|
||||||
Tags []Tag // loaded with the file
|
Tags []Tag // loaded with the file
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileListParams holds all parameters for listing/filtering files.
|
// FileListParams holds all parameters for listing/filtering files.
|
||||||
type FileListParams struct {
|
type FileListParams struct {
|
||||||
// Pagination
|
Filter string
|
||||||
|
Sort string
|
||||||
|
Order string
|
||||||
Cursor string
|
Cursor string
|
||||||
Direction string // "forward" or "backward"
|
|
||||||
Anchor *uuid.UUID
|
Anchor *uuid.UUID
|
||||||
|
Direction string // "forward" or "backward"
|
||||||
Limit int
|
Limit int
|
||||||
|
Trash bool
|
||||||
// Sorting
|
Search string
|
||||||
Sort string // "content_datetime" | "created" | "original_name" | "mime"
|
|
||||||
Order string // "asc" | "desc"
|
|
||||||
|
|
||||||
// Filtering
|
|
||||||
Filter string // filter DSL expression
|
|
||||||
Search string // substring match on original_name
|
|
||||||
Trash bool // if true, return only soft-deleted files
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilePage is the result of a cursor-based file listing.
|
// FilePage is the result of a cursor-based file listing.
|
||||||
@@ -57,11 +52,3 @@ type FilePage struct {
|
|||||||
NextCursor *string
|
NextCursor *string
|
||||||
PrevCursor *string
|
PrevCursor *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UUIDCreatedAt extracts the creation timestamp embedded in a UUID v7.
|
|
||||||
// UUID v7 stores Unix milliseconds in the most-significant 48 bits.
|
|
||||||
func UUIDCreatedAt(id uuid.UUID) time.Time {
|
|
||||||
ms := int64(id[0])<<40 | int64(id[1])<<32 | int64(id[2])<<24 |
|
|
||||||
int64(id[3])<<16 | int64(id[4])<<8 | int64(id[5])
|
|
||||||
return time.Unix(ms/1000, (ms%1000)*int64(time.Millisecond)).UTC()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type Pool struct {
|
|||||||
CreatorName string // denormalized
|
CreatorName string // denormalized
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
FileCount int
|
FileCount int
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
CreatedAt time.Time // extracted from UUID v7
|
||||||
}
|
}
|
||||||
|
|
||||||
// PoolFile is a File with its ordering position within a pool.
|
// PoolFile is a File with its ordering position within a pool.
|
||||||
@@ -31,11 +31,3 @@ type PoolFilePage struct {
|
|||||||
Items []PoolFile
|
Items []PoolFile
|
||||||
NextCursor *string
|
NextCursor *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PoolOffsetPage is an offset-based page of pools.
|
|
||||||
type PoolOffsetPage struct {
|
|
||||||
Items []Pool
|
|
||||||
Total int
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type Tag struct {
|
|||||||
CreatorID int16
|
CreatorID int16
|
||||||
CreatorName string // denormalized
|
CreatorName string // denormalized
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
CreatedAt time.Time // extracted from UUID v7
|
||||||
}
|
}
|
||||||
|
|
||||||
// TagRule defines an auto-tagging rule: when WhenTagID is applied,
|
// TagRule defines an auto-tagging rule: when WhenTagID is applied,
|
||||||
@@ -31,11 +31,3 @@ type TagRule struct {
|
|||||||
ThenTagName string // denormalized
|
ThenTagName string // denormalized
|
||||||
IsActive bool
|
IsActive bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// TagOffsetPage is an offset-based page of tags.
|
|
||||||
type TagOffsetPage struct {
|
|
||||||
Items []Tag
|
|
||||||
Total int
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type Session struct {
|
|||||||
IsCurrent bool // true when this session matches the caller's token
|
IsCurrent bool // true when this session matches the caller's token
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserPage is an offset-based page of users.
|
// OffsetPage is a generic offset-based page of users.
|
||||||
type UserPage struct {
|
type UserPage struct {
|
||||||
Items []User
|
Items []User
|
||||||
Total int
|
Total int
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// objectTypeIDs maps the URL segment to the object_type PK in core.object_types.
|
|
||||||
// Row order matches 007_seed_data.sql: file=1, tag=2, category=3, pool=4.
|
|
||||||
var objectTypeIDs = map[string]int16{
|
|
||||||
"file": 1,
|
|
||||||
"tag": 2,
|
|
||||||
"category": 3,
|
|
||||||
"pool": 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ACLHandler handles GET/PUT /acl/:object_type/:object_id.
|
|
||||||
type ACLHandler struct {
|
|
||||||
aclSvc *service.ACLService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewACLHandler creates an ACLHandler.
|
|
||||||
func NewACLHandler(aclSvc *service.ACLService) *ACLHandler {
|
|
||||||
return &ACLHandler{aclSvc: aclSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response type
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type permissionJSON struct {
|
|
||||||
UserID int16 `json:"user_id"`
|
|
||||||
UserName string `json:"user_name"`
|
|
||||||
CanView bool `json:"can_view"`
|
|
||||||
CanEdit bool `json:"can_edit"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPermissionJSON(p domain.Permission) permissionJSON {
|
|
||||||
return permissionJSON{
|
|
||||||
UserID: p.UserID,
|
|
||||||
UserName: p.UserName,
|
|
||||||
CanView: p.CanView,
|
|
||||||
CanEdit: p.CanEdit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func parseACLPath(c *gin.Context) (objectTypeID int16, objectID uuid.UUID, ok bool) {
|
|
||||||
typeStr := c.Param("object_type")
|
|
||||||
id, exists := objectTypeIDs[typeStr]
|
|
||||||
if !exists {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return 0, uuid.UUID{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
objectID, err := uuid.Parse(c.Param("object_id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return 0, uuid.UUID{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return id, objectID, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /acl/:object_type/:object_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *ACLHandler) GetPermissions(c *gin.Context) {
|
|
||||||
objectTypeID, objectID, ok := parseACLPath(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
perms, err := h.aclSvc.GetPermissions(c.Request.Context(), objectTypeID, objectID)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]permissionJSON, len(perms))
|
|
||||||
for i, p := range perms {
|
|
||||||
out[i] = toPermissionJSON(p)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PUT /acl/:object_type/:object_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *ACLHandler) SetPermissions(c *gin.Context) {
|
|
||||||
objectTypeID, objectID, ok := parseACLPath(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
Permissions []struct {
|
|
||||||
UserID int16 `json:"user_id" binding:"required"`
|
|
||||||
CanView bool `json:"can_view"`
|
|
||||||
CanEdit bool `json:"can_edit"`
|
|
||||||
} `json:"permissions" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
perms := make([]domain.Permission, len(body.Permissions))
|
|
||||||
for i, p := range body.Permissions {
|
|
||||||
perms[i] = domain.Permission{
|
|
||||||
UserID: p.UserID,
|
|
||||||
CanView: p.CanView,
|
|
||||||
CanEdit: p.CanEdit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.aclSvc.SetPermissions(c.Request.Context(), objectTypeID, objectID, perms); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-read to return the stored permissions (with UserName denormalized).
|
|
||||||
stored, err := h.aclSvc.GetPermissions(c.Request.Context(), objectTypeID, objectID)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]permissionJSON, len(stored))
|
|
||||||
for i, p := range stored {
|
|
||||||
out[i] = toPermissionJSON(p)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, out)
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuditHandler handles GET /audit.
|
|
||||||
type AuditHandler struct {
|
|
||||||
auditSvc *service.AuditService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuditHandler creates an AuditHandler.
|
|
||||||
func NewAuditHandler(auditSvc *service.AuditService) *AuditHandler {
|
|
||||||
return &AuditHandler{auditSvc: auditSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response type
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type auditEntryJSON struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
UserID int16 `json:"user_id"`
|
|
||||||
UserName string `json:"user_name"`
|
|
||||||
Action string `json:"action"`
|
|
||||||
ObjectType *string `json:"object_type"`
|
|
||||||
ObjectID *string `json:"object_id"`
|
|
||||||
PerformedAt string `json:"performed_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toAuditEntryJSON(e domain.AuditEntry) auditEntryJSON {
|
|
||||||
j := auditEntryJSON{
|
|
||||||
ID: e.ID,
|
|
||||||
UserID: e.UserID,
|
|
||||||
UserName: e.UserName,
|
|
||||||
Action: e.Action,
|
|
||||||
ObjectType: e.ObjectType,
|
|
||||||
PerformedAt: e.PerformedAt.UTC().Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
if e.ObjectID != nil {
|
|
||||||
s := e.ObjectID.String()
|
|
||||||
j.ObjectID = &s
|
|
||||||
}
|
|
||||||
return j
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /audit (admin)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *AuditHandler) List(c *gin.Context) {
|
|
||||||
if !requireAdmin(c) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
filter := domain.AuditFilter{}
|
|
||||||
|
|
||||||
if s := c.Query("limit"); s != "" {
|
|
||||||
if n, err := strconv.Atoi(s); err == nil {
|
|
||||||
filter.Limit = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s := c.Query("offset"); s != "" {
|
|
||||||
if n, err := strconv.Atoi(s); err == nil {
|
|
||||||
filter.Offset = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s := c.Query("user_id"); s != "" {
|
|
||||||
if n, err := strconv.ParseInt(s, 10, 16); err == nil {
|
|
||||||
id := int16(n)
|
|
||||||
filter.UserID = &id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s := c.Query("action"); s != "" {
|
|
||||||
filter.Action = s
|
|
||||||
}
|
|
||||||
if s := c.Query("object_type"); s != "" {
|
|
||||||
filter.ObjectType = s
|
|
||||||
}
|
|
||||||
if s := c.Query("object_id"); s != "" {
|
|
||||||
if id, err := uuid.Parse(s); err == nil {
|
|
||||||
filter.ObjectID = &id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s := c.Query("from"); s != "" {
|
|
||||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
|
||||||
filter.From = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s := c.Query("to"); s != "" {
|
|
||||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
|
||||||
filter.To = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := h.auditSvc.Query(c.Request.Context(), filter)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]auditEntryJSON, len(page.Items))
|
|
||||||
for i, e := range page.Items {
|
|
||||||
items[i] = toAuditEntryJSON(e)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": page.Total,
|
|
||||||
"offset": page.Offset,
|
|
||||||
"limit": page.Limit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuthHandler handles all /auth endpoints.
|
|
||||||
type AuthHandler struct {
|
|
||||||
authSvc *service.AuthService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuthHandler creates an AuthHandler backed by authSvc.
|
|
||||||
func NewAuthHandler(authSvc *service.AuthService) *AuthHandler {
|
|
||||||
return &AuthHandler{authSvc: authSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login handles POST /auth/login.
|
|
||||||
func (h *AuthHandler) Login(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
Password string `json:"password" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pair, err := h.authSvc.Login(c.Request.Context(), req.Name, req.Password, c.GetHeader("User-Agent"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"access_token": pair.AccessToken,
|
|
||||||
"refresh_token": pair.RefreshToken,
|
|
||||||
"expires_in": pair.ExpiresIn,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh handles POST /auth/refresh.
|
|
||||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pair, err := h.authSvc.Refresh(c.Request.Context(), req.RefreshToken, c.GetHeader("User-Agent"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"access_token": pair.AccessToken,
|
|
||||||
"refresh_token": pair.RefreshToken,
|
|
||||||
"expires_in": pair.ExpiresIn,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout handles POST /auth/logout. Requires authentication.
|
|
||||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
|
||||||
_, _, sessionID := domain.UserFromContext(c.Request.Context())
|
|
||||||
|
|
||||||
if err := h.authSvc.Logout(c.Request.Context(), sessionID); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListSessions handles GET /auth/sessions. Requires authentication.
|
|
||||||
func (h *AuthHandler) ListSessions(c *gin.Context) {
|
|
||||||
userID, _, sessionID := domain.UserFromContext(c.Request.Context())
|
|
||||||
|
|
||||||
list, err := h.authSvc.ListSessions(c.Request.Context(), userID, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type sessionItem struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
UserAgent string `json:"user_agent"`
|
|
||||||
StartedAt string `json:"started_at"`
|
|
||||||
ExpiresAt any `json:"expires_at"`
|
|
||||||
LastActivity string `json:"last_activity"`
|
|
||||||
IsCurrent bool `json:"is_current"`
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]sessionItem, len(list.Items))
|
|
||||||
for i, s := range list.Items {
|
|
||||||
var expiresAt any
|
|
||||||
if s.ExpiresAt != nil {
|
|
||||||
expiresAt = s.ExpiresAt.Format("2006-01-02T15:04:05Z07:00")
|
|
||||||
}
|
|
||||||
items[i] = sessionItem{
|
|
||||||
ID: s.ID,
|
|
||||||
UserAgent: s.UserAgent,
|
|
||||||
StartedAt: s.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
LastActivity: s.LastActivity.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
IsCurrent: s.IsCurrent,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": list.Total,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TerminateSession handles DELETE /auth/sessions/:id. Requires authentication.
|
|
||||||
func (h *AuthHandler) TerminateSession(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := strconv.Atoi(idStr)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(c.Request.Context())
|
|
||||||
|
|
||||||
if err := h.authSvc.TerminateSession(c.Request.Context(), userID, isAdmin, id); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CategoryHandler handles all /categories endpoints.
|
|
||||||
type CategoryHandler struct {
|
|
||||||
categorySvc *service.CategoryService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCategoryHandler creates a CategoryHandler.
|
|
||||||
func NewCategoryHandler(categorySvc *service.CategoryService) *CategoryHandler {
|
|
||||||
return &CategoryHandler{categorySvc: categorySvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type categoryJSON struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
Color *string `json:"color"`
|
|
||||||
CreatorID int16 `json:"creator_id"`
|
|
||||||
CreatorName string `json:"creator_name"`
|
|
||||||
IsPublic bool `json:"is_public"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toCategoryJSON(c domain.Category) categoryJSON {
|
|
||||||
return categoryJSON{
|
|
||||||
ID: c.ID.String(),
|
|
||||||
Name: c.Name,
|
|
||||||
Notes: c.Notes,
|
|
||||||
Color: c.Color,
|
|
||||||
CreatorID: c.CreatorID,
|
|
||||||
CreatorName: c.CreatorName,
|
|
||||||
IsPublic: c.IsPublic,
|
|
||||||
CreatedAt: c.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func parseCategoryID(c *gin.Context) (uuid.UUID, bool) {
|
|
||||||
id, err := uuid.Parse(c.Param("category_id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return uuid.UUID{}, false
|
|
||||||
}
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /categories
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *CategoryHandler) List(c *gin.Context) {
|
|
||||||
params := parseOffsetParams(c, "created")
|
|
||||||
|
|
||||||
page, err := h.categorySvc.List(c.Request.Context(), params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]categoryJSON, len(page.Items))
|
|
||||||
for i, cat := range page.Items {
|
|
||||||
items[i] = toCategoryJSON(cat)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": page.Total,
|
|
||||||
"offset": page.Offset,
|
|
||||||
"limit": page.Limit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /categories
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *CategoryHandler) Create(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
Color *string `json:"color"`
|
|
||||||
IsPublic *bool `json:"is_public"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := h.categorySvc.Create(c.Request.Context(), service.CategoryParams{
|
|
||||||
Name: body.Name,
|
|
||||||
Notes: body.Notes,
|
|
||||||
Color: body.Color,
|
|
||||||
IsPublic: body.IsPublic,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusCreated, toCategoryJSON(*created))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /categories/:category_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *CategoryHandler) Get(c *gin.Context) {
|
|
||||||
id, ok := parseCategoryID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cat, err := h.categorySvc.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, toCategoryJSON(*cat))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PATCH /categories/:category_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *CategoryHandler) Update(c *gin.Context) {
|
|
||||||
id, ok := parseCategoryID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a raw map to detect explicitly-null fields.
|
|
||||||
var raw map[string]any
|
|
||||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := service.CategoryParams{}
|
|
||||||
|
|
||||||
if v, ok := raw["name"]; ok {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
params.Name = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok := raw["notes"]; ok {
|
|
||||||
if raw["notes"] == nil {
|
|
||||||
empty := ""
|
|
||||||
params.Notes = &empty
|
|
||||||
} else if s, ok := raw["notes"].(string); ok {
|
|
||||||
params.Notes = &s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok := raw["color"]; ok {
|
|
||||||
if raw["color"] == nil {
|
|
||||||
empty := ""
|
|
||||||
params.Color = &empty
|
|
||||||
} else if s, ok := raw["color"].(string); ok {
|
|
||||||
params.Color = &s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, ok := raw["is_public"]; ok {
|
|
||||||
if b, ok := v.(bool); ok {
|
|
||||||
params.IsPublic = &b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := h.categorySvc.Update(c.Request.Context(), id, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, toCategoryJSON(*updated))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DELETE /categories/:category_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *CategoryHandler) Delete(c *gin.Context) {
|
|
||||||
id, ok := parseCategoryID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.categorySvc.Delete(c.Request.Context(), id); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /categories/:category_id/tags
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *CategoryHandler) ListTags(c *gin.Context) {
|
|
||||||
id, ok := parseCategoryID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := parseOffsetParams(c, "created")
|
|
||||||
|
|
||||||
page, err := h.categorySvc.ListTags(c.Request.Context(), id, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]tagJSON, len(page.Items))
|
|
||||||
for i, t := range page.Items {
|
|
||||||
items[i] = toTagJSON(t)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": page.Total,
|
|
||||||
"offset": page.Offset,
|
|
||||||
"limit": page.Limit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,645 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileHandler handles all /files endpoints.
|
|
||||||
type FileHandler struct {
|
|
||||||
fileSvc *service.FileService
|
|
||||||
tagSvc *service.TagService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileHandler creates a FileHandler.
|
|
||||||
func NewFileHandler(fileSvc *service.FileService, tagSvc *service.TagService) *FileHandler {
|
|
||||||
return &FileHandler{fileSvc: fileSvc, tagSvc: tagSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type tagJSON struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
Color *string `json:"color"`
|
|
||||||
CategoryID *string `json:"category_id"`
|
|
||||||
CategoryName *string `json:"category_name"`
|
|
||||||
CategoryColor *string `json:"category_color"`
|
|
||||||
CreatorID int16 `json:"creator_id"`
|
|
||||||
CreatorName string `json:"creator_name"`
|
|
||||||
IsPublic bool `json:"is_public"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type fileJSON struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
OriginalName *string `json:"original_name"`
|
|
||||||
MIMEType string `json:"mime_type"`
|
|
||||||
MIMEExtension string `json:"mime_extension"`
|
|
||||||
ContentDatetime string `json:"content_datetime"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
Metadata json.RawMessage `json:"metadata"`
|
|
||||||
EXIF json.RawMessage `json:"exif"`
|
|
||||||
PHash *int64 `json:"phash"`
|
|
||||||
CreatorID int16 `json:"creator_id"`
|
|
||||||
CreatorName string `json:"creator_name"`
|
|
||||||
IsPublic bool `json:"is_public"`
|
|
||||||
IsDeleted bool `json:"is_deleted"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
Tags []tagJSON `json:"tags"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toTagJSON(t domain.Tag) tagJSON {
|
|
||||||
j := tagJSON{
|
|
||||||
ID: t.ID.String(),
|
|
||||||
Name: t.Name,
|
|
||||||
Notes: t.Notes,
|
|
||||||
Color: t.Color,
|
|
||||||
CategoryName: t.CategoryName,
|
|
||||||
CategoryColor: t.CategoryColor,
|
|
||||||
CreatorID: t.CreatorID,
|
|
||||||
CreatorName: t.CreatorName,
|
|
||||||
IsPublic: t.IsPublic,
|
|
||||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
if t.CategoryID != nil {
|
|
||||||
s := t.CategoryID.String()
|
|
||||||
j.CategoryID = &s
|
|
||||||
}
|
|
||||||
return j
|
|
||||||
}
|
|
||||||
|
|
||||||
func toFileJSON(f domain.File) fileJSON {
|
|
||||||
tags := make([]tagJSON, len(f.Tags))
|
|
||||||
for i, t := range f.Tags {
|
|
||||||
tags[i] = toTagJSON(t)
|
|
||||||
}
|
|
||||||
exif := f.EXIF
|
|
||||||
if exif == nil {
|
|
||||||
exif = json.RawMessage("{}")
|
|
||||||
}
|
|
||||||
return fileJSON{
|
|
||||||
ID: f.ID.String(),
|
|
||||||
OriginalName: f.OriginalName,
|
|
||||||
MIMEType: f.MIMEType,
|
|
||||||
MIMEExtension: f.MIMEExtension,
|
|
||||||
ContentDatetime: f.ContentDatetime.Format(time.RFC3339),
|
|
||||||
Notes: f.Notes,
|
|
||||||
Metadata: f.Metadata,
|
|
||||||
EXIF: exif,
|
|
||||||
PHash: f.PHash,
|
|
||||||
CreatorID: f.CreatorID,
|
|
||||||
CreatorName: f.CreatorName,
|
|
||||||
IsPublic: f.IsPublic,
|
|
||||||
IsDeleted: f.IsDeleted,
|
|
||||||
CreatedAt: f.CreatedAt.Format(time.RFC3339),
|
|
||||||
Tags: tags,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func parseFileID(c *gin.Context) (uuid.UUID, bool) {
|
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return uuid.UUID{}, false
|
|
||||||
}
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /files
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) List(c *gin.Context) {
|
|
||||||
params := domain.FileListParams{
|
|
||||||
Cursor: c.Query("cursor"),
|
|
||||||
Direction: c.DefaultQuery("direction", "forward"),
|
|
||||||
Sort: c.DefaultQuery("sort", "created"),
|
|
||||||
Order: c.DefaultQuery("order", "desc"),
|
|
||||||
Filter: c.Query("filter"),
|
|
||||||
Search: c.Query("search"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if limitStr := c.Query("limit"); limitStr != "" {
|
|
||||||
n, err := strconv.Atoi(limitStr)
|
|
||||||
if err != nil || n < 1 || n > 200 {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.Limit = n
|
|
||||||
} else {
|
|
||||||
params.Limit = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
if anchorStr := c.Query("anchor"); anchorStr != "" {
|
|
||||||
id, err := uuid.Parse(anchorStr)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.Anchor = &id
|
|
||||||
}
|
|
||||||
|
|
||||||
if trashStr := c.Query("trash"); trashStr == "true" || trashStr == "1" {
|
|
||||||
params.Trash = true
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := h.fileSvc.List(c.Request.Context(), params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]fileJSON, len(page.Items))
|
|
||||||
for i, f := range page.Items {
|
|
||||||
items[i] = toFileJSON(f)
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"next_cursor": page.NextCursor,
|
|
||||||
"prev_cursor": page.PrevCursor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /files (multipart upload)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) Upload(c *gin.Context) {
|
|
||||||
fh, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := fh.Open()
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer src.Close()
|
|
||||||
|
|
||||||
// Detect MIME from actual bytes (ignore client-supplied Content-Type).
|
|
||||||
mt, err := mimetype.DetectReader(src)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Rewind by reopening — FormFile gives a multipart.File which supports Seek.
|
|
||||||
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mimeStr := strings.SplitN(mt.String(), ";", 2)[0]
|
|
||||||
|
|
||||||
params := service.UploadParams{
|
|
||||||
Reader: src,
|
|
||||||
MIMEType: mimeStr,
|
|
||||||
IsPublic: c.PostForm("is_public") == "true",
|
|
||||||
}
|
|
||||||
|
|
||||||
if name := fh.Filename; name != "" {
|
|
||||||
params.OriginalName = &name
|
|
||||||
}
|
|
||||||
if notes := c.PostForm("notes"); notes != "" {
|
|
||||||
params.Notes = ¬es
|
|
||||||
}
|
|
||||||
if metaStr := c.PostForm("metadata"); metaStr != "" {
|
|
||||||
params.Metadata = json.RawMessage(metaStr)
|
|
||||||
}
|
|
||||||
if dtStr := c.PostForm("content_datetime"); dtStr != "" {
|
|
||||||
t, err := time.Parse(time.RFC3339, dtStr)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.ContentDatetime = &t
|
|
||||||
}
|
|
||||||
if tagIDsStr := c.PostForm("tag_ids"); tagIDsStr != "" {
|
|
||||||
for _, raw := range strings.Split(tagIDsStr, ",") {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id, err := uuid.Parse(raw)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.TagIDs = append(params.TagIDs, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := h.fileSvc.Upload(c.Request.Context(), params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusCreated, toFileJSON(*f))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /files/:id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) GetMeta(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := h.fileSvc.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PATCH /files/:id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) UpdateMeta(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
OriginalName *string `json:"original_name"`
|
|
||||||
ContentDatetime *string `json:"content_datetime"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
Metadata json.RawMessage `json:"metadata"`
|
|
||||||
IsPublic *bool `json:"is_public"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := service.UpdateParams{
|
|
||||||
OriginalName: body.OriginalName,
|
|
||||||
Notes: body.Notes,
|
|
||||||
Metadata: body.Metadata,
|
|
||||||
IsPublic: body.IsPublic,
|
|
||||||
}
|
|
||||||
if body.ContentDatetime != nil {
|
|
||||||
t, err := time.Parse(time.RFC3339, *body.ContentDatetime)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.ContentDatetime = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := h.fileSvc.Update(c.Request.Context(), id, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DELETE /files/:id (soft-delete)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) SoftDelete(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.fileSvc.Delete(c.Request.Context(), id); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /files/:id/content
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) GetContent(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := h.fileSvc.GetContent(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
c.Header("Content-Type", res.MIMEType)
|
|
||||||
if res.OriginalName != nil {
|
|
||||||
c.Header("Content-Disposition",
|
|
||||||
fmt.Sprintf("attachment; filename=%q", *res.OriginalName))
|
|
||||||
}
|
|
||||||
c.Status(http.StatusOK)
|
|
||||||
io.Copy(c.Writer, res.Body) //nolint:errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PUT /files/:id/content (replace)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) ReplaceContent(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fh, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := fh.Open()
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer src.Close()
|
|
||||||
|
|
||||||
mt, err := mimetype.DetectReader(src)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mimeStr := strings.SplitN(mt.String(), ";", 2)[0]
|
|
||||||
|
|
||||||
name := fh.Filename
|
|
||||||
params := service.UploadParams{
|
|
||||||
Reader: src,
|
|
||||||
MIMEType: mimeStr,
|
|
||||||
OriginalName: &name,
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := h.fileSvc.Replace(c.Request.Context(), id, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /files/:id/thumbnail
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) GetThumbnail(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rc, err := h.fileSvc.GetThumbnail(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
|
|
||||||
c.Header("Content-Type", "image/jpeg")
|
|
||||||
c.Status(http.StatusOK)
|
|
||||||
io.Copy(c.Writer, rc) //nolint:errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /files/:id/preview
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) GetPreview(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rc, err := h.fileSvc.GetPreview(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
|
|
||||||
c.Header("Content-Type", "image/jpeg")
|
|
||||||
c.Status(http.StatusOK)
|
|
||||||
io.Copy(c.Writer, rc) //nolint:errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /files/:id/restore
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) Restore(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := h.fileSvc.Restore(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, toFileJSON(*f))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DELETE /files/:id/permanent
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) PermanentDelete(c *gin.Context) {
|
|
||||||
id, ok := parseFileID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.fileSvc.PermanentDelete(c.Request.Context(), id); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /files/bulk/tags
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) BulkSetTags(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
FileIDs []string `json:"file_ids" binding:"required"`
|
|
||||||
Action string `json:"action" binding:"required"`
|
|
||||||
TagIDs []string `json:"tag_ids" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if body.Action != "add" && body.Action != "remove" {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIDs, err := parseUUIDs(body.FileIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tagIDs, err := parseUUIDs(body.TagIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
applied, err := h.tagSvc.BulkSetTags(c.Request.Context(), fileIDs, body.Action, tagIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
strs := make([]string, len(applied))
|
|
||||||
for i, id := range applied {
|
|
||||||
strs[i] = id.String()
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{"applied_tag_ids": strs})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /files/bulk/delete
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) BulkDelete(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
FileIDs []string `json:"file_ids" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIDs, err := parseUUIDs(body.FileIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.fileSvc.BulkDelete(c.Request.Context(), fileIDs); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /files/bulk/common-tags
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) CommonTags(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
FileIDs []string `json:"file_ids" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIDs, err := parseUUIDs(body.FileIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common, partial, err := h.tagSvc.CommonTags(c.Request.Context(), fileIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toStrs := func(tags []domain.Tag) []string {
|
|
||||||
s := make([]string, len(tags))
|
|
||||||
for i, t := range tags {
|
|
||||||
s[i] = t.ID.String()
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"common_tag_ids": toStrs(common),
|
|
||||||
"partial_tag_ids": toStrs(partial),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /files/import
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *FileHandler) Import(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
}
|
|
||||||
// Body is optional; ignore bind errors.
|
|
||||||
_ = c.ShouldBindJSON(&body)
|
|
||||||
|
|
||||||
result, err := h.fileSvc.Import(c.Request.Context(), body.Path)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func parseUUIDs(strs []string) ([]uuid.UUID, error) {
|
|
||||||
ids := make([]uuid.UUID, 0, len(strs))
|
|
||||||
for _, s := range strs {
|
|
||||||
id, err := uuid.Parse(s)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuthMiddleware validates Bearer JWTs and injects user identity into context.
|
|
||||||
type AuthMiddleware struct {
|
|
||||||
authSvc *service.AuthService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuthMiddleware creates an AuthMiddleware backed by authSvc.
|
|
||||||
func NewAuthMiddleware(authSvc *service.AuthService) *AuthMiddleware {
|
|
||||||
return &AuthMiddleware{authSvc: authSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle returns a Gin handler function that enforces authentication.
|
|
||||||
// On success it calls c.Next(); on failure it aborts with 401 JSON.
|
|
||||||
func (m *AuthMiddleware) Handle() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
raw := c.GetHeader("Authorization")
|
|
||||||
if !strings.HasPrefix(raw, "Bearer ") {
|
|
||||||
c.JSON(http.StatusUnauthorized, errorBody{
|
|
||||||
Code: domain.ErrUnauthorized.Code(),
|
|
||||||
Message: "authorization header missing or malformed",
|
|
||||||
})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token := strings.TrimPrefix(raw, "Bearer ")
|
|
||||||
|
|
||||||
claims, err := m.authSvc.ParseAccessToken(token)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, errorBody{
|
|
||||||
Code: domain.ErrUnauthorized.Code(),
|
|
||||||
Message: "invalid or expired token",
|
|
||||||
})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin, claims.SessionID)
|
|
||||||
c.Request = c.Request.WithContext(ctx)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PoolHandler handles all /pools endpoints.
|
|
||||||
type PoolHandler struct {
|
|
||||||
poolSvc *service.PoolService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPoolHandler creates a PoolHandler.
|
|
||||||
func NewPoolHandler(poolSvc *service.PoolService) *PoolHandler {
|
|
||||||
return &PoolHandler{poolSvc: poolSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type poolJSON struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
CreatorID int16 `json:"creator_id"`
|
|
||||||
CreatorName string `json:"creator_name"`
|
|
||||||
IsPublic bool `json:"is_public"`
|
|
||||||
FileCount int `json:"file_count"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type poolFileJSON struct {
|
|
||||||
fileJSON
|
|
||||||
Position int `json:"position"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPoolJSON(p domain.Pool) poolJSON {
|
|
||||||
return poolJSON{
|
|
||||||
ID: p.ID.String(),
|
|
||||||
Name: p.Name,
|
|
||||||
Notes: p.Notes,
|
|
||||||
CreatorID: p.CreatorID,
|
|
||||||
CreatorName: p.CreatorName,
|
|
||||||
IsPublic: p.IsPublic,
|
|
||||||
FileCount: p.FileCount,
|
|
||||||
CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPoolFileJSON(pf domain.PoolFile) poolFileJSON {
|
|
||||||
return poolFileJSON{
|
|
||||||
fileJSON: toFileJSON(pf.File),
|
|
||||||
Position: pf.Position,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func parsePoolID(c *gin.Context) (uuid.UUID, bool) {
|
|
||||||
id, err := uuid.Parse(c.Param("pool_id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return uuid.UUID{}, false
|
|
||||||
}
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePoolFileParams(c *gin.Context) port.PoolFileListParams {
|
|
||||||
limit := 50
|
|
||||||
if s := c.Query("limit"); s != "" {
|
|
||||||
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 200 {
|
|
||||||
limit = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return port.PoolFileListParams{
|
|
||||||
Cursor: c.Query("cursor"),
|
|
||||||
Limit: limit,
|
|
||||||
Filter: c.Query("filter"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /pools
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) List(c *gin.Context) {
|
|
||||||
params := parseOffsetParams(c, "created")
|
|
||||||
|
|
||||||
page, err := h.poolSvc.List(c.Request.Context(), params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]poolJSON, len(page.Items))
|
|
||||||
for i, p := range page.Items {
|
|
||||||
items[i] = toPoolJSON(p)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": page.Total,
|
|
||||||
"offset": page.Offset,
|
|
||||||
"limit": page.Limit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /pools
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) Create(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
IsPublic *bool `json:"is_public"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := h.poolSvc.Create(c.Request.Context(), service.PoolParams{
|
|
||||||
Name: body.Name,
|
|
||||||
Notes: body.Notes,
|
|
||||||
IsPublic: body.IsPublic,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusCreated, toPoolJSON(*created))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /pools/:pool_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) Get(c *gin.Context) {
|
|
||||||
id, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := h.poolSvc.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, toPoolJSON(*p))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PATCH /pools/:pool_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) Update(c *gin.Context) {
|
|
||||||
id, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var raw map[string]any
|
|
||||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := service.PoolParams{}
|
|
||||||
if v, ok := raw["name"]; ok {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
params.Name = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok := raw["notes"]; ok {
|
|
||||||
if raw["notes"] == nil {
|
|
||||||
empty := ""
|
|
||||||
params.Notes = &empty
|
|
||||||
} else if s, ok := raw["notes"].(string); ok {
|
|
||||||
params.Notes = &s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, ok := raw["is_public"]; ok {
|
|
||||||
if b, ok := v.(bool); ok {
|
|
||||||
params.IsPublic = &b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := h.poolSvc.Update(c.Request.Context(), id, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, toPoolJSON(*updated))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DELETE /pools/:pool_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) Delete(c *gin.Context) {
|
|
||||||
id, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.poolSvc.Delete(c.Request.Context(), id); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /pools/:pool_id/files
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) ListFiles(c *gin.Context) {
|
|
||||||
poolID, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := parsePoolFileParams(c)
|
|
||||||
|
|
||||||
page, err := h.poolSvc.ListFiles(c.Request.Context(), poolID, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]poolFileJSON, len(page.Items))
|
|
||||||
for i, pf := range page.Items {
|
|
||||||
items[i] = toPoolFileJSON(pf)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"next_cursor": page.NextCursor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /pools/:pool_id/files
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) AddFiles(c *gin.Context) {
|
|
||||||
poolID, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
FileIDs []string `json:"file_ids" binding:"required"`
|
|
||||||
Position *int `json:"position"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIDs := make([]uuid.UUID, 0, len(body.FileIDs))
|
|
||||||
for _, s := range body.FileIDs {
|
|
||||||
id, err := uuid.Parse(s)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fileIDs = append(fileIDs, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.poolSvc.AddFiles(c.Request.Context(), poolID, fileIDs, body.Position); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusCreated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /pools/:pool_id/files/remove
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) RemoveFiles(c *gin.Context) {
|
|
||||||
poolID, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
FileIDs []string `json:"file_ids" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIDs := make([]uuid.UUID, 0, len(body.FileIDs))
|
|
||||||
for _, s := range body.FileIDs {
|
|
||||||
id, err := uuid.Parse(s)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fileIDs = append(fileIDs, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.poolSvc.RemoveFiles(c.Request.Context(), poolID, fileIDs); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PUT /pools/:pool_id/files/reorder
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) Reorder(c *gin.Context) {
|
|
||||||
poolID, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
FileIDs []string `json:"file_ids" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIDs := make([]uuid.UUID, 0, len(body.FileIDs))
|
|
||||||
for _, s := range body.FileIDs {
|
|
||||||
id, err := uuid.Parse(s)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fileIDs = append(fileIDs, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.poolSvc.Reorder(c.Request.Context(), poolID, fileIDs); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// errorBody is the JSON shape returned for all error responses.
|
|
||||||
type errorBody struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func respondJSON(c *gin.Context, status int, data any) {
|
|
||||||
c.JSON(status, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// respondError maps a domain error to the appropriate HTTP status and writes
|
|
||||||
// a JSON error body. Unknown errors become 500.
|
|
||||||
func respondError(c *gin.Context, err error) {
|
|
||||||
var de *domain.DomainError
|
|
||||||
if errors.As(err, &de) {
|
|
||||||
c.JSON(domainStatus(de), errorBody{Code: de.Code(), Message: de.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, errorBody{
|
|
||||||
Code: "internal_error",
|
|
||||||
Message: "internal server error",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// domainStatus maps a DomainError sentinel to its HTTP status code per the
|
|
||||||
// error mapping table in docs/GO_PROJECT_STRUCTURE.md.
|
|
||||||
func domainStatus(de *domain.DomainError) int {
|
|
||||||
switch de {
|
|
||||||
case domain.ErrNotFound:
|
|
||||||
return http.StatusNotFound
|
|
||||||
case domain.ErrForbidden:
|
|
||||||
return http.StatusForbidden
|
|
||||||
case domain.ErrUnauthorized:
|
|
||||||
return http.StatusUnauthorized
|
|
||||||
case domain.ErrConflict:
|
|
||||||
return http.StatusConflict
|
|
||||||
case domain.ErrValidation:
|
|
||||||
return http.StatusBadRequest
|
|
||||||
case domain.ErrUnsupportedMIME:
|
|
||||||
return http.StatusUnsupportedMediaType
|
|
||||||
default:
|
|
||||||
return http.StatusInternalServerError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewRouter builds and returns a configured Gin engine.
|
|
||||||
func NewRouter(
|
|
||||||
auth *AuthMiddleware,
|
|
||||||
authHandler *AuthHandler,
|
|
||||||
fileHandler *FileHandler,
|
|
||||||
tagHandler *TagHandler,
|
|
||||||
categoryHandler *CategoryHandler,
|
|
||||||
poolHandler *PoolHandler,
|
|
||||||
userHandler *UserHandler,
|
|
||||||
aclHandler *ACLHandler,
|
|
||||||
auditHandler *AuditHandler,
|
|
||||||
) *gin.Engine {
|
|
||||||
r := gin.New()
|
|
||||||
r.Use(gin.Logger(), gin.Recovery())
|
|
||||||
|
|
||||||
// Health check — no auth required.
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
v1 := r.Group("/api/v1")
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Auth
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
authGroup := v1.Group("/auth")
|
|
||||||
{
|
|
||||||
authGroup.POST("/login", authHandler.Login)
|
|
||||||
authGroup.POST("/refresh", authHandler.Refresh)
|
|
||||||
|
|
||||||
protected := authGroup.Group("", auth.Handle())
|
|
||||||
{
|
|
||||||
protected.POST("/logout", authHandler.Logout)
|
|
||||||
protected.GET("/sessions", authHandler.ListSessions)
|
|
||||||
protected.DELETE("/sessions/:id", authHandler.TerminateSession)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Files (all require auth)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
files := v1.Group("/files", auth.Handle())
|
|
||||||
{
|
|
||||||
files.GET("", fileHandler.List)
|
|
||||||
files.POST("", fileHandler.Upload)
|
|
||||||
|
|
||||||
// Bulk + import routes registered before /:id to prevent param collision.
|
|
||||||
files.POST("/bulk/tags", fileHandler.BulkSetTags)
|
|
||||||
files.POST("/bulk/delete", fileHandler.BulkDelete)
|
|
||||||
files.POST("/bulk/common-tags", fileHandler.CommonTags)
|
|
||||||
files.POST("/import", fileHandler.Import)
|
|
||||||
|
|
||||||
// Per-file routes.
|
|
||||||
files.GET("/:id", fileHandler.GetMeta)
|
|
||||||
files.PATCH("/:id", fileHandler.UpdateMeta)
|
|
||||||
files.DELETE("/:id", fileHandler.SoftDelete)
|
|
||||||
|
|
||||||
files.GET("/:id/content", fileHandler.GetContent)
|
|
||||||
files.PUT("/:id/content", fileHandler.ReplaceContent)
|
|
||||||
files.GET("/:id/thumbnail", fileHandler.GetThumbnail)
|
|
||||||
files.GET("/:id/preview", fileHandler.GetPreview)
|
|
||||||
files.POST("/:id/restore", fileHandler.Restore)
|
|
||||||
files.DELETE("/:id/permanent", fileHandler.PermanentDelete)
|
|
||||||
|
|
||||||
// File–tag relations — served by TagHandler for auto-rule support.
|
|
||||||
files.GET("/:id/tags", tagHandler.FileListTags)
|
|
||||||
files.PUT("/:id/tags", tagHandler.FileSetTags)
|
|
||||||
files.PUT("/:id/tags/:tag_id", tagHandler.FileAddTag)
|
|
||||||
files.DELETE("/:id/tags/:tag_id", tagHandler.FileRemoveTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Tags (all require auth)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
tags := v1.Group("/tags", auth.Handle())
|
|
||||||
{
|
|
||||||
tags.GET("", tagHandler.List)
|
|
||||||
tags.POST("", tagHandler.Create)
|
|
||||||
|
|
||||||
tags.GET("/:tag_id", tagHandler.Get)
|
|
||||||
tags.PATCH("/:tag_id", tagHandler.Update)
|
|
||||||
tags.DELETE("/:tag_id", tagHandler.Delete)
|
|
||||||
|
|
||||||
tags.GET("/:tag_id/files", tagHandler.ListFiles)
|
|
||||||
|
|
||||||
tags.GET("/:tag_id/rules", tagHandler.ListRules)
|
|
||||||
tags.POST("/:tag_id/rules", tagHandler.CreateRule)
|
|
||||||
tags.PATCH("/:tag_id/rules/:then_tag_id", tagHandler.PatchRule)
|
|
||||||
tags.DELETE("/:tag_id/rules/:then_tag_id", tagHandler.DeleteRule)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Categories (all require auth)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
categories := v1.Group("/categories", auth.Handle())
|
|
||||||
{
|
|
||||||
categories.GET("", categoryHandler.List)
|
|
||||||
categories.POST("", categoryHandler.Create)
|
|
||||||
|
|
||||||
categories.GET("/:category_id", categoryHandler.Get)
|
|
||||||
categories.PATCH("/:category_id", categoryHandler.Update)
|
|
||||||
categories.DELETE("/:category_id", categoryHandler.Delete)
|
|
||||||
|
|
||||||
categories.GET("/:category_id/tags", categoryHandler.ListTags)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Pools (all require auth)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
pools := v1.Group("/pools", auth.Handle())
|
|
||||||
{
|
|
||||||
pools.GET("", poolHandler.List)
|
|
||||||
pools.POST("", poolHandler.Create)
|
|
||||||
|
|
||||||
pools.GET("/:pool_id", poolHandler.Get)
|
|
||||||
pools.PATCH("/:pool_id", poolHandler.Update)
|
|
||||||
pools.DELETE("/:pool_id", poolHandler.Delete)
|
|
||||||
|
|
||||||
// Sub-routes registered before /:pool_id/files to avoid param conflicts.
|
|
||||||
pools.POST("/:pool_id/files/remove", poolHandler.RemoveFiles)
|
|
||||||
pools.PUT("/:pool_id/files/reorder", poolHandler.Reorder)
|
|
||||||
|
|
||||||
pools.GET("/:pool_id/files", poolHandler.ListFiles)
|
|
||||||
pools.POST("/:pool_id/files", poolHandler.AddFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Users (auth required; admin checks enforced in handler)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
users := v1.Group("/users", auth.Handle())
|
|
||||||
{
|
|
||||||
// /users/me must be registered before /:user_id to avoid param capture.
|
|
||||||
users.GET("/me", userHandler.GetMe)
|
|
||||||
users.PATCH("/me", userHandler.UpdateMe)
|
|
||||||
|
|
||||||
users.GET("", userHandler.List)
|
|
||||||
users.POST("", userHandler.Create)
|
|
||||||
|
|
||||||
users.GET("/:user_id", userHandler.Get)
|
|
||||||
users.PATCH("/:user_id", userHandler.UpdateAdmin)
|
|
||||||
users.DELETE("/:user_id", userHandler.Delete)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// ACL (auth required)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
acl := v1.Group("/acl", auth.Handle())
|
|
||||||
{
|
|
||||||
acl.GET("/:object_type/:object_id", aclHandler.GetPermissions)
|
|
||||||
acl.PUT("/:object_type/:object_id", aclHandler.SetPermissions)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Audit (auth required; admin check enforced in handler)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
v1.GET("/audit", auth.Handle(), auditHandler.List)
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
@@ -1,532 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TagHandler handles all /tags endpoints.
|
|
||||||
type TagHandler struct {
|
|
||||||
tagSvc *service.TagService
|
|
||||||
fileSvc *service.FileService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTagHandler creates a TagHandler.
|
|
||||||
func NewTagHandler(tagSvc *service.TagService, fileSvc *service.FileService) *TagHandler {
|
|
||||||
return &TagHandler{tagSvc: tagSvc, fileSvc: fileSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type tagRuleJSON struct {
|
|
||||||
WhenTagID string `json:"when_tag_id"`
|
|
||||||
ThenTagID string `json:"then_tag_id"`
|
|
||||||
ThenTagName string `json:"then_tag_name"`
|
|
||||||
IsActive bool `json:"is_active"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toTagRuleJSON(r domain.TagRule) tagRuleJSON {
|
|
||||||
return tagRuleJSON{
|
|
||||||
WhenTagID: r.WhenTagID.String(),
|
|
||||||
ThenTagID: r.ThenTagID.String(),
|
|
||||||
ThenTagName: r.ThenTagName,
|
|
||||||
IsActive: r.IsActive,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func parseTagID(c *gin.Context) (uuid.UUID, bool) {
|
|
||||||
id, err := uuid.Parse(c.Param("tag_id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return uuid.UUID{}, false
|
|
||||||
}
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseOffsetParams(c *gin.Context, defaultSort string) port.OffsetParams {
|
|
||||||
limit := 50
|
|
||||||
if s := c.Query("limit"); s != "" {
|
|
||||||
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 200 {
|
|
||||||
limit = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
offset := 0
|
|
||||||
if s := c.Query("offset"); s != "" {
|
|
||||||
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
|
|
||||||
offset = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort := c.DefaultQuery("sort", defaultSort)
|
|
||||||
order := c.DefaultQuery("order", "desc")
|
|
||||||
search := c.Query("search")
|
|
||||||
return port.OffsetParams{Sort: sort, Order: order, Search: search, Limit: limit, Offset: offset}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /tags
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *TagHandler) List(c *gin.Context) {
|
|
||||||
params := parseOffsetParams(c, "created")
|
|
||||||
|
|
||||||
page, err := h.tagSvc.List(c.Request.Context(), params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]tagJSON, len(page.Items))
|
|
||||||
for i, t := range page.Items {
|
|
||||||
items[i] = toTagJSON(t)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": page.Total,
|
|
||||||
"offset": page.Offset,
|
|
||||||
"limit": page.Limit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /tags
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *TagHandler) Create(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
Color *string `json:"color"`
|
|
||||||
CategoryID *string `json:"category_id"`
|
|
||||||
IsPublic *bool `json:"is_public"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := service.TagParams{
|
|
||||||
Name: body.Name,
|
|
||||||
Notes: body.Notes,
|
|
||||||
Color: body.Color,
|
|
||||||
IsPublic: body.IsPublic,
|
|
||||||
}
|
|
||||||
if body.CategoryID != nil {
|
|
||||||
id, err := uuid.Parse(*body.CategoryID)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.CategoryID = &id
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := h.tagSvc.Create(c.Request.Context(), params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusCreated, toTagJSON(*t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /tags/:tag_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *TagHandler) Get(c *gin.Context) {
|
|
||||||
id, ok := parseTagID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := h.tagSvc.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, toTagJSON(*t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PATCH /tags/:tag_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *TagHandler) Update(c *gin.Context) {
|
|
||||||
id, ok := parseTagID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a raw map to distinguish "field absent" from "field = null".
|
|
||||||
var raw map[string]any
|
|
||||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := service.TagParams{}
|
|
||||||
|
|
||||||
if v, ok := raw["name"]; ok {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
params.Name = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok := raw["notes"]; ok {
|
|
||||||
if raw["notes"] == nil {
|
|
||||||
params.Notes = ptr("")
|
|
||||||
} else if s, ok := raw["notes"].(string); ok {
|
|
||||||
params.Notes = &s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok := raw["color"]; ok {
|
|
||||||
if raw["color"] == nil {
|
|
||||||
nilStr := ""
|
|
||||||
params.Color = &nilStr
|
|
||||||
} else if s, ok := raw["color"].(string); ok {
|
|
||||||
params.Color = &s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok := raw["category_id"]; ok {
|
|
||||||
if raw["category_id"] == nil {
|
|
||||||
nilID := uuid.Nil
|
|
||||||
params.CategoryID = &nilID // signals "unassign"
|
|
||||||
} else if s, ok := raw["category_id"].(string); ok {
|
|
||||||
cid, err := uuid.Parse(s)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params.CategoryID = &cid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, ok := raw["is_public"]; ok {
|
|
||||||
if b, ok := v.(bool); ok {
|
|
||||||
params.IsPublic = &b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := h.tagSvc.Update(c.Request.Context(), id, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, toTagJSON(*t))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DELETE /tags/:tag_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *TagHandler) Delete(c *gin.Context) {
|
|
||||||
id, ok := parseTagID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.tagSvc.Delete(c.Request.Context(), id); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /tags/:tag_id/files
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *TagHandler) ListFiles(c *gin.Context) {
|
|
||||||
id, ok := parseTagID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := 50
|
|
||||||
if s := c.Query("limit"); s != "" {
|
|
||||||
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 200 {
|
|
||||||
limit = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delegate to file service with a tag filter.
|
|
||||||
page, err := h.fileSvc.List(c.Request.Context(), domain.FileListParams{
|
|
||||||
Cursor: c.Query("cursor"),
|
|
||||||
Direction: "forward",
|
|
||||||
Limit: limit,
|
|
||||||
Sort: "created",
|
|
||||||
Order: "desc",
|
|
||||||
Filter: "{t=" + id.String() + "}",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]fileJSON, len(page.Items))
|
|
||||||
for i, f := range page.Items {
|
|
||||||
items[i] = toFileJSON(f)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"next_cursor": page.NextCursor,
|
|
||||||
"prev_cursor": page.PrevCursor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /tags/:tag_id/rules
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *TagHandler) ListRules(c *gin.Context) {
|
|
||||||
id, ok := parseTagID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rules, err := h.tagSvc.ListRules(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]tagRuleJSON, len(rules))
|
|
||||||
for i, r := range rules {
|
|
||||||
items[i] = toTagRuleJSON(r)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /tags/:tag_id/rules
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *TagHandler) CreateRule(c *gin.Context) {
|
|
||||||
whenTagID, ok := parseTagID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
ThenTagID string `json:"then_tag_id" binding:"required"`
|
|
||||||
IsActive *bool `json:"is_active"`
|
|
||||||
ApplyToExisting *bool `json:"apply_to_existing"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
thenTagID, err := uuid.Parse(body.ThenTagID)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isActive := true
|
|
||||||
if body.IsActive != nil {
|
|
||||||
isActive = *body.IsActive
|
|
||||||
}
|
|
||||||
applyToExisting := true
|
|
||||||
if body.ApplyToExisting != nil {
|
|
||||||
applyToExisting = *body.ApplyToExisting
|
|
||||||
}
|
|
||||||
|
|
||||||
rule, err := h.tagSvc.CreateRule(c.Request.Context(), whenTagID, thenTagID, isActive, applyToExisting)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusCreated, toTagRuleJSON(*rule))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PATCH /tags/:tag_id/rules/:then_tag_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *TagHandler) PatchRule(c *gin.Context) {
|
|
||||||
whenTagID, ok := parseTagID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
thenTagID, err := uuid.Parse(c.Param("then_tag_id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
IsActive *bool `json:"is_active"`
|
|
||||||
ApplyToExisting *bool `json:"apply_to_existing"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil || body.IsActive == nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
applyToExisting := false
|
|
||||||
if body.ApplyToExisting != nil {
|
|
||||||
applyToExisting = *body.ApplyToExisting
|
|
||||||
}
|
|
||||||
|
|
||||||
rule, err := h.tagSvc.SetRuleActive(c.Request.Context(), whenTagID, thenTagID, *body.IsActive, applyToExisting)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(c, http.StatusOK, toTagRuleJSON(*rule))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DELETE /tags/:tag_id/rules/:then_tag_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *TagHandler) DeleteRule(c *gin.Context) {
|
|
||||||
whenTagID, ok := parseTagID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
thenTagID, err := uuid.Parse(c.Param("then_tag_id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.tagSvc.DeleteRule(c.Request.Context(), whenTagID, thenTagID); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// File-tag endpoints wired through TagService
|
|
||||||
// (called from file routes, shared handler logic lives here)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// FileListTags handles GET /files/:id/tags.
|
|
||||||
func (h *TagHandler) FileListTags(c *gin.Context) {
|
|
||||||
fileID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tags, err := h.tagSvc.ListFileTags(c.Request.Context(), fileID)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]tagJSON, len(tags))
|
|
||||||
for i, t := range tags {
|
|
||||||
items[i] = toTagJSON(t)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileSetTags handles PUT /files/:id/tags.
|
|
||||||
func (h *TagHandler) FileSetTags(c *gin.Context) {
|
|
||||||
fileID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
TagIDs []string `json:"tag_ids" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tagIDs, err := parseUUIDs(body.TagIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tags, err := h.tagSvc.SetFileTags(c.Request.Context(), fileID, tagIDs)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]tagJSON, len(tags))
|
|
||||||
for i, t := range tags {
|
|
||||||
items[i] = toTagJSON(t)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileAddTag handles PUT /files/:id/tags/:tag_id.
|
|
||||||
func (h *TagHandler) FileAddTag(c *gin.Context) {
|
|
||||||
fileID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tagID, err := uuid.Parse(c.Param("tag_id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tags, err := h.tagSvc.AddFileTag(c.Request.Context(), fileID, tagID)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]tagJSON, len(tags))
|
|
||||||
for i, t := range tags {
|
|
||||||
items[i] = toTagJSON(t)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileRemoveTag handles DELETE /files/:id/tags/:tag_id.
|
|
||||||
func (h *TagHandler) FileRemoveTag(c *gin.Context) {
|
|
||||||
fileID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tagID, err := uuid.Parse(c.Param("tag_id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.tagSvc.RemoveFileTag(c.Request.Context(), fileID, tagID); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func ptr(s string) *string { return &s }
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UserHandler handles all /users endpoints.
|
|
||||||
type UserHandler struct {
|
|
||||||
userSvc *service.UserService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUserHandler creates a UserHandler.
|
|
||||||
func NewUserHandler(userSvc *service.UserService) *UserHandler {
|
|
||||||
return &UserHandler{userSvc: userSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type userJSON struct {
|
|
||||||
ID int16 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
IsAdmin bool `json:"is_admin"`
|
|
||||||
CanCreate bool `json:"can_create"`
|
|
||||||
IsBlocked bool `json:"is_blocked"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toUserJSON(u domain.User) userJSON {
|
|
||||||
return userJSON{
|
|
||||||
ID: u.ID,
|
|
||||||
Name: u.Name,
|
|
||||||
IsAdmin: u.IsAdmin,
|
|
||||||
CanCreate: u.CanCreate,
|
|
||||||
IsBlocked: u.IsBlocked,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func requireAdmin(c *gin.Context) bool {
|
|
||||||
_, isAdmin, _ := domain.UserFromContext(c.Request.Context())
|
|
||||||
if !isAdmin {
|
|
||||||
respondError(c, domain.ErrForbidden)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseUserID(c *gin.Context) (int16, bool) {
|
|
||||||
n, err := strconv.ParseInt(c.Param("user_id"), 10, 16)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return int16(n), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /users/me
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *UserHandler) GetMe(c *gin.Context) {
|
|
||||||
u, err := h.userSvc.GetMe(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, toUserJSON(*u))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PATCH /users/me
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *UserHandler) UpdateMe(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Password *string `json:"password"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := h.userSvc.UpdateMe(c.Request.Context(), service.UpdateMeParams{
|
|
||||||
Name: body.Name,
|
|
||||||
Password: body.Password,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, toUserJSON(*updated))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /users (admin)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *UserHandler) List(c *gin.Context) {
|
|
||||||
if !requireAdmin(c) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := port.OffsetParams{
|
|
||||||
Sort: c.DefaultQuery("sort", "id"),
|
|
||||||
Order: c.DefaultQuery("order", "asc"),
|
|
||||||
}
|
|
||||||
if s := c.Query("limit"); s != "" {
|
|
||||||
if n, err := strconv.Atoi(s); err == nil {
|
|
||||||
params.Limit = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s := c.Query("offset"); s != "" {
|
|
||||||
if n, err := strconv.Atoi(s); err == nil {
|
|
||||||
params.Offset = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := h.userSvc.List(c.Request.Context(), params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]userJSON, len(page.Items))
|
|
||||||
for i, u := range page.Items {
|
|
||||||
items[i] = toUserJSON(u)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": page.Total,
|
|
||||||
"offset": page.Offset,
|
|
||||||
"limit": page.Limit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /users (admin)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *UserHandler) Create(c *gin.Context) {
|
|
||||||
if !requireAdmin(c) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
Password string `json:"password" binding:"required"`
|
|
||||||
IsAdmin bool `json:"is_admin"`
|
|
||||||
CanCreate bool `json:"can_create"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := h.userSvc.Create(c.Request.Context(), service.CreateUserParams{
|
|
||||||
Name: body.Name,
|
|
||||||
Password: body.Password,
|
|
||||||
IsAdmin: body.IsAdmin,
|
|
||||||
CanCreate: body.CanCreate,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusCreated, toUserJSON(*created))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /users/:user_id (admin)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *UserHandler) Get(c *gin.Context) {
|
|
||||||
if !requireAdmin(c) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, ok := parseUserID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := h.userSvc.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, toUserJSON(*u))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PATCH /users/:user_id (admin)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *UserHandler) UpdateAdmin(c *gin.Context) {
|
|
||||||
if !requireAdmin(c) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, ok := parseUserID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
IsAdmin *bool `json:"is_admin"`
|
|
||||||
CanCreate *bool `json:"can_create"`
|
|
||||||
IsBlocked *bool `json:"is_blocked"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := h.userSvc.UpdateAdmin(c.Request.Context(), id, service.UpdateAdminParams{
|
|
||||||
IsAdmin: body.IsAdmin,
|
|
||||||
CanCreate: body.CanCreate,
|
|
||||||
IsBlocked: body.IsBlocked,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, toUserJSON(*updated))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DELETE /users/:user_id (admin)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *UserHandler) Delete(c *gin.Context) {
|
|
||||||
if !requireAdmin(c) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, ok := parseUserID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.userSvc.Delete(c.Request.Context(), id); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
@@ -1,767 +0,0 @@
|
|||||||
// Package integration contains end-to-end tests that start a real HTTP server
|
|
||||||
// against a disposable PostgreSQL database created on the fly.
|
|
||||||
//
|
|
||||||
// The test connects to an admin DSN (defaults to the local PG 16 socket) to
|
|
||||||
// CREATE / DROP an ephemeral database per test suite run, then runs all goose
|
|
||||||
// migrations on it.
|
|
||||||
//
|
|
||||||
// Override the admin DSN with TANABATA_TEST_ADMIN_DSN:
|
|
||||||
//
|
|
||||||
// export TANABATA_TEST_ADMIN_DSN="host=/var/run/postgresql port=5434 user=h1k0 dbname=postgres sslmode=disable"
|
|
||||||
// go test -v -timeout 120s tanabata/backend/internal/integration
|
|
||||||
package integration
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
"github.com/jackc/pgx/v5/stdlib"
|
|
||||||
"github.com/pressly/goose/v3"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db/postgres"
|
|
||||||
"tanabata/backend/internal/handler"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
"tanabata/backend/internal/storage"
|
|
||||||
"tanabata/backend/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
// defaultAdminDSN is the fallback when TANABATA_TEST_ADMIN_DSN is unset.
|
|
||||||
// Targets the PG 16 cluster on this machine (port 5434, Unix socket).
|
|
||||||
const defaultAdminDSN = "host=/var/run/postgresql port=5434 user=h1k0 dbname=postgres sslmode=disable"
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test harness
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type harness struct {
|
|
||||||
t *testing.T
|
|
||||||
server *httptest.Server
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupSuite creates an ephemeral database, runs migrations, wires the full
|
|
||||||
// service graph into an httptest.Server, and registers cleanup.
|
|
||||||
func setupSuite(t *testing.T) *harness {
|
|
||||||
t.Helper()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// --- Create an isolated test database ------------------------------------
|
|
||||||
adminDSN := os.Getenv("TANABATA_TEST_ADMIN_DSN")
|
|
||||||
if adminDSN == "" {
|
|
||||||
adminDSN = defaultAdminDSN
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a unique name so parallel test runs don't collide.
|
|
||||||
dbName := fmt.Sprintf("tanabata_test_%d", time.Now().UnixNano())
|
|
||||||
|
|
||||||
adminConn, err := pgx.Connect(ctx, adminDSN)
|
|
||||||
require.NoError(t, err, "connect to admin DSN: %s", adminDSN)
|
|
||||||
|
|
||||||
_, err = adminConn.Exec(ctx, "CREATE DATABASE "+dbName)
|
|
||||||
require.NoError(t, err)
|
|
||||||
adminConn.Close(ctx)
|
|
||||||
|
|
||||||
// Build the DSN for the new database (replace dbname= in adminDSN).
|
|
||||||
testDSN := replaceDSNDatabase(adminDSN, dbName)
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
// Drop all connections then drop the database.
|
|
||||||
conn, err := pgx.Connect(context.Background(), adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer conn.Close(context.Background())
|
|
||||||
_, _ = conn.Exec(context.Background(),
|
|
||||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1", dbName)
|
|
||||||
_, _ = conn.Exec(context.Background(), "DROP DATABASE IF EXISTS "+dbName)
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Migrations ----------------------------------------------------------
|
|
||||||
pool, err := pgxpool.New(ctx, testDSN)
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(pool.Close)
|
|
||||||
|
|
||||||
migDB := stdlib.OpenDBFromPool(pool)
|
|
||||||
goose.SetBaseFS(migrations.FS)
|
|
||||||
require.NoError(t, goose.SetDialect("postgres"))
|
|
||||||
require.NoError(t, goose.Up(migDB, "."))
|
|
||||||
migDB.Close()
|
|
||||||
|
|
||||||
// --- Temp directories for storage ----------------------------------------
|
|
||||||
filesDir := t.TempDir()
|
|
||||||
thumbsDir := t.TempDir()
|
|
||||||
|
|
||||||
diskStorage, err := storage.NewDiskStorage(filesDir, thumbsDir, 160, 160, 1920, 1080)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// --- Repositories --------------------------------------------------------
|
|
||||||
userRepo := postgres.NewUserRepo(pool)
|
|
||||||
sessionRepo := postgres.NewSessionRepo(pool)
|
|
||||||
fileRepo := postgres.NewFileRepo(pool)
|
|
||||||
mimeRepo := postgres.NewMimeRepo(pool)
|
|
||||||
aclRepo := postgres.NewACLRepo(pool)
|
|
||||||
auditRepo := postgres.NewAuditRepo(pool)
|
|
||||||
tagRepo := postgres.NewTagRepo(pool)
|
|
||||||
tagRuleRepo := postgres.NewTagRuleRepo(pool)
|
|
||||||
categoryRepo := postgres.NewCategoryRepo(pool)
|
|
||||||
poolRepo := postgres.NewPoolRepo(pool)
|
|
||||||
transactor := postgres.NewTransactor(pool)
|
|
||||||
|
|
||||||
// --- Services ------------------------------------------------------------
|
|
||||||
authSvc := service.NewAuthService(userRepo, sessionRepo, "test-secret", 15*time.Minute, 720*time.Hour)
|
|
||||||
aclSvc := service.NewACLService(aclRepo)
|
|
||||||
auditSvc := service.NewAuditService(auditRepo)
|
|
||||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
|
||||||
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
|
|
||||||
poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc)
|
|
||||||
fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, filesDir)
|
|
||||||
userSvc := service.NewUserService(userRepo, auditSvc)
|
|
||||||
|
|
||||||
// --- Handlers ------------------------------------------------------------
|
|
||||||
authMiddleware := handler.NewAuthMiddleware(authSvc)
|
|
||||||
authHandler := handler.NewAuthHandler(authSvc)
|
|
||||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
|
||||||
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
|
||||||
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
|
||||||
poolHandler := handler.NewPoolHandler(poolSvc)
|
|
||||||
userHandler := handler.NewUserHandler(userSvc)
|
|
||||||
aclHandler := handler.NewACLHandler(aclSvc)
|
|
||||||
auditHandler := handler.NewAuditHandler(auditSvc)
|
|
||||||
|
|
||||||
r := handler.NewRouter(
|
|
||||||
authMiddleware, authHandler,
|
|
||||||
fileHandler, tagHandler, categoryHandler, poolHandler,
|
|
||||||
userHandler, aclHandler, auditHandler,
|
|
||||||
)
|
|
||||||
|
|
||||||
srv := httptest.NewServer(r)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
return &harness{
|
|
||||||
t: t,
|
|
||||||
server: srv,
|
|
||||||
client: srv.Client(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// HTTP helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// testResponse wraps an HTTP response with the body already read into memory.
|
|
||||||
// This avoids the "body consumed by error-message arg before decode" pitfall.
|
|
||||||
type testResponse struct {
|
|
||||||
StatusCode int
|
|
||||||
bodyBytes []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the body as a string (for use in assertion messages).
|
|
||||||
func (r *testResponse) String() string { return string(r.bodyBytes) }
|
|
||||||
|
|
||||||
// decode unmarshals the body JSON into dst.
|
|
||||||
func (r *testResponse) decode(t *testing.T, dst any) {
|
|
||||||
t.Helper()
|
|
||||||
require.NoError(t, json.Unmarshal(r.bodyBytes, dst), "decode body: %s", r.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *harness) url(path string) string {
|
|
||||||
return h.server.URL + "/api/v1" + path
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *harness) do(method, path string, body io.Reader, token string, contentType string) *testResponse {
|
|
||||||
h.t.Helper()
|
|
||||||
req, err := http.NewRequest(method, h.url(path), body)
|
|
||||||
require.NoError(h.t, err)
|
|
||||||
if token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
}
|
|
||||||
if contentType != "" {
|
|
||||||
req.Header.Set("Content-Type", contentType)
|
|
||||||
}
|
|
||||||
httpResp, err := h.client.Do(req)
|
|
||||||
require.NoError(h.t, err)
|
|
||||||
b, _ := io.ReadAll(httpResp.Body)
|
|
||||||
httpResp.Body.Close()
|
|
||||||
return &testResponse{StatusCode: httpResp.StatusCode, bodyBytes: b}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *harness) doJSON(method, path string, payload any, token string) *testResponse {
|
|
||||||
h.t.Helper()
|
|
||||||
var buf io.Reader
|
|
||||||
if payload != nil {
|
|
||||||
b, err := json.Marshal(payload)
|
|
||||||
require.NoError(h.t, err)
|
|
||||||
buf = bytes.NewReader(b)
|
|
||||||
}
|
|
||||||
return h.do(method, path, buf, token, "application/json")
|
|
||||||
}
|
|
||||||
|
|
||||||
// login posts credentials and returns an access token.
|
|
||||||
func (h *harness) login(name, password string) string {
|
|
||||||
h.t.Helper()
|
|
||||||
resp := h.doJSON("POST", "/auth/login", map[string]string{
|
|
||||||
"name": name, "password": password,
|
|
||||||
}, "")
|
|
||||||
require.Equal(h.t, http.StatusOK, resp.StatusCode, "login failed: %s", resp)
|
|
||||||
var out struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
}
|
|
||||||
resp.decode(h.t, &out)
|
|
||||||
require.NotEmpty(h.t, out.AccessToken)
|
|
||||||
return out.AccessToken
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadJPEG uploads a minimal valid JPEG and returns the created file object.
|
|
||||||
func (h *harness) uploadJPEG(token, originalName string) map[string]any {
|
|
||||||
h.t.Helper()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
mw := multipart.NewWriter(&buf)
|
|
||||||
fw, err := mw.CreateFormFile("file", originalName)
|
|
||||||
require.NoError(h.t, err)
|
|
||||||
_, err = fw.Write(minimalJPEG())
|
|
||||||
require.NoError(h.t, err)
|
|
||||||
require.NoError(h.t, mw.Close())
|
|
||||||
|
|
||||||
resp := h.do("POST", "/files", &buf, token, mw.FormDataContentType())
|
|
||||||
require.Equal(h.t, http.StatusCreated, resp.StatusCode, "upload failed: %s", resp)
|
|
||||||
|
|
||||||
var out map[string]any
|
|
||||||
resp.decode(h.t, &out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main integration test
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestFullFlow(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("skipping integration test in short mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
h := setupSuite(t)
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 1. Admin login (seeded by 007_seed_data.sql)
|
|
||||||
// =========================================================================
|
|
||||||
adminToken := h.login("admin", "admin")
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 2. Create a regular user
|
|
||||||
// =========================================================================
|
|
||||||
resp := h.doJSON("POST", "/users", map[string]any{
|
|
||||||
"name": "alice", "password": "alicepass", "can_create": true,
|
|
||||||
}, adminToken)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
|
||||||
var aliceUser map[string]any
|
|
||||||
resp.decode(t, &aliceUser)
|
|
||||||
assert.Equal(t, "alice", aliceUser["name"])
|
|
||||||
|
|
||||||
// Create a second regular user for ACL testing.
|
|
||||||
resp = h.doJSON("POST", "/users", map[string]any{
|
|
||||||
"name": "bob", "password": "bobpass", "can_create": true,
|
|
||||||
}, adminToken)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 3. Log in as alice
|
|
||||||
// =========================================================================
|
|
||||||
aliceToken := h.login("alice", "alicepass")
|
|
||||||
bobToken := h.login("bob", "bobpass")
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 4. Alice uploads a private JPEG
|
|
||||||
// =========================================================================
|
|
||||||
fileObj := h.uploadJPEG(aliceToken, "sunset.jpg")
|
|
||||||
fileID, ok := fileObj["id"].(string)
|
|
||||||
require.True(t, ok, "file id missing")
|
|
||||||
assert.Equal(t, "sunset.jpg", fileObj["original_name"])
|
|
||||||
assert.Equal(t, false, fileObj["is_public"])
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 5. Create a tag and assign it to the file
|
|
||||||
// =========================================================================
|
|
||||||
resp = h.doJSON("POST", "/tags", map[string]any{
|
|
||||||
"name": "nature", "is_public": true,
|
|
||||||
}, aliceToken)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
|
||||||
var tagObj map[string]any
|
|
||||||
resp.decode(t, &tagObj)
|
|
||||||
tagID := tagObj["id"].(string)
|
|
||||||
|
|
||||||
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
|
|
||||||
"tag_ids": []string{tagID},
|
|
||||||
}, aliceToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// Verify tag is returned with the file.
|
|
||||||
resp = h.doJSON("GET", "/files/"+fileID, nil, aliceToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
var fileWithTags map[string]any
|
|
||||||
resp.decode(t, &fileWithTags)
|
|
||||||
tags := fileWithTags["tags"].([]any)
|
|
||||||
require.Len(t, tags, 1)
|
|
||||||
assert.Equal(t, "nature", tags[0].(map[string]any)["name"])
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 6. Filter files by tag
|
|
||||||
// =========================================================================
|
|
||||||
resp = h.doJSON("GET", "/files?filter=%7Bt%3D"+tagID+"%7D", nil, aliceToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
var filePage map[string]any
|
|
||||||
resp.decode(t, &filePage)
|
|
||||||
items := filePage["items"].([]any)
|
|
||||||
require.Len(t, items, 1)
|
|
||||||
assert.Equal(t, fileID, items[0].(map[string]any)["id"])
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 7. ACL — Bob cannot see Alice's private file
|
|
||||||
// =========================================================================
|
|
||||||
resp = h.doJSON("GET", "/files/"+fileID, nil, bobToken)
|
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// Grant Bob view access.
|
|
||||||
bobUserID := int(aliceUser["id"].(float64)) // alice's id used for reference; get bob's
|
|
||||||
// Resolve bob's real ID via admin.
|
|
||||||
resp = h.doJSON("GET", "/users", nil, adminToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
var usersPage map[string]any
|
|
||||||
resp.decode(t, &usersPage)
|
|
||||||
var bobID float64
|
|
||||||
for _, u := range usersPage["items"].([]any) {
|
|
||||||
um := u.(map[string]any)
|
|
||||||
if um["name"] == "bob" {
|
|
||||||
bobID = um["id"].(float64)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = bobUserID
|
|
||||||
require.NotZero(t, bobID)
|
|
||||||
|
|
||||||
resp = h.doJSON("PUT", "/acl/file/"+fileID, map[string]any{
|
|
||||||
"permissions": []map[string]any{
|
|
||||||
{"user_id": bobID, "can_view": true, "can_edit": false},
|
|
||||||
},
|
|
||||||
}, aliceToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// Now Bob can view.
|
|
||||||
resp = h.doJSON("GET", "/files/"+fileID, nil, bobToken)
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 8. Create a pool and add the file
|
|
||||||
// =========================================================================
|
|
||||||
resp = h.doJSON("POST", "/pools", map[string]any{
|
|
||||||
"name": "alice's pool", "is_public": false,
|
|
||||||
}, aliceToken)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
|
||||||
var poolObj map[string]any
|
|
||||||
resp.decode(t, &poolObj)
|
|
||||||
poolID := poolObj["id"].(string)
|
|
||||||
assert.Equal(t, "alice's pool", poolObj["name"])
|
|
||||||
|
|
||||||
resp = h.doJSON("POST", "/pools/"+poolID+"/files", map[string]any{
|
|
||||||
"file_ids": []string{fileID},
|
|
||||||
}, aliceToken)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// Pool file count should now be 1.
|
|
||||||
resp = h.doJSON("GET", "/pools/"+poolID, nil, aliceToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
var poolFull map[string]any
|
|
||||||
resp.decode(t, &poolFull)
|
|
||||||
assert.Equal(t, float64(1), poolFull["file_count"])
|
|
||||||
|
|
||||||
// List pool files and verify position.
|
|
||||||
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, aliceToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
var poolFiles map[string]any
|
|
||||||
resp.decode(t, &poolFiles)
|
|
||||||
poolItems := poolFiles["items"].([]any)
|
|
||||||
require.Len(t, poolItems, 1)
|
|
||||||
assert.Equal(t, fileID, poolItems[0].(map[string]any)["id"])
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 9. Trash flow: soft-delete → list trash → restore → permanent delete
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
// Soft-delete the file.
|
|
||||||
resp = h.doJSON("DELETE", "/files/"+fileID, nil, aliceToken)
|
|
||||||
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// File no longer appears in normal listing.
|
|
||||||
resp = h.doJSON("GET", "/files", nil, aliceToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
var normalPage map[string]any
|
|
||||||
resp.decode(t, &normalPage)
|
|
||||||
normalItems, _ := normalPage["items"].([]any)
|
|
||||||
assert.Len(t, normalItems, 0)
|
|
||||||
|
|
||||||
// File appears in trash listing.
|
|
||||||
resp = h.doJSON("GET", "/files?trash=true", nil, aliceToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
var trashPage map[string]any
|
|
||||||
resp.decode(t, &trashPage)
|
|
||||||
trashItems := trashPage["items"].([]any)
|
|
||||||
require.Len(t, trashItems, 1)
|
|
||||||
assert.Equal(t, fileID, trashItems[0].(map[string]any)["id"])
|
|
||||||
assert.Equal(t, true, trashItems[0].(map[string]any)["is_deleted"])
|
|
||||||
|
|
||||||
// Restore the file.
|
|
||||||
resp = h.doJSON("POST", "/files/"+fileID+"/restore", nil, aliceToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// File is back in normal listing.
|
|
||||||
resp = h.doJSON("GET", "/files", nil, aliceToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
var restoredPage map[string]any
|
|
||||||
resp.decode(t, &restoredPage)
|
|
||||||
restoredItems := restoredPage["items"].([]any)
|
|
||||||
require.Len(t, restoredItems, 1)
|
|
||||||
assert.Equal(t, fileID, restoredItems[0].(map[string]any)["id"])
|
|
||||||
|
|
||||||
// Soft-delete again then permanently delete.
|
|
||||||
resp = h.doJSON("DELETE", "/files/"+fileID, nil, aliceToken)
|
|
||||||
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
|
||||||
|
|
||||||
resp = h.doJSON("DELETE", "/files/"+fileID+"/permanent", nil, aliceToken)
|
|
||||||
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// File is gone entirely.
|
|
||||||
resp = h.doJSON("GET", "/files/"+fileID, nil, aliceToken)
|
|
||||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// 10. Audit log records actions (admin only)
|
|
||||||
// =========================================================================
|
|
||||||
resp = h.doJSON("GET", "/audit", nil, adminToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
var auditPage map[string]any
|
|
||||||
resp.decode(t, &auditPage)
|
|
||||||
auditItems := auditPage["items"].([]any)
|
|
||||||
assert.NotEmpty(t, auditItems, "audit log should have entries")
|
|
||||||
|
|
||||||
// Non-admin cannot read the audit log.
|
|
||||||
resp = h.doJSON("GET", "/audit", nil, aliceToken)
|
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Additional targeted tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// TestBlockedUserCannotLogin verifies that blocking a user prevents login.
|
|
||||||
func TestBlockedUserCannotLogin(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("skipping integration test in short mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
h := setupSuite(t)
|
|
||||||
adminToken := h.login("admin", "admin")
|
|
||||||
|
|
||||||
// Create user.
|
|
||||||
resp := h.doJSON("POST", "/users", map[string]any{
|
|
||||||
"name": "charlie", "password": "charliepass",
|
|
||||||
}, adminToken)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
||||||
var u map[string]any
|
|
||||||
resp.decode(t, &u)
|
|
||||||
userID := u["id"].(float64)
|
|
||||||
|
|
||||||
// Block charlie.
|
|
||||||
resp = h.doJSON("PATCH", fmt.Sprintf("/users/%.0f", userID), map[string]any{
|
|
||||||
"is_blocked": true,
|
|
||||||
}, adminToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
// Login attempt should return 403.
|
|
||||||
resp = h.doJSON("POST", "/auth/login", map[string]any{
|
|
||||||
"name": "charlie", "password": "charliepass",
|
|
||||||
}, "")
|
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPoolReorder verifies gap-based position reassignment.
|
|
||||||
func TestPoolReorder(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("skipping integration test in short mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
h := setupSuite(t)
|
|
||||||
adminToken := h.login("admin", "admin")
|
|
||||||
|
|
||||||
// Upload two files.
|
|
||||||
f1 := h.uploadJPEG(adminToken, "a.jpg")
|
|
||||||
f2 := h.uploadJPEG(adminToken, "b.jpg")
|
|
||||||
id1 := f1["id"].(string)
|
|
||||||
id2 := f2["id"].(string)
|
|
||||||
|
|
||||||
// Create pool and add both files.
|
|
||||||
resp := h.doJSON("POST", "/pools", map[string]any{"name": "reorder-test"}, adminToken)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
||||||
var pool map[string]any
|
|
||||||
resp.decode(t, &pool)
|
|
||||||
poolID := pool["id"].(string)
|
|
||||||
|
|
||||||
resp = h.doJSON("POST", "/pools/"+poolID+"/files", map[string]any{
|
|
||||||
"file_ids": []string{id1, id2},
|
|
||||||
}, adminToken)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
||||||
|
|
||||||
// Verify initial order: id1 before id2.
|
|
||||||
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, adminToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
var page map[string]any
|
|
||||||
resp.decode(t, &page)
|
|
||||||
items := page["items"].([]any)
|
|
||||||
require.Len(t, items, 2)
|
|
||||||
assert.Equal(t, id1, items[0].(map[string]any)["id"])
|
|
||||||
assert.Equal(t, id2, items[1].(map[string]any)["id"])
|
|
||||||
|
|
||||||
// Reorder: id2 first.
|
|
||||||
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder", map[string]any{
|
|
||||||
"file_ids": []string{id2, id1},
|
|
||||||
}, adminToken)
|
|
||||||
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// Verify new order.
|
|
||||||
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, adminToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
var page2 map[string]any
|
|
||||||
resp.decode(t, &page2)
|
|
||||||
items2 := page2["items"].([]any)
|
|
||||||
require.Len(t, items2, 2)
|
|
||||||
assert.Equal(t, id2, items2[0].(map[string]any)["id"])
|
|
||||||
assert.Equal(t, id1, items2[1].(map[string]any)["id"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTagRuleActivateApplyToExisting verifies that activating a rule with
|
|
||||||
// apply_to_existing=true retroactively tags existing files, including
|
|
||||||
// transitive rules (A→B active+apply, B→C already active → file gets A,B,C).
|
|
||||||
func TestTagRuleActivateApplyToExisting(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("skipping integration test in short mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
h := setupSuite(t)
|
|
||||||
tok := h.login("admin", "admin")
|
|
||||||
|
|
||||||
// Create three tags: A, B, C.
|
|
||||||
mkTag := func(name string) string {
|
|
||||||
resp := h.doJSON("POST", "/tags", map[string]any{"name": name}, tok)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
|
||||||
var obj map[string]any
|
|
||||||
resp.decode(t, &obj)
|
|
||||||
return obj["id"].(string)
|
|
||||||
}
|
|
||||||
tagA := mkTag("animal")
|
|
||||||
tagB := mkTag("living-thing")
|
|
||||||
tagC := mkTag("organism")
|
|
||||||
|
|
||||||
// Rule A→B: created inactive so it does NOT fire on assign.
|
|
||||||
resp := h.doJSON("POST", "/tags/"+tagA+"/rules", map[string]any{
|
|
||||||
"then_tag_id": tagB,
|
|
||||||
"is_active": false,
|
|
||||||
}, tok)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// Rule B→C: active, so it fires transitively when B is applied.
|
|
||||||
resp = h.doJSON("POST", "/tags/"+tagB+"/rules", map[string]any{
|
|
||||||
"then_tag_id": tagC,
|
|
||||||
"is_active": true,
|
|
||||||
}, tok)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// Upload a file and assign only tag A. A→B is inactive so only A is set.
|
|
||||||
file := h.uploadJPEG(tok, "cat.jpg")
|
|
||||||
fileID := file["id"].(string)
|
|
||||||
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
|
|
||||||
"tag_ids": []string{tagA},
|
|
||||||
}, tok)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
tagNames := func() []string {
|
|
||||||
r := h.doJSON("GET", "/files/"+fileID+"/tags", nil, tok)
|
|
||||||
require.Equal(t, http.StatusOK, r.StatusCode)
|
|
||||||
var items []any
|
|
||||||
r.decode(t, &items)
|
|
||||||
names := make([]string, 0, len(items))
|
|
||||||
for _, it := range items {
|
|
||||||
names = append(names, it.(map[string]any)["name"].(string))
|
|
||||||
}
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before activation: file should only have tag A.
|
|
||||||
assert.ElementsMatch(t, []string{"animal"}, tagNames())
|
|
||||||
|
|
||||||
// Activate A→B WITHOUT apply_to_existing — existing file must not change.
|
|
||||||
resp = h.doJSON("PATCH", "/tags/"+tagA+"/rules/"+tagB, map[string]any{
|
|
||||||
"is_active": true,
|
|
||||||
"apply_to_existing": false,
|
|
||||||
}, tok)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
|
||||||
assert.ElementsMatch(t, []string{"animal"}, tagNames(), "file should be unchanged when apply_to_existing=false")
|
|
||||||
|
|
||||||
// Deactivate again so we can test the positive case cleanly.
|
|
||||||
resp = h.doJSON("PATCH", "/tags/"+tagA+"/rules/"+tagB, map[string]any{
|
|
||||||
"is_active": false,
|
|
||||||
}, tok)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// Activate A→B WITH apply_to_existing=true.
|
|
||||||
// Expectation: file gets B directly, and C transitively via the active B→C rule.
|
|
||||||
resp = h.doJSON("PATCH", "/tags/"+tagA+"/rules/"+tagB, map[string]any{
|
|
||||||
"is_active": true,
|
|
||||||
"apply_to_existing": true,
|
|
||||||
}, tok)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
|
||||||
assert.ElementsMatch(t, []string{"animal", "living-thing", "organism"}, tagNames())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTagAutoRule verifies that adding a tag automatically applies then_tags.
|
|
||||||
func TestTagAutoRule(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("skipping integration test in short mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
h := setupSuite(t)
|
|
||||||
adminToken := h.login("admin", "admin")
|
|
||||||
|
|
||||||
// Create two tags: "outdoor" and "nature".
|
|
||||||
resp := h.doJSON("POST", "/tags", map[string]any{"name": "outdoor"}, adminToken)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
||||||
var outdoor map[string]any
|
|
||||||
resp.decode(t, &outdoor)
|
|
||||||
outdoorID := outdoor["id"].(string)
|
|
||||||
|
|
||||||
resp = h.doJSON("POST", "/tags", map[string]any{"name": "nature"}, adminToken)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
||||||
var nature map[string]any
|
|
||||||
resp.decode(t, &nature)
|
|
||||||
natureID := nature["id"].(string)
|
|
||||||
|
|
||||||
// Create rule: when "outdoor" → also apply "nature".
|
|
||||||
resp = h.doJSON("POST", "/tags/"+outdoorID+"/rules", map[string]any{
|
|
||||||
"then_tag_id": natureID,
|
|
||||||
}, adminToken)
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// Upload a file and assign only "outdoor".
|
|
||||||
file := h.uploadJPEG(adminToken, "park.jpg")
|
|
||||||
fileID := file["id"].(string)
|
|
||||||
|
|
||||||
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
|
|
||||||
"tag_ids": []string{outdoorID},
|
|
||||||
}, adminToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
|
||||||
|
|
||||||
// Both "outdoor" and "nature" should be on the file.
|
|
||||||
resp = h.doJSON("GET", "/files/"+fileID+"/tags", nil, adminToken)
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
var tagsResp []any
|
|
||||||
resp.decode(t, &tagsResp)
|
|
||||||
names := make([]string, 0, len(tagsResp))
|
|
||||||
for _, tg := range tagsResp {
|
|
||||||
names = append(names, tg.(map[string]any)["name"].(string))
|
|
||||||
}
|
|
||||||
assert.ElementsMatch(t, []string{"outdoor", "nature"}, names)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// minimalJPEG returns the bytes of a 1×1 white JPEG image.
|
|
||||||
// Generated offline; no external dependency needed.
|
|
||||||
func minimalJPEG() []byte {
|
|
||||||
// This is a valid minimal JPEG: SOI + APP0 + DQT + SOF0 + DHT + SOS + EOI.
|
|
||||||
// 1×1 white pixel, quality ~50. Verified with `file` and browsers.
|
|
||||||
return []byte{
|
|
||||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
|
|
||||||
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43,
|
|
||||||
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09,
|
|
||||||
0x09, 0x08, 0x0a, 0x0c, 0x14, 0x0d, 0x0c, 0x0b, 0x0b, 0x0c, 0x19, 0x12,
|
|
||||||
0x13, 0x0f, 0x14, 0x1d, 0x1a, 0x1f, 0x1e, 0x1d, 0x1a, 0x1c, 0x1c, 0x20,
|
|
||||||
0x24, 0x2e, 0x27, 0x20, 0x22, 0x2c, 0x23, 0x1c, 0x1c, 0x28, 0x37, 0x29,
|
|
||||||
0x2c, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1f, 0x27, 0x39, 0x3d, 0x38, 0x32,
|
|
||||||
0x3c, 0x2e, 0x33, 0x34, 0x32, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01,
|
|
||||||
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x1f, 0x00, 0x00,
|
|
||||||
0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
|
||||||
0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03,
|
|
||||||
0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d,
|
|
||||||
0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06,
|
|
||||||
0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08,
|
|
||||||
0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, 0x72,
|
|
||||||
0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28,
|
|
||||||
0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45,
|
|
||||||
0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59,
|
|
||||||
0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75,
|
|
||||||
0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
|
|
||||||
0x8a, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4,
|
|
||||||
0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7,
|
|
||||||
0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca,
|
|
||||||
0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3,
|
|
||||||
0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5,
|
|
||||||
0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00,
|
|
||||||
0x00, 0x3f, 0x00, 0xfb, 0xd3, 0xff, 0xd9,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// replaceDSNDatabase returns a copy of dsn with the dbname parameter replaced.
|
|
||||||
// Handles both key=value libpq-style strings and postgres:// URLs.
|
|
||||||
func replaceDSNDatabase(dsn, newDB string) string {
|
|
||||||
// key=value style: replace dbname=xxx or append if absent.
|
|
||||||
if !strings.Contains(dsn, "://") {
|
|
||||||
const key = "dbname="
|
|
||||||
if idx := strings.Index(dsn, key); idx >= 0 {
|
|
||||||
end := strings.IndexByte(dsn[idx+len(key):], ' ')
|
|
||||||
if end < 0 {
|
|
||||||
return dsn[:idx] + key + newDB
|
|
||||||
}
|
|
||||||
return dsn[:idx] + key + newDB + dsn[idx+len(key)+end:]
|
|
||||||
}
|
|
||||||
return dsn + " dbname=" + newDB
|
|
||||||
}
|
|
||||||
// URL style: not used in our defaults, but handled for completeness.
|
|
||||||
return dsn
|
|
||||||
}
|
|
||||||
|
|
||||||
// freePort returns an available TCP port on localhost.
|
|
||||||
func freePort() int {
|
|
||||||
l, _ := net.Listen("tcp", ":0")
|
|
||||||
defer l.Close()
|
|
||||||
return l.Addr().(*net.TCPAddr).Port
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeFile writes content to a temp file and returns its path.
|
|
||||||
func writeFile(t *testing.T, dir, name string, content []byte) string {
|
|
||||||
t.Helper()
|
|
||||||
path := filepath.Join(dir, name)
|
|
||||||
require.NoError(t, os.WriteFile(path, content, 0o644))
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// suppress unused-import warnings for helpers kept for future use.
|
|
||||||
var (
|
|
||||||
_ = freePort
|
|
||||||
_ = writeFile
|
|
||||||
)
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
package port
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Transactor executes fn inside a single database transaction.
|
|
||||||
// All repository calls made within fn receive the transaction via context.
|
|
||||||
type Transactor interface {
|
|
||||||
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// OffsetParams holds common offset-pagination and sort parameters.
|
|
||||||
type OffsetParams struct {
|
|
||||||
Sort string
|
|
||||||
Order string // "asc" | "desc"
|
|
||||||
Search string
|
|
||||||
Offset int
|
|
||||||
Limit int
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolFileListParams holds parameters for listing files inside a pool.
|
|
||||||
type PoolFileListParams struct {
|
|
||||||
Cursor string
|
|
||||||
Limit int
|
|
||||||
Filter string // filter DSL expression
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileRepo is the persistence interface for file records.
|
|
||||||
type FileRepo interface {
|
|
||||||
// List returns a cursor-based page of files.
|
|
||||||
List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error)
|
|
||||||
// GetByID returns the file with its tags loaded.
|
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (*domain.File, error)
|
|
||||||
// Create inserts a new file record and returns it.
|
|
||||||
Create(ctx context.Context, f *domain.File) (*domain.File, error)
|
|
||||||
// Update applies partial metadata changes and returns the updated record.
|
|
||||||
Update(ctx context.Context, id uuid.UUID, f *domain.File) (*domain.File, error)
|
|
||||||
// SoftDelete moves a file to trash (sets is_deleted = true).
|
|
||||||
SoftDelete(ctx context.Context, id uuid.UUID) error
|
|
||||||
// Restore moves a file out of trash (sets is_deleted = false).
|
|
||||||
Restore(ctx context.Context, id uuid.UUID) (*domain.File, error)
|
|
||||||
// DeletePermanent removes a file record. Only allowed when is_deleted = true.
|
|
||||||
DeletePermanent(ctx context.Context, id uuid.UUID) error
|
|
||||||
|
|
||||||
// ListTags returns all tags assigned to a file.
|
|
||||||
ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error)
|
|
||||||
// SetTags replaces all tags on a file (full replace semantics).
|
|
||||||
SetTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagRepo is the persistence interface for tags.
|
|
||||||
type TagRepo interface {
|
|
||||||
List(ctx context.Context, params OffsetParams) (*domain.TagOffsetPage, error)
|
|
||||||
// ListByCategory returns tags belonging to a specific category.
|
|
||||||
ListByCategory(ctx context.Context, categoryID uuid.UUID, params OffsetParams) (*domain.TagOffsetPage, error)
|
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (*domain.Tag, error)
|
|
||||||
Create(ctx context.Context, t *domain.Tag) (*domain.Tag, error)
|
|
||||||
Update(ctx context.Context, id uuid.UUID, t *domain.Tag) (*domain.Tag, error)
|
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
|
||||||
|
|
||||||
// ListByFile returns all tags assigned to a specific file, ordered by name.
|
|
||||||
ListByFile(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error)
|
|
||||||
// AddFileTag inserts a single file→tag relation. No-op if already present.
|
|
||||||
AddFileTag(ctx context.Context, fileID, tagID uuid.UUID) error
|
|
||||||
// RemoveFileTag deletes a single file→tag relation.
|
|
||||||
RemoveFileTag(ctx context.Context, fileID, tagID uuid.UUID) error
|
|
||||||
// SetFileTags replaces all tags on a file (full replace semantics).
|
|
||||||
SetFileTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error
|
|
||||||
// CommonTagsForFiles returns tags present on every one of the given files.
|
|
||||||
CommonTagsForFiles(ctx context.Context, fileIDs []uuid.UUID) ([]domain.Tag, error)
|
|
||||||
// PartialTagsForFiles returns tags present on some but not all of the given files.
|
|
||||||
PartialTagsForFiles(ctx context.Context, fileIDs []uuid.UUID) ([]domain.Tag, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagRuleRepo is the persistence interface for auto-tag rules.
|
|
||||||
type TagRuleRepo interface {
|
|
||||||
// ListByTag returns all rules where WhenTagID == tagID.
|
|
||||||
ListByTag(ctx context.Context, tagID uuid.UUID) ([]domain.TagRule, error)
|
|
||||||
Create(ctx context.Context, r domain.TagRule) (*domain.TagRule, error)
|
|
||||||
// SetActive toggles a rule's is_active flag. When active and applyToExisting
|
|
||||||
// are both true, the full transitive expansion of thenTagID is retroactively
|
|
||||||
// applied to all files that already carry whenTagID.
|
|
||||||
SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active, applyToExisting bool) error
|
|
||||||
Delete(ctx context.Context, whenTagID, thenTagID uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// CategoryRepo is the persistence interface for categories.
|
|
||||||
type CategoryRepo interface {
|
|
||||||
List(ctx context.Context, params OffsetParams) (*domain.CategoryOffsetPage, error)
|
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (*domain.Category, error)
|
|
||||||
Create(ctx context.Context, c *domain.Category) (*domain.Category, error)
|
|
||||||
Update(ctx context.Context, id uuid.UUID, c *domain.Category) (*domain.Category, error)
|
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolRepo is the persistence interface for pools and pool–file membership.
|
|
||||||
type PoolRepo interface {
|
|
||||||
List(ctx context.Context, params OffsetParams) (*domain.PoolOffsetPage, error)
|
|
||||||
GetByID(ctx context.Context, id uuid.UUID) (*domain.Pool, error)
|
|
||||||
Create(ctx context.Context, p *domain.Pool) (*domain.Pool, error)
|
|
||||||
Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*domain.Pool, error)
|
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
|
||||||
|
|
||||||
// ListFiles returns pool files ordered by position (cursor-based).
|
|
||||||
ListFiles(ctx context.Context, poolID uuid.UUID, params PoolFileListParams) (*domain.PoolFilePage, error)
|
|
||||||
// AddFiles appends files starting at position; nil position means append at end.
|
|
||||||
AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error
|
|
||||||
// RemoveFiles removes files from the pool.
|
|
||||||
RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
|
||||||
// Reorder sets the full ordered sequence of file IDs in the pool.
|
|
||||||
Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserRepo is the persistence interface for users.
|
|
||||||
type UserRepo interface {
|
|
||||||
List(ctx context.Context, params OffsetParams) (*domain.UserPage, error)
|
|
||||||
GetByID(ctx context.Context, id int16) (*domain.User, error)
|
|
||||||
// GetByName is used during login to look up credentials.
|
|
||||||
GetByName(ctx context.Context, name string) (*domain.User, error)
|
|
||||||
Create(ctx context.Context, u *domain.User) (*domain.User, error)
|
|
||||||
Update(ctx context.Context, id int16, u *domain.User) (*domain.User, error)
|
|
||||||
Delete(ctx context.Context, id int16) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionRepo is the persistence interface for auth sessions.
|
|
||||||
type SessionRepo interface {
|
|
||||||
// ListByUser returns all active sessions for a user.
|
|
||||||
ListByUser(ctx context.Context, userID int16) (*domain.SessionList, error)
|
|
||||||
// GetByTokenHash looks up a session by the hashed refresh token.
|
|
||||||
GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error)
|
|
||||||
Create(ctx context.Context, s *domain.Session) (*domain.Session, error)
|
|
||||||
// UpdateLastActivity refreshes the last_activity timestamp.
|
|
||||||
UpdateLastActivity(ctx context.Context, id int, t time.Time) error
|
|
||||||
// Delete terminates a single session.
|
|
||||||
Delete(ctx context.Context, id int) error
|
|
||||||
// DeleteByUserID terminates all sessions for a user (logout everywhere).
|
|
||||||
DeleteByUserID(ctx context.Context, userID int16) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ACLRepo is the persistence interface for per-object permissions.
|
|
||||||
type ACLRepo interface {
|
|
||||||
// List returns all permission entries for a given object.
|
|
||||||
List(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error)
|
|
||||||
// Get returns the permission entry for a specific user and object; returns
|
|
||||||
// ErrNotFound if no entry exists.
|
|
||||||
Get(ctx context.Context, userID int16, objectTypeID int16, objectID uuid.UUID) (*domain.Permission, error)
|
|
||||||
// Set replaces all permissions for an object (full replace semantics).
|
|
||||||
Set(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuditRepo is the persistence interface for the audit log.
|
|
||||||
type AuditRepo interface {
|
|
||||||
Log(ctx context.Context, entry domain.AuditEntry) error
|
|
||||||
List(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MimeRepo is the persistence interface for the MIME type whitelist.
|
|
||||||
type MimeRepo interface {
|
|
||||||
// List returns all supported MIME types.
|
|
||||||
List(ctx context.Context) ([]domain.MIMEType, error)
|
|
||||||
// GetByName returns the MIME type record for a given MIME name (e.g. "image/jpeg").
|
|
||||||
// Returns ErrUnsupportedMIME if not in the whitelist.
|
|
||||||
GetByName(ctx context.Context, name string) (*domain.MIMEType, error)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package port
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileStorage abstracts disk (or object-store) operations for file content,
|
|
||||||
// thumbnails, and previews.
|
|
||||||
type FileStorage interface {
|
|
||||||
// Save writes the reader's content to storage and returns the number of
|
|
||||||
// bytes written.
|
|
||||||
Save(ctx context.Context, id uuid.UUID, r io.Reader) (int64, error)
|
|
||||||
|
|
||||||
// Read opens the file content for reading. The caller must close the returned
|
|
||||||
// ReadCloser.
|
|
||||||
Read(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
|
|
||||||
|
|
||||||
// Delete removes the file content from storage.
|
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
|
||||||
|
|
||||||
// Thumbnail opens the pre-generated thumbnail (JPEG). Returns ErrNotFound
|
|
||||||
// if the thumbnail has not been generated yet.
|
|
||||||
Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
|
|
||||||
|
|
||||||
// Preview opens the pre-generated preview image (JPEG). Returns ErrNotFound
|
|
||||||
// if the preview has not been generated yet.
|
|
||||||
Preview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error)
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ACLService handles access control checks and permission management.
|
|
||||||
type ACLService struct {
|
|
||||||
aclRepo port.ACLRepo
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewACLService(aclRepo port.ACLRepo) *ACLService {
|
|
||||||
return &ACLService{aclRepo: aclRepo}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanView returns true if the user may view the object.
|
|
||||||
// isAdmin, creatorID, isPublic must be populated from the object record by the caller.
|
|
||||||
func (s *ACLService) CanView(
|
|
||||||
ctx context.Context,
|
|
||||||
userID int16, isAdmin bool,
|
|
||||||
creatorID int16, isPublic bool,
|
|
||||||
objectTypeID int16, objectID uuid.UUID,
|
|
||||||
) (bool, error) {
|
|
||||||
if isAdmin {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
if isPublic {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
if userID == creatorID {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
perm, err := s.aclRepo.Get(ctx, userID, objectTypeID, objectID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, domain.ErrNotFound) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return perm.CanView, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanEdit returns true if the user may edit the object.
|
|
||||||
// is_public does not grant edit access; only admins, creators, and explicit grants.
|
|
||||||
func (s *ACLService) CanEdit(
|
|
||||||
ctx context.Context,
|
|
||||||
userID int16, isAdmin bool,
|
|
||||||
creatorID int16,
|
|
||||||
objectTypeID int16, objectID uuid.UUID,
|
|
||||||
) (bool, error) {
|
|
||||||
if isAdmin {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
if userID == creatorID {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
perm, err := s.aclRepo.Get(ctx, userID, objectTypeID, objectID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, domain.ErrNotFound) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return perm.CanEdit, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPermissions returns all explicit ACL entries for an object.
|
|
||||||
func (s *ACLService) GetPermissions(ctx context.Context, objectTypeID int16, objectID uuid.UUID) ([]domain.Permission, error) {
|
|
||||||
return s.aclRepo.List(ctx, objectTypeID, objectID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPermissions replaces all ACL entries for an object (full replace semantics).
|
|
||||||
func (s *ACLService) SetPermissions(ctx context.Context, objectTypeID int16, objectID uuid.UUID, perms []domain.Permission) error {
|
|
||||||
return s.aclRepo.Set(ctx, objectTypeID, objectID, perms)
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuditService records user actions to the audit trail.
|
|
||||||
type AuditService struct {
|
|
||||||
repo port.AuditRepo
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAuditService(repo port.AuditRepo) *AuditService {
|
|
||||||
return &AuditService{repo: repo}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log records an action performed by the user in ctx.
|
|
||||||
// objectType and objectID are optional — pass nil when the action has no target object.
|
|
||||||
// details can be any JSON-serializable value, or nil.
|
|
||||||
func (s *AuditService) Log(
|
|
||||||
ctx context.Context,
|
|
||||||
action string,
|
|
||||||
objectType *string,
|
|
||||||
objectID *uuid.UUID,
|
|
||||||
details any,
|
|
||||||
) error {
|
|
||||||
userID, _, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
var raw json.RawMessage
|
|
||||||
if details != nil {
|
|
||||||
b, err := json.Marshal(details)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("AuditService.Log marshal details: %w", err)
|
|
||||||
}
|
|
||||||
raw = b
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := domain.AuditEntry{
|
|
||||||
UserID: userID,
|
|
||||||
Action: action,
|
|
||||||
ObjectType: objectType,
|
|
||||||
ObjectID: objectID,
|
|
||||||
Details: raw,
|
|
||||||
}
|
|
||||||
return s.repo.Log(ctx, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query returns a filtered, paginated page of audit log entries.
|
|
||||||
func (s *AuditService) Query(ctx context.Context, filter domain.AuditFilter) (*domain.AuditPage, error) {
|
|
||||||
return s.repo.List(ctx, filter)
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Claims is the JWT payload for both access and refresh tokens.
|
|
||||||
type Claims struct {
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
UserID int16 `json:"uid"`
|
|
||||||
IsAdmin bool `json:"adm"`
|
|
||||||
SessionID int `json:"sid"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenPair holds an issued access/refresh token pair with the access TTL.
|
|
||||||
type TokenPair struct {
|
|
||||||
AccessToken string
|
|
||||||
RefreshToken string
|
|
||||||
ExpiresIn int // access token TTL in seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthService handles authentication and session lifecycle.
|
|
||||||
type AuthService struct {
|
|
||||||
users port.UserRepo
|
|
||||||
sessions port.SessionRepo
|
|
||||||
secret []byte
|
|
||||||
accessTTL time.Duration
|
|
||||||
refreshTTL time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuthService creates an AuthService.
|
|
||||||
func NewAuthService(
|
|
||||||
users port.UserRepo,
|
|
||||||
sessions port.SessionRepo,
|
|
||||||
jwtSecret string,
|
|
||||||
accessTTL time.Duration,
|
|
||||||
refreshTTL time.Duration,
|
|
||||||
) *AuthService {
|
|
||||||
return &AuthService{
|
|
||||||
users: users,
|
|
||||||
sessions: sessions,
|
|
||||||
secret: []byte(jwtSecret),
|
|
||||||
accessTTL: accessTTL,
|
|
||||||
refreshTTL: refreshTTL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login validates credentials, creates a session, and returns a token pair.
|
|
||||||
func (s *AuthService) Login(ctx context.Context, name, password, userAgent string) (*TokenPair, error) {
|
|
||||||
user, err := s.users.GetByName(ctx, name)
|
|
||||||
if err != nil {
|
|
||||||
// Return ErrUnauthorized regardless of whether the user exists,
|
|
||||||
// to avoid username enumeration.
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.IsBlocked {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
var expiresAt *time.Time
|
|
||||||
if s.refreshTTL > 0 {
|
|
||||||
t := time.Now().Add(s.refreshTTL)
|
|
||||||
expiresAt = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue the refresh token first so we can store its hash.
|
|
||||||
refreshToken, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := s.sessions.Create(ctx, &domain.Session{
|
|
||||||
TokenHash: hashToken(refreshToken),
|
|
||||||
UserID: user.ID,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := s.issueToken(user.ID, user.IsAdmin, session.ID, s.accessTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue access token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-issue the refresh token with the real session ID now that we have it.
|
|
||||||
refreshToken, err = s.issueToken(user.ID, user.IsAdmin, session.ID, s.refreshTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TokenPair{
|
|
||||||
AccessToken: accessToken,
|
|
||||||
RefreshToken: refreshToken,
|
|
||||||
ExpiresIn: int(s.accessTTL.Seconds()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout deactivates the session identified by sessionID.
|
|
||||||
func (s *AuthService) Logout(ctx context.Context, sessionID int) error {
|
|
||||||
if err := s.sessions.Delete(ctx, sessionID); err != nil {
|
|
||||||
return fmt.Errorf("logout: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh validates a refresh token, issues a new token pair, and deactivates
|
|
||||||
// the old session.
|
|
||||||
func (s *AuthService) Refresh(ctx context.Context, refreshToken, userAgent string) (*TokenPair, error) {
|
|
||||||
claims, err := s.parseToken(refreshToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := s.sessions.GetByTokenHash(ctx, hashToken(refreshToken))
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
if session.ExpiresAt != nil && time.Now().After(*session.ExpiresAt) {
|
|
||||||
_ = s.sessions.Delete(ctx, session.ID)
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotate: deactivate old session.
|
|
||||||
if err := s.sessions.Delete(ctx, session.ID); err != nil {
|
|
||||||
return nil, fmt.Errorf("deactivate old session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.users.GetByID(ctx, claims.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.IsBlocked {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
var expiresAt *time.Time
|
|
||||||
if s.refreshTTL > 0 {
|
|
||||||
t := time.Now().Add(s.refreshTTL)
|
|
||||||
expiresAt = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
newRefresh, err := s.issueToken(user.ID, user.IsAdmin, 0, s.refreshTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
newSession, err := s.sessions.Create(ctx, &domain.Session{
|
|
||||||
TokenHash: hashToken(newRefresh),
|
|
||||||
UserID: user.ID,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, err := s.issueToken(user.ID, user.IsAdmin, newSession.ID, s.accessTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue access token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
newRefresh, err = s.issueToken(user.ID, user.IsAdmin, newSession.ID, s.refreshTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TokenPair{
|
|
||||||
AccessToken: accessToken,
|
|
||||||
RefreshToken: newRefresh,
|
|
||||||
ExpiresIn: int(s.accessTTL.Seconds()),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListSessions returns all active sessions for the given user.
|
|
||||||
func (s *AuthService) ListSessions(ctx context.Context, userID int16, currentSessionID int) (*domain.SessionList, error) {
|
|
||||||
list, err := s.sessions.ListByUser(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list sessions: %w", err)
|
|
||||||
}
|
|
||||||
for i := range list.Items {
|
|
||||||
list.Items[i].IsCurrent = list.Items[i].ID == currentSessionID
|
|
||||||
}
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TerminateSession deactivates a specific session, enforcing ownership.
|
|
||||||
func (s *AuthService) TerminateSession(ctx context.Context, callerID int16, isAdmin bool, sessionID int) error {
|
|
||||||
if !isAdmin {
|
|
||||||
list, err := s.sessions.ListByUser(ctx, callerID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("terminate session: %w", err)
|
|
||||||
}
|
|
||||||
owned := false
|
|
||||||
for _, sess := range list.Items {
|
|
||||||
if sess.ID == sessionID {
|
|
||||||
owned = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !owned {
|
|
||||||
return domain.ErrForbidden
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.sessions.Delete(ctx, sessionID); err != nil {
|
|
||||||
return fmt.Errorf("terminate session: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseAccessToken parses and validates an access token, returning its claims.
|
|
||||||
func (s *AuthService) ParseAccessToken(tokenStr string) (*Claims, error) {
|
|
||||||
claims, err := s.parseToken(tokenStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// issueToken signs a JWT with the given parameters.
|
|
||||||
func (s *AuthService) issueToken(userID int16, isAdmin bool, sessionID int, ttl time.Duration) (string, error) {
|
|
||||||
now := time.Now()
|
|
||||||
claims := Claims{
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
|
||||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
|
||||||
},
|
|
||||||
UserID: userID,
|
|
||||||
IsAdmin: isAdmin,
|
|
||||||
SessionID: sessionID,
|
|
||||||
}
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
signed, err := token.SignedString(s.secret)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("sign token: %w", err)
|
|
||||||
}
|
|
||||||
return signed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseToken verifies the signature and parses claims from a token string.
|
|
||||||
func (s *AuthService) parseToken(tokenStr string) (*Claims, error) {
|
|
||||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
|
||||||
}
|
|
||||||
return s.secret, nil
|
|
||||||
})
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
claims, ok := token.Claims.(*Claims)
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrUnauthorized
|
|
||||||
}
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// hashToken returns the SHA-256 hex digest of a token string.
|
|
||||||
// The raw token is never stored; only the hash goes to the database.
|
|
||||||
func hashToken(token string) string {
|
|
||||||
sum := sha256.Sum256([]byte(token))
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
const categoryObjectType = "category"
|
|
||||||
const categoryObjectTypeID int16 = 3 // third row in 007_seed_data.sql object_types
|
|
||||||
|
|
||||||
// CategoryParams holds the fields for creating or patching a category.
|
|
||||||
type CategoryParams struct {
|
|
||||||
Name string
|
|
||||||
Notes *string
|
|
||||||
Color *string // nil = no change; pointer to empty string = clear
|
|
||||||
Metadata json.RawMessage
|
|
||||||
IsPublic *bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// CategoryService handles category CRUD with ACL enforcement and audit logging.
|
|
||||||
type CategoryService struct {
|
|
||||||
categories port.CategoryRepo
|
|
||||||
tags port.TagRepo
|
|
||||||
acl *ACLService
|
|
||||||
audit *AuditService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCategoryService creates a CategoryService.
|
|
||||||
func NewCategoryService(
|
|
||||||
categories port.CategoryRepo,
|
|
||||||
tags port.TagRepo,
|
|
||||||
acl *ACLService,
|
|
||||||
audit *AuditService,
|
|
||||||
) *CategoryService {
|
|
||||||
return &CategoryService{
|
|
||||||
categories: categories,
|
|
||||||
tags: tags,
|
|
||||||
acl: acl,
|
|
||||||
audit: audit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CRUD
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// List returns a paginated, optionally filtered list of categories.
|
|
||||||
func (s *CategoryService) List(ctx context.Context, params port.OffsetParams) (*domain.CategoryOffsetPage, error) {
|
|
||||||
return s.categories.List(ctx, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a category by ID.
|
|
||||||
func (s *CategoryService) Get(ctx context.Context, id uuid.UUID) (*domain.Category, error) {
|
|
||||||
return s.categories.GetByID(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a new category record.
|
|
||||||
func (s *CategoryService) Create(ctx context.Context, p CategoryParams) (*domain.Category, error) {
|
|
||||||
userID, _, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
c := &domain.Category{
|
|
||||||
Name: p.Name,
|
|
||||||
Notes: p.Notes,
|
|
||||||
Color: p.Color,
|
|
||||||
Metadata: p.Metadata,
|
|
||||||
CreatorID: userID,
|
|
||||||
}
|
|
||||||
if p.IsPublic != nil {
|
|
||||||
c.IsPublic = *p.IsPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := s.categories.Create(ctx, c)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := categoryObjectType
|
|
||||||
_ = s.audit.Log(ctx, "category_create", &objType, &created.ID, nil)
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update applies a partial patch to a category.
|
|
||||||
func (s *CategoryService) Update(ctx context.Context, id uuid.UUID, p CategoryParams) (*domain.Category, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
current, err := s.categories.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, current.CreatorID, categoryObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := *current
|
|
||||||
if p.Name != "" {
|
|
||||||
patch.Name = p.Name
|
|
||||||
}
|
|
||||||
if p.Notes != nil {
|
|
||||||
patch.Notes = p.Notes
|
|
||||||
}
|
|
||||||
if p.Color != nil {
|
|
||||||
patch.Color = p.Color
|
|
||||||
}
|
|
||||||
if len(p.Metadata) > 0 {
|
|
||||||
patch.Metadata = p.Metadata
|
|
||||||
}
|
|
||||||
if p.IsPublic != nil {
|
|
||||||
patch.IsPublic = *p.IsPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.categories.Update(ctx, id, &patch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := categoryObjectType
|
|
||||||
_ = s.audit.Log(ctx, "category_edit", &objType, &id, nil)
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a category by ID, enforcing edit ACL.
|
|
||||||
func (s *CategoryService) Delete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
c, err := s.categories.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, c.CreatorID, categoryObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.categories.Delete(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := categoryObjectType
|
|
||||||
_ = s.audit.Log(ctx, "category_delete", &objType, &id, nil)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tags in category
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ListTags returns a paginated list of tags belonging to this category.
|
|
||||||
func (s *CategoryService) ListTags(ctx context.Context, categoryID uuid.UUID, params port.OffsetParams) (*domain.TagOffsetPage, error) {
|
|
||||||
return s.tags.ListByCategory(ctx, categoryID, params)
|
|
||||||
}
|
|
||||||
@@ -1,569 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/rwcarlsen/goexif/exif"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
const fileObjectType = "file"
|
|
||||||
|
|
||||||
// fileObjectTypeID is the primary key of the "file" row in core.object_types.
|
|
||||||
// It matches the first value inserted in 007_seed_data.sql.
|
|
||||||
const fileObjectTypeID int16 = 1
|
|
||||||
|
|
||||||
// UploadParams holds the parameters for uploading a new file.
|
|
||||||
type UploadParams struct {
|
|
||||||
Reader io.Reader
|
|
||||||
MIMEType string
|
|
||||||
OriginalName *string
|
|
||||||
Notes *string
|
|
||||||
Metadata json.RawMessage
|
|
||||||
ContentDatetime *time.Time
|
|
||||||
IsPublic bool
|
|
||||||
TagIDs []uuid.UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateParams holds the parameters for updating file metadata.
|
|
||||||
type UpdateParams struct {
|
|
||||||
OriginalName *string
|
|
||||||
Notes *string
|
|
||||||
Metadata json.RawMessage
|
|
||||||
ContentDatetime *time.Time
|
|
||||||
IsPublic *bool
|
|
||||||
TagIDs *[]uuid.UUID // nil means don't change tags
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContentResult holds the open reader and metadata for a file download.
|
|
||||||
type ContentResult struct {
|
|
||||||
Body io.ReadCloser
|
|
||||||
MIMEType string
|
|
||||||
OriginalName *string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportFileError records a failed file during an import operation.
|
|
||||||
type ImportFileError struct {
|
|
||||||
Filename string `json:"filename"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportResult summarises a directory import.
|
|
||||||
type ImportResult struct {
|
|
||||||
Imported int `json:"imported"`
|
|
||||||
Skipped int `json:"skipped"`
|
|
||||||
Errors []ImportFileError `json:"errors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileService handles business logic for file records.
|
|
||||||
type FileService struct {
|
|
||||||
files port.FileRepo
|
|
||||||
mimes port.MimeRepo
|
|
||||||
storage port.FileStorage
|
|
||||||
acl *ACLService
|
|
||||||
audit *AuditService
|
|
||||||
tags *TagService
|
|
||||||
tx port.Transactor
|
|
||||||
importPath string // default server-side import directory
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileService creates a FileService.
|
|
||||||
func NewFileService(
|
|
||||||
files port.FileRepo,
|
|
||||||
mimes port.MimeRepo,
|
|
||||||
storage port.FileStorage,
|
|
||||||
acl *ACLService,
|
|
||||||
audit *AuditService,
|
|
||||||
tags *TagService,
|
|
||||||
tx port.Transactor,
|
|
||||||
importPath string,
|
|
||||||
) *FileService {
|
|
||||||
return &FileService{
|
|
||||||
files: files,
|
|
||||||
mimes: mimes,
|
|
||||||
storage: storage,
|
|
||||||
acl: acl,
|
|
||||||
audit: audit,
|
|
||||||
tags: tags,
|
|
||||||
tx: tx,
|
|
||||||
importPath: importPath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Core CRUD
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Upload validates the MIME type, saves the file to storage, creates the DB
|
|
||||||
// record, and applies any initial tags — all within a single transaction.
|
|
||||||
// If ContentDatetime is nil and EXIF DateTimeOriginal is present, it is used.
|
|
||||||
func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File, error) {
|
|
||||||
userID, _, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
// Validate MIME type against the whitelist.
|
|
||||||
mime, err := s.mimes.GetByName(ctx, p.MIMEType)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err // ErrUnsupportedMIME or DB error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buffer the upload so we can extract EXIF without re-reading storage.
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if _, err := io.Copy(&buf, p.Reader); err != nil {
|
|
||||||
return nil, fmt.Errorf("FileService.Upload: read body: %w", err)
|
|
||||||
}
|
|
||||||
data := buf.Bytes()
|
|
||||||
|
|
||||||
// Extract EXIF metadata (best-effort; non-image files will error silently).
|
|
||||||
exifData, exifDatetime := extractEXIFWithDatetime(data)
|
|
||||||
|
|
||||||
// Resolve content datetime: explicit > EXIF > zero value.
|
|
||||||
var contentDatetime time.Time
|
|
||||||
if p.ContentDatetime != nil {
|
|
||||||
contentDatetime = *p.ContentDatetime
|
|
||||||
} else if exifDatetime != nil {
|
|
||||||
contentDatetime = *exifDatetime
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign UUID v7 so CreatedAt can be derived from it later.
|
|
||||||
fileID, err := uuid.NewV7()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileService.Upload: generate UUID: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save file bytes to disk before opening the transaction so that a disk
|
|
||||||
// failure does not abort an otherwise healthy DB transaction.
|
|
||||||
if _, err := s.storage.Save(ctx, fileID, bytes.NewReader(data)); err != nil {
|
|
||||||
return nil, fmt.Errorf("FileService.Upload: save to storage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var created *domain.File
|
|
||||||
txErr := s.tx.WithTx(ctx, func(ctx context.Context) error {
|
|
||||||
f := &domain.File{
|
|
||||||
ID: fileID,
|
|
||||||
OriginalName: p.OriginalName,
|
|
||||||
MIMEType: mime.Name,
|
|
||||||
MIMEExtension: mime.Extension,
|
|
||||||
ContentDatetime: contentDatetime,
|
|
||||||
Notes: p.Notes,
|
|
||||||
Metadata: p.Metadata,
|
|
||||||
EXIF: exifData,
|
|
||||||
CreatorID: userID,
|
|
||||||
IsPublic: p.IsPublic,
|
|
||||||
}
|
|
||||||
|
|
||||||
var createErr error
|
|
||||||
created, createErr = s.files.Create(ctx, f)
|
|
||||||
if createErr != nil {
|
|
||||||
return createErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(p.TagIDs) > 0 {
|
|
||||||
tags, err := s.tags.SetFileTags(ctx, created.ID, p.TagIDs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
created.Tags = tags
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if txErr != nil {
|
|
||||||
// Attempt to clean up the orphaned file; ignore cleanup errors.
|
|
||||||
_ = s.storage.Delete(ctx, fileID)
|
|
||||||
return nil, txErr
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_create", &objType, &created.ID, nil)
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a file by ID, enforcing view ACL.
|
|
||||||
func (s *FileService) Get(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanView(ctx, userID, isAdmin, f.CreatorID, f.IsPublic, fileObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update applies metadata changes to a file, enforcing edit ACL.
|
|
||||||
func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams) (*domain.File, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := &domain.File{}
|
|
||||||
if p.OriginalName != nil {
|
|
||||||
patch.OriginalName = p.OriginalName
|
|
||||||
}
|
|
||||||
if p.Notes != nil {
|
|
||||||
patch.Notes = p.Notes
|
|
||||||
}
|
|
||||||
if p.Metadata != nil {
|
|
||||||
patch.Metadata = p.Metadata
|
|
||||||
}
|
|
||||||
if p.ContentDatetime != nil {
|
|
||||||
patch.ContentDatetime = *p.ContentDatetime
|
|
||||||
}
|
|
||||||
if p.IsPublic != nil {
|
|
||||||
patch.IsPublic = *p.IsPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
var updated *domain.File
|
|
||||||
txErr := s.tx.WithTx(ctx, func(ctx context.Context) error {
|
|
||||||
var updateErr error
|
|
||||||
updated, updateErr = s.files.Update(ctx, id, patch)
|
|
||||||
if updateErr != nil {
|
|
||||||
return updateErr
|
|
||||||
}
|
|
||||||
if p.TagIDs != nil {
|
|
||||||
tags, err := s.tags.SetFileTags(ctx, id, *p.TagIDs)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
updated.Tags = tags
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if txErr != nil {
|
|
||||||
return nil, txErr
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_edit", &objType, &id, nil)
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete soft-deletes a file (moves to trash), enforcing edit ACL.
|
|
||||||
func (s *FileService) Delete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.files.SoftDelete(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_delete", &objType, &id, nil)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore moves a soft-deleted file out of trash, enforcing edit ACL.
|
|
||||||
func (s *FileService) Restore(ctx context.Context, id uuid.UUID) (*domain.File, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
restored, err := s.files.Restore(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_restore", &objType, &id, nil)
|
|
||||||
return restored, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PermanentDelete removes the file record and its stored bytes. Only allowed
|
|
||||||
// when the file is already in trash.
|
|
||||||
func (s *FileService) PermanentDelete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !f.IsDeleted {
|
|
||||||
return domain.ErrConflict
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.files.DeletePermanent(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_ = s.storage.Delete(ctx, id)
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_permanent_delete", &objType, &id, nil)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace swaps the stored bytes for a file with new content.
|
|
||||||
func (s *FileService) Replace(ctx context.Context, id uuid.UUID, p UploadParams) (*domain.File, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
f, err := s.files.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, f.CreatorID, fileObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
mime, err := s.mimes.GetByName(ctx, p.MIMEType)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if _, err := io.Copy(&buf, p.Reader); err != nil {
|
|
||||||
return nil, fmt.Errorf("FileService.Replace: read body: %w", err)
|
|
||||||
}
|
|
||||||
data := buf.Bytes()
|
|
||||||
exifData, _ := extractEXIFWithDatetime(data)
|
|
||||||
|
|
||||||
if _, err := s.storage.Save(ctx, id, bytes.NewReader(data)); err != nil {
|
|
||||||
return nil, fmt.Errorf("FileService.Replace: save to storage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := &domain.File{
|
|
||||||
MIMEType: mime.Name,
|
|
||||||
MIMEExtension: mime.Extension,
|
|
||||||
EXIF: exifData,
|
|
||||||
}
|
|
||||||
if p.OriginalName != nil {
|
|
||||||
patch.OriginalName = p.OriginalName
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.files.Update(ctx, id, patch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_replace", &objType, &id, nil)
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List delegates to FileRepo with the given params.
|
|
||||||
func (s *FileService) List(ctx context.Context, params domain.FileListParams) (*domain.FilePage, error) {
|
|
||||||
return s.files.List(ctx, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Content / thumbnail / preview streaming
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// GetContent opens the raw file for download, enforcing view ACL.
|
|
||||||
func (s *FileService) GetContent(ctx context.Context, id uuid.UUID) (*ContentResult, error) {
|
|
||||||
f, err := s.Get(ctx, id) // ACL checked inside Get
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rc, err := s.storage.Read(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &ContentResult{
|
|
||||||
Body: rc,
|
|
||||||
MIMEType: f.MIMEType,
|
|
||||||
OriginalName: f.OriginalName,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetThumbnail returns the thumbnail JPEG, enforcing view ACL.
|
|
||||||
func (s *FileService) GetThumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
|
||||||
if _, err := s.Get(ctx, id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.storage.Thumbnail(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPreview returns the preview JPEG, enforcing view ACL.
|
|
||||||
func (s *FileService) GetPreview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
|
||||||
if _, err := s.Get(ctx, id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.storage.Preview(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Bulk operations
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// BulkDelete soft-deletes multiple files. Files the caller cannot edit are silently skipped.
|
|
||||||
func (s *FileService) BulkDelete(ctx context.Context, fileIDs []uuid.UUID) error {
|
|
||||||
for _, id := range fileIDs {
|
|
||||||
if err := s.Delete(ctx, id); err != nil {
|
|
||||||
if err == domain.ErrNotFound || err == domain.ErrForbidden {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Import
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Import scans a server-side directory and uploads all supported files.
|
|
||||||
// If path is empty, the configured default import path is used.
|
|
||||||
func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, error) {
|
|
||||||
dir := path
|
|
||||||
if dir == "" {
|
|
||||||
dir = s.importPath
|
|
||||||
}
|
|
||||||
if dir == "" {
|
|
||||||
return nil, domain.ErrValidation
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FileService.Import: read dir %q: %w", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &ImportResult{Errors: []ImportFileError{}}
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() {
|
|
||||||
result.Skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fullPath := filepath.Join(dir, entry.Name())
|
|
||||||
|
|
||||||
mt, err := mimetype.DetectFile(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
result.Errors = append(result.Errors, ImportFileError{
|
|
||||||
Filename: entry.Name(),
|
|
||||||
Reason: fmt.Sprintf("MIME detection failed: %s", err),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
mimeStr := mt.String()
|
|
||||||
// Strip parameters (e.g. "text/plain; charset=utf-8" → "text/plain").
|
|
||||||
if idx := len(mimeStr); idx > 0 {
|
|
||||||
for i, c := range mimeStr {
|
|
||||||
if c == ';' {
|
|
||||||
mimeStr = mimeStr[:i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.mimes.GetByName(ctx, mimeStr); err != nil {
|
|
||||||
result.Skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.Open(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
result.Errors = append(result.Errors, ImportFileError{
|
|
||||||
Filename: entry.Name(),
|
|
||||||
Reason: fmt.Sprintf("open failed: %s", err),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
name := entry.Name()
|
|
||||||
_, uploadErr := s.Upload(ctx, UploadParams{
|
|
||||||
Reader: f,
|
|
||||||
MIMEType: mimeStr,
|
|
||||||
OriginalName: &name,
|
|
||||||
})
|
|
||||||
f.Close()
|
|
||||||
|
|
||||||
if uploadErr != nil {
|
|
||||||
result.Errors = append(result.Errors, ImportFileError{
|
|
||||||
Filename: entry.Name(),
|
|
||||||
Reason: uploadErr.Error(),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result.Imported++
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Internal helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// extractEXIFWithDatetime parses EXIF from raw bytes, returning both the JSON
|
|
||||||
// representation and the DateTimeOriginal (if present). Both may be nil.
|
|
||||||
func extractEXIFWithDatetime(data []byte) (json.RawMessage, *time.Time) {
|
|
||||||
x, err := exif.Decode(bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
b, err := x.MarshalJSON()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
var dt *time.Time
|
|
||||||
if t, err := x.DateTime(); err == nil {
|
|
||||||
dt = &t
|
|
||||||
}
|
|
||||||
return json.RawMessage(b), dt
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
const poolObjectType = "pool"
|
|
||||||
const poolObjectTypeID int16 = 4 // fourth row in 007_seed_data.sql object_types
|
|
||||||
|
|
||||||
// PoolParams holds the fields for creating or patching a pool.
|
|
||||||
type PoolParams struct {
|
|
||||||
Name string
|
|
||||||
Notes *string
|
|
||||||
Metadata json.RawMessage
|
|
||||||
IsPublic *bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolService handles pool CRUD and pool–file management with ACL + audit.
|
|
||||||
type PoolService struct {
|
|
||||||
pools port.PoolRepo
|
|
||||||
acl *ACLService
|
|
||||||
audit *AuditService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPoolService creates a PoolService.
|
|
||||||
func NewPoolService(
|
|
||||||
pools port.PoolRepo,
|
|
||||||
acl *ACLService,
|
|
||||||
audit *AuditService,
|
|
||||||
) *PoolService {
|
|
||||||
return &PoolService{pools: pools, acl: acl, audit: audit}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CRUD
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// List returns a paginated list of pools.
|
|
||||||
func (s *PoolService) List(ctx context.Context, params port.OffsetParams) (*domain.PoolOffsetPage, error) {
|
|
||||||
return s.pools.List(ctx, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a pool by ID.
|
|
||||||
func (s *PoolService) Get(ctx context.Context, id uuid.UUID) (*domain.Pool, error) {
|
|
||||||
return s.pools.GetByID(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a new pool.
|
|
||||||
func (s *PoolService) Create(ctx context.Context, p PoolParams) (*domain.Pool, error) {
|
|
||||||
userID, _, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
pool := &domain.Pool{
|
|
||||||
Name: p.Name,
|
|
||||||
Notes: p.Notes,
|
|
||||||
Metadata: p.Metadata,
|
|
||||||
CreatorID: userID,
|
|
||||||
}
|
|
||||||
if p.IsPublic != nil {
|
|
||||||
pool.IsPublic = *p.IsPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := s.pools.Create(ctx, pool)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := poolObjectType
|
|
||||||
_ = s.audit.Log(ctx, "pool_create", &objType, &created.ID, nil)
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update applies a partial patch to a pool.
|
|
||||||
func (s *PoolService) Update(ctx context.Context, id uuid.UUID, p PoolParams) (*domain.Pool, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
current, err := s.pools.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, current.CreatorID, poolObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := *current
|
|
||||||
if p.Name != "" {
|
|
||||||
patch.Name = p.Name
|
|
||||||
}
|
|
||||||
if p.Notes != nil {
|
|
||||||
patch.Notes = p.Notes
|
|
||||||
}
|
|
||||||
if len(p.Metadata) > 0 {
|
|
||||||
patch.Metadata = p.Metadata
|
|
||||||
}
|
|
||||||
if p.IsPublic != nil {
|
|
||||||
patch.IsPublic = *p.IsPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.pools.Update(ctx, id, &patch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := poolObjectType
|
|
||||||
_ = s.audit.Log(ctx, "pool_edit", &objType, &id, nil)
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a pool by ID, enforcing edit ACL.
|
|
||||||
func (s *PoolService) Delete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
pool, err := s.pools.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, pool.CreatorID, poolObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.pools.Delete(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := poolObjectType
|
|
||||||
_ = s.audit.Log(ctx, "pool_delete", &objType, &id, nil)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Pool–file operations
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ListFiles returns cursor-paginated files within a pool ordered by position.
|
|
||||||
func (s *PoolService) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) {
|
|
||||||
return s.pools.ListFiles(ctx, poolID, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddFiles adds files to a pool at the given position (nil = append).
|
|
||||||
func (s *PoolService) AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error {
|
|
||||||
if err := s.pools.AddFiles(ctx, poolID, fileIDs, position); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
objType := poolObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_pool_add", &objType, &poolID, map[string]any{"count": len(fileIDs)})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveFiles removes files from a pool.
|
|
||||||
func (s *PoolService) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
|
|
||||||
if err := s.pools.RemoveFiles(ctx, poolID, fileIDs); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
objType := poolObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_pool_remove", &objType, &poolID, map[string]any{"count": len(fileIDs)})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reorder sets the full ordered sequence of file IDs within a pool.
|
|
||||||
func (s *PoolService) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
|
|
||||||
return s.pools.Reorder(ctx, poolID, fileIDs)
|
|
||||||
}
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
const tagObjectType = "tag"
|
|
||||||
const tagObjectTypeID int16 = 2 // second row in 007_seed_data.sql object_types
|
|
||||||
|
|
||||||
// TagParams holds the fields for creating or patching a tag.
|
|
||||||
type TagParams struct {
|
|
||||||
Name string
|
|
||||||
Notes *string
|
|
||||||
Color *string // nil = no change; pointer to empty string = clear
|
|
||||||
CategoryID *uuid.UUID // nil = no change; Nil UUID = unassign
|
|
||||||
Metadata json.RawMessage
|
|
||||||
IsPublic *bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagService handles tag CRUD, tag-rule management, and file–tag operations
|
|
||||||
// including automatic recursive rule application.
|
|
||||||
type TagService struct {
|
|
||||||
tags port.TagRepo
|
|
||||||
rules port.TagRuleRepo
|
|
||||||
acl *ACLService
|
|
||||||
audit *AuditService
|
|
||||||
tx port.Transactor
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTagService creates a TagService.
|
|
||||||
func NewTagService(
|
|
||||||
tags port.TagRepo,
|
|
||||||
rules port.TagRuleRepo,
|
|
||||||
acl *ACLService,
|
|
||||||
audit *AuditService,
|
|
||||||
tx port.Transactor,
|
|
||||||
) *TagService {
|
|
||||||
return &TagService{
|
|
||||||
tags: tags,
|
|
||||||
rules: rules,
|
|
||||||
acl: acl,
|
|
||||||
audit: audit,
|
|
||||||
tx: tx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tag CRUD
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// List returns a paginated, optionally filtered list of tags.
|
|
||||||
func (s *TagService) List(ctx context.Context, params port.OffsetParams) (*domain.TagOffsetPage, error) {
|
|
||||||
return s.tags.List(ctx, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a tag by ID.
|
|
||||||
func (s *TagService) Get(ctx context.Context, id uuid.UUID) (*domain.Tag, error) {
|
|
||||||
return s.tags.GetByID(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a new tag record.
|
|
||||||
func (s *TagService) Create(ctx context.Context, p TagParams) (*domain.Tag, error) {
|
|
||||||
userID, _, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
t := &domain.Tag{
|
|
||||||
Name: p.Name,
|
|
||||||
Notes: p.Notes,
|
|
||||||
Color: p.Color,
|
|
||||||
CategoryID: p.CategoryID,
|
|
||||||
Metadata: p.Metadata,
|
|
||||||
CreatorID: userID,
|
|
||||||
}
|
|
||||||
if p.IsPublic != nil {
|
|
||||||
t.IsPublic = *p.IsPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := s.tags.Create(ctx, t)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := tagObjectType
|
|
||||||
_ = s.audit.Log(ctx, "tag_create", &objType, &created.ID, nil)
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update applies a partial patch to a tag.
|
|
||||||
// The service reads the current tag first so the caller only needs to supply
|
|
||||||
// the fields that should change.
|
|
||||||
func (s *TagService) Update(ctx context.Context, id uuid.UUID, p TagParams) (*domain.Tag, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
current, err := s.tags.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, current.CreatorID, tagObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge patch into current.
|
|
||||||
patch := *current // copy
|
|
||||||
if p.Name != "" {
|
|
||||||
patch.Name = p.Name
|
|
||||||
}
|
|
||||||
if p.Notes != nil {
|
|
||||||
patch.Notes = p.Notes
|
|
||||||
}
|
|
||||||
if p.Color != nil {
|
|
||||||
patch.Color = p.Color
|
|
||||||
}
|
|
||||||
if p.CategoryID != nil {
|
|
||||||
if *p.CategoryID == uuid.Nil {
|
|
||||||
patch.CategoryID = nil // explicit unassign
|
|
||||||
} else {
|
|
||||||
patch.CategoryID = p.CategoryID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(p.Metadata) > 0 {
|
|
||||||
patch.Metadata = p.Metadata
|
|
||||||
}
|
|
||||||
if p.IsPublic != nil {
|
|
||||||
patch.IsPublic = *p.IsPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.tags.Update(ctx, id, &patch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := tagObjectType
|
|
||||||
_ = s.audit.Log(ctx, "tag_edit", &objType, &id, nil)
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a tag by ID, enforcing edit ACL.
|
|
||||||
func (s *TagService) Delete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
t, err := s.tags.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, t.CreatorID, tagObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.tags.Delete(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := tagObjectType
|
|
||||||
_ = s.audit.Log(ctx, "tag_delete", &objType, &id, nil)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tag rules
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ListRules returns all rules for a tag (when this tag is applied, these follow).
|
|
||||||
func (s *TagService) ListRules(ctx context.Context, tagID uuid.UUID) ([]domain.TagRule, error) {
|
|
||||||
return s.rules.ListByTag(ctx, tagID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateRule adds a tag rule. If applyToExisting is true, the then_tag is
|
|
||||||
// retroactively applied to all files that already carry the when_tag.
|
|
||||||
// Retroactive application requires a FileRepo; it is deferred until wired
|
|
||||||
// in a future iteration (see port.FileRepo.ListByTag).
|
|
||||||
func (s *TagService) CreateRule(ctx context.Context, whenTagID, thenTagID uuid.UUID, isActive, _ bool) (*domain.TagRule, error) {
|
|
||||||
return s.rules.Create(ctx, domain.TagRule{
|
|
||||||
WhenTagID: whenTagID,
|
|
||||||
ThenTagID: thenTagID,
|
|
||||||
IsActive: isActive,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetRuleActive toggles a rule's is_active flag and returns the updated rule.
|
|
||||||
// When active and applyToExisting are both true, the full transitive expansion
|
|
||||||
// of thenTagID is retroactively applied to files already carrying whenTagID.
|
|
||||||
func (s *TagService) SetRuleActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active, applyToExisting bool) (*domain.TagRule, error) {
|
|
||||||
if err := s.rules.SetActive(ctx, whenTagID, thenTagID, active, applyToExisting); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rules, err := s.rules.ListByTag(ctx, whenTagID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, r := range rules {
|
|
||||||
if r.ThenTagID == thenTagID {
|
|
||||||
return &r, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteRule removes a tag rule.
|
|
||||||
func (s *TagService) DeleteRule(ctx context.Context, whenTagID, thenTagID uuid.UUID) error {
|
|
||||||
return s.rules.Delete(ctx, whenTagID, thenTagID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// File–tag operations (with auto-rule expansion)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ListFileTags returns the tags on a file.
|
|
||||||
func (s *TagService) ListFileTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) {
|
|
||||||
return s.tags.ListByFile(ctx, fileID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetFileTags replaces all tags on a file, then applies active rules for all
|
|
||||||
// newly set tags (BFS expansion). Returns the full resulting tag set.
|
|
||||||
func (s *TagService) SetFileTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) ([]domain.Tag, error) {
|
|
||||||
expanded, err := s.expandTagSet(ctx, tagIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.tags.SetFileTags(ctx, fileID, expanded); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, nil)
|
|
||||||
return s.tags.ListByFile(ctx, fileID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddFileTag adds a single tag to a file, then recursively applies active rules.
|
|
||||||
// Returns the full resulting tag set.
|
|
||||||
func (s *TagService) AddFileTag(ctx context.Context, fileID, tagID uuid.UUID) ([]domain.Tag, error) {
|
|
||||||
// Compute the full set including rule-expansion from tagID.
|
|
||||||
extra, err := s.expandTagSet(ctx, []uuid.UUID{tagID})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch current tags so we don't lose them.
|
|
||||||
current, err := s.tags.ListByFile(ctx, fileID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Union: existing + expanded new tags.
|
|
||||||
seen := make(map[uuid.UUID]bool, len(current)+len(extra))
|
|
||||||
for _, t := range current {
|
|
||||||
seen[t.ID] = true
|
|
||||||
}
|
|
||||||
merged := make([]uuid.UUID, len(current))
|
|
||||||
for i, t := range current {
|
|
||||||
merged[i] = t.ID
|
|
||||||
}
|
|
||||||
for _, id := range extra {
|
|
||||||
if !seen[id] {
|
|
||||||
seen[id] = true
|
|
||||||
merged = append(merged, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.tags.SetFileTags(ctx, fileID, merged); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_tag_add", &objType, &fileID, map[string]any{"tag_id": tagID})
|
|
||||||
return s.tags.ListByFile(ctx, fileID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveFileTag removes a single tag from a file.
|
|
||||||
func (s *TagService) RemoveFileTag(ctx context.Context, fileID, tagID uuid.UUID) error {
|
|
||||||
if err := s.tags.RemoveFileTag(ctx, fileID, tagID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := fileObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_tag_remove", &objType, &fileID, map[string]any{"tag_id": tagID})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BulkSetTags adds or removes tags on multiple files (with rule expansion for add).
|
|
||||||
// Returns the tagIDs that were applied (the expanded input set for add; empty for remove).
|
|
||||||
func (s *TagService) BulkSetTags(ctx context.Context, fileIDs []uuid.UUID, action string, tagIDs []uuid.UUID) ([]uuid.UUID, error) {
|
|
||||||
if action != "add" && action != "remove" {
|
|
||||||
return nil, domain.ErrValidation
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-expand tag set once; all files get the same expansion.
|
|
||||||
var expanded []uuid.UUID
|
|
||||||
if action == "add" {
|
|
||||||
var err error
|
|
||||||
expanded, err = s.expandTagSet(ctx, tagIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, fileID := range fileIDs {
|
|
||||||
switch action {
|
|
||||||
case "add":
|
|
||||||
current, err := s.tags.ListByFile(ctx, fileID)
|
|
||||||
if err != nil {
|
|
||||||
if err == domain.ErrNotFound {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
seen := make(map[uuid.UUID]bool, len(current))
|
|
||||||
merged := make([]uuid.UUID, len(current))
|
|
||||||
for i, t := range current {
|
|
||||||
seen[t.ID] = true
|
|
||||||
merged[i] = t.ID
|
|
||||||
}
|
|
||||||
for _, id := range expanded {
|
|
||||||
if !seen[id] {
|
|
||||||
seen[id] = true
|
|
||||||
merged = append(merged, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.tags.SetFileTags(ctx, fileID, merged); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
case "remove":
|
|
||||||
current, err := s.tags.ListByFile(ctx, fileID)
|
|
||||||
if err != nil {
|
|
||||||
if err == domain.ErrNotFound {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
remove := make(map[uuid.UUID]bool, len(tagIDs))
|
|
||||||
for _, id := range tagIDs {
|
|
||||||
remove[id] = true
|
|
||||||
}
|
|
||||||
kept := make([]uuid.UUID, 0, len(current))
|
|
||||||
for _, t := range current {
|
|
||||||
if !remove[t.ID] {
|
|
||||||
kept = append(kept, t.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.tags.SetFileTags(ctx, fileID, kept); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if action == "add" {
|
|
||||||
return expanded, nil
|
|
||||||
}
|
|
||||||
return []uuid.UUID{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CommonTags returns tags present on ALL given files and tags present on SOME.
|
|
||||||
func (s *TagService) CommonTags(ctx context.Context, fileIDs []uuid.UUID) (common, partial []domain.Tag, err error) {
|
|
||||||
common, err = s.tags.CommonTagsForFiles(ctx, fileIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
partial, err = s.tags.PartialTagsForFiles(ctx, fileIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return common, partial, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Internal helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// expandTagSet runs a BFS from the given seed tags, following active tag rules,
|
|
||||||
// and returns the full set of tag IDs that should be applied (seeds + auto-applied).
|
|
||||||
func (s *TagService) expandTagSet(ctx context.Context, seeds []uuid.UUID) ([]uuid.UUID, error) {
|
|
||||||
visited := make(map[uuid.UUID]bool, len(seeds))
|
|
||||||
queue := make([]uuid.UUID, 0, len(seeds))
|
|
||||||
|
|
||||||
for _, id := range seeds {
|
|
||||||
if !visited[id] {
|
|
||||||
visited[id] = true
|
|
||||||
queue = append(queue, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < len(queue); i++ {
|
|
||||||
tagID := queue[i]
|
|
||||||
rules, err := s.rules.ListByTag(ctx, tagID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, r := range rules {
|
|
||||||
if r.IsActive && !visited[r.ThenTagID] {
|
|
||||||
visited[r.ThenTagID] = true
|
|
||||||
queue = append(queue, r.ThenTagID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return queue, nil
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UserService handles user CRUD and profile management.
|
|
||||||
type UserService struct {
|
|
||||||
users port.UserRepo
|
|
||||||
audit *AuditService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUserService creates a UserService.
|
|
||||||
func NewUserService(users port.UserRepo, audit *AuditService) *UserService {
|
|
||||||
return &UserService{users: users, audit: audit}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Self-service
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// GetMe returns the profile of the currently authenticated user.
|
|
||||||
func (s *UserService) GetMe(ctx context.Context) (*domain.User, error) {
|
|
||||||
userID, _, _ := domain.UserFromContext(ctx)
|
|
||||||
return s.users.GetByID(ctx, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateMeParams holds fields a user may change on their own profile.
|
|
||||||
type UpdateMeParams struct {
|
|
||||||
Name string // empty = no change
|
|
||||||
Password *string // nil = no change
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateMe allows a user to change their own name and/or password.
|
|
||||||
func (s *UserService) UpdateMe(ctx context.Context, p UpdateMeParams) (*domain.User, error) {
|
|
||||||
userID, _, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
current, err := s.users.GetByID(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := *current
|
|
||||||
if p.Name != "" {
|
|
||||||
patch.Name = p.Name
|
|
||||||
}
|
|
||||||
if p.Password != nil {
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(*p.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserService.UpdateMe hash: %w", err)
|
|
||||||
}
|
|
||||||
patch.Password = string(hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.users.Update(ctx, userID, &patch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Admin CRUD
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// List returns a paginated list of users (admin only — caller must enforce).
|
|
||||||
func (s *UserService) List(ctx context.Context, params port.OffsetParams) (*domain.UserPage, error) {
|
|
||||||
return s.users.List(ctx, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a user by ID (admin only).
|
|
||||||
func (s *UserService) Get(ctx context.Context, id int16) (*domain.User, error) {
|
|
||||||
return s.users.GetByID(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateParams holds fields for creating a new user.
|
|
||||||
type CreateUserParams struct {
|
|
||||||
Name string
|
|
||||||
Password string
|
|
||||||
IsAdmin bool
|
|
||||||
CanCreate bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a new user with a bcrypt-hashed password (admin only).
|
|
||||||
func (s *UserService) Create(ctx context.Context, p CreateUserParams) (*domain.User, error) {
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(p.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("UserService.Create hash: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
u := &domain.User{
|
|
||||||
Name: p.Name,
|
|
||||||
Password: string(hash),
|
|
||||||
IsAdmin: p.IsAdmin,
|
|
||||||
CanCreate: p.CanCreate,
|
|
||||||
}
|
|
||||||
created, err := s.users.Create(ctx, u)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = s.audit.Log(ctx, "user_create", nil, nil, map[string]any{"target_user_id": created.ID})
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateAdminParams holds fields an admin may change on any user.
|
|
||||||
type UpdateAdminParams struct {
|
|
||||||
IsAdmin *bool
|
|
||||||
CanCreate *bool
|
|
||||||
IsBlocked *bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateAdmin applies an admin-level patch to a user.
|
|
||||||
func (s *UserService) UpdateAdmin(ctx context.Context, id int16, p UpdateAdminParams) (*domain.User, error) {
|
|
||||||
current, err := s.users.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := *current
|
|
||||||
if p.IsAdmin != nil {
|
|
||||||
patch.IsAdmin = *p.IsAdmin
|
|
||||||
}
|
|
||||||
if p.CanCreate != nil {
|
|
||||||
patch.CanCreate = *p.CanCreate
|
|
||||||
}
|
|
||||||
if p.IsBlocked != nil {
|
|
||||||
patch.IsBlocked = *p.IsBlocked
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.users.Update(ctx, id, &patch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log block/unblock specifically.
|
|
||||||
if p.IsBlocked != nil {
|
|
||||||
action := "user_unblock"
|
|
||||||
if *p.IsBlocked {
|
|
||||||
action = "user_block"
|
|
||||||
}
|
|
||||||
_ = s.audit.Log(ctx, action, nil, nil, map[string]any{"target_user_id": id})
|
|
||||||
}
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a user by ID (admin only).
|
|
||||||
func (s *UserService) Delete(ctx context.Context, id int16) error {
|
|
||||||
if err := s.users.Delete(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_ = s.audit.Log(ctx, "user_delete", nil, nil, map[string]any{"target_user_id": id})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
// Package storage provides a local-filesystem implementation of port.FileStorage.
|
|
||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
"image/color"
|
|
||||||
"image/jpeg"
|
|
||||||
_ "image/gif" // register GIF decoder
|
|
||||||
_ "image/png" // register PNG decoder
|
|
||||||
_ "golang.org/x/image/webp" // register WebP decoder
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DiskStorage implements port.FileStorage using the local filesystem.
|
|
||||||
//
|
|
||||||
// Directory layout:
|
|
||||||
//
|
|
||||||
// {filesPath}/{id} — original file (UUID basename, no extension)
|
|
||||||
// {thumbsPath}/{id}_thumb.jpg — thumbnail cache
|
|
||||||
// {thumbsPath}/{id}_preview.jpg — preview cache
|
|
||||||
type DiskStorage struct {
|
|
||||||
filesPath string
|
|
||||||
thumbsPath string
|
|
||||||
thumbWidth int
|
|
||||||
thumbHeight int
|
|
||||||
previewWidth int
|
|
||||||
previewHeight int
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.FileStorage = (*DiskStorage)(nil)
|
|
||||||
|
|
||||||
// NewDiskStorage creates a DiskStorage and ensures both directories exist.
|
|
||||||
func NewDiskStorage(
|
|
||||||
filesPath, thumbsPath string,
|
|
||||||
thumbW, thumbH, prevW, prevH int,
|
|
||||||
) (*DiskStorage, error) {
|
|
||||||
for _, p := range []string{filesPath, thumbsPath} {
|
|
||||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
|
||||||
return nil, fmt.Errorf("storage: create directory %q: %w", p, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &DiskStorage{
|
|
||||||
filesPath: filesPath,
|
|
||||||
thumbsPath: thumbsPath,
|
|
||||||
thumbWidth: thumbW,
|
|
||||||
thumbHeight: thumbH,
|
|
||||||
previewWidth: prevW,
|
|
||||||
previewHeight: prevH,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// port.FileStorage implementation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Save writes r to {filesPath}/{id} and returns the number of bytes written.
|
|
||||||
func (s *DiskStorage) Save(_ context.Context, id uuid.UUID, r io.Reader) (int64, error) {
|
|
||||||
dst := s.originalPath(id)
|
|
||||||
f, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("storage.Save create %q: %w", dst, err)
|
|
||||||
}
|
|
||||||
n, copyErr := io.Copy(f, r)
|
|
||||||
closeErr := f.Close()
|
|
||||||
if copyErr != nil {
|
|
||||||
os.Remove(dst)
|
|
||||||
return 0, fmt.Errorf("storage.Save write: %w", copyErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
os.Remove(dst)
|
|
||||||
return 0, fmt.Errorf("storage.Save close: %w", closeErr)
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read opens the original file for reading. The caller must close the result.
|
|
||||||
func (s *DiskStorage) Read(_ context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
|
||||||
f, err := os.Open(s.originalPath(id))
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("storage.Read: %w", err)
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes the original file. Returns ErrNotFound if it does not exist.
|
|
||||||
func (s *DiskStorage) Delete(_ context.Context, id uuid.UUID) error {
|
|
||||||
if err := os.Remove(s.originalPath(id)); err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return fmt.Errorf("storage.Delete: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thumbnail returns a JPEG that fits within the configured max width×height
|
|
||||||
// (never upscaled, never cropped). Generated on first call and cached.
|
|
||||||
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder.
|
|
||||||
func (s *DiskStorage) Thumbnail(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
|
||||||
return s.serveGenerated(ctx, id, s.thumbCachePath(id), s.thumbWidth, s.thumbHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preview returns a JPEG that fits within the configured max width×height
|
|
||||||
// (never upscaled, never cropped). Generated on first call and cached.
|
|
||||||
// Video files are thumbnailed via ffmpeg; other non-image files get a placeholder.
|
|
||||||
func (s *DiskStorage) Preview(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) {
|
|
||||||
return s.serveGenerated(ctx, id, s.previewCachePath(id), s.previewWidth, s.previewHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Internal helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// serveGenerated is the shared implementation for Thumbnail and Preview.
|
|
||||||
// imaging.Thumbnail fits the source within maxW×maxH without upscaling or cropping.
|
|
||||||
//
|
|
||||||
// Resolution order:
|
|
||||||
// 1. Return cached JPEG if present.
|
|
||||||
// 2. Decode as still image (JPEG/PNG/GIF via imaging).
|
|
||||||
// 3. Extract a frame with ffmpeg (video files).
|
|
||||||
// 4. Solid-colour placeholder (archives, unrecognised formats, etc.).
|
|
||||||
func (s *DiskStorage) serveGenerated(ctx context.Context, id uuid.UUID, cachePath string, maxW, maxH int) (io.ReadCloser, error) {
|
|
||||||
// Fast path: cache hit.
|
|
||||||
if f, err := os.Open(cachePath); err == nil {
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the original file exists before doing any work.
|
|
||||||
srcPath := s.originalPath(id)
|
|
||||||
if _, err := os.Stat(srcPath); err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Try still-image decode (JPEG/PNG/GIF).
|
|
||||||
// 2. Try video frame extraction via ffmpeg.
|
|
||||||
// 3. Fall back to placeholder.
|
|
||||||
var img image.Image
|
|
||||||
if decoded, err := imaging.Open(srcPath, imaging.AutoOrientation(true)); err == nil {
|
|
||||||
img = imaging.Thumbnail(decoded, maxW, maxH, imaging.Lanczos)
|
|
||||||
} else if frame, err := extractVideoFrame(ctx, srcPath); err == nil {
|
|
||||||
img = imaging.Thumbnail(frame, maxW, maxH, imaging.Lanczos)
|
|
||||||
} else {
|
|
||||||
img = placeholder(maxW, maxH)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to cache atomically (temp→rename) and return an open reader.
|
|
||||||
if rc, err := writeCache(cachePath, img); err == nil {
|
|
||||||
return rc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache write failed (read-only fs, disk full, …). Serve from an
|
|
||||||
// in-memory buffer so the request still succeeds.
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
|
||||||
return nil, fmt.Errorf("storage: encode in-memory JPEG: %w", err)
|
|
||||||
}
|
|
||||||
return io.NopCloser(&buf), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeCache encodes img as JPEG to cachePath via an atomic temp→rename write,
|
|
||||||
// then opens and returns the cache file.
|
|
||||||
func writeCache(cachePath string, img image.Image) (io.ReadCloser, error) {
|
|
||||||
dir := filepath.Dir(cachePath)
|
|
||||||
tmp, err := os.CreateTemp(dir, ".cache-*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("storage: create temp file: %w", err)
|
|
||||||
}
|
|
||||||
tmpName := tmp.Name()
|
|
||||||
|
|
||||||
encErr := jpeg.Encode(tmp, img, &jpeg.Options{Quality: 85})
|
|
||||||
closeErr := tmp.Close()
|
|
||||||
if encErr != nil {
|
|
||||||
os.Remove(tmpName)
|
|
||||||
return nil, fmt.Errorf("storage: encode cache JPEG: %w", encErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
os.Remove(tmpName)
|
|
||||||
return nil, fmt.Errorf("storage: close temp file: %w", closeErr)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpName, cachePath); err != nil {
|
|
||||||
os.Remove(tmpName)
|
|
||||||
return nil, fmt.Errorf("storage: rename cache file: %w", err)
|
|
||||||
}
|
|
||||||
f, err := os.Open(cachePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("storage: open cache file: %w", err)
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractVideoFrame uses ffmpeg to extract a single frame from a video file.
|
|
||||||
// It seeks 1 second in (keyframe-accurate fast seek) and pipes the frame out
|
|
||||||
// as PNG. If the video is shorter than 1 s the seek is silently ignored by
|
|
||||||
// ffmpeg and the first available frame is returned instead.
|
|
||||||
// Returns an error if ffmpeg is not installed or produces no output.
|
|
||||||
func extractVideoFrame(ctx context.Context, srcPath string) (image.Image, error) {
|
|
||||||
var out bytes.Buffer
|
|
||||||
cmd := exec.CommandContext(ctx, "ffmpeg",
|
|
||||||
"-ss", "1", // fast input seek; ignored gracefully on short files
|
|
||||||
"-i", srcPath,
|
|
||||||
"-vframes", "1",
|
|
||||||
"-f", "image2",
|
|
||||||
"-vcodec", "png",
|
|
||||||
"pipe:1",
|
|
||||||
)
|
|
||||||
cmd.Stdout = &out
|
|
||||||
cmd.Stderr = io.Discard // suppress ffmpeg progress output
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil || out.Len() == 0 {
|
|
||||||
return nil, fmt.Errorf("ffmpeg frame extract: %w", err)
|
|
||||||
}
|
|
||||||
return imaging.Decode(&out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Path helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (s *DiskStorage) originalPath(id uuid.UUID) string {
|
|
||||||
return filepath.Join(s.filesPath, id.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage) thumbCachePath(id uuid.UUID) string {
|
|
||||||
return filepath.Join(s.thumbsPath, id.String()+"_thumb.jpg")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage) previewCachePath(id uuid.UUID) string {
|
|
||||||
return filepath.Join(s.thumbsPath, id.String()+"_preview.jpg")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Image helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// placeholder returns a solid-colour image of size w×h for files that cannot
|
|
||||||
// be decoded as images. Uses #444455 from the design palette.
|
|
||||||
func placeholder(w, h int) *image.NRGBA {
|
|
||||||
return imaging.New(w, h, color.NRGBA{R: 0x44, G: 0x44, B: 0x55, A: 0xFF})
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ CREATE SCHEMA IF NOT EXISTS acl;
|
|||||||
CREATE SCHEMA IF NOT EXISTS activity;
|
CREATE SCHEMA IF NOT EXISTS activity;
|
||||||
|
|
||||||
-- UUID v7 generator
|
-- UUID v7 generator
|
||||||
-- +goose StatementBegin
|
|
||||||
CREATE OR REPLACE FUNCTION public.uuid_v7(cts timestamptz DEFAULT clock_timestamp())
|
CREATE OR REPLACE FUNCTION public.uuid_v7(cts timestamptz DEFAULT clock_timestamp())
|
||||||
RETURNS uuid LANGUAGE plpgsql AS $$
|
RETURNS uuid LANGUAGE plpgsql AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
@@ -39,17 +38,14 @@ BEGIN
|
|||||||
substring(entropy from 1 for 12))::uuid;
|
substring(entropy from 1 for 12))::uuid;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
-- +goose StatementEnd
|
|
||||||
|
|
||||||
-- Extract timestamp from UUID v7
|
-- Extract timestamp from UUID v7
|
||||||
-- +goose StatementBegin
|
|
||||||
CREATE OR REPLACE FUNCTION public.uuid_extract_timestamp(uuid_val uuid)
|
CREATE OR REPLACE FUNCTION public.uuid_extract_timestamp(uuid_val uuid)
|
||||||
RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
||||||
SELECT to_timestamp(
|
SELECT to_timestamp(
|
||||||
('x' || left(replace(uuid_val::text, '-', ''), 12))::bit(48)::bigint / 1000.0
|
('x' || left(replace(uuid_val::text, '-', ''), 12))::bit(48)::bigint / 1000.0
|
||||||
);
|
);
|
||||||
$$;
|
$$;
|
||||||
-- +goose StatementEnd
|
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ CREATE TABLE core.users (
|
|||||||
password text NOT NULL, -- bcrypt hash via pgcrypto
|
password text NOT NULL, -- bcrypt hash via pgcrypto
|
||||||
is_admin boolean NOT NULL DEFAULT false,
|
is_admin boolean NOT NULL DEFAULT false,
|
||||||
can_create boolean NOT NULL DEFAULT false,
|
can_create boolean NOT NULL DEFAULT false,
|
||||||
is_blocked boolean NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
CONSTRAINT uni__users__name UNIQUE (name)
|
CONSTRAINT uni__users__name UNIQUE (name)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ CREATE TABLE data.files (
|
|||||||
content_datetime timestamptz NOT NULL DEFAULT clock_timestamp(), -- content datetime (e.g. photo taken)
|
content_datetime timestamptz NOT NULL DEFAULT clock_timestamp(), -- content datetime (e.g. photo taken)
|
||||||
notes text,
|
notes text,
|
||||||
metadata jsonb, -- user-editable key-value data
|
metadata jsonb, -- user-editable key-value data
|
||||||
exif jsonb, -- EXIF data extracted at upload (immutable)
|
exif jsonb NOT NULL DEFAULT '{}'::jsonb, -- EXIF data extracted at upload (immutable)
|
||||||
phash bigint, -- perceptual hash for duplicate detection (future)
|
phash bigint, -- perceptual hash for duplicate detection (future)
|
||||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
creator_id smallint NOT NULL REFERENCES core.users(id)
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
ON UPDATE CASCADE ON DELETE RESTRICT,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ CREATE TABLE activity.sessions (
|
|||||||
started_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
started_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
||||||
expires_at timestamptz,
|
expires_at timestamptz,
|
||||||
last_activity timestamptz NOT NULL DEFAULT statement_timestamp(),
|
last_activity timestamptz NOT NULL DEFAULT statement_timestamp(),
|
||||||
is_active boolean NOT NULL DEFAULT true,
|
|
||||||
|
|
||||||
CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash)
|
CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
-- +goose Up
|
-- +goose Up
|
||||||
|
|
||||||
INSERT INTO core.mime_types (name, extension) VALUES
|
|
||||||
('image/jpeg', 'jpg'),
|
|
||||||
('image/png', 'png'),
|
|
||||||
('image/gif', 'gif'),
|
|
||||||
('image/webp', 'webp'),
|
|
||||||
('video/mp4', 'mp4'),
|
|
||||||
('video/quicktime', 'mov'),
|
|
||||||
('video/x-msvideo', 'avi'),
|
|
||||||
('video/webm', 'webm'),
|
|
||||||
('video/3gpp', '3gp'),
|
|
||||||
('video/x-m4v', 'm4v');
|
|
||||||
|
|
||||||
INSERT INTO core.object_types (name) VALUES
|
INSERT INTO core.object_types (name) VALUES
|
||||||
('file'), ('tag'), ('category'), ('pool');
|
('file'), ('tag'), ('category'), ('pool');
|
||||||
|
|
||||||
@@ -38,12 +26,7 @@ INSERT INTO activity.action_types (name) VALUES
|
|||||||
-- Sessions
|
-- Sessions
|
||||||
('session_terminate');
|
('session_terminate');
|
||||||
|
|
||||||
INSERT INTO core.users (name, password, is_admin, can_create) VALUES
|
|
||||||
('admin', '$2a$10$zk.VTFjRRxbkTE7cKfc7KOWeZfByk1VEkbkgZMJggI1fFf.yDEHZy', true, true);
|
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
|
|
||||||
DELETE FROM core.users WHERE name = 'admin';
|
|
||||||
DELETE FROM activity.action_types;
|
DELETE FROM activity.action_types;
|
||||||
DELETE FROM core.object_types;
|
DELETE FROM core.object_types;
|
||||||
DELETE FROM core.mime_types;
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import "embed"
|
|
||||||
|
|
||||||
// FS holds all goose migration SQL files, embedded at build time.
|
|
||||||
//
|
|
||||||
//go:embed *.sql
|
|
||||||
var FS embed.FS
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
node_modules
|
|
||||||
|
|
||||||
# Output
|
|
||||||
.output
|
|
||||||
.vercel
|
|
||||||
.netlify
|
|
||||||
.wrangler
|
|
||||||
/.svelte-kit
|
|
||||||
/build
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Env
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
!.env.test
|
|
||||||
|
|
||||||
# Vite
|
|
||||||
vite.config.js.timestamp-*
|
|
||||||
vite.config.ts.timestamp-*
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
engine-strict=true
|
|
||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["svelte.svelte-vscode"]
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# sv
|
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
|
||||||
|
|
||||||
## Creating a project
|
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# create a new project
|
|
||||||
npx sv create my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
To recreate this project with the same configuration:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# recreate this project
|
|
||||||
npx sv@0.13.2 create --template minimal --types ts --install npm frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
## Developing
|
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
To create a production version of your app:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
|
||||||
Generated
-2601
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.1",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
|
|
||||||
"dev": "npm run generate:types && vite dev",
|
|
||||||
"build": "npm run generate:types && vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
|
||||||
"@sveltejs/kit": "^2.50.2",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
|
||||||
"@types/node": "^25.5.2",
|
|
||||||
"openapi-typescript": "^7.13.0",
|
|
||||||
"svelte": "^5.54.0",
|
|
||||||
"svelte-check": "^4.4.2",
|
|
||||||
"tailwindcss": "^4.2.2",
|
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"vite": "^7.3.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
@import 'tailwindcss';
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--color-bg-primary: #312F45;
|
|
||||||
--color-bg-secondary: #181721;
|
|
||||||
--color-bg-elevated: #111118;
|
|
||||||
--color-accent: #9592B5;
|
|
||||||
--color-accent-hover: #7D7AA4;
|
|
||||||
--color-text-primary: #f0f0f0;
|
|
||||||
--color-text-muted: #9999AD;
|
|
||||||
--color-danger: #DB6060;
|
|
||||||
--color-info: #4DC7ED;
|
|
||||||
--color-warning: #F5E872;
|
|
||||||
--color-tag-default: #444455;
|
|
||||||
--color-nav-bg: rgba(0, 0, 0, 0.45);
|
|
||||||
--color-nav-active: rgba(52, 50, 73, 0.72);
|
|
||||||
|
|
||||||
--font-sans: 'Epilogue', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="light"] {
|
|
||||||
--color-bg-primary: #f5f5f5;
|
|
||||||
--color-bg-secondary: #ffffff;
|
|
||||||
--color-bg-elevated: #e8e8ec;
|
|
||||||
--color-accent: #6B68A0;
|
|
||||||
--color-accent-hover: #5A578F;
|
|
||||||
--color-text-primary: #111118;
|
|
||||||
--color-text-muted: #555566;
|
|
||||||
--color-tag-default: #ccccdd;
|
|
||||||
--color-nav-bg: rgba(240, 240, 245, 0.85);
|
|
||||||
--color-nav-active: rgba(90, 87, 143, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Epilogue';
|
|
||||||
src: url('/fonts/Epilogue-VariableFont_wght.ttf') format('truetype');
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
Vendored
-13
@@ -1,13 +0,0 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
|
||||||
// for information about these interfaces
|
|
||||||
declare global {
|
|
||||||
namespace App {
|
|
||||||
// interface Error {}
|
|
||||||
// interface Locals {}
|
|
||||||
// interface PageData {}
|
|
||||||
// interface PageState {}
|
|
||||||
// interface Platform {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="theme-color" content="#312F45" />
|
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Tanabata" />
|
|
||||||
<meta name="msapplication-TileColor" content="#312F45" />
|
|
||||||
<meta name="msapplication-TileImage" content="/images/ms-icon-144x144.png" />
|
|
||||||
<meta name="msapplication-config" content="/browserconfig.xml" />
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon-96x96.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="57x57" href="/images/apple-icon-57x57.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="60x60" href="/images/apple-icon-60x60.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="72x72" href="/images/apple-icon-72x72.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="76x76" href="/images/apple-icon-76x76.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="114x114" href="/images/apple-icon-114x114.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="120x120" href="/images/apple-icon-120x120.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="144x144" href="/images/apple-icon-144x144.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="152x152" href="/images/apple-icon-152x152.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-icon-180x180.png" />
|
|
||||||
<link rel="preload" href="/fonts/Epilogue-VariableFont_wght.ttf" as="font" type="font/ttf" crossorigin="anonymous" />
|
|
||||||
%sveltekit.head%
|
|
||||||
</head>
|
|
||||||
<body data-sveltekit-preload-data="hover">
|
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
import { authStore } from '$lib/stores/auth';
|
|
||||||
import { api } from './client';
|
|
||||||
import type { TokenPair, SessionList } from './types';
|
|
||||||
|
|
||||||
export async function login(name: string, password: string): Promise<void> {
|
|
||||||
const tokens = await api.post<TokenPair>('/auth/login', { name, password });
|
|
||||||
authStore.update((s) => ({
|
|
||||||
...s,
|
|
||||||
accessToken: tokens.access_token ?? null,
|
|
||||||
refreshToken: tokens.refresh_token ?? null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await api.post('/auth/logout');
|
|
||||||
} finally {
|
|
||||||
authStore.set({ accessToken: null, refreshToken: null, user: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refresh(): Promise<void> {
|
|
||||||
const { refreshToken } = get(authStore);
|
|
||||||
if (!refreshToken) throw new Error('No refresh token');
|
|
||||||
|
|
||||||
const tokens = await api.post<TokenPair>('/auth/refresh', { refresh_token: refreshToken });
|
|
||||||
authStore.update((s) => ({
|
|
||||||
...s,
|
|
||||||
accessToken: tokens.access_token ?? null,
|
|
||||||
refreshToken: tokens.refresh_token ?? null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listSessions(params?: { offset?: number; limit?: number }): Promise<SessionList> {
|
|
||||||
const entries = Object.entries(params ?? {})
|
|
||||||
.filter(([, v]) => v !== undefined)
|
|
||||||
.map(([k, v]) => [k, String(v)]);
|
|
||||||
const qs = entries.length ? '?' + new URLSearchParams(entries).toString() : '';
|
|
||||||
return api.get<SessionList>(`/auth/sessions${qs}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function terminateSession(sessionId: number): Promise<void> {
|
|
||||||
return api.delete<void>(`/auth/sessions/${sessionId}`);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
import { authStore } from '$lib/stores/auth';
|
|
||||||
|
|
||||||
const BASE = '/api/v1';
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly status: number,
|
|
||||||
public readonly code: string,
|
|
||||||
message: string,
|
|
||||||
public readonly details?: Array<{ field?: string; message?: string }>,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'ApiError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduplicates concurrent 401 refresh attempts into a single in-flight request.
|
|
||||||
let refreshPromise: Promise<void> | null = null;
|
|
||||||
|
|
||||||
async function refreshTokens(): Promise<void> {
|
|
||||||
const { refreshToken } = get(authStore);
|
|
||||||
if (!refreshToken) {
|
|
||||||
authStore.set({ accessToken: null, refreshToken: null, user: null });
|
|
||||||
throw new ApiError(401, 'unauthorized', 'Session expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${BASE}/auth/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
authStore.set({ accessToken: null, refreshToken: null, user: null });
|
|
||||||
throw new ApiError(401, 'unauthorized', 'Session expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
authStore.update((s) => ({
|
|
||||||
...s,
|
|
||||||
accessToken: data.access_token ?? null,
|
|
||||||
refreshToken: data.refresh_token ?? null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildHeaders(init: RequestInit | undefined, accessToken: string | null): HeadersInit {
|
|
||||||
const isFormData = init?.body instanceof FormData;
|
|
||||||
const base: Record<string, string> = isFormData ? {} : { 'Content-Type': 'application/json' };
|
|
||||||
if (accessToken) base['Authorization'] = `Bearer ${accessToken}`;
|
|
||||||
return { ...base, ...(init?.headers as Record<string, string> | undefined) };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
let res = await fetch(BASE + path, {
|
|
||||||
...init,
|
|
||||||
headers: buildHeaders(init, get(authStore).accessToken),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
|
||||||
if (!refreshPromise) {
|
|
||||||
refreshPromise = refreshTokens().finally(() => {
|
|
||||||
refreshPromise = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await refreshPromise;
|
|
||||||
} catch {
|
|
||||||
throw new ApiError(401, 'unauthorized', 'Session expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
res = await fetch(BASE + path, {
|
|
||||||
...init,
|
|
||||||
headers: buildHeaders(init, get(authStore).accessToken),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
let body: { code?: string; message?: string; details?: Array<{ field?: string; message?: string }> } = {};
|
|
||||||
try {
|
|
||||||
body = await res.json();
|
|
||||||
} catch {
|
|
||||||
// ignore parse failure
|
|
||||||
}
|
|
||||||
throw new ApiError(res.status, body.code ?? 'error', body.message ?? res.statusText, body.details);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === 204) return undefined as T;
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Upload with XHR so we can track progress via onProgress(0–100). */
|
|
||||||
export function uploadWithProgress<T>(
|
|
||||||
path: string,
|
|
||||||
formData: FormData,
|
|
||||||
onProgress: (pct: number) => void,
|
|
||||||
): Promise<T> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const token = get(authStore).accessToken;
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', BASE + path);
|
|
||||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
|
||||||
|
|
||||||
xhr.upload.onprogress = (e) => {
|
|
||||||
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onload = () => {
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(xhr.responseText) as T);
|
|
||||||
} catch {
|
|
||||||
resolve(undefined as T);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let body: { code?: string; message?: string } = {};
|
|
||||||
try {
|
|
||||||
body = JSON.parse(xhr.responseText);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
reject(new ApiError(xhr.status, body.code ?? 'error', body.message ?? xhr.statusText));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onerror = () => reject(new ApiError(0, 'network_error', 'Network error'));
|
|
||||||
xhr.send(formData);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
get: <T>(path: string) => request<T>(path),
|
|
||||||
post: <T>(path: string, body?: unknown) =>
|
|
||||||
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
|
||||||
patch: <T>(path: string, body?: unknown) =>
|
|
||||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
|
||||||
put: <T>(path: string, body?: unknown) =>
|
|
||||||
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
|
||||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
|
||||||
upload: <T>(path: string, formData: FormData) =>
|
|
||||||
request<T>(path, { method: 'POST', body: formData }),
|
|
||||||
};
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { components } from './schema';
|
|
||||||
|
|
||||||
export type File = components['schemas']['File'];
|
|
||||||
export type Tag = components['schemas']['Tag'];
|
|
||||||
export type Category = components['schemas']['Category'];
|
|
||||||
export type Pool = components['schemas']['Pool'];
|
|
||||||
export type PoolFile = components['schemas']['PoolFile'];
|
|
||||||
export type User = components['schemas']['User'];
|
|
||||||
export type Session = components['schemas']['Session'];
|
|
||||||
export type TokenPair = components['schemas']['TokenPair'];
|
|
||||||
export type Permission = components['schemas']['Permission'];
|
|
||||||
export type AuditEntry = components['schemas']['AuditLogEntry'];
|
|
||||||
export type TagRule = components['schemas']['TagRule'];
|
|
||||||
|
|
||||||
export type FileCursorPage = components['schemas']['FileCursorPage'];
|
|
||||||
export type TagOffsetPage = components['schemas']['TagOffsetPage'];
|
|
||||||
export type CategoryOffsetPage = components['schemas']['CategoryOffsetPage'];
|
|
||||||
export type PoolOffsetPage = components['schemas']['PoolOffsetPage'];
|
|
||||||
export type UserOffsetPage = components['schemas']['UserOffsetPage'];
|
|
||||||
export type AuditOffsetPage = components['schemas']['AuditLogOffsetPage'];
|
|
||||||
export type PoolFileCursorPage = components['schemas']['PoolFileCursorPage'];
|
|
||||||
export type SessionList = components['schemas']['SessionList'];
|
|
||||||
|
|
||||||
export type ApiError = components['schemas']['Error'];
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,123 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
message: string;
|
|
||||||
confirmLabel?: string;
|
|
||||||
danger?: boolean;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { message, confirmLabel = 'Confirm', danger = false, onConfirm, onCancel }: Props = $props();
|
|
||||||
|
|
||||||
let dialog = $state<HTMLDialogElement | undefined>();
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
dialog?.showModal();
|
|
||||||
return () => dialog?.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') onCancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBackdropClick(e: MouseEvent) {
|
|
||||||
if (e.target === dialog) onCancel();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
||||||
<dialog
|
|
||||||
bind:this={dialog}
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
onclick={handleBackdropClick}
|
|
||||||
aria-modal="true"
|
|
||||||
>
|
|
||||||
<div class="body">
|
|
||||||
<p class="message">{message}</p>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn cancel" onclick={onCancel}>Cancel</button>
|
|
||||||
<button class="btn confirm" class:danger onclick={onConfirm}>{confirmLabel}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
dialog {
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
background-color: var(--color-bg-secondary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
|
||||||
max-width: min(340px, calc(100vw - 32px));
|
|
||||||
width: 100%;
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog::backdrop {
|
|
||||||
background-color: rgba(0, 0, 0, 0.55);
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
padding: 20px 20px 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.cancel {
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.cancel:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.confirm {
|
|
||||||
border: none;
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
color: var(--color-bg-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.confirm:hover {
|
|
||||||
background-color: var(--color-accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.confirm.danger {
|
|
||||||
background-color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.confirm.danger:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-danger) 80%, #fff);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
loading?: boolean;
|
|
||||||
hasMore?: boolean;
|
|
||||||
onLoadMore: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { loading = false, hasMore = true, onLoadMore }: Props = $props();
|
|
||||||
|
|
||||||
let sentinel = $state<HTMLDivElement | undefined>();
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!sentinel) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (entries[0].isIntersecting && !loading && hasMore) {
|
|
||||||
onLoadMore();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: '300px' },
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(sentinel);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="loading-row">
|
|
||||||
<span class="spinner" role="status" aria-label="Loading"></span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.sentinel {
|
|
||||||
height: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 24px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: block;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
|
||||||
border-top-color: var(--color-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { api } from '$lib/api/client';
|
|
||||||
import type { Tag, TagOffsetPage } from '$lib/api/types';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
fileIds: string[];
|
|
||||||
onDone: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { fileIds, onDone }: Props = $props();
|
|
||||||
|
|
||||||
// Tags present on ALL selected files
|
|
||||||
let commonIds = $state(new Set<string>());
|
|
||||||
// Tags present on SOME but not all selected files
|
|
||||||
let partialIds = $state(new Set<string>());
|
|
||||||
// All available tags from /tags
|
|
||||||
let allTags = $state<Tag[]>([]);
|
|
||||||
|
|
||||||
let search = $state('');
|
|
||||||
let busy = $state(false);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
load();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const [tagsRes, commonRes] = await Promise.all([
|
|
||||||
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc'),
|
|
||||||
api.post<{ common_tag_ids: string[]; partial_tag_ids: string[] }>(
|
|
||||||
'/files/bulk/common-tags',
|
|
||||||
{ file_ids: fileIds },
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
allTags = tagsRes.items ?? [];
|
|
||||||
commonIds = new Set(commonRes.common_tag_ids ?? []);
|
|
||||||
partialIds = new Set(commonRes.partial_tag_ids ?? []);
|
|
||||||
} catch {
|
|
||||||
error = 'Failed to load tags';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assigned = common + partial (shown in assigned section)
|
|
||||||
let assignedIds = $derived(new Set([...commonIds, ...partialIds]));
|
|
||||||
|
|
||||||
let assignedTags = $derived(
|
|
||||||
allTags.filter(
|
|
||||||
(t) =>
|
|
||||||
assignedIds.has(t.id ?? '') &&
|
|
||||||
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let availableTags = $derived(
|
|
||||||
allTags.filter(
|
|
||||||
(t) =>
|
|
||||||
!assignedIds.has(t.id ?? '') &&
|
|
||||||
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
function tagStyle(tag: Tag) {
|
|
||||||
const color = tag.color ?? tag.category_color;
|
|
||||||
return color ? `background-color: #${color}` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function add(tagId: string) {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
try {
|
|
||||||
await api.post('/files/bulk/tags', { file_ids: fileIds, action: 'add', tag_ids: [tagId] });
|
|
||||||
commonIds = new Set([...commonIds, tagId]);
|
|
||||||
partialIds.delete(tagId);
|
|
||||||
partialIds = new Set(partialIds);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clicking a partial tag promotes it to common (adds to all files that don't have it)
|
|
||||||
async function promotePartial(tagId: string) {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
try {
|
|
||||||
await api.post('/files/bulk/tags', { file_ids: fileIds, action: 'add', tag_ids: [tagId] });
|
|
||||||
commonIds = new Set([...commonIds, tagId]);
|
|
||||||
partialIds.delete(tagId);
|
|
||||||
partialIds = new Set(partialIds);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(tagId: string) {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
try {
|
|
||||||
await api.post('/files/bulk/tags', { file_ids: fileIds, action: 'remove', tag_ids: [tagId] });
|
|
||||||
commonIds.delete(tagId);
|
|
||||||
partialIds.delete(tagId);
|
|
||||||
commonIds = new Set(commonIds);
|
|
||||||
partialIds = new Set(partialIds);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="editor" class:busy>
|
|
||||||
{#if loading}
|
|
||||||
<p class="status">Loading…</p>
|
|
||||||
{:else if error}
|
|
||||||
<p class="status err">{error}</p>
|
|
||||||
{:else}
|
|
||||||
<!-- Assigned tags -->
|
|
||||||
{#if assignedTags.length > 0}
|
|
||||||
<div class="section-label">
|
|
||||||
Assigned
|
|
||||||
<span class="hint">— partial tags shown with dashed border, click to apply to all</span>
|
|
||||||
</div>
|
|
||||||
<div class="tag-row">
|
|
||||||
{#each assignedTags as tag (tag.id)}
|
|
||||||
{@const isPartial = partialIds.has(tag.id ?? '')}
|
|
||||||
<div class="tag-wrap">
|
|
||||||
<button
|
|
||||||
class="tag assigned"
|
|
||||||
class:partial={isPartial}
|
|
||||||
style={tagStyle(tag)}
|
|
||||||
onclick={() => isPartial ? promotePartial(tag.id!) : remove(tag.id!)}
|
|
||||||
title={isPartial ? 'Partial — click to add to all files' : 'Click to remove from all files'}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
{#if isPartial}
|
|
||||||
<span class="partial-icon" aria-label="partial">~</span>
|
|
||||||
{:else}
|
|
||||||
<span class="remove" aria-label="remove">×</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Search -->
|
|
||||||
<div class="search-wrap">
|
|
||||||
<input
|
|
||||||
class="search"
|
|
||||||
type="search"
|
|
||||||
placeholder="Search tags…"
|
|
||||||
bind:value={search}
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
{#if search}
|
|
||||||
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Available tags -->
|
|
||||||
{#if availableTags.length > 0}
|
|
||||||
<div class="section-label">Add tag</div>
|
|
||||||
<div class="tag-row available-row">
|
|
||||||
{#each availableTags as tag (tag.id)}
|
|
||||||
<button
|
|
||||||
class="tag available"
|
|
||||||
style={tagStyle(tag)}
|
|
||||||
onclick={() => add(tag.id!)}
|
|
||||||
title="Add to all selected files"
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if search.trim() && availableTags.length === 0 && assignedTags.length === 0}
|
|
||||||
<p class="empty">No matching tags</p>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor.busy {
|
|
||||||
opacity: 0.6;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.err {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
font-weight: 400;
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: 0;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-row {
|
|
||||||
max-height: 140px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-wrap {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
height: 26px;
|
|
||||||
padding: 0 9px;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background-color: var(--color-tag-default);
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Common tag — solid, slightly faded ×, full opacity */
|
|
||||||
.tag.assigned {
|
|
||||||
opacity: 0.95;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.assigned:hover {
|
|
||||||
filter: brightness(1.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Partial tag — dashed border, reduced opacity */
|
|
||||||
.tag.assigned.partial {
|
|
||||||
opacity: 0.65;
|
|
||||||
border-style: dashed;
|
|
||||||
border-color: rgba(255, 255, 255, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.assigned.partial:hover {
|
|
||||||
opacity: 1;
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove {
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.partial-icon {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.available {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.available:hover {
|
|
||||||
opacity: 1;
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear {
|
|
||||||
position: absolute;
|
|
||||||
right: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { authStore } from '$lib/stores/auth';
|
|
||||||
import type { File } from '$lib/api/types';
|
|
||||||
|
|
||||||
const LONG_PRESS_MS = 400;
|
|
||||||
const DRAG_THRESHOLD = 8; // px — cancel long-press if pointer moves more than this
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
file: File;
|
|
||||||
index: number;
|
|
||||||
selected?: boolean;
|
|
||||||
selectionMode?: boolean;
|
|
||||||
onTap?: (e: MouseEvent) => void;
|
|
||||||
/** Called when long-press fires; receives the pointerType of the gesture. */
|
|
||||||
onLongPress?: (pointerType: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
file,
|
|
||||||
index,
|
|
||||||
selected = false,
|
|
||||||
selectionMode = false,
|
|
||||||
onTap,
|
|
||||||
onLongPress,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let imgSrc = $state<string | null>(null);
|
|
||||||
let failed = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const token = get(authStore).accessToken;
|
|
||||||
let objectUrl: string | null = null;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
fetch(`/api/v1/files/${file.id}/thumbnail`, {
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
||||||
})
|
|
||||||
.then((res) => (res.ok ? res.blob() : null))
|
|
||||||
.then((blob) => {
|
|
||||||
if (cancelled || !blob) {
|
|
||||||
if (!cancelled) failed = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
objectUrl = URL.createObjectURL(blob);
|
|
||||||
imgSrc = objectUrl;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) failed = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Long press + drag detection ---
|
|
||||||
let pressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let didLongPress = false;
|
|
||||||
let pressStartX = 0;
|
|
||||||
let pressStartY = 0;
|
|
||||||
let currentPointerType = '';
|
|
||||||
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
|
||||||
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
|
||||||
didLongPress = false;
|
|
||||||
pressStartX = e.clientX;
|
|
||||||
pressStartY = e.clientY;
|
|
||||||
currentPointerType = e.pointerType;
|
|
||||||
pressTimer = setTimeout(() => {
|
|
||||||
didLongPress = true;
|
|
||||||
onLongPress?.(currentPointerType);
|
|
||||||
}, LONG_PRESS_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerMoveInternal(e: PointerEvent) {
|
|
||||||
// Cancel long-press if pointer has moved significantly (user is scrolling)
|
|
||||||
if (pressTimer !== null) {
|
|
||||||
const dx = e.clientX - pressStartX;
|
|
||||||
const dy = e.clientY - pressStartY;
|
|
||||||
if (Math.hypot(dx, dy) > DRAG_THRESHOLD) {
|
|
||||||
clearTimeout(pressTimer);
|
|
||||||
pressTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelPress() {
|
|
||||||
if (pressTimer !== null) {
|
|
||||||
clearTimeout(pressTimer);
|
|
||||||
pressTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClick(e: MouseEvent) {
|
|
||||||
if (didLongPress) {
|
|
||||||
didLongPress = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cancelPress();
|
|
||||||
onTap?.(e);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
class="card"
|
|
||||||
class:loaded={!!imgSrc}
|
|
||||||
class:selected
|
|
||||||
data-file-index={index}
|
|
||||||
onpointerdown={onPointerDown}
|
|
||||||
onpointermove={onPointerMoveInternal}
|
|
||||||
onpointerup={() => { cancelPress(); didLongPress = false; }}
|
|
||||||
onpointerleave={cancelPress}
|
|
||||||
oncontextmenu={(e) => e.preventDefault()}
|
|
||||||
onclick={onClick}
|
|
||||||
title={file.original_name ?? undefined}
|
|
||||||
>
|
|
||||||
{#if imgSrc}
|
|
||||||
<img src={imgSrc} alt={file.original_name ?? ''} class="thumb" draggable="false" />
|
|
||||||
{:else if failed}
|
|
||||||
<div class="placeholder failed" aria-label="Failed to load"></div>
|
|
||||||
{:else}
|
|
||||||
<div class="placeholder loading" aria-label="Loading"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="overlay"></div>
|
|
||||||
{#if selected}
|
|
||||||
<div class="check" aria-hidden="true">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
|
||||||
<circle cx="9" cy="9" r="8.5" fill="rgba(0,0,0,0.55)" stroke="white" stroke-width="1"/>
|
|
||||||
<path d="M5 9l3 3 5-5" stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{:else if selectionMode}
|
|
||||||
<div class="check" aria-hidden="true">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
|
||||||
<circle cx="9" cy="9" r="8.5" fill="rgba(0,0,0,0.35)" stroke="rgba(255,255,255,0.5)" stroke-width="1"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.card {
|
|
||||||
position: relative;
|
|
||||||
width: 160px;
|
|
||||||
height: 160px;
|
|
||||||
max-width: calc(33vw - 7px);
|
|
||||||
max-height: calc(33vw - 7px);
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
flex-shrink: 0;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
object-position: center;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder.loading {
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--color-bg-elevated) 25%,
|
|
||||||
color-mix(in srgb, var(--color-accent) 12%, var(--color-bg-elevated)) 50%,
|
|
||||||
var(--color-bg-elevated) 75%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 1.4s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder.failed {
|
|
||||||
background-color: color-mix(in srgb, var(--color-danger) 15%, var(--color-bg-elevated));
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
|
||||||
transition: background-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover .overlay {
|
|
||||||
background-color: rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card.selected .overlay {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 35%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.check {
|
|
||||||
position: absolute;
|
|
||||||
top: 6px;
|
|
||||||
right: 6px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: 200% 0; }
|
|
||||||
100% { background-position: -200% 0; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { uploadWithProgress, ApiError } from '$lib/api/client';
|
|
||||||
import type { File as ApiFile } from '$lib/api/types';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onUploaded: (file: ApiFile) => void;
|
|
||||||
children: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onUploaded, children }: Props = $props();
|
|
||||||
|
|
||||||
// ---- Upload queue ----
|
|
||||||
type UploadStatus = 'uploading' | 'done' | 'error';
|
|
||||||
|
|
||||||
interface QueueItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
progress: number;
|
|
||||||
status: UploadStatus;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let queue = $state<QueueItem[]>([]);
|
|
||||||
let fileInput = $state<HTMLInputElement | undefined>();
|
|
||||||
|
|
||||||
let allSettled = $derived(queue.length > 0 && queue.every((i) => i.status !== 'uploading'));
|
|
||||||
|
|
||||||
// ---- File input ----
|
|
||||||
export function open() {
|
|
||||||
fileInput?.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInputChange(e: Event) {
|
|
||||||
const files = (e.currentTarget as HTMLInputElement).files;
|
|
||||||
if (files?.length) {
|
|
||||||
void enqueue(Array.from(files));
|
|
||||||
// Reset so the same file can be re-selected
|
|
||||||
(e.currentTarget as HTMLInputElement).value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Upload logic ----
|
|
||||||
function uid() {
|
|
||||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueue(files: globalThis.File[]) {
|
|
||||||
const items: QueueItem[] = files.map((f) => ({
|
|
||||||
id: uid(),
|
|
||||||
name: f.name,
|
|
||||||
progress: 0,
|
|
||||||
status: 'uploading',
|
|
||||||
}));
|
|
||||||
queue = [...queue, ...items];
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
files.map((file, i) => uploadOne(file, items[i].id)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadOne(file: globalThis.File, itemId: string) {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await uploadWithProgress<ApiFile>(
|
|
||||||
'/files',
|
|
||||||
fd,
|
|
||||||
(pct) => updateItem(itemId, { progress: pct }),
|
|
||||||
);
|
|
||||||
updateItem(itemId, { status: 'done', progress: 100 });
|
|
||||||
onUploaded(result);
|
|
||||||
} catch (e) {
|
|
||||||
const msg =
|
|
||||||
e instanceof ApiError
|
|
||||||
? e.status === 415
|
|
||||||
? `Unsupported file type`
|
|
||||||
: e.message
|
|
||||||
: 'Upload failed';
|
|
||||||
updateItem(itemId, { status: 'error', error: msg });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateItem(id: string, patch: Partial<QueueItem>) {
|
|
||||||
queue = queue.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearQueue() {
|
|
||||||
queue = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Drag and drop ----
|
|
||||||
let dragCounter = $state(0);
|
|
||||||
let dragOver = $derived(dragCounter > 0);
|
|
||||||
|
|
||||||
function onDragEnter(e: DragEvent) {
|
|
||||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
|
||||||
e.preventDefault();
|
|
||||||
dragCounter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragLeave() {
|
|
||||||
dragCounter = Math.max(0, dragCounter - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragOver(e: DragEvent) {
|
|
||||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDrop(e: DragEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
dragCounter = 0;
|
|
||||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
|
||||||
if (files.length) void enqueue(files);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Hidden file input -->
|
|
||||||
<input
|
|
||||||
bind:this={fileInput}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept="image/*,video/*"
|
|
||||||
style="display:none"
|
|
||||||
onchange={onInputChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Drop zone wrapper -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
class="drop-zone"
|
|
||||||
class:drag-over={dragOver}
|
|
||||||
ondragenter={onDragEnter}
|
|
||||||
ondragleave={onDragLeave}
|
|
||||||
ondragover={onDragOver}
|
|
||||||
ondrop={onDrop}
|
|
||||||
>
|
|
||||||
{@render children()}
|
|
||||||
|
|
||||||
{#if dragOver}
|
|
||||||
<div class="drop-overlay" aria-hidden="true">
|
|
||||||
<div class="drop-label">
|
|
||||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">
|
|
||||||
<path d="M18 4v20M10 14l8-10 8 10" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M6 28h24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
Drop files to upload
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload progress panel -->
|
|
||||||
{#if queue.length > 0}
|
|
||||||
<div class="upload-panel" role="status">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-title">
|
|
||||||
{#if allSettled}
|
|
||||||
Uploads complete
|
|
||||||
{:else}
|
|
||||||
Uploading {queue.filter((i) => i.status === 'uploading').length} file(s)…
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{#if allSettled}
|
|
||||||
<button class="clear-btn" onclick={clearQueue}>Dismiss</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="upload-list">
|
|
||||||
{#each queue as item (item.id)}
|
|
||||||
<li class="upload-item" class:done={item.status === 'done'} class:error={item.status === 'error'}>
|
|
||||||
<span class="item-name" title={item.name}>{item.name}</span>
|
|
||||||
<div class="item-right">
|
|
||||||
{#if item.status === 'uploading'}
|
|
||||||
<div class="progress-track">
|
|
||||||
<div class="progress-fill" style="width: {item.progress}%"></div>
|
|
||||||
</div>
|
|
||||||
<span class="pct">{item.progress}%</span>
|
|
||||||
{:else if item.status === 'done'}
|
|
||||||
<svg class="icon-ok" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-label="Done">
|
|
||||||
<path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<span class="err-msg" title={item.error}>{item.error}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* ---- Drop zone ---- */
|
|
||||||
.drop-zone {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 50;
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 18%, rgba(0, 0, 0, 0.7));
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 2px dashed var(--color-accent);
|
|
||||||
border-radius: 4px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Upload panel ---- */
|
|
||||||
.upload-panel {
|
|
||||||
position: fixed;
|
|
||||||
left: 10px;
|
|
||||||
right: 10px;
|
|
||||||
bottom: 65px;
|
|
||||||
z-index: 110;
|
|
||||||
background-color: var(--color-bg-secondary);
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 0 16px rgba(0, 0, 0, 0.6);
|
|
||||||
padding: 10px 12px;
|
|
||||||
animation: slide-up 0.18s ease-out;
|
|
||||||
max-height: 50vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slide-up {
|
|
||||||
from { transform: translateY(10px); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-title {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-btn:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-name {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-item.done .item-name {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-item.error .item-name {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-track {
|
|
||||||
width: 80px;
|
|
||||||
height: 4px;
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 20%, var(--color-bg-elevated));
|
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.1s linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pct {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
min-width: 30px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-ok {
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.err-msg {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-danger);
|
|
||||||
max-width: 140px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { api } from '$lib/api/client';
|
|
||||||
import type { Tag, TagOffsetPage } from '$lib/api/types';
|
|
||||||
import { buildDslFilter, parseDslFilter, tokenLabel } from '$lib/utils/dsl';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Current DSL filter string (e.g. "{t=uuid1,&,t=uuid2}"). */
|
|
||||||
value?: string | null;
|
|
||||||
onApply: (filter: string | null) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { value = null, onApply, onClose }: Props = $props();
|
|
||||||
|
|
||||||
const OPERATORS = ['(', ')', '&', '|', '!'] as const;
|
|
||||||
|
|
||||||
let tags = $state<Tag[]>([]);
|
|
||||||
let search = $state('');
|
|
||||||
let tokens = $state<string[]>(parseDslFilter(value));
|
|
||||||
let tagNames = $derived(
|
|
||||||
new Map(
|
|
||||||
tags
|
|
||||||
.filter((t) => t.id && t.name)
|
|
||||||
.map((t) => [t.id as string, t.name as string]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
tokens = parseDslFilter(value ?? null);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc').then((page) => {
|
|
||||||
tags = page.items ?? [];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let filteredTags = $derived(
|
|
||||||
search.trim()
|
|
||||||
? tags.filter((t) => t.name?.toLowerCase().includes(search.toLowerCase()))
|
|
||||||
: tags,
|
|
||||||
);
|
|
||||||
|
|
||||||
function addToken(t: string) {
|
|
||||||
tokens = [...tokens, t];
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeToken(i: number) {
|
|
||||||
tokens = tokens.filter((_, idx) => idx !== i);
|
|
||||||
}
|
|
||||||
|
|
||||||
function apply() {
|
|
||||||
onApply(buildDslFilter(tokens));
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
tokens = [];
|
|
||||||
search = '';
|
|
||||||
onApply(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Drag-and-drop reordering ---
|
|
||||||
let dragIndex = $state<number | null>(null);
|
|
||||||
let dropIndex = $state<number | null>(null);
|
|
||||||
|
|
||||||
function onDragStart(i: number, e: DragEvent) {
|
|
||||||
dragIndex = i;
|
|
||||||
e.dataTransfer!.effectAllowed = 'move';
|
|
||||||
// Set minimal drag image so the token itself acts as the ghost
|
|
||||||
e.dataTransfer!.setData('text/plain', String(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragOver(i: number, e: DragEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer!.dropEffect = 'move';
|
|
||||||
dropIndex = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDrop(i: number, e: DragEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (dragIndex === null || dragIndex === i) return;
|
|
||||||
const next = [...tokens];
|
|
||||||
const [moved] = next.splice(dragIndex, 1);
|
|
||||||
next.splice(i, 0, moved);
|
|
||||||
tokens = next;
|
|
||||||
dragIndex = null;
|
|
||||||
dropIndex = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragEnd() {
|
|
||||||
dragIndex = null;
|
|
||||||
dropIndex = null;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="bar">
|
|
||||||
<!-- Active tokens -->
|
|
||||||
<div class="active" class:empty={tokens.length === 0}>
|
|
||||||
{#if tokens.length === 0}
|
|
||||||
<span class="hint">No filter — tap a tag or operator below to build one</span>
|
|
||||||
{:else}
|
|
||||||
{#each tokens as token, i (i)}
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
class="token active-token"
|
|
||||||
class:dragging={dragIndex === i}
|
|
||||||
class:drop-before={dropIndex === i && dragIndex !== null && dragIndex !== i}
|
|
||||||
draggable="true"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
title="Drag to reorder · Click to remove"
|
|
||||||
ondragstart={(e) => onDragStart(i, e)}
|
|
||||||
ondragover={(e) => onDragOver(i, e)}
|
|
||||||
ondrop={(e) => onDrop(i, e)}
|
|
||||||
ondragend={onDragEnd}
|
|
||||||
onclick={() => removeToken(i)}
|
|
||||||
onkeydown={(e) => e.key === 'Delete' && removeToken(i)}
|
|
||||||
>
|
|
||||||
{tokenLabel(token, tagNames)}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Operator buttons -->
|
|
||||||
<div class="ops">
|
|
||||||
{#each OPERATORS as op}
|
|
||||||
<button class="token op-token" onclick={() => addToken(op)}>{op}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tag search -->
|
|
||||||
<input
|
|
||||||
class="search"
|
|
||||||
type="search"
|
|
||||||
placeholder="Search tags…"
|
|
||||||
bind:value={search}
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Tag list -->
|
|
||||||
<div class="tag-list">
|
|
||||||
{#each filteredTags as tag (tag.id)}
|
|
||||||
<button
|
|
||||||
class="token tag-token"
|
|
||||||
style="background-color: {tag.color ? '#' + tag.color : tag.category_color ? '#' + tag.category_color : 'var(--color-tag-default)'}"
|
|
||||||
onclick={() => addToken(`t=${tag.id}`)}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span class="no-tags">{search ? 'No matching tags' : 'No tags yet'}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn btn-reset" onclick={reset}>Reset</button>
|
|
||||||
<button class="btn btn-apply" onclick={apply}>Apply</button>
|
|
||||||
<button class="btn btn-close" onclick={onClose}>Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.bar {
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
|
|
||||||
padding: 8px 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
position: sticky;
|
|
||||||
top: 43px; /* header height */
|
|
||||||
z-index: 9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
min-height: 32px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active.empty {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ops {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 26px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-token {
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
color: var(--color-bg-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: grab;
|
|
||||||
user-select: none;
|
|
||||||
transition: opacity 0.15s, outline 0.1s;
|
|
||||||
outline: 2px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-token:hover {
|
|
||||||
background-color: var(--color-accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-token.dragging {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-token.drop-before {
|
|
||||||
outline: 2px solid var(--color-accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.op-token {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 18%, var(--color-bg-elevated));
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
min-width: 30px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.op-token:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 35%, var(--color-bg-elevated));
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 30px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
max-height: 120px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-token {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-token:hover {
|
|
||||||
filter: brightness(1.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-tags {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
height: 30px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: none;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-apply {
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
color: var(--color-bg-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-apply:hover {
|
|
||||||
background-color: var(--color-accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-reset {
|
|
||||||
background-color: color-mix(in srgb, var(--color-danger) 20%, var(--color-bg-elevated));
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-reset:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-danger) 35%, var(--color-bg-elevated));
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-close {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 15%, var(--color-bg-elevated));
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-close:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { api } from '$lib/api/client';
|
|
||||||
import type { Tag, TagOffsetPage } from '$lib/api/types';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
fileTags: Tag[];
|
|
||||||
onAdd: (tagId: string) => Promise<void>;
|
|
||||||
onRemove: (tagId: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { fileTags, onAdd, onRemove }: Props = $props();
|
|
||||||
|
|
||||||
let allTags = $state<Tag[]>([]);
|
|
||||||
let search = $state('');
|
|
||||||
let busy = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc').then((p) => {
|
|
||||||
allTags = p.items ?? [];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let assignedIds = $derived(new Set(fileTags.map((t) => t.id)));
|
|
||||||
|
|
||||||
let filteredAvailable = $derived(
|
|
||||||
allTags.filter(
|
|
||||||
(t) =>
|
|
||||||
!assignedIds.has(t.id) &&
|
|
||||||
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let filteredAssigned = $derived(
|
|
||||||
search.trim()
|
|
||||||
? fileTags.filter((t) => t.name?.toLowerCase().includes(search.toLowerCase()))
|
|
||||||
: fileTags,
|
|
||||||
);
|
|
||||||
|
|
||||||
async function handleAdd(tagId: string) {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
try {
|
|
||||||
await onAdd(tagId);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemove(tagId: string) {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
try {
|
|
||||||
await onRemove(tagId);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function tagStyle(tag: Tag) {
|
|
||||||
const color = tag.color ?? tag.category_color;
|
|
||||||
return color ? `background-color: #${color}` : '';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="picker" class:busy>
|
|
||||||
<!-- Assigned tags -->
|
|
||||||
{#if fileTags.length > 0}
|
|
||||||
<div class="section-label">Assigned</div>
|
|
||||||
<div class="tag-row">
|
|
||||||
{#each filteredAssigned as tag (tag.id)}
|
|
||||||
<button
|
|
||||||
class="tag assigned"
|
|
||||||
style={tagStyle(tag)}
|
|
||||||
onclick={() => handleRemove(tag.id!)}
|
|
||||||
title="Remove tag"
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
<span class="remove">×</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Search -->
|
|
||||||
<div class="search-wrap">
|
|
||||||
<input
|
|
||||||
class="search"
|
|
||||||
type="search"
|
|
||||||
placeholder="Search tags…"
|
|
||||||
bind:value={search}
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
{#if search}
|
|
||||||
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Available tags -->
|
|
||||||
{#if filteredAvailable.length > 0}
|
|
||||||
<div class="section-label">Add tag</div>
|
|
||||||
<div class="tag-row available-row">
|
|
||||||
{#each filteredAvailable as tag (tag.id)}
|
|
||||||
<button
|
|
||||||
class="tag available"
|
|
||||||
style={tagStyle(tag)}
|
|
||||||
onclick={() => handleAdd(tag.id!)}
|
|
||||||
title="Add tag"
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if search.trim()}
|
|
||||||
<p class="empty">No matching tags</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.picker {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker.busy {
|
|
||||||
opacity: 0.6;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-row {
|
|
||||||
max-height: 140px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
height: 26px;
|
|
||||||
padding: 0 9px;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
background-color: var(--color-tag-default);
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.assigned {
|
|
||||||
opacity: 0.95;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.assigned:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove {
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.available {
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag.available:hover {
|
|
||||||
opacity: 1;
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear {
|
|
||||||
position: absolute;
|
|
||||||
right: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { SortOrder } from '$lib/stores/sorting';
|
|
||||||
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
sortOptions: { value: string; label: string }[];
|
|
||||||
sort: string;
|
|
||||||
order: SortOrder;
|
|
||||||
filterActive?: boolean;
|
|
||||||
onSortChange: (sort: string) => void;
|
|
||||||
onOrderToggle: () => void;
|
|
||||||
onFilterToggle: () => void;
|
|
||||||
onUpload?: () => void;
|
|
||||||
onTrash?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
sortOptions,
|
|
||||||
sort,
|
|
||||||
order,
|
|
||||||
filterActive = false,
|
|
||||||
onSortChange,
|
|
||||||
onOrderToggle,
|
|
||||||
onFilterToggle,
|
|
||||||
onUpload,
|
|
||||||
onTrash,
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<button
|
|
||||||
class="select-btn"
|
|
||||||
class:active={$selectionActive}
|
|
||||||
onclick={() => ($selectionActive ? selectionStore.exit() : selectionStore.enter())}
|
|
||||||
>
|
|
||||||
{$selectionActive ? 'Cancel' : 'Select'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if onUpload}
|
|
||||||
<button class="upload-btn icon-btn" onclick={onUpload} title="Upload files">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
||||||
<path d="M8 2v9M4 6l4-4 4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M2 13h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if onTrash}
|
|
||||||
<button class="icon-btn trash-btn" onclick={onTrash} title="Trash">
|
|
||||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
|
||||||
<path d="M2 4h11M5 4V2.5h5V4M5.5 7v4.5M9.5 7v4.5M3 4l.8 9h7.4l.8-9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<select
|
|
||||||
class="sort-select"
|
|
||||||
value={sort}
|
|
||||||
onchange={(e) => onSortChange((e.currentTarget as HTMLSelectElement).value)}
|
|
||||||
>
|
|
||||||
{#each sortOptions as opt}
|
|
||||||
<option value={opt.value}>{opt.label}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button class="icon-btn order-btn" onclick={onOrderToggle} title={order === 'asc' ? 'Ascending' : 'Descending'}>
|
|
||||||
{#if order === 'asc'}
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
||||||
<path d="M4 10L8 6L12 10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
||||||
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="icon-btn filter-btn"
|
|
||||||
class:active={filterActive}
|
|
||||||
onclick={onFilterToggle}
|
|
||||||
title="Filter"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
||||||
<path d="M2 4h12M4 8h8M6 12h4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-btn {
|
|
||||||
height: 30px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-btn:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-btn.active {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
|
||||||
color: var(--color-accent);
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-select {
|
|
||||||
height: 30px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-select:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn.active {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
|
||||||
color: var(--color-accent);
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { selectionStore, selectionCount } from '$lib/stores/selection';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onEditTags: () => void;
|
|
||||||
onAddToPool: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onEditTags, onAddToPool, onDelete }: Props = $props();
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') selectionStore.exit();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
|
||||||
|
|
||||||
<div class="bar" role="toolbar" aria-label="Selection actions">
|
|
||||||
<div class="row">
|
|
||||||
<!-- Count / deselect all -->
|
|
||||||
<button class="count" onclick={() => selectionStore.exit()} title="Clear selection">
|
|
||||||
<span class="num">{$selectionCount}</span>
|
|
||||||
<span class="label">selected</span>
|
|
||||||
<svg class="close-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="spacer"></div>
|
|
||||||
|
|
||||||
<button class="action edit-tags" onclick={onEditTags}>Edit tags</button>
|
|
||||||
<button class="action add-pool" onclick={onAddToPool}>Add to pool</button>
|
|
||||||
<button class="action delete" onclick={onDelete}>Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.bar {
|
|
||||||
position: fixed;
|
|
||||||
left: 10px;
|
|
||||||
right: 10px;
|
|
||||||
bottom: 65px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background-color: var(--color-bg-secondary);
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
|
|
||||||
padding: 12px 14px;
|
|
||||||
z-index: 100;
|
|
||||||
animation: slide-up 0.18s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slide-up {
|
|
||||||
from { transform: translateY(12px); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.num {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-icon {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count:hover .close-icon {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-tags {
|
|
||||||
color: var(--color-info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-tags:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-info) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-pool {
|
|
||||||
color: var(--color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-pool:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.delete:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Tag } from '$lib/api/types';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tag: Tag;
|
|
||||||
onclick?: () => void;
|
|
||||||
size?: 'sm' | 'md';
|
|
||||||
}
|
|
||||||
|
|
||||||
let { tag, onclick, size = 'md' }: Props = $props();
|
|
||||||
|
|
||||||
const color = tag.color ?? tag.category_color;
|
|
||||||
const style = color ? `background-color: #${color}` : '';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if onclick}
|
|
||||||
<button class="badge {size}" {style} {onclick} type="button">
|
|
||||||
{tag.name}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span class="badge {size}" {style}>{tag.name}</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: var(--color-tag-default);
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: none;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.md {
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 10px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.sm {
|
|
||||||
height: 22px;
|
|
||||||
padding: 0 7px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.badge {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.badge:hover {
|
|
||||||
filter: brightness(1.15);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { api, ApiError } from '$lib/api/client';
|
|
||||||
import type { Tag, TagOffsetPage, TagRule } from '$lib/api/types';
|
|
||||||
import TagBadge from './TagBadge.svelte';
|
|
||||||
import { appSettings } from '$lib/stores/appSettings';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tagId: string;
|
|
||||||
rules: TagRule[];
|
|
||||||
onRulesChange: (rules: TagRule[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { tagId, rules, onRulesChange }: Props = $props();
|
|
||||||
|
|
||||||
let allTags = $state<Tag[]>([]);
|
|
||||||
let search = $state('');
|
|
||||||
let busy = $state(false);
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc').then((p) => {
|
|
||||||
allTags = p.items ?? [];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// IDs already used in rules
|
|
||||||
let usedIds = $derived(new Set(rules.map((r) => r.then_tag_id)));
|
|
||||||
|
|
||||||
let filteredTags = $derived(
|
|
||||||
allTags.filter(
|
|
||||||
(t) =>
|
|
||||||
t.id !== tagId &&
|
|
||||||
!usedIds.has(t.id) &&
|
|
||||||
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
function tagForId(id: string | undefined) {
|
|
||||||
return allTags.find((t) => t.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addRule(thenTagId: string) {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const rule = await api.post<TagRule>(`/tags/${tagId}/rules`, {
|
|
||||||
then_tag_id: thenTagId,
|
|
||||||
is_active: true,
|
|
||||||
apply_to_existing: false,
|
|
||||||
});
|
|
||||||
onRulesChange([...rules, rule]);
|
|
||||||
search = '';
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to add rule';
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleRule(rule: TagRule) {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
error = '';
|
|
||||||
const thenTagId = rule.then_tag_id!;
|
|
||||||
const activating = !rule.is_active;
|
|
||||||
try {
|
|
||||||
const body: Record<string, unknown> = { is_active: activating };
|
|
||||||
if (activating) body.apply_to_existing = $appSettings.tagRuleApplyToExisting;
|
|
||||||
const updated = await api.patch<TagRule>(`/tags/${tagId}/rules/${thenTagId}`, body);
|
|
||||||
onRulesChange(rules.map((r) => r.then_tag_id === thenTagId ? updated : r));
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to update rule';
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeRule(thenTagId: string) {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await api.delete(`/tags/${tagId}/rules/${thenTagId}`);
|
|
||||||
onRulesChange(rules.filter((r) => r.then_tag_id !== thenTagId));
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to remove rule';
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="editor" class:busy>
|
|
||||||
<p class="desc">
|
|
||||||
When this tag is applied, also apply:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<p class="error" role="alert">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Current rules -->
|
|
||||||
{#if rules.length > 0}
|
|
||||||
<div class="rule-list">
|
|
||||||
{#each rules as rule (rule.then_tag_id)}
|
|
||||||
{@const t = tagForId(rule.then_tag_id)}
|
|
||||||
<div class="rule-row" class:inactive={!rule.is_active}>
|
|
||||||
{#if t}
|
|
||||||
<TagBadge tag={t} size="sm" />
|
|
||||||
{:else}
|
|
||||||
<span class="unknown">{rule.then_tag_name ?? rule.then_tag_id}</span>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
class="toggle-btn"
|
|
||||||
class:active={rule.is_active}
|
|
||||||
onclick={() => toggleRule(rule)}
|
|
||||||
title={rule.is_active ? 'Deactivate rule' : 'Activate rule'}
|
|
||||||
aria-label={rule.is_active ? 'Deactivate rule' : 'Activate rule'}
|
|
||||||
>
|
|
||||||
{#if rule.is_active}
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
||||||
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<circle cx="6" cy="6" r="2.5" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
||||||
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="remove-btn"
|
|
||||||
onclick={() => removeRule(rule.then_tag_id!)}
|
|
||||||
aria-label="Remove rule"
|
|
||||||
>×</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="empty">No rules — when this tag is applied, nothing extra happens.</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Add rule -->
|
|
||||||
<div class="add-section">
|
|
||||||
<div class="section-label">Add rule</div>
|
|
||||||
<div class="search-wrap">
|
|
||||||
<input
|
|
||||||
class="search"
|
|
||||||
type="search"
|
|
||||||
placeholder="Search tags to add…"
|
|
||||||
bind:value={search}
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
{#if search}
|
|
||||||
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="tag-pick">
|
|
||||||
{#each filteredTags as t (t.id)}
|
|
||||||
<TagBadge tag={t} size="sm" onclick={() => addRule(t.id!)} />
|
|
||||||
{:else}
|
|
||||||
<span class="empty">{search.trim() ? 'No matching tags' : 'All tags already added'}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.editor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor.busy {
|
|
||||||
opacity: 0.6;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desc {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-row {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-row.inactive {
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 3px;
|
|
||||||
border-radius: 3px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn.active {
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 1px 3px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-btn:hover {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.unknown {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear {
|
|
||||||
position: absolute;
|
|
||||||
right: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-pick {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 5px;
|
|
||||||
max-height: 100px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-danger);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
|
|
||||||
export interface AppSettings {
|
|
||||||
fileLoadLimit: number;
|
|
||||||
tagRuleApplyToExisting: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULTS: AppSettings = {
|
|
||||||
fileLoadLimit: 100,
|
|
||||||
tagRuleApplyToExisting: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function load(): AppSettings {
|
|
||||||
if (!browser) return { ...DEFAULTS };
|
|
||||||
try {
|
|
||||||
const stored = JSON.parse(localStorage.getItem('app-settings') ?? 'null');
|
|
||||||
return stored ? { ...DEFAULTS, ...stored } : { ...DEFAULTS };
|
|
||||||
} catch {
|
|
||||||
return { ...DEFAULTS };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const appSettings = writable<AppSettings>(load());
|
|
||||||
|
|
||||||
appSettings.subscribe((v) => {
|
|
||||||
if (browser) localStorage.setItem('app-settings', JSON.stringify(v));
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { writable, derived } from 'svelte/store';
|
|
||||||
|
|
||||||
export interface AuthUser {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
isAdmin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthState {
|
|
||||||
accessToken: string | null;
|
|
||||||
refreshToken: string | null;
|
|
||||||
user: AuthUser | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initial: AuthState = { accessToken: null, refreshToken: null, user: null };
|
|
||||||
|
|
||||||
function loadStored(): AuthState {
|
|
||||||
if (typeof localStorage === 'undefined') return initial;
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem('auth') ?? 'null') ?? initial;
|
|
||||||
} catch {
|
|
||||||
return initial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authStore = writable<AuthState>(loadStored());
|
|
||||||
|
|
||||||
authStore.subscribe((state) => {
|
|
||||||
if (typeof localStorage !== 'undefined') {
|
|
||||||
localStorage.setItem('auth', JSON.stringify(state));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const isAuthenticated = derived(authStore, ($auth) => !!$auth.accessToken);
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { writable, derived } from 'svelte/store';
|
|
||||||
|
|
||||||
interface SelectionState {
|
|
||||||
active: boolean;
|
|
||||||
ids: Set<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSelectionStore() {
|
|
||||||
const { subscribe, update, set } = writable<SelectionState>({
|
|
||||||
active: false,
|
|
||||||
ids: new Set(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscribe,
|
|
||||||
|
|
||||||
enter() {
|
|
||||||
update((s) => ({ ...s, active: true }));
|
|
||||||
},
|
|
||||||
|
|
||||||
exit() {
|
|
||||||
set({ active: false, ids: new Set() });
|
|
||||||
},
|
|
||||||
|
|
||||||
toggle(id: string) {
|
|
||||||
update((s) => {
|
|
||||||
const ids = new Set(s.ids);
|
|
||||||
if (ids.has(id)) {
|
|
||||||
ids.delete(id);
|
|
||||||
} else {
|
|
||||||
ids.add(id);
|
|
||||||
}
|
|
||||||
// Exit selection mode automatically when last item is deselected
|
|
||||||
const active = ids.size > 0;
|
|
||||||
return { active, ids };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
select(id: string) {
|
|
||||||
update((s) => {
|
|
||||||
const ids = new Set(s.ids);
|
|
||||||
ids.add(id);
|
|
||||||
return { active: true, ids };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
deselect(id: string) {
|
|
||||||
update((s) => {
|
|
||||||
const ids = new Set(s.ids);
|
|
||||||
ids.delete(id);
|
|
||||||
const active = ids.size > 0;
|
|
||||||
return { active, ids };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
set({ active: false, ids: new Set() });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const selectionStore = createSelectionStore();
|
|
||||||
|
|
||||||
export const selectionCount = derived(selectionStore, ($s) => $s.ids.size);
|
|
||||||
export const selectionActive = derived(selectionStore, ($s) => $s.active);
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
|
|
||||||
export type FileSortField = 'content_datetime' | 'created' | 'original_name' | 'mime';
|
|
||||||
export type TagSortField = 'name' | 'color' | 'category_name' | 'created';
|
|
||||||
export type SortOrder = 'asc' | 'desc';
|
|
||||||
|
|
||||||
export interface SortState<F extends string> {
|
|
||||||
sort: F;
|
|
||||||
order: SortOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSortStore<F extends string>(key: string, defaults: SortState<F>) {
|
|
||||||
const stored = browser ? localStorage.getItem(key) : null;
|
|
||||||
const initial: SortState<F> = stored ? (JSON.parse(stored) as SortState<F>) : defaults;
|
|
||||||
const store = writable<SortState<F>>(initial);
|
|
||||||
|
|
||||||
store.subscribe((v) => {
|
|
||||||
if (browser) localStorage.setItem(key, JSON.stringify(v));
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscribe: store.subscribe,
|
|
||||||
setSort(sort: F) {
|
|
||||||
store.update((s) => ({ ...s, sort }));
|
|
||||||
},
|
|
||||||
setOrder(order: SortOrder) {
|
|
||||||
store.update((s) => ({ ...s, order }));
|
|
||||||
},
|
|
||||||
toggleOrder() {
|
|
||||||
store.update((s) => ({ ...s, order: s.order === 'asc' ? 'desc' : 'asc' }));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fileSorting = makeSortStore<FileSortField>('sort:files', {
|
|
||||||
sort: 'created',
|
|
||||||
order: 'desc',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tagSorting = makeSortStore<TagSortField>('sort:tags', {
|
|
||||||
sort: 'created',
|
|
||||||
order: 'desc',
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CategorySortField = 'name' | 'color' | 'created';
|
|
||||||
|
|
||||||
export const categorySorting = makeSortStore<CategorySortField>('sort:categories', {
|
|
||||||
sort: 'name',
|
|
||||||
order: 'asc',
|
|
||||||
});
|
|
||||||
|
|
||||||
export type PoolSortField = 'name' | 'created';
|
|
||||||
|
|
||||||
export const poolSorting = makeSortStore<PoolSortField>('sort:pools', {
|
|
||||||
sort: 'created',
|
|
||||||
order: 'desc',
|
|
||||||
});
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
|
|
||||||
type Theme = 'dark' | 'light';
|
|
||||||
|
|
||||||
function loadTheme(): Theme {
|
|
||||||
if (!browser) return 'dark';
|
|
||||||
return (localStorage.getItem('theme') as Theme) ?? 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTheme(theme: Theme) {
|
|
||||||
if (!browser) return;
|
|
||||||
if (theme === 'light') {
|
|
||||||
document.documentElement.setAttribute('data-theme', 'light');
|
|
||||||
} else {
|
|
||||||
document.documentElement.removeAttribute('data-theme');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const themeStore = writable<Theme>(loadTheme());
|
|
||||||
|
|
||||||
themeStore.subscribe((theme) => {
|
|
||||||
applyTheme(theme);
|
|
||||||
if (browser) localStorage.setItem('theme', theme);
|
|
||||||
});
|
|
||||||
|
|
||||||
export function toggleTheme() {
|
|
||||||
themeStore.update((t) => (t === 'dark' ? 'light' : 'dark'));
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Filter DSL utilities.
|
|
||||||
*
|
|
||||||
* Token format (comma-separated inside braces):
|
|
||||||
* t=<uuid> — has tag
|
|
||||||
* m=<mime> — exact MIME
|
|
||||||
* m~<pattern> — MIME LIKE pattern
|
|
||||||
* ( ) & | ! — grouping / boolean operators
|
|
||||||
*
|
|
||||||
* Example: {t=uuid1,&,!,t=uuid2} → has tag1 AND NOT tag2
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Build the filter query string value from an ordered token list. */
|
|
||||||
export function buildDslFilter(tokens: string[]): string | null {
|
|
||||||
if (tokens.length === 0) return null;
|
|
||||||
return '{' + tokens.join(',') + '}';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse the filter query string value back into a token list. */
|
|
||||||
export function parseDslFilter(value: string | null): string[] {
|
|
||||||
if (!value) return [];
|
|
||||||
const inner = value.replace(/^\{/, '').replace(/\}$/, '').trim();
|
|
||||||
if (!inner) return [];
|
|
||||||
return inner.split(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Return a human-readable label for a single DSL token (for display). */
|
|
||||||
export function tokenLabel(token: string, tagNames: Map<string, string>): string {
|
|
||||||
if (token === '&') return 'AND';
|
|
||||||
if (token === '|') return 'OR';
|
|
||||||
if (token === '!') return 'NOT';
|
|
||||||
if (token === '(') return '(';
|
|
||||||
if (token === ')') return ')';
|
|
||||||
if (token.startsWith('t=')) {
|
|
||||||
const id = token.slice(2);
|
|
||||||
return tagNames.get(id) ?? token;
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unregisters all service workers and clears all caches, then reloads.
|
|
||||||
* Use this when the app feels stale or to force a clean re-fetch of all assets.
|
|
||||||
*/
|
|
||||||
export async function resetPwa(): Promise<void> {
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
||||||
await Promise.all(registrations.map((r) => r.unregister()));
|
|
||||||
}
|
|
||||||
if ('caches' in window) {
|
|
||||||
const keys = await caches.keys();
|
|
||||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import '../app.css';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { themeStore, toggleTheme } from '$lib/stores/theme';
|
|
||||||
|
|
||||||
let { children } = $props();
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{
|
|
||||||
href: '/categories',
|
|
||||||
label: 'Categories',
|
|
||||||
match: '/categories',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/tags',
|
|
||||||
label: 'Tags',
|
|
||||||
match: '/tags',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/files',
|
|
||||||
label: 'Files',
|
|
||||||
match: '/files',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/pools',
|
|
||||||
label: 'Pools',
|
|
||||||
match: '/pools',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/settings',
|
|
||||||
label: 'Settings',
|
|
||||||
match: '/settings',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const isLogin = $derived($page.url.pathname === '/login');
|
|
||||||
const isAdmin = $derived($page.url.pathname.startsWith('/admin'));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{@render children()}
|
|
||||||
|
|
||||||
{#if !isLogin && !isAdmin}
|
|
||||||
<footer>
|
|
||||||
{#each navItems as item}
|
|
||||||
{@const active = $page.url.pathname.startsWith(item.match)}
|
|
||||||
<a href={item.href} class="nav" class:curr={active} aria-label={item.label}>
|
|
||||||
{#if item.label === 'Categories'}
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<path d="M18.875 9.25C21.1532 9.25 23 7.40317 23 5.125C23 2.84683 21.1532 1 18.875 1C16.5968 1 14.75 2.84683 14.75 5.125C14.75 7.40317 16.5968 9.25 18.875 9.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M5.125 23C7.40317 23 9.25 21.1532 9.25 18.875C9.25 16.5968 7.40317 14.75 5.125 14.75C2.84683 14.75 1 16.5968 1 18.875C1 21.1532 2.84683 23 5.125 23Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M14.75 14.75H23V21.625C23 21.9897 22.8551 22.3394 22.5973 22.5973C22.3394 22.8551 21.9897 23 21.625 23H16.125C15.7603 23 15.4106 22.8551 15.1527 22.5973C14.8949 22.3394 14.75 21.9897 14.75 21.625V14.75ZM1 1H9.25V7.875C9.25 8.23967 9.10513 8.58941 8.84727 8.84727C8.58941 9.10513 8.23967 9.25 7.875 9.25H2.375C2.01033 9.25 1.66059 9.10513 1.40273 8.84727C1.14487 8.58941 1 8.23967 1 7.875V1Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
{:else if item.label === 'Tags'}
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<path d="M4.00067 16.5511C2.30116 14.8505 1.45086 14.0013 1.13515 12.8979C0.818352 11.7946 1.08895 10.6231 1.63016 8.28122L1.94146 6.93041C2.39576 4.9592 2.62346 3.97359 3.29777 3.29819C3.97317 2.62388 4.95878 2.39618 6.92999 1.94188L8.2808 1.62947C10.6238 1.08937 11.7942 0.818769 12.8975 1.13447C14.0008 1.45127 14.85 2.30158 16.5496 4.00109L18.5626 6.0141C21.5227 8.97312 23 10.4515 23 12.2885C23 14.1267 21.5216 15.6051 18.5637 18.563C15.6046 21.522 14.1262 23.0004 12.2881 23.0004C10.4511 23.0004 8.97161 21.522 6.01369 18.5641L4.00067 16.5511Z" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<path d="M9.82325 10.1229C10.6824 9.26375 10.6824 7.87077 9.82325 7.01161C8.96409 6.15246 7.57112 6.15246 6.71196 7.01162C5.8528 7.87077 5.8528 9.26375 6.71196 10.1229C7.57112 10.9821 8.96409 10.9821 9.82325 10.1229Z" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<path d="M11.4962 19.1504L19.1731 11.4724" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
{:else if item.label === 'Files'}
|
|
||||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<path d="M13.0465 20.4651H8.95346V22H13.0465V20.4651ZM1.53488 13.0465V8.95352H1.29521e-06V13.0465H1.53488ZM20.4651 12.5994V13.0465H21.9999V12.5994H20.4651ZM13.9582 3.43921L18.0092 7.08506L19.0356 5.94311L14.9855 2.29726L13.9582 3.43921ZM21.9999 12.5994C21.9999 10.8711 22.0153 9.77621 21.5804 8.79798L20.1775 9.42319C20.4497 10.0351 20.4651 10.736 20.4651 12.5994H21.9999ZM18.0092 7.08506C19.3937 8.33138 19.9053 8.81231 20.1775 9.42319L21.5804 8.79798C21.1445 7.81873 20.3208 7.09938 19.0356 5.94311L18.0092 7.08506ZM8.98416 1.53494C10.6029 1.53494 11.2138 1.54722 11.7572 1.75596L12.3077 0.323405C11.4359 -0.012222 10.4863 5.70826e-05 8.98416 5.70826e-05V1.53494ZM14.9855 2.29828C13.8743 1.29856 13.1795 0.656985 12.3077 0.323405L11.7582 1.75596C12.3026 1.9647 12.761 2.36172 13.9582 3.43921L14.9855 2.29828ZM8.95346 20.4651C7.00212 20.4651 5.61664 20.4631 4.56371 20.3219C3.53534 20.1837 2.94185 19.9238 2.50902 19.491L1.42437 20.5756C2.18976 21.3431 3.16083 21.6818 4.36008 21.8434C5.53682 22.002 7.04612 22 8.95346 22V20.4651ZM1.29521e-06 13.0465C1.29521e-06 14.9539 -0.00204521 16.4621 0.156559 17.6399C0.318233 18.8392 0.657953 19.8102 1.42335 20.5766L2.50799 19.492C2.07618 19.0581 1.81627 18.4646 1.67814 17.4353C1.53693 16.3844 1.53488 14.9979 1.53488 13.0465H1.29521e-06ZM13.0465 22C14.9538 22 16.4621 22.002 17.6399 21.8434C18.8391 21.6818 19.8102 21.342 20.5766 20.5766L19.4919 19.492C19.0581 19.9238 18.4646 20.1837 17.4352 20.3219C16.3843 20.4631 14.9978 20.4651 13.0465 20.4651V22ZM20.4651 13.0465C20.4651 14.9979 20.463 16.3844 20.3218 17.4363C20.1837 18.4647 19.9238 19.0581 19.4909 19.491L20.5756 20.5756C21.343 19.8102 21.6817 18.8392 21.8434 17.6399C22.002 16.4632 21.9999 14.9539 21.9999 13.0465H20.4651ZM1.53488 8.95352C1.53488 7.00217 1.53693 5.61669 1.67814 4.56376C1.81627 3.53539 2.07618 2.94191 2.50902 2.50907L1.42437 1.42442C0.656929 2.18982 0.318233 3.16088 0.156559 4.36014C-0.00204521 5.53688 1.29521e-06 7.04617 1.29521e-06 8.95352H1.53488ZM8.98416 5.70826e-05C7.06556 5.70826e-05 5.55012 -0.00198942 4.36827 0.156615C3.1639 0.318289 2.18976 0.658009 1.42335 1.4234L2.50799 2.50805C2.94185 2.07624 3.53636 1.81633 4.57189 1.67819C5.62891 1.53698 7.02258 1.53494 8.98416 1.53494V5.70826e-05Z" fill="currentColor"/>
|
|
||||||
<path d="M12.0232 1.27905V3.83718C12.0232 6.24899 12.0232 7.45541 12.7722 8.20443C13.5212 8.95345 14.7276 8.95345 17.1395 8.95345H21.2325" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
</svg>
|
|
||||||
{:else if item.label === 'Pools'}
|
|
||||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<path d="M6.07102 2.7508H4.64702C4.80588 1.9743 5.22797 1.27646 5.84196 0.775241C6.45595 0.274027 7.22417 0.000183205 8.01676 0H16.7276C18.1259 0 19.467 0.55548 20.4558 1.54424C21.4445 2.533 22 3.87405 22 5.27237V13.9832C22 14.7743 21.7273 15.5411 21.2279 16.1546C20.7285 16.7681 20.033 17.1907 19.2584 17.3511V15.9253C19.6583 15.7816 20.0042 15.518 20.2487 15.1704C20.4932 14.8228 20.6245 14.4082 20.6246 13.9832V5.27237C20.6246 4.23883 20.214 3.24762 19.4832 2.5168C18.7524 1.78597 17.7612 1.3754 16.7276 1.3754H8.01676C7.59001 1.37527 7.17373 1.50748 6.82525 1.75381C6.47678 2.00014 6.21327 2.34846 6.07102 2.7508ZM3.4385 3.66774C2.52656 3.66774 1.65196 4.03001 1.00711 4.67485C0.36227 5.3197 0 6.19429 0 7.10624V18.5679C0 19.4799 0.36227 20.3545 1.00711 20.9993C1.65196 21.6442 2.52656 22.0064 3.4385 22.0064H14.9002C15.8121 22.0064 16.6867 21.6442 17.3316 20.9993C17.9764 20.3545 18.3387 19.4799 18.3387 18.5679V7.10624C18.3387 6.19429 17.9764 5.3197 17.3316 4.67485C16.6867 4.03001 15.8121 3.66774 14.9002 3.66774H3.4385ZM1.3754 7.10624C1.3754 6.55907 1.59276 6.03431 1.97967 5.64741C2.36658 5.2605 2.89133 5.04314 3.4385 5.04314H14.9002C15.4473 5.04314 15.9721 5.2605 16.359 5.64741C16.7459 6.03431 16.9633 6.55907 16.9633 7.10624V18.5679C16.9633 19.1151 16.7459 19.6398 16.359 20.0268C15.9721 20.4137 15.4473 20.631 14.9002 20.631H3.4385C2.89133 20.631 2.36658 20.4137 1.97967 20.0268C1.59276 19.6398 1.3754 19.1151 1.3754 18.5679V7.10624Z" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
{:else if item.label === 'Settings'}
|
|
||||||
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
||||||
<path d="M11.371 15.3C13.1935 15.3 14.671 13.8226 14.671 12C14.671 10.1775 13.1935 8.70001 11.371 8.70001C9.54844 8.70001 8.07098 10.1775 8.07098 12C8.07098 13.8226 9.54844 15.3 11.371 15.3Z" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
<path d="M13.3125 1.1672C12.9088 1 12.3962 1 11.371 1C10.3458 1 9.8332 1 9.4295 1.1672C9.1624 1.27776 8.91971 1.43989 8.7153 1.6443C8.51089 1.84871 8.34877 2.0914 8.2382 2.3585C8.137 2.6038 8.0963 2.8909 8.0809 3.3078C8.07405 3.60919 7.9907 3.90389 7.8387 4.16423C7.68669 4.42456 7.47101 4.642 7.2119 4.7961C6.94889 4.94355 6.65271 5.02171 6.35119 5.02325C6.04967 5.02479 5.75271 4.94965 5.4882 4.8049C5.1186 4.6091 4.8513 4.5013 4.5862 4.4661C4.00795 4.39006 3.42317 4.54674 2.9604 4.9017C2.615 5.169 2.3576 5.6123 1.845 6.5C1.3324 7.3877 1.075 7.831 1.0189 8.2655C0.981102 8.552 1.00012 8.84314 1.07486 9.12228C1.1496 9.40143 1.2786 9.66312 1.4545 9.8924C1.6173 10.1036 1.845 10.2807 2.1981 10.5029C2.7184 10.8296 3.0528 11.3862 3.0528 12C3.0528 12.6138 2.7184 13.1704 2.1981 13.496C1.845 13.7193 1.6162 13.8964 1.4545 14.1076C1.2786 14.3369 1.1496 14.5986 1.07486 14.8777C1.00012 15.1569 0.981102 15.448 1.0189 15.7345C1.0761 16.1679 1.3324 16.6123 1.8439 17.5C2.3576 18.3877 2.6139 18.831 2.9604 19.0983C3.18968 19.2742 3.45137 19.4032 3.73052 19.4779C4.00967 19.5527 4.30081 19.5717 4.5873 19.5339C4.8513 19.4987 5.1186 19.3909 5.4882 19.1951C5.75271 19.0503 6.04967 18.9752 6.35119 18.9767C6.65271 18.9783 6.94889 19.0565 7.2119 19.2039C7.7432 19.5119 8.0589 20.0784 8.0809 20.6922C8.0963 21.1102 8.1359 21.3962 8.2382 21.6415C8.34877 21.9086 8.51089 22.1513 8.7153 22.3557C8.91971 22.5601 9.1624 22.7222 9.4295 22.8328C9.8332 23 10.3458 23 11.371 23C12.3962 23 12.9088 23 13.3125 22.8328C13.5796 22.7222 13.8223 22.5601 14.0267 22.3557C14.2311 22.1513 14.3932 21.9086 14.5038 21.6415C14.605 21.3962 14.6457 21.1102 14.6611 20.6922C14.6831 20.0784 14.9988 19.5108 15.5301 19.2039C15.7931 19.0565 16.0893 18.9783 16.3908 18.9767C16.6923 18.9752 16.9893 19.0503 17.2538 19.1951C17.6234 19.3909 17.8907 19.4987 18.1547 19.5339C18.4412 19.5717 18.7323 19.5527 19.0115 19.4779C19.2906 19.4032 19.5523 19.2742 19.7816 19.0983C20.1281 18.8321 20.3844 18.3877 20.897 17.5C21.4096 16.6123 21.667 16.169 21.7231 15.7345C21.7609 15.448 21.7419 15.1569 21.6672 14.8777C21.5924 14.5986 21.4634 14.3369 21.2875 14.1076C21.1247 13.8964 20.897 13.7193 20.5439 13.4971C20.2862 13.3405 20.0726 13.1209 19.9231 12.859C19.7736 12.5971 19.6931 12.3015 19.6892 12C19.6892 11.3862 20.0236 10.8296 20.5439 10.504C20.897 10.2807 21.1258 10.1036 21.2875 9.8924C21.4634 9.66312 21.5924 9.40143 21.6672 9.12228C21.7419 8.84314 21.7609 8.552 21.7231 8.2655C21.6659 7.8321 21.4096 7.3877 20.8981 6.5C20.3844 5.6123 20.1281 5.169 19.7816 4.9017C19.5523 4.7258 19.2906 4.59679 19.0115 4.52205C18.7323 4.44731 18.4412 4.4283 18.1547 4.4661C17.8907 4.5013 17.6234 4.6091 17.2527 4.8049C16.9883 4.94946 16.6916 5.02449 16.3903 5.02295C16.089 5.02141 15.793 4.94335 15.5301 4.7961C15.271 4.642 15.0553 4.42456 14.9033 4.16423C14.7513 3.90389 14.668 3.60919 14.6611 3.3078C14.6457 2.8898 14.6061 2.6038 14.5038 2.3585C14.3932 2.0914 14.2311 1.84871 14.0267 1.6443C13.8223 1.43989 13.5796 1.27776 13.3125 1.1672Z" stroke="currentColor" stroke-width="1.5"/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</footer>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:global(body) {
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
min-height: 100dvh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px;
|
|
||||||
background-color: var(--color-nav-bg);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 40px;
|
|
||||||
width: 18vw;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav:hover,
|
|
||||||
.nav.curr {
|
|
||||||
background-color: var(--color-nav-active);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav :global(svg) {
|
|
||||||
display: block;
|
|
||||||
height: 28px;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { authStore } from '$lib/stores/auth';
|
|
||||||
|
|
||||||
export const ssr = false;
|
|
||||||
|
|
||||||
export const load = ({ url }: { url: URL }) => {
|
|
||||||
if (!browser) return;
|
|
||||||
if (url.pathname === '/login') return;
|
|
||||||
|
|
||||||
const { accessToken } = get(authStore);
|
|
||||||
if (!accessToken) {
|
|
||||||
redirect(307, '/login');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<h1>Welcome to SvelteKit</h1>
|
|
||||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
|
|
||||||
let { children } = $props();
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ href: '/admin/users', label: 'Users' },
|
|
||||||
{ href: '/admin/audit', label: 'Audit log' },
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="admin-shell">
|
|
||||||
<nav class="admin-nav">
|
|
||||||
<button class="back-btn" onclick={() => goto('/files')} aria-label="Back to files">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
||||||
<path d="M10 3L5 8L10 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<span class="admin-title">Admin</span>
|
|
||||||
<div class="tabs">
|
|
||||||
{#each tabs as tab}
|
|
||||||
<a
|
|
||||||
href={tab.href}
|
|
||||||
class="tab"
|
|
||||||
class:active={$page.url.pathname.startsWith(tab.href)}
|
|
||||||
>{tab.label}</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<div class="admin-content">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.admin-shell {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-title {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-content {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { authStore } from '$lib/stores/auth';
|
|
||||||
|
|
||||||
export const load = () => {
|
|
||||||
if (!browser) return;
|
|
||||||
const { user } = get(authStore);
|
|
||||||
if (!user?.isAdmin) {
|
|
||||||
redirect(307, '/files');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,513 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { api, ApiError } from '$lib/api/client';
|
|
||||||
import type { AuditEntry, AuditOffsetPage, User, UserOffsetPage } from '$lib/api/types';
|
|
||||||
|
|
||||||
const LIMIT = 50;
|
|
||||||
const OBJECT_TYPES = ['file', 'tag', 'category', 'pool'];
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
|
||||||
// Auth
|
|
||||||
user_login: 'User logged in',
|
|
||||||
user_logout: 'User logged out',
|
|
||||||
// Files
|
|
||||||
file_create: 'File uploaded',
|
|
||||||
file_edit: 'File edited',
|
|
||||||
file_delete: 'File deleted',
|
|
||||||
file_restore: 'File restored',
|
|
||||||
file_permanent_delete: 'File permanently deleted',
|
|
||||||
file_replace: 'File replaced',
|
|
||||||
// Tags
|
|
||||||
tag_create: 'Tag created',
|
|
||||||
tag_edit: 'Tag edited',
|
|
||||||
tag_delete: 'Tag deleted',
|
|
||||||
// Categories
|
|
||||||
category_create: 'Category created',
|
|
||||||
category_edit: 'Category edited',
|
|
||||||
category_delete: 'Category deleted',
|
|
||||||
// Pools
|
|
||||||
pool_create: 'Pool created',
|
|
||||||
pool_edit: 'Pool edited',
|
|
||||||
pool_delete: 'Pool deleted',
|
|
||||||
// Relations
|
|
||||||
file_tag_add: 'Tag added to file',
|
|
||||||
file_tag_remove: 'Tag removed from file',
|
|
||||||
file_pool_add: 'File added to pool',
|
|
||||||
file_pool_remove: 'File removed from pool',
|
|
||||||
// ACL
|
|
||||||
acl_change: 'ACL changed',
|
|
||||||
// Admin
|
|
||||||
user_create: 'User created',
|
|
||||||
user_delete: 'User deleted',
|
|
||||||
user_block: 'User blocked',
|
|
||||||
user_unblock: 'User unblocked',
|
|
||||||
user_role_change: 'User role changed',
|
|
||||||
// Sessions
|
|
||||||
session_terminate: 'Session terminated',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Filters ----
|
|
||||||
let filterUserId = $state('');
|
|
||||||
let filterAction = $state('');
|
|
||||||
let filterObjectType = $state('');
|
|
||||||
let filterObjectId = $state('');
|
|
||||||
let filterFrom = $state('');
|
|
||||||
let filterTo = $state('');
|
|
||||||
|
|
||||||
// ---- Data ----
|
|
||||||
let entries = $state<AuditEntry[]>([]);
|
|
||||||
let total = $state(0);
|
|
||||||
let page = $state(0); // 0-based
|
|
||||||
let loading = $state(false);
|
|
||||||
let error = $state('');
|
|
||||||
let initialLoaded = $state(false);
|
|
||||||
|
|
||||||
let totalPages = $derived(Math.max(1, Math.ceil(total / LIMIT)));
|
|
||||||
|
|
||||||
// ---- Users for filter dropdown ----
|
|
||||||
let allUsers = $state<User[]>([]);
|
|
||||||
$effect(() => {
|
|
||||||
api.get<UserOffsetPage>('/users?limit=200').then((r) => { allUsers = r.items ?? []; }).catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Unknown action types not in ACTION_LABELS (server may add new ones)
|
|
||||||
let knownActions = $derived([...new Set(entries.map((e) => e.action).filter(Boolean))].sort() as string[]);
|
|
||||||
|
|
||||||
// ---- Reset on filter change ----
|
|
||||||
let filterKey = $derived(`${filterUserId}|${filterAction}|${filterObjectType}|${filterObjectId}|${filterFrom}|${filterTo}`);
|
|
||||||
let prevFilterKey = $state('');
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (filterKey !== prevFilterKey) {
|
|
||||||
prevFilterKey = filterKey;
|
|
||||||
page = 0;
|
|
||||||
initialLoaded = false;
|
|
||||||
error = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!initialLoaded && !loading) void load();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
if (loading) return;
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(page * LIMIT) });
|
|
||||||
if (filterUserId) params.set('user_id', filterUserId);
|
|
||||||
if (filterAction) params.set('action', filterAction);
|
|
||||||
if (filterObjectType) params.set('object_type', filterObjectType);
|
|
||||||
if (filterObjectId.trim()) params.set('object_id', filterObjectId.trim());
|
|
||||||
if (filterFrom) params.set('from', new Date(filterFrom).toISOString());
|
|
||||||
if (filterTo) params.set('to', new Date(filterTo).toISOString());
|
|
||||||
|
|
||||||
const res = await api.get<AuditOffsetPage>(`/audit?${params}`);
|
|
||||||
entries = res.items ?? [];
|
|
||||||
total = res.total ?? entries.length;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to load audit log';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
initialLoaded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function goToPage(p: number) {
|
|
||||||
if (p < 0 || p >= totalPages || p === page) return;
|
|
||||||
page = p;
|
|
||||||
initialLoaded = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTs(iso: string | undefined | null): string {
|
|
||||||
if (!iso) return '—';
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleString(undefined, {
|
|
||||||
year: 'numeric', month: 'short', day: 'numeric',
|
|
||||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionLabel(action: string | undefined | null): string {
|
|
||||||
if (!action) return '—';
|
|
||||||
return ACTION_LABELS[action] ?? action.replace(/_/g, ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortId(id: string | undefined | null): string {
|
|
||||||
if (!id) return '—';
|
|
||||||
return id.slice(-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearFilters() {
|
|
||||||
filterUserId = '';
|
|
||||||
filterAction = '';
|
|
||||||
filterObjectType = '';
|
|
||||||
filterObjectId = '';
|
|
||||||
filterFrom = '';
|
|
||||||
filterTo = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
let filtersActive = $derived(
|
|
||||||
!!(filterUserId || filterAction || filterObjectType || filterObjectId || filterFrom || filterTo)
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head><title>Audit Log — Admin | Tanabata</title></svelte:head>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<!-- Filters -->
|
|
||||||
<div class="filters">
|
|
||||||
<div class="filters-row">
|
|
||||||
<select class="filter-select" bind:value={filterUserId} title="Filter by user">
|
|
||||||
<option value="">All users</option>
|
|
||||||
{#each allUsers as u (u.id)}
|
|
||||||
<option value={String(u.id)}>{u.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select class="filter-select" bind:value={filterAction} title="Filter by action">
|
|
||||||
<option value="">All actions</option>
|
|
||||||
{#each Object.keys(ACTION_LABELS) as a}
|
|
||||||
<option value={a}>{ACTION_LABELS[a]}</option>
|
|
||||||
{/each}
|
|
||||||
{#each knownActions.filter((a) => !(a in ACTION_LABELS)) as a}
|
|
||||||
<option value={a}>{a}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select class="filter-select" bind:value={filterObjectType} title="Filter by object type">
|
|
||||||
<option value="">All objects</option>
|
|
||||||
{#each OBJECT_TYPES as t}
|
|
||||||
<option value={t}>{t}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<input
|
|
||||||
class="filter-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Object ID…"
|
|
||||||
bind:value={filterObjectId}
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filters-row">
|
|
||||||
<label class="date-label">
|
|
||||||
From
|
|
||||||
<input class="filter-input date" type="datetime-local" bind:value={filterFrom} />
|
|
||||||
</label>
|
|
||||||
<label class="date-label">
|
|
||||||
To
|
|
||||||
<input class="filter-input date" type="datetime-local" bind:value={filterTo} />
|
|
||||||
</label>
|
|
||||||
{#if filtersActive}
|
|
||||||
<button class="clear-btn" onclick={clearFilters}>Clear filters</button>
|
|
||||||
{/if}
|
|
||||||
<span class="total-hint">{total} entr{total !== 1 ? 'ies' : 'y'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table -->
|
|
||||||
{#if error}
|
|
||||||
<p class="msg error" role="alert">{error}</p>
|
|
||||||
{:else}
|
|
||||||
<div class="content-area">
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Time</th>
|
|
||||||
<th>User</th>
|
|
||||||
<th>Action</th>
|
|
||||||
<th>Object</th>
|
|
||||||
<th>ID</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each entries as e (e.id)}
|
|
||||||
<tr>
|
|
||||||
<td class="ts-cell">{formatTs(e.performed_at)}</td>
|
|
||||||
<td class="user-cell">{e.user_name ?? '—'}</td>
|
|
||||||
<td class="action-cell">
|
|
||||||
<span class="action-tag" class:file={e.object_type === 'file'} class:tag={e.object_type === 'tag'} class:pool={e.object_type === 'pool'} class:cat={e.object_type === 'category'}>
|
|
||||||
{actionLabel(e.action)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="obj-type-cell">{e.object_type ?? '—'}</td>
|
|
||||||
<td class="obj-id-cell" title={e.object_id ?? ''}>{shortId(e.object_id)}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<tr class="loading-row">
|
|
||||||
<td colspan="5">
|
|
||||||
<span class="spinner" role="status" aria-label="Loading"></span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !loading && initialLoaded && entries.length === 0}
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="empty-cell">No entries match the current filters.</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if totalPages > 1}
|
|
||||||
<div class="pagination">
|
|
||||||
<button class="page-btn" onclick={() => goToPage(page - 1)} disabled={page === 0 || loading}>
|
|
||||||
← Prev
|
|
||||||
</button>
|
|
||||||
<span class="page-info">Page {page + 1} of {totalPages}</span>
|
|
||||||
<button class="page-btn" onclick={() => goToPage(page + 1)} disabled={page >= totalPages - 1 || loading}>
|
|
||||||
Next →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page {
|
|
||||||
padding: 14px 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Filters ---- */
|
|
||||||
.filters {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-select,
|
|
||||||
.filter-input {
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border-radius: 7px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-select:focus,
|
|
||||||
.filter-input:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-input {
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-input.date {
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-btn {
|
|
||||||
height: 30px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 7px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-danger) 45%, transparent);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-danger);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-btn:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.total-hint {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Table ---- */
|
|
||||||
.content-area {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table thead {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 7px 10px;
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 8%, transparent);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr:hover td {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 5%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ts-cell {
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-cell {
|
|
||||||
white-space: nowrap;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-tag {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 7px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
|
||||||
color: var(--color-accent);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-tag.file { background-color: color-mix(in srgb, var(--color-info) 12%, transparent); color: var(--color-info); }
|
|
||||||
.action-tag.tag { background-color: color-mix(in srgb, #7ECBA1 12%, transparent); color: #7ECBA1; }
|
|
||||||
.action-tag.pool { background-color: color-mix(in srgb, var(--color-warning) 12%, transparent); color: var(--color-warning); }
|
|
||||||
.action-tag.cat { background-color: color-mix(in srgb, var(--color-danger) 12%, transparent); color: var(--color-danger); }
|
|
||||||
|
|
||||||
.obj-type-cell {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: capitalize;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.obj-id-cell {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-row td {
|
|
||||||
text-align: center;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-cell {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
padding: 40px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border: 2px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
|
||||||
border-top-color: var(--color-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-btn {
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border-radius: 7px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-btn:hover:not(:disabled) {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-btn:disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-info {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
min-width: 100px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg.error {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--color-danger);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { api, ApiError } from '$lib/api/client';
|
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
|
||||||
import type { User, UserOffsetPage } from '$lib/api/types';
|
|
||||||
|
|
||||||
const LIMIT = 100;
|
|
||||||
|
|
||||||
let users = $state<User[]>([]);
|
|
||||||
let total = $state(0);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
// Create form
|
|
||||||
let showCreate = $state(false);
|
|
||||||
let newName = $state('');
|
|
||||||
let newPassword = $state('');
|
|
||||||
let newCanCreate = $state(false);
|
|
||||||
let newIsAdmin = $state(false);
|
|
||||||
let creating = $state(false);
|
|
||||||
let createError = $state('');
|
|
||||||
|
|
||||||
// Delete confirm
|
|
||||||
let confirmDeleteUser = $state<User | null>(null);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const res = await api.get<UserOffsetPage>(`/users?limit=${LIMIT}&offset=0`);
|
|
||||||
users = res.items ?? [];
|
|
||||||
total = res.total ?? users.length;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to load users';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createUser() {
|
|
||||||
if (!newName.trim() || !newPassword.trim()) return;
|
|
||||||
creating = true;
|
|
||||||
createError = '';
|
|
||||||
try {
|
|
||||||
const u = await api.post<User>('/users', {
|
|
||||||
name: newName.trim(),
|
|
||||||
password: newPassword.trim(),
|
|
||||||
can_create: newCanCreate,
|
|
||||||
is_admin: newIsAdmin,
|
|
||||||
});
|
|
||||||
users = [u, ...users];
|
|
||||||
total++;
|
|
||||||
showCreate = false;
|
|
||||||
newName = '';
|
|
||||||
newPassword = '';
|
|
||||||
newCanCreate = false;
|
|
||||||
newIsAdmin = false;
|
|
||||||
} catch (e) {
|
|
||||||
createError = e instanceof ApiError ? e.message : 'Failed to create user';
|
|
||||||
} finally {
|
|
||||||
creating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteUser(u: User) {
|
|
||||||
confirmDeleteUser = null;
|
|
||||||
try {
|
|
||||||
await api.delete(`/users/${u.id}`);
|
|
||||||
users = users.filter((x) => x.id !== u.id);
|
|
||||||
total--;
|
|
||||||
} catch {
|
|
||||||
// silently ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { void load(); });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head><title>Users — Admin | Tanabata</title></svelte:head>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<div class="toolbar">
|
|
||||||
<span class="count">{total} user{total !== 1 ? 's' : ''}</span>
|
|
||||||
<button class="btn primary" onclick={() => (showCreate = !showCreate)}>
|
|
||||||
{showCreate ? 'Cancel' : '+ New user'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showCreate}
|
|
||||||
<div class="create-form">
|
|
||||||
{#if createError}<p class="form-error" role="alert">{createError}</p>{/if}
|
|
||||||
<div class="form-row">
|
|
||||||
<input class="input" type="text" placeholder="Username" bind:value={newName} autocomplete="off" />
|
|
||||||
<input class="input" type="password" placeholder="Password" bind:value={newPassword} autocomplete="new-password" />
|
|
||||||
</div>
|
|
||||||
<div class="form-row checks">
|
|
||||||
<label class="check-label">
|
|
||||||
<input type="checkbox" bind:checked={newCanCreate} />
|
|
||||||
Can create
|
|
||||||
</label>
|
|
||||||
<label class="check-label">
|
|
||||||
<input type="checkbox" bind:checked={newIsAdmin} />
|
|
||||||
Admin
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
class="btn primary"
|
|
||||||
onclick={createUser}
|
|
||||||
disabled={creating || !newName.trim() || !newPassword.trim()}
|
|
||||||
>
|
|
||||||
{creating ? 'Creating…' : 'Create'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<p class="error" role="alert">{error}</p>
|
|
||||||
{:else if loading}
|
|
||||||
<div class="loading"><span class="spinner" role="status" aria-label="Loading"></span></div>
|
|
||||||
{:else if users.length === 0}
|
|
||||||
<p class="empty">No users found.</p>
|
|
||||||
{:else}
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Role</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each users as u (u.id)}
|
|
||||||
<tr class="user-row" class:blocked={u.is_blocked}>
|
|
||||||
<td class="id-cell">{u.id}</td>
|
|
||||||
<td class="name-cell">
|
|
||||||
<button class="name-btn" onclick={() => goto(`/admin/users/${u.id}`)}>
|
|
||||||
{u.name}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge" class:admin={u.is_admin} class:creator={!u.is_admin && u.can_create}>
|
|
||||||
{u.is_admin ? 'Admin' : u.can_create ? 'Creator' : 'Viewer'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{#if u.is_blocked}
|
|
||||||
<span class="badge blocked">Blocked</span>
|
|
||||||
{:else}
|
|
||||||
<span class="badge active">Active</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="actions-cell">
|
|
||||||
<button class="icon-btn" onclick={() => goto(`/admin/users/${u.id}`)} title="Edit">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
||||||
<path d="M9.5 2.5l2 2-7 7H2.5v-2l7-7z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="icon-btn danger" onclick={() => (confirmDeleteUser = u)} title="Delete">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
||||||
<path d="M2 4h10M5 4V2.5h4V4M5.5 6.5v4M8.5 6.5v4M3 4l.8 7.5h6.4L11 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if confirmDeleteUser}
|
|
||||||
<ConfirmDialog
|
|
||||||
message="Delete user “{confirmDeleteUser.name}”? This cannot be undone."
|
|
||||||
confirmLabel="Delete"
|
|
||||||
danger
|
|
||||||
onConfirm={() => deleteUser(confirmDeleteUser!)}
|
|
||||||
onCancel={() => (confirmDeleteUser = null)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page {
|
|
||||||
padding: 16px;
|
|
||||||
max-width: 760px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form {
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 14px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row.checks {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 140px;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 7px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-error {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--color-danger);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border-radius: 7px;
|
|
||||||
border: none;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.btn.primary {
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.primary:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 9px 10px;
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 10%, transparent);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-row.blocked td {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.id-cell {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name-btn:hover {
|
|
||||||
color: var(--color-accent);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
padding: 2px 7px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.admin {
|
|
||||||
background-color: color-mix(in srgb, var(--color-warning) 20%, transparent);
|
|
||||||
color: var(--color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.creator {
|
|
||||||
background-color: color-mix(in srgb, var(--color-info) 15%, transparent);
|
|
||||||
color: var(--color-info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.active {
|
|
||||||
background-color: color-mix(in srgb, #7ECBA1 15%, transparent);
|
|
||||||
color: #7ECBA1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge.blocked {
|
|
||||||
background-color: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-cell {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn.danger:hover {
|
|
||||||
color: var(--color-danger);
|
|
||||||
border-color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: block;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
|
||||||
border-top-color: var(--color-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
.error, .empty {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error { color: var(--color-danger); }
|
|
||||||
</style>
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { api, ApiError } from '$lib/api/client';
|
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
|
||||||
import type { User } from '$lib/api/types';
|
|
||||||
|
|
||||||
let userId = $derived(page.params.id);
|
|
||||||
|
|
||||||
let user = $state<User | null>(null);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
let saving = $state(false);
|
|
||||||
let saveError = $state('');
|
|
||||||
let saveSuccess = $state(false);
|
|
||||||
let confirmDelete = $state(false);
|
|
||||||
let deleting = $state(false);
|
|
||||||
|
|
||||||
// editable fields
|
|
||||||
let isAdmin = $state(false);
|
|
||||||
let canCreate = $state(false);
|
|
||||||
let isBlocked = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const id = userId;
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
void api.get<User>(`/users/${id}`).then((u) => {
|
|
||||||
user = u;
|
|
||||||
isAdmin = u.is_admin ?? false;
|
|
||||||
canCreate = u.can_create ?? false;
|
|
||||||
isBlocked = u.is_blocked ?? false;
|
|
||||||
}).catch((e) => {
|
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to load user';
|
|
||||||
}).finally(() => {
|
|
||||||
loading = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (saving || !user) return;
|
|
||||||
saving = true;
|
|
||||||
saveError = '';
|
|
||||||
saveSuccess = false;
|
|
||||||
try {
|
|
||||||
const updated = await api.patch<User>(`/users/${user.id}`, {
|
|
||||||
is_admin: isAdmin,
|
|
||||||
can_create: canCreate,
|
|
||||||
is_blocked: isBlocked,
|
|
||||||
});
|
|
||||||
user = updated;
|
|
||||||
saveSuccess = true;
|
|
||||||
setTimeout(() => (saveSuccess = false), 2500);
|
|
||||||
} catch (e) {
|
|
||||||
saveError = e instanceof ApiError ? e.message : 'Failed to save';
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doDelete() {
|
|
||||||
confirmDelete = false;
|
|
||||||
deleting = true;
|
|
||||||
try {
|
|
||||||
await api.delete(`/users/${user!.id}`);
|
|
||||||
goto('/admin/users');
|
|
||||||
} catch (e) {
|
|
||||||
saveError = e instanceof ApiError ? e.message : 'Failed to delete';
|
|
||||||
deleting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head><title>{user?.name ?? 'User'} — Admin | Tanabata</title></svelte:head>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<button class="back-link" onclick={() => goto('/admin/users')}>
|
|
||||||
← All users
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<p class="msg error" role="alert">{error}</p>
|
|
||||||
{:else if loading}
|
|
||||||
<div class="loading"><span class="spinner" role="status" aria-label="Loading"></span></div>
|
|
||||||
{:else if user}
|
|
||||||
<div class="card">
|
|
||||||
<div class="user-header">
|
|
||||||
<span class="user-name">{user.name}</span>
|
|
||||||
<span class="user-id">#{user.id}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if saveError}<p class="msg error" role="alert">{saveError}</p>{/if}
|
|
||||||
{#if saveSuccess}<p class="msg success" role="status">Saved.</p>{/if}
|
|
||||||
|
|
||||||
<div class="section-label">Role & permissions</div>
|
|
||||||
|
|
||||||
<div class="toggle-group">
|
|
||||||
<div class="toggle-row">
|
|
||||||
<div>
|
|
||||||
<span class="toggle-label">Admin</span>
|
|
||||||
<p class="toggle-hint">Full access to all data and admin panel.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="toggle" class:on={isAdmin}
|
|
||||||
role="switch" aria-checked={isAdmin}
|
|
||||||
onclick={() => (isAdmin = !isAdmin)}
|
|
||||||
><span class="thumb"></span></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toggle-row">
|
|
||||||
<div>
|
|
||||||
<span class="toggle-label">Can create</span>
|
|
||||||
<p class="toggle-hint">Can upload files and create tags, pools, categories.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="toggle" class:on={canCreate}
|
|
||||||
role="switch" aria-checked={canCreate}
|
|
||||||
onclick={() => (canCreate = !canCreate)}
|
|
||||||
><span class="thumb"></span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section-label">Account status</div>
|
|
||||||
|
|
||||||
<div class="toggle-group">
|
|
||||||
<div class="toggle-row">
|
|
||||||
<div>
|
|
||||||
<span class="toggle-label" class:danger-label={isBlocked}>Blocked</span>
|
|
||||||
<p class="toggle-hint">Blocked users cannot log in.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="toggle" class:on={isBlocked} class:danger={isBlocked}
|
|
||||||
role="switch" aria-checked={isBlocked}
|
|
||||||
onclick={() => (isBlocked = !isBlocked)}
|
|
||||||
><span class="thumb"></span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-row">
|
|
||||||
<button class="btn primary" onclick={save} disabled={saving}>
|
|
||||||
{saving ? 'Saving…' : 'Save changes'}
|
|
||||||
</button>
|
|
||||||
<button class="btn danger-outline" onclick={() => (confirmDelete = true)} disabled={deleting}>
|
|
||||||
{deleting ? 'Deleting…' : 'Delete user'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if confirmDelete && user}
|
|
||||||
<ConfirmDialog
|
|
||||||
message="Delete user “{user.name}”? This cannot be undone."
|
|
||||||
confirmLabel="Delete"
|
|
||||||
danger
|
|
||||||
onConfirm={doDelete}
|
|
||||||
onCancel={() => (confirmDelete = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page {
|
|
||||||
padding: 16px;
|
|
||||||
max-width: 520px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
text-align: left;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover { color: var(--color-accent); }
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 18px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name {
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-id {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-label {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-bottom: -6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-label.danger-label {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-hint {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin: 2px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* toggle switch */
|
|
||||||
.toggle {
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
width: 40px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 11px;
|
|
||||||
border: none;
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 22%, var(--color-bg-primary));
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
transition: background-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle.on { background-color: var(--color-accent); }
|
|
||||||
.toggle.on.danger { background-color: var(--color-danger); }
|
|
||||||
|
|
||||||
.toggle .thumb {
|
|
||||||
position: absolute;
|
|
||||||
top: 3px;
|
|
||||||
left: 3px;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #fff;
|
|
||||||
transition: transform 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle.on .thumb { transform: translateX(18px); }
|
|
||||||
|
|
||||||
.action-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
height: 34px;
|
|
||||||
padding: 0 16px;
|
|
||||||
border-radius: 7px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.btn.primary {
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.primary:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.danger-outline {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--color-danger);
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.danger-outline:hover:not(:disabled) {
|
|
||||||
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg.error { color: var(--color-danger); }
|
|
||||||
.msg.success { color: #7ECBA1; }
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: block;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
|
||||||
border-top-color: var(--color-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
</style>
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { api, ApiError } from '$lib/api/client';
|
|
||||||
import { categorySorting, type CategorySortField } from '$lib/stores/sorting';
|
|
||||||
import type { Category, CategoryOffsetPage } from '$lib/api/types';
|
|
||||||
|
|
||||||
const LIMIT = 100;
|
|
||||||
|
|
||||||
const SORT_OPTIONS: { value: CategorySortField; label: string }[] = [
|
|
||||||
{ value: 'name', label: 'Name' },
|
|
||||||
{ value: 'color', label: 'Color' },
|
|
||||||
{ value: 'created', label: 'Created' },
|
|
||||||
];
|
|
||||||
|
|
||||||
let categories = $state<Category[]>([]);
|
|
||||||
let total = $state(0);
|
|
||||||
let offset = $state(0);
|
|
||||||
let loading = $state(false);
|
|
||||||
let initialLoaded = $state(false);
|
|
||||||
let error = $state('');
|
|
||||||
let search = $state('');
|
|
||||||
|
|
||||||
let sortState = $derived($categorySorting);
|
|
||||||
|
|
||||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
|
||||||
let prevKey = $state('');
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (resetKey !== prevKey) {
|
|
||||||
prevKey = resetKey;
|
|
||||||
categories = [];
|
|
||||||
offset = 0;
|
|
||||||
total = 0;
|
|
||||||
initialLoaded = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!initialLoaded && !loading) void load();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
if (loading) return;
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
limit: String(LIMIT),
|
|
||||||
offset: String(offset),
|
|
||||||
sort: sortState.sort,
|
|
||||||
order: sortState.order,
|
|
||||||
});
|
|
||||||
if (search.trim()) params.set('search', search.trim());
|
|
||||||
const page = await api.get<CategoryOffsetPage>(`/categories?${params}`);
|
|
||||||
categories = offset === 0 ? (page.items ?? []) : [...categories, ...(page.items ?? [])];
|
|
||||||
total = page.total ?? 0;
|
|
||||||
offset = categories.length;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to load categories';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
initialLoaded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasMore = $derived(categories.length < total);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Categories | Tanabata</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<header class="top-bar">
|
|
||||||
<h1 class="page-title">Categories</h1>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<select
|
|
||||||
class="sort-select"
|
|
||||||
value={sortState.sort}
|
|
||||||
onchange={(e) => categorySorting.setSort((e.currentTarget as HTMLSelectElement).value as CategorySortField)}
|
|
||||||
>
|
|
||||||
{#each SORT_OPTIONS as opt}
|
|
||||||
<option value={opt.value}>{opt.label}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="icon-btn"
|
|
||||||
onclick={() => categorySorting.toggleOrder()}
|
|
||||||
title={sortState.order === 'asc' ? 'Ascending' : 'Descending'}
|
|
||||||
>
|
|
||||||
{#if sortState.order === 'asc'}
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
||||||
<path d="M3 9L7 5L11 9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
||||||
<path d="M3 5L7 9L11 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="new-btn" onclick={() => goto('/categories/new')}>+ New</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="search-bar">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<input
|
|
||||||
class="search-input"
|
|
||||||
type="search"
|
|
||||||
placeholder="Search categories…"
|
|
||||||
value={search}
|
|
||||||
oninput={(e) => (search = (e.currentTarget as HTMLInputElement).value)}
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
{#if search}
|
|
||||||
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
{#if error}
|
|
||||||
<p class="error" role="alert">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="category-grid">
|
|
||||||
{#each categories as cat (cat.id)}
|
|
||||||
<button
|
|
||||||
class="category-pill"
|
|
||||||
style={cat.color ? `background-color: #${cat.color}` : ''}
|
|
||||||
onclick={() => goto(`/categories/${cat.id}`)}
|
|
||||||
>
|
|
||||||
{cat.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="loading-row">
|
|
||||||
<span class="spinner" role="status" aria-label="Loading"></span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if hasMore && !loading}
|
|
||||||
<button class="load-more" onclick={load}>Load more</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !loading && categories.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
{search ? 'No categories match your search.' : 'No categories yet.'}
|
|
||||||
{#if !search}
|
|
||||||
<a href="/categories/new">Create one</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-bar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
margin: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-select {
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-btn {
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: none;
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
color: var(--color-bg-primary);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-btn:hover {
|
|
||||||
background-color: var(--color-accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 10%, transparent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear {
|
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 12px 12px calc(60px + 12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
align-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: none;
|
|
||||||
background-color: var(--color-tag-default);
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-pill:hover {
|
|
||||||
filter: brightness(1.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: block;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
|
||||||
border-top-color: var(--color-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
.load-more {
|
|
||||||
display: block;
|
|
||||||
margin: 16px auto 0;
|
|
||||||
padding: 8px 24px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: var(--color-danger);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
padding: 60px 20px;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty a {
|
|
||||||
color: var(--color-accent);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { api, ApiError } from '$lib/api/client';
|
|
||||||
import type { Category, Tag, TagOffsetPage } from '$lib/api/types';
|
|
||||||
import TagBadge from '$lib/components/tag/TagBadge.svelte';
|
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
|
||||||
|
|
||||||
let categoryId = $derived(page.params.id);
|
|
||||||
|
|
||||||
let category = $state<Category | null>(null);
|
|
||||||
let tags = $state<Tag[]>([]);
|
|
||||||
let tagsTotal = $state(0);
|
|
||||||
let tagsOffset = $state(0);
|
|
||||||
let tagsLoading = $state(false);
|
|
||||||
|
|
||||||
let name = $state('');
|
|
||||||
let notes = $state('');
|
|
||||||
let color = $state('#9592B5');
|
|
||||||
let isPublic = $state(false);
|
|
||||||
|
|
||||||
let saving = $state(false);
|
|
||||||
let deleting = $state(false);
|
|
||||||
let loadError = $state('');
|
|
||||||
let saveError = $state('');
|
|
||||||
let loaded = $state(false);
|
|
||||||
let confirmDelete = $state(false);
|
|
||||||
|
|
||||||
const TAGS_LIMIT = 100;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const id = categoryId;
|
|
||||||
loaded = false;
|
|
||||||
loadError = '';
|
|
||||||
tags = [];
|
|
||||||
tagsOffset = 0;
|
|
||||||
tagsTotal = 0;
|
|
||||||
void api.get<Category>(`/categories/${id}`).then((cat) => {
|
|
||||||
category = cat;
|
|
||||||
name = cat.name ?? '';
|
|
||||||
notes = cat.notes ?? '';
|
|
||||||
color = cat.color ? `#${cat.color}` : '#9592B5';
|
|
||||||
isPublic = cat.is_public ?? false;
|
|
||||||
loaded = true;
|
|
||||||
}).catch((e) => {
|
|
||||||
loadError = e instanceof ApiError ? e.message : 'Failed to load category';
|
|
||||||
});
|
|
||||||
void loadTags(id, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadTags(id: string, startOffset: number) {
|
|
||||||
tagsLoading = true;
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
limit: String(TAGS_LIMIT),
|
|
||||||
offset: String(startOffset),
|
|
||||||
sort: 'name',
|
|
||||||
order: 'asc',
|
|
||||||
});
|
|
||||||
const p = await api.get<TagOffsetPage>(`/categories/${id}/tags?${params}`);
|
|
||||||
tags = startOffset === 0 ? (p.items ?? []) : [...tags, ...(p.items ?? [])];
|
|
||||||
tagsTotal = p.total ?? 0;
|
|
||||||
tagsOffset = tags.length;
|
|
||||||
} catch {
|
|
||||||
// non-fatal — tags section just stays empty
|
|
||||||
} finally {
|
|
||||||
tagsLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tagsHasMore = $derived(tags.length < tagsTotal);
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (!name.trim() || saving) return;
|
|
||||||
saving = true;
|
|
||||||
saveError = '';
|
|
||||||
try {
|
|
||||||
await api.patch(`/categories/${categoryId}`, {
|
|
||||||
name: name.trim(),
|
|
||||||
notes: notes.trim() || null,
|
|
||||||
color: color.slice(1),
|
|
||||||
is_public: isPublic,
|
|
||||||
});
|
|
||||||
goto('/categories');
|
|
||||||
} catch (e) {
|
|
||||||
saveError = e instanceof ApiError ? e.message : 'Failed to save category';
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doDeleteCategory() {
|
|
||||||
confirmDelete = false;
|
|
||||||
deleting = true;
|
|
||||||
try {
|
|
||||||
await api.delete(`/categories/${categoryId}`);
|
|
||||||
goto('/categories');
|
|
||||||
} catch (e) {
|
|
||||||
saveError = e instanceof ApiError ? e.message : 'Failed to delete category';
|
|
||||||
deleting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{category?.name ?? 'Category'} | Tanabata</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<header class="top-bar">
|
|
||||||
<button class="back-btn" onclick={() => goto('/categories')} aria-label="Back">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
||||||
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<h1 class="page-title">{category?.name ?? 'Category'}</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
{#if loadError}
|
|
||||||
<p class="error" role="alert">{loadError}</p>
|
|
||||||
{:else if !loaded}
|
|
||||||
<div class="loading-row">
|
|
||||||
<span class="spinner" role="status" aria-label="Loading"></span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#if saveError}
|
|
||||||
<p class="error" role="alert">{saveError}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<form class="form" onsubmit={(e) => { e.preventDefault(); void save(); }}>
|
|
||||||
<div class="row-fields">
|
|
||||||
<div class="field" style="flex: 1">
|
|
||||||
<label class="label" for="name">Name <span class="required">*</span></label>
|
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
class="input"
|
|
||||||
type="text"
|
|
||||||
bind:value={name}
|
|
||||||
required
|
|
||||||
placeholder="Category name"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field color-field">
|
|
||||||
<label class="label" for="color">Color</label>
|
|
||||||
<input id="color" class="color-input" type="color" bind:value={color} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="notes">Notes</label>
|
|
||||||
<textarea id="notes" class="textarea" rows="3" bind:value={notes} placeholder="Optional notes…"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toggle-row">
|
|
||||||
<span class="label">Public</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="toggle"
|
|
||||||
class:on={isPublic}
|
|
||||||
onclick={() => (isPublic = !isPublic)}
|
|
||||||
role="switch"
|
|
||||||
aria-checked={isPublic}
|
|
||||||
aria-label="Public"
|
|
||||||
>
|
|
||||||
<span class="thumb"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-row">
|
|
||||||
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
|
||||||
{saving ? 'Saving…' : 'Save changes'}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="delete-btn" onclick={() => (confirmDelete = true)} disabled={deleting}>
|
|
||||||
{deleting ? 'Deleting…' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Tags in this category -->
|
|
||||||
<section class="section">
|
|
||||||
<h2 class="section-title">
|
|
||||||
Tags
|
|
||||||
{#if tagsTotal > 0}<span class="count">({tagsTotal})</span>{/if}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{#if tagsLoading && tags.length === 0}
|
|
||||||
<div class="loading-row">
|
|
||||||
<span class="spinner" role="status" aria-label="Loading"></span>
|
|
||||||
</div>
|
|
||||||
{:else if tags.length === 0}
|
|
||||||
<p class="empty-tags">No tags in this category.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="tag-grid">
|
|
||||||
{#each tags as tag (tag.id)}
|
|
||||||
<TagBadge {tag} onclick={() => goto(`/tags/${tag.id}`)} size="sm" />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if tagsHasMore}
|
|
||||||
<button
|
|
||||||
class="load-more"
|
|
||||||
onclick={() => loadTags(categoryId, tagsOffset)}
|
|
||||||
disabled={tagsLoading}
|
|
||||||
>
|
|
||||||
{tagsLoading ? 'Loading…' : 'Load more'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if confirmDelete}
|
|
||||||
<ConfirmDialog
|
|
||||||
message={`Delete category "${name}"? Tags in this category will be unassigned.`}
|
|
||||||
confirmLabel="Delete"
|
|
||||||
danger
|
|
||||||
onConfirm={doDeleteCategory}
|
|
||||||
onCancel={() => (confirmDelete = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
||||||
|
|
||||||
.top-bar {
|
|
||||||
position: sticky; top: 0; z-index: 10;
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
padding: 6px 10px; min-height: 44px;
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 36px; height: 36px; border-radius: 8px;
|
|
||||||
border: none; background: none;
|
|
||||||
color: var(--color-text-primary); cursor: pointer;
|
|
||||||
}
|
|
||||||
.back-btn:hover { background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); }
|
|
||||||
|
|
||||||
.page-title { font-size: 1rem; font-weight: 600; margin: 0; }
|
|
||||||
|
|
||||||
main {
|
|
||||||
flex: 1; overflow-y: auto;
|
|
||||||
padding: 16px 14px calc(60px + 16px);
|
|
||||||
display: flex; flex-direction: column; gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-row { display: flex; justify-content: center; padding: 40px; }
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: block; width: 28px; height: 28px;
|
|
||||||
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
|
||||||
border-top-color: var(--color-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
|
||||||
|
|
||||||
.row-fields { display: flex; gap: 10px; align-items: flex-end; }
|
|
||||||
|
|
||||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
|
||||||
|
|
||||||
.color-field { flex-shrink: 0; }
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 0.75rem; font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.required { color: var(--color-danger); }
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%; box-sizing: border-box;
|
|
||||||
height: 36px; padding: 0 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.875rem; font-family: inherit; outline: none;
|
|
||||||
}
|
|
||||||
.input:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.color-input {
|
|
||||||
width: 50px; height: 36px; padding: 2px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea {
|
|
||||||
width: 100%; box-sizing: border-box; padding: 8px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.875rem; font-family: inherit;
|
|
||||||
resize: vertical; outline: none; min-height: 70px;
|
|
||||||
}
|
|
||||||
.textarea:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.toggle-row {
|
|
||||||
display: flex; align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
.toggle-row .label { margin: 0; }
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
position: relative; width: 44px; height: 26px;
|
|
||||||
border-radius: 13px; border: none;
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
|
||||||
cursor: pointer; transition: background-color 0.2s; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.toggle.on { background-color: var(--color-accent); }
|
|
||||||
.thumb {
|
|
||||||
position: absolute; top: 3px; left: 3px;
|
|
||||||
width: 20px; height: 20px; border-radius: 50%;
|
|
||||||
background-color: #fff; transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
.toggle.on .thumb { transform: translateX(18px); }
|
|
||||||
|
|
||||||
.action-row { display: flex; gap: 8px; }
|
|
||||||
|
|
||||||
.submit-btn {
|
|
||||||
flex: 1; height: 42px; border-radius: 8px; border: none;
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
color: var(--color-bg-primary);
|
|
||||||
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
|
||||||
}
|
|
||||||
.submit-btn:hover:not(:disabled) { background-color: var(--color-accent-hover); }
|
|
||||||
.submit-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.delete-btn {
|
|
||||||
height: 42px; padding: 0 18px; border-radius: 8px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-danger) 50%, transparent);
|
|
||||||
background: none; color: var(--color-danger);
|
|
||||||
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
|
||||||
}
|
|
||||||
.delete-btn:hover:not(:disabled) { background-color: color-mix(in srgb, var(--color-danger) 12%, transparent); }
|
|
||||||
.delete-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.section { display: flex; flex-direction: column; gap: 10px; }
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 0.75rem; font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
|
||||||
margin: 0;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
||||||
display: flex; gap: 6px; align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count {
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-tags {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 6px 20px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more:hover:not(:disabled) {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.load-more:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.error { color: var(--color-danger); font-size: 0.875rem; margin: 0; }
|
|
||||||
</style>
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { api, ApiError } from '$lib/api/client';
|
|
||||||
|
|
||||||
let name = $state('');
|
|
||||||
let notes = $state('');
|
|
||||||
let color = $state('#9592B5');
|
|
||||||
let isPublic = $state(false);
|
|
||||||
let saving = $state(false);
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (!name.trim() || saving) return;
|
|
||||||
saving = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await api.post('/categories', {
|
|
||||||
name: name.trim(),
|
|
||||||
notes: notes.trim() || null,
|
|
||||||
color: color.slice(1),
|
|
||||||
is_public: isPublic,
|
|
||||||
});
|
|
||||||
goto('/categories');
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to create category';
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>New Category | Tanabata</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<header class="top-bar">
|
|
||||||
<button class="back-btn" onclick={() => goto('/categories')} aria-label="Back">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
||||||
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<h1 class="page-title">New Category</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
{#if error}
|
|
||||||
<p class="error" role="alert">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<form class="form" onsubmit={(e) => { e.preventDefault(); void submit(); }}>
|
|
||||||
<div class="row-fields">
|
|
||||||
<div class="field" style="flex: 1">
|
|
||||||
<label class="label" for="name">Name <span class="required">*</span></label>
|
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
class="input"
|
|
||||||
type="text"
|
|
||||||
bind:value={name}
|
|
||||||
required
|
|
||||||
placeholder="Category name"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field color-field">
|
|
||||||
<label class="label" for="color">Color</label>
|
|
||||||
<input id="color" class="color-input" type="color" bind:value={color} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="label" for="notes">Notes</label>
|
|
||||||
<textarea id="notes" class="textarea" rows="3" bind:value={notes} placeholder="Optional notes…"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toggle-row">
|
|
||||||
<span class="label">Public</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="toggle"
|
|
||||||
class:on={isPublic}
|
|
||||||
onclick={() => (isPublic = !isPublic)}
|
|
||||||
role="switch"
|
|
||||||
aria-checked={isPublic}
|
|
||||||
aria-label="Public"
|
|
||||||
>
|
|
||||||
<span class="thumb"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
|
||||||
{saving ? 'Creating…' : 'Create category'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
||||||
|
|
||||||
.top-bar {
|
|
||||||
position: sticky; top: 0; z-index: 10;
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
padding: 6px 10px; min-height: 44px;
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn {
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 36px; height: 36px; border-radius: 8px;
|
|
||||||
border: none; background: none;
|
|
||||||
color: var(--color-text-primary); cursor: pointer;
|
|
||||||
}
|
|
||||||
.back-btn:hover { background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); }
|
|
||||||
|
|
||||||
.page-title { font-size: 1rem; font-weight: 600; margin: 0; }
|
|
||||||
|
|
||||||
main { flex: 1; overflow-y: auto; padding: 16px 14px calc(60px + 16px); }
|
|
||||||
|
|
||||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
|
||||||
|
|
||||||
.row-fields { display: flex; gap: 10px; align-items: flex-end; }
|
|
||||||
|
|
||||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
|
||||||
|
|
||||||
.color-field { flex-shrink: 0; }
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 0.75rem; font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.required { color: var(--color-danger); }
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%; box-sizing: border-box;
|
|
||||||
height: 36px; padding: 0 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.875rem; font-family: inherit; outline: none;
|
|
||||||
}
|
|
||||||
.input:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.color-input {
|
|
||||||
width: 50px; height: 36px; padding: 2px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea {
|
|
||||||
width: 100%; box-sizing: border-box; padding: 8px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.875rem; font-family: inherit;
|
|
||||||
resize: vertical; outline: none; min-height: 70px;
|
|
||||||
}
|
|
||||||
.textarea:focus { border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.toggle-row {
|
|
||||||
display: flex; align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
.toggle-row .label { margin: 0; }
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
position: relative; width: 44px; height: 26px;
|
|
||||||
border-radius: 13px; border: none;
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
|
||||||
cursor: pointer; transition: background-color 0.2s; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.toggle.on { background-color: var(--color-accent); }
|
|
||||||
.thumb {
|
|
||||||
position: absolute; top: 3px; left: 3px;
|
|
||||||
width: 20px; height: 20px; border-radius: 50%;
|
|
||||||
background-color: #fff; transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
.toggle.on .thumb { transform: translateX(18px); }
|
|
||||||
|
|
||||||
.submit-btn {
|
|
||||||
width: 100%; height: 42px; border-radius: 8px; border: none;
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
color: var(--color-bg-primary);
|
|
||||||
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
|
||||||
}
|
|
||||||
.submit-btn:hover:not(:disabled) { background-color: var(--color-accent-hover); }
|
|
||||||
.submit-btn:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.error { color: var(--color-danger); font-size: 0.875rem; }
|
|
||||||
</style>
|
|
||||||
@@ -1,550 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { api } from '$lib/api/client';
|
|
||||||
import { ApiError } from '$lib/api/client';
|
|
||||||
import FileCard from '$lib/components/file/FileCard.svelte';
|
|
||||||
import FileUpload from '$lib/components/file/FileUpload.svelte';
|
|
||||||
import FilterBar from '$lib/components/file/FilterBar.svelte';
|
|
||||||
import Header from '$lib/components/layout/Header.svelte';
|
|
||||||
import SelectionBar from '$lib/components/layout/SelectionBar.svelte';
|
|
||||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
|
||||||
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
|
|
||||||
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
|
||||||
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
|
|
||||||
import { tick } from 'svelte';
|
|
||||||
import { parseDslFilter } from '$lib/utils/dsl';
|
|
||||||
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
|
|
||||||
import { appSettings } from '$lib/stores/appSettings';
|
|
||||||
|
|
||||||
let scrollContainer = $state<HTMLElement | undefined>();
|
|
||||||
|
|
||||||
let uploader = $state<{ open: () => void } | undefined>();
|
|
||||||
let confirmDeleteFiles = $state(false);
|
|
||||||
|
|
||||||
// ---- Bulk tag editor ----
|
|
||||||
let tagEditorOpen = $state(false);
|
|
||||||
|
|
||||||
// ---- Add to pool picker ----
|
|
||||||
let poolPickerOpen = $state(false);
|
|
||||||
let pools = $state<Pool[]>([]);
|
|
||||||
let poolsLoading = $state(false);
|
|
||||||
let poolPickerSearch = $state('');
|
|
||||||
let poolPickerError = $state('');
|
|
||||||
|
|
||||||
async function openPoolPicker() {
|
|
||||||
poolPickerOpen = true;
|
|
||||||
poolPickerError = '';
|
|
||||||
poolsLoading = true;
|
|
||||||
poolPickerSearch = '';
|
|
||||||
try {
|
|
||||||
const res = await api.get<PoolOffsetPage>('/pools?limit=200&sort=name&order=asc');
|
|
||||||
pools = res.items ?? [];
|
|
||||||
} catch {
|
|
||||||
poolPickerError = 'Failed to load pools';
|
|
||||||
} finally {
|
|
||||||
poolsLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addToPool(poolId: string) {
|
|
||||||
const ids = [...$selectionStore.ids];
|
|
||||||
poolPickerOpen = false;
|
|
||||||
selectionStore.exit();
|
|
||||||
try {
|
|
||||||
await api.post(`/pools/${poolId}/files`, { file_ids: ids });
|
|
||||||
} catch {
|
|
||||||
// silently ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let filteredPools = $derived(
|
|
||||||
poolPickerSearch.trim()
|
|
||||||
? pools.filter((p) => p.name?.toLowerCase().includes(poolPickerSearch.toLowerCase()))
|
|
||||||
: pools
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleUploaded(file: File) {
|
|
||||||
files = [file, ...files];
|
|
||||||
}
|
|
||||||
|
|
||||||
let LIMIT = $derived($appSettings.fileLoadLimit);
|
|
||||||
|
|
||||||
const FILE_SORT_OPTIONS = [
|
|
||||||
{ value: 'created', label: 'Created' },
|
|
||||||
{ value: 'content_datetime', label: 'Date taken' },
|
|
||||||
{ value: 'original_name', label: 'Name' },
|
|
||||||
{ value: 'mime', label: 'Type' },
|
|
||||||
];
|
|
||||||
|
|
||||||
let files = $state<File[]>([]);
|
|
||||||
let nextCursor = $state<string | null>(null);
|
|
||||||
let loading = $state(false);
|
|
||||||
let hasMore = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
let filterOpen = $state(false);
|
|
||||||
|
|
||||||
let filterParam = $derived(page.url.searchParams.get('filter'));
|
|
||||||
let activeTokens = $derived(parseDslFilter(filterParam));
|
|
||||||
let sortState = $derived($fileSorting);
|
|
||||||
|
|
||||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
|
|
||||||
let prevKey = $state('');
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (resetKey !== prevKey) {
|
|
||||||
prevKey = resetKey;
|
|
||||||
files = [];
|
|
||||||
nextCursor = null;
|
|
||||||
hasMore = true;
|
|
||||||
error = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
if (loading || !hasMore) return;
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
limit: String(LIMIT),
|
|
||||||
sort: sortState.sort,
|
|
||||||
order: sortState.order,
|
|
||||||
});
|
|
||||||
if (nextCursor) params.set('cursor', nextCursor);
|
|
||||||
if (filterParam) params.set('filter', filterParam);
|
|
||||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
|
||||||
files = [...files, ...(res.items ?? [])];
|
|
||||||
nextCursor = res.next_cursor ?? null;
|
|
||||||
hasMore = !!res.next_cursor;
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof ApiError ? err.message : 'Failed to load files';
|
|
||||||
hasMore = false;
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
// If the loaded content doesn't fill the viewport yet (no scrollbar),
|
|
||||||
// keep loading until it does or there's nothing left.
|
|
||||||
await tick();
|
|
||||||
if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
|
|
||||||
void loadMore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilter(filter: string | null) {
|
|
||||||
const url = new URL(page.url);
|
|
||||||
if (filter) {
|
|
||||||
url.searchParams.set('filter', filter);
|
|
||||||
} else {
|
|
||||||
url.searchParams.delete('filter');
|
|
||||||
}
|
|
||||||
goto(url.toString(), { replaceState: true });
|
|
||||||
filterOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFile(file: File) {
|
|
||||||
if (file.id) goto(`/files/${file.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Selection logic ----
|
|
||||||
|
|
||||||
let lastSelectedIdx = $state<number | null>(null);
|
|
||||||
|
|
||||||
function handleTap(file: File, idx: number, e: MouseEvent) {
|
|
||||||
if (!$selectionActive) {
|
|
||||||
openFile(file);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.shiftKey && lastSelectedIdx !== null) {
|
|
||||||
// Range-select between lastSelectedIdx and idx (desktop)
|
|
||||||
const from = Math.min(lastSelectedIdx, idx);
|
|
||||||
const to = Math.max(lastSelectedIdx, idx);
|
|
||||||
for (let i = from; i <= to; i++) {
|
|
||||||
if (files[i]?.id) selectionStore.select(files[i].id!);
|
|
||||||
}
|
|
||||||
lastSelectedIdx = idx;
|
|
||||||
} else {
|
|
||||||
if (file.id) selectionStore.toggle(file.id);
|
|
||||||
lastSelectedIdx = idx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLongPress(file: File, idx: number, pointerType: string) {
|
|
||||||
// Determine drag mode from whether this card is already selected
|
|
||||||
const alreadySelected = $selectionStore.ids.has(file.id!);
|
|
||||||
if (alreadySelected) {
|
|
||||||
selectionStore.deselect(file.id!);
|
|
||||||
dragMode = 'deselect';
|
|
||||||
} else {
|
|
||||||
selectionStore.select(file.id!);
|
|
||||||
dragMode = 'select';
|
|
||||||
}
|
|
||||||
lastSelectedIdx = idx;
|
|
||||||
// Only enter drag-select for touch — shift+click covers desktop range selection
|
|
||||||
if (pointerType === 'touch') dragSelecting = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Drag-to-select / deselect (touch only) ----
|
|
||||||
// Entered only after a long-press (400ms stillness), so by the time we
|
|
||||||
// add the touchmove listener the scroll gesture hasn't started yet.
|
|
||||||
// A non-passive touchmove listener lets us call preventDefault() to block
|
|
||||||
// scroll while the user slides their finger across cards.
|
|
||||||
|
|
||||||
let dragSelecting = $state(false);
|
|
||||||
let dragMode = $state<'select' | 'deselect'>('select');
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!dragSelecting) return;
|
|
||||||
|
|
||||||
function onTouchMove(e: TouchEvent) {
|
|
||||||
e.preventDefault(); // block scroll while drag-selecting
|
|
||||||
const touch = e.touches[0];
|
|
||||||
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
||||||
const card = el?.closest<HTMLElement>('[data-file-index]');
|
|
||||||
if (!card) return;
|
|
||||||
const idx = parseInt(card.dataset.fileIndex ?? '');
|
|
||||||
if (isNaN(idx) || !files[idx]?.id) return;
|
|
||||||
if (dragMode === 'select') {
|
|
||||||
selectionStore.select(files[idx].id!);
|
|
||||||
} else {
|
|
||||||
selectionStore.deselect(files[idx].id!);
|
|
||||||
}
|
|
||||||
lastSelectedIdx = idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchEnd() {
|
|
||||||
dragSelecting = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
||||||
document.addEventListener('touchend', onTouchEnd);
|
|
||||||
document.addEventListener('touchcancel', onTouchEnd);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('touchmove', onTouchMove);
|
|
||||||
document.removeEventListener('touchend', onTouchEnd);
|
|
||||||
document.removeEventListener('touchcancel', onTouchEnd);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Files | Tanabata</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<Header
|
|
||||||
sortOptions={FILE_SORT_OPTIONS}
|
|
||||||
sort={sortState.sort}
|
|
||||||
order={sortState.order}
|
|
||||||
filterActive={activeTokens.length > 0 || filterOpen}
|
|
||||||
onSortChange={(s) => fileSorting.setSort(s as FileSortField)}
|
|
||||||
onOrderToggle={() => fileSorting.toggleOrder()}
|
|
||||||
onFilterToggle={() => (filterOpen = !filterOpen)}
|
|
||||||
onUpload={() => uploader?.open()}
|
|
||||||
onTrash={() => goto('/files/trash')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if filterOpen}
|
|
||||||
<FilterBar
|
|
||||||
value={filterParam}
|
|
||||||
onApply={applyFilter}
|
|
||||||
onClose={() => (filterOpen = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<FileUpload bind:this={uploader} onUploaded={handleUploaded}>
|
|
||||||
<main bind:this={scrollContainer}>
|
|
||||||
{#if error}
|
|
||||||
<p class="error" role="alert">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
{#each files as file, i (file.id)}
|
|
||||||
<FileCard
|
|
||||||
{file}
|
|
||||||
index={i}
|
|
||||||
selected={$selectionStore.ids.has(file.id ?? '')}
|
|
||||||
selectionMode={$selectionActive}
|
|
||||||
onTap={(e) => handleTap(file, i, e)}
|
|
||||||
onLongPress={(pt) => handleLongPress(file, i, pt)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<InfiniteScroll {loading} {hasMore} onLoadMore={loadMore} />
|
|
||||||
|
|
||||||
{#if !loading && !hasMore && files.length === 0}
|
|
||||||
<div class="empty">No files yet.</div>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</FileUpload>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if $selectionActive}
|
|
||||||
<SelectionBar
|
|
||||||
onEditTags={() => (tagEditorOpen = true)}
|
|
||||||
onAddToPool={openPoolPicker}
|
|
||||||
onDelete={() => (confirmDeleteFiles = true)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if tagEditorOpen}
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<div class="picker-backdrop" role="presentation" onclick={() => (tagEditorOpen = false)}></div>
|
|
||||||
<div class="picker-sheet tag-sheet" role="dialog" aria-label="Edit tags">
|
|
||||||
<div class="picker-header">
|
|
||||||
<span class="picker-title">Edit tags — {$selectionStore.ids.size} file{$selectionStore.ids.size !== 1 ? 's' : ''}</span>
|
|
||||||
<button class="picker-close" onclick={() => (tagEditorOpen = false)} aria-label="Close">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
||||||
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="tag-sheet-body">
|
|
||||||
<BulkTagEditor fileIds={[...$selectionStore.ids]} onDone={() => (tagEditorOpen = false)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if poolPickerOpen}
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<div class="picker-backdrop" role="presentation" onclick={() => (poolPickerOpen = false)}></div>
|
|
||||||
<div class="picker-sheet" role="dialog" aria-label="Add to pool">
|
|
||||||
<div class="picker-header">
|
|
||||||
<span class="picker-title">Add {$selectionStore.ids.size} file{$selectionStore.ids.size !== 1 ? 's' : ''} to pool</span>
|
|
||||||
<button class="picker-close" onclick={() => (poolPickerOpen = false)} aria-label="Close">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
||||||
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="picker-search-wrap">
|
|
||||||
<input
|
|
||||||
class="picker-search"
|
|
||||||
type="search"
|
|
||||||
placeholder="Search pools…"
|
|
||||||
bind:value={poolPickerSearch}
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{#if poolPickerError}
|
|
||||||
<p class="picker-error">{poolPickerError}</p>
|
|
||||||
{:else if poolsLoading}
|
|
||||||
<p class="picker-empty">Loading…</p>
|
|
||||||
{:else if filteredPools.length === 0}
|
|
||||||
<p class="picker-empty">No pools found.</p>
|
|
||||||
{:else}
|
|
||||||
<ul class="picker-list">
|
|
||||||
{#each filteredPools as pool (pool.id)}
|
|
||||||
<li>
|
|
||||||
<button class="picker-item" onclick={() => pool.id && addToPool(pool.id)}>
|
|
||||||
<span class="picker-item-name">{pool.name}</span>
|
|
||||||
<span class="picker-item-count">{pool.file_count ?? 0} files</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if confirmDeleteFiles}
|
|
||||||
<ConfirmDialog
|
|
||||||
message={`Move ${$selectionStore.ids.size} file(s) to trash?`}
|
|
||||||
confirmLabel="Move to trash"
|
|
||||||
danger
|
|
||||||
onConfirm={async () => {
|
|
||||||
const ids = [...$selectionStore.ids];
|
|
||||||
confirmDeleteFiles = false;
|
|
||||||
selectionStore.exit();
|
|
||||||
try {
|
|
||||||
await api.post('/files/bulk/delete', { file_ids: ids });
|
|
||||||
files = files.filter((f) => !ids.includes(f.id ?? ''));
|
|
||||||
} catch {
|
|
||||||
// silently ignore — file list already updated optimistically
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCancel={() => (confirmDeleteFiles = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 10px 10px calc(60px + 10px); /* clear fixed navbar */
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* phantom last item so justify-content doesn't stretch final row */
|
|
||||||
.grid::after {
|
|
||||||
content: '';
|
|
||||||
flex: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: var(--color-danger);
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
padding: 60px 20px;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Tag editor sheet ---- */
|
|
||||||
.tag-sheet {
|
|
||||||
max-height: 80dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-sheet-body {
|
|
||||||
padding: 0 14px 16px;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Pool picker ---- */
|
|
||||||
.picker-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 110;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-sheet {
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 111;
|
|
||||||
background-color: var(--color-bg-secondary);
|
|
||||||
border-radius: 14px 14px 0 0;
|
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
||||||
max-height: 70dvh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
animation: slide-up 0.18s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slide-up {
|
|
||||||
from { transform: translateY(20px); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 14px 16px 10px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-title {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
padding: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-close:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-search-wrap {
|
|
||||||
padding: 0 14px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-search {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-search:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 8px 12px;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
padding: 11px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-item:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-item-name {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-item-count {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-empty,
|
|
||||||
.picker-error {
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-error {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,588 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { api, ApiError } from '$lib/api/client';
|
|
||||||
import { authStore } from '$lib/stores/auth';
|
|
||||||
import { fileSorting } from '$lib/stores/sorting';
|
|
||||||
import TagPicker from '$lib/components/file/TagPicker.svelte';
|
|
||||||
import type { File, Tag, FileCursorPage } from '$lib/api/types';
|
|
||||||
|
|
||||||
// ---- State ----
|
|
||||||
let fileId = $derived(page.params.id);
|
|
||||||
|
|
||||||
let file = $state<File | null>(null);
|
|
||||||
let fileTags = $state<Tag[]>([]);
|
|
||||||
let previewSrc = $state<string | null>(null);
|
|
||||||
let prevFile = $state<File | null>(null);
|
|
||||||
let nextFile = $state<File | null>(null);
|
|
||||||
let loading = $state(true);
|
|
||||||
let saving = $state(false);
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
// Editable fields (initialised on load)
|
|
||||||
let notes = $state('');
|
|
||||||
let contentDatetime = $state('');
|
|
||||||
let isPublic = $state(false);
|
|
||||||
let dirty = $state(false);
|
|
||||||
|
|
||||||
let exifEntries = $derived(
|
|
||||||
file?.exif ? Object.entries(file.exif as Record<string, unknown>) : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- Load ----
|
|
||||||
$effect(() => {
|
|
||||||
if (!fileId) return;
|
|
||||||
const id = fileId; // snapshot — don't re-run if other state changes
|
|
||||||
// Revoke old blob URL without tracking previewSrc as a dependency
|
|
||||||
untrack(() => {
|
|
||||||
if (previewSrc) URL.revokeObjectURL(previewSrc);
|
|
||||||
previewSrc = null;
|
|
||||||
});
|
|
||||||
void loadPage(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadPage(id: string) {
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const [fileData, tags] = await Promise.all([
|
|
||||||
api.get<File>(`/files/${id}`),
|
|
||||||
api.get<Tag[]>(`/files/${id}/tags`),
|
|
||||||
]);
|
|
||||||
file = fileData;
|
|
||||||
fileTags = tags;
|
|
||||||
notes = fileData.notes ?? '';
|
|
||||||
contentDatetime = fileData.content_datetime
|
|
||||||
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
|
|
||||||
: '';
|
|
||||||
isPublic = fileData.is_public ?? false;
|
|
||||||
dirty = false;
|
|
||||||
|
|
||||||
void fetchPreview(id);
|
|
||||||
void loadNeighbors(id);
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to load file';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchPreview(id: string) {
|
|
||||||
const token = get(authStore).accessToken;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/v1/files/${id}/preview`, {
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const blob = await res.blob();
|
|
||||||
previewSrc = URL.createObjectURL(blob);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// non-critical — thumbnail stays as fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadNeighbors(id: string) {
|
|
||||||
const sort = get(fileSorting);
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
anchor: id,
|
|
||||||
limit: '3',
|
|
||||||
sort: sort.sort,
|
|
||||||
order: sort.order,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const result = await api.get<FileCursorPage>(`/files?${params}`);
|
|
||||||
const items = result.items ?? [];
|
|
||||||
const idx = items.findIndex((f) => f.id === id);
|
|
||||||
prevFile = idx > 0 ? items[idx - 1] : null;
|
|
||||||
nextFile = idx >= 0 && idx < items.length - 1 ? items[idx + 1] : null;
|
|
||||||
} catch {
|
|
||||||
// non-critical
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Save ----
|
|
||||||
async function save() {
|
|
||||||
if (!file || saving) return;
|
|
||||||
saving = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const updated = await api.patch<File>(`/files/${file.id}`, {
|
|
||||||
notes: notes.trim() || null,
|
|
||||||
content_datetime: contentDatetime
|
|
||||||
? new Date(contentDatetime).toISOString()
|
|
||||||
: undefined,
|
|
||||||
is_public: isPublic,
|
|
||||||
});
|
|
||||||
file = updated;
|
|
||||||
dirty = false;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to save';
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Tags ----
|
|
||||||
async function addTag(tagId: string) {
|
|
||||||
const updated = await api.put<Tag[]>(`/files/${fileId}/tags/${tagId}`);
|
|
||||||
fileTags = updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeTag(tagId: string) {
|
|
||||||
await api.delete(`/files/${fileId}/tags/${tagId}`);
|
|
||||||
fileTags = fileTags.filter((t) => t.id !== tagId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Navigation ----
|
|
||||||
function navigateTo(f: File | null) {
|
|
||||||
if (f?.id) goto(`/files/${f.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
||||||
if (e.key === 'ArrowLeft') navigateTo(prevFile);
|
|
||||||
if (e.key === 'ArrowRight') navigateTo(nextFile);
|
|
||||||
if (e.key === 'Escape') goto('/files');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Helpers ----
|
|
||||||
function formatDatetime(iso: string | null | undefined): string {
|
|
||||||
if (!iso) return '—';
|
|
||||||
return new Date(iso).toLocaleString();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>
|
|
||||||
{file?.original_name ?? fileId} | Tanabata
|
|
||||||
</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
|
||||||
|
|
||||||
<div class="viewer-page">
|
|
||||||
<!-- Top bar -->
|
|
||||||
<div class="top-bar">
|
|
||||||
<button class="back-btn" onclick={() => goto('/files')} aria-label="Back to files">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
||||||
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<span class="filename">{file?.original_name ?? ''}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Preview -->
|
|
||||||
<div class="preview-wrap">
|
|
||||||
{#if previewSrc}
|
|
||||||
<img src={previewSrc} alt={file?.original_name ?? ''} class="preview-img" />
|
|
||||||
{:else if loading}
|
|
||||||
<div class="preview-placeholder shimmer"></div>
|
|
||||||
{:else}
|
|
||||||
<div class="preview-placeholder failed"></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Prev / Next -->
|
|
||||||
{#if prevFile}
|
|
||||||
<button
|
|
||||||
class="nav-btn nav-prev"
|
|
||||||
onclick={() => navigateTo(prevFile)}
|
|
||||||
aria-label="Previous file"
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
||||||
<path d="M11 3L5 9L11 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if nextFile}
|
|
||||||
<button
|
|
||||||
class="nav-btn nav-next"
|
|
||||||
onclick={() => navigateTo(nextFile)}
|
|
||||||
aria-label="Next file"
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
||||||
<path d="M7 3L13 9L7 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Metadata panel -->
|
|
||||||
<div class="meta-panel">
|
|
||||||
{#if error}
|
|
||||||
<p class="error" role="alert">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if file}
|
|
||||||
<!-- File info -->
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="mime">{file.mime_type}</span>
|
|
||||||
<span class="sep">·</span>
|
|
||||||
<span class="created">Added {formatDatetime(file.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Edit form -->
|
|
||||||
<section class="section">
|
|
||||||
<label class="field-label" for="notes">Notes</label>
|
|
||||||
<textarea
|
|
||||||
id="notes"
|
|
||||||
class="textarea"
|
|
||||||
rows="3"
|
|
||||||
bind:value={notes}
|
|
||||||
oninput={() => (dirty = true)}
|
|
||||||
placeholder="Add notes…"
|
|
||||||
></textarea>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<label class="field-label" for="datetime">Date taken</label>
|
|
||||||
<input
|
|
||||||
id="datetime"
|
|
||||||
type="datetime-local"
|
|
||||||
class="input"
|
|
||||||
bind:value={contentDatetime}
|
|
||||||
oninput={() => (dirty = true)}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section toggle-row">
|
|
||||||
<span class="field-label">Public</span>
|
|
||||||
<button
|
|
||||||
class="toggle"
|
|
||||||
class:on={isPublic}
|
|
||||||
onclick={() => { isPublic = !isPublic; dirty = true; }}
|
|
||||||
role="switch"
|
|
||||||
aria-checked={isPublic}
|
|
||||||
aria-label="Public"
|
|
||||||
>
|
|
||||||
<span class="thumb"></span>
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="save-btn"
|
|
||||||
onclick={save}
|
|
||||||
disabled={!dirty || saving}
|
|
||||||
>
|
|
||||||
{saving ? 'Saving…' : 'Save changes'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Tags -->
|
|
||||||
<section class="section">
|
|
||||||
<div class="field-label">Tags</div>
|
|
||||||
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- EXIF -->
|
|
||||||
{#if exifEntries.length > 0}
|
|
||||||
<section class="section">
|
|
||||||
<div class="field-label">EXIF</div>
|
|
||||||
<dl class="exif">
|
|
||||||
{#each exifEntries as [key, val]}
|
|
||||||
<dt>{key}</dt>
|
|
||||||
<dd>{String(val)}</dd>
|
|
||||||
{/each}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
{:else if !loading}
|
|
||||||
<p class="empty">File not found.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.viewer-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
padding-bottom: 70px; /* clear navbar */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Top bar ---- */
|
|
||||||
.top-bar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 20;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filename {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Preview ---- */
|
|
||||||
.preview-wrap {
|
|
||||||
position: relative;
|
|
||||||
background-color: #000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
/* Fill viewport below the top bar (44px) */
|
|
||||||
height: calc(100dvh - 44px);
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-placeholder {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-placeholder.shimmer {
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
#111 25%,
|
|
||||||
#222 50%,
|
|
||||||
#111 75%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 1.4s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-placeholder.failed {
|
|
||||||
background-color: #1a1010;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Nav buttons ---- */
|
|
||||||
.nav-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background-color: rgba(0, 0, 0, 0.55);
|
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-prev { left: 10px; }
|
|
||||||
.nav-next { right: 10px; }
|
|
||||||
|
|
||||||
/* ---- Metadata panel ---- */
|
|
||||||
.meta-panel {
|
|
||||||
padding: 14px 14px 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sep { opacity: 0.4; }
|
|
||||||
|
|
||||||
.section {
|
|
||||||
padding: 10px 0;
|
|
||||||
border-top: 1px solid color-mix(in srgb, var(--color-accent) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-family: inherit;
|
|
||||||
resize: vertical;
|
|
||||||
outline: none;
|
|
||||||
min-height: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Toggle ---- */
|
|
||||||
.toggle-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-top: 12px;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-row .field-label {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
position: relative;
|
|
||||||
width: 44px;
|
|
||||||
height: 26px;
|
|
||||||
border-radius: 13px;
|
|
||||||
border: none;
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle.on {
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb {
|
|
||||||
position: absolute;
|
|
||||||
top: 3px;
|
|
||||||
left: 3px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #fff;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle.on .thumb {
|
|
||||||
transform: translateX(18px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Save button ---- */
|
|
||||||
.save-btn {
|
|
||||||
width: 100%;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
background-color: var(--color-accent);
|
|
||||||
color: var(--color-bg-primary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 4px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
transition: background-color 0.15s, opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- EXIF ---- */
|
|
||||||
.exif {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 3px 12px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Misc ---- */
|
|
||||||
.error {
|
|
||||||
color: var(--color-danger);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: 200% 0; }
|
|
||||||
100% { background-position: -200% 0; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { api, ApiError } from '$lib/api/client';
|
|
||||||
import { tick } from 'svelte';
|
|
||||||
import FileCard from '$lib/components/file/FileCard.svelte';
|
|
||||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
|
||||||
import { selectionStore, selectionActive, selectionCount } from '$lib/stores/selection';
|
|
||||||
import { appSettings } from '$lib/stores/appSettings';
|
|
||||||
import type { File, FileCursorPage } from '$lib/api/types';
|
|
||||||
|
|
||||||
let scrollContainer = $state<HTMLElement | undefined>();
|
|
||||||
|
|
||||||
let LIMIT = $derived($appSettings.fileLoadLimit);
|
|
||||||
|
|
||||||
let files = $state<File[]>([]);
|
|
||||||
let nextCursor = $state<string | null>(null);
|
|
||||||
let loading = $state(false);
|
|
||||||
let hasMore = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
let initialLoaded = $state(false);
|
|
||||||
|
|
||||||
// confirmation dialogs
|
|
||||||
let confirmRestore = $state(false);
|
|
||||||
let confirmPermDelete = $state(false);
|
|
||||||
let actionBusy = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!initialLoaded && !loading) void loadMore();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
if (loading || !hasMore) return;
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ limit: String(LIMIT), trash: 'true' });
|
|
||||||
if (nextCursor) params.set('cursor', nextCursor);
|
|
||||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
|
||||||
files = [...files, ...(res.items ?? [])];
|
|
||||||
nextCursor = res.next_cursor ?? null;
|
|
||||||
hasMore = !!res.next_cursor;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to load trash';
|
|
||||||
hasMore = false;
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
initialLoaded = true;
|
|
||||||
}
|
|
||||||
await tick();
|
|
||||||
if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
|
|
||||||
void loadMore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Selection ----
|
|
||||||
let lastSelectedIdx = $state<number | null>(null);
|
|
||||||
let dragSelecting = $state(false);
|
|
||||||
let dragMode = $state<'select' | 'deselect'>('select');
|
|
||||||
|
|
||||||
function handleTap(file: File, idx: number, e: MouseEvent) {
|
|
||||||
// In trash, tap always selects (no detail page)
|
|
||||||
if (e.shiftKey && lastSelectedIdx !== null) {
|
|
||||||
const from = Math.min(lastSelectedIdx, idx);
|
|
||||||
const to = Math.max(lastSelectedIdx, idx);
|
|
||||||
for (let i = from; i <= to; i++) {
|
|
||||||
if (files[i]?.id) selectionStore.select(files[i].id!);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!$selectionActive) selectionStore.enter();
|
|
||||||
if (file.id) selectionStore.toggle(file.id);
|
|
||||||
}
|
|
||||||
lastSelectedIdx = idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLongPress(file: File, idx: number, pointerType: string) {
|
|
||||||
const alreadySelected = $selectionStore.ids.has(file.id!);
|
|
||||||
if (alreadySelected) {
|
|
||||||
selectionStore.deselect(file.id!);
|
|
||||||
dragMode = 'deselect';
|
|
||||||
} else {
|
|
||||||
selectionStore.select(file.id!);
|
|
||||||
dragMode = 'select';
|
|
||||||
}
|
|
||||||
lastSelectedIdx = idx;
|
|
||||||
if (pointerType === 'touch') dragSelecting = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!dragSelecting) return;
|
|
||||||
function onTouchMove(e: TouchEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
const touch = e.touches[0];
|
|
||||||
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
||||||
const card = el?.closest<HTMLElement>('[data-file-index]');
|
|
||||||
if (!card) return;
|
|
||||||
const idx = parseInt(card.dataset.fileIndex ?? '');
|
|
||||||
if (isNaN(idx) || !files[idx]?.id) return;
|
|
||||||
if (dragMode === 'select') selectionStore.select(files[idx].id!);
|
|
||||||
else selectionStore.deselect(files[idx].id!);
|
|
||||||
lastSelectedIdx = idx;
|
|
||||||
}
|
|
||||||
function onTouchEnd() { dragSelecting = false; }
|
|
||||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
||||||
document.addEventListener('touchend', onTouchEnd);
|
|
||||||
document.addEventListener('touchcancel', onTouchEnd);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('touchmove', onTouchMove);
|
|
||||||
document.removeEventListener('touchend', onTouchEnd);
|
|
||||||
document.removeEventListener('touchcancel', onTouchEnd);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Actions ----
|
|
||||||
async function restoreSelected() {
|
|
||||||
const ids = [...$selectionStore.ids];
|
|
||||||
confirmRestore = false;
|
|
||||||
actionBusy = true;
|
|
||||||
selectionStore.exit();
|
|
||||||
try {
|
|
||||||
await Promise.all(ids.map((id) => api.post(`/files/${id}/restore`, {})));
|
|
||||||
files = files.filter((f) => !ids.includes(f.id ?? ''));
|
|
||||||
} catch {
|
|
||||||
// partial failure: reload
|
|
||||||
} finally {
|
|
||||||
actionBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function permDeleteSelected() {
|
|
||||||
const ids = [...$selectionStore.ids];
|
|
||||||
confirmPermDelete = false;
|
|
||||||
actionBusy = true;
|
|
||||||
selectionStore.exit();
|
|
||||||
try {
|
|
||||||
await Promise.all(ids.map((id) => api.delete(`/files/${id}/permanent`)));
|
|
||||||
files = files.filter((f) => !ids.includes(f.id ?? ''));
|
|
||||||
} catch {
|
|
||||||
// partial failure: reload
|
|
||||||
} finally {
|
|
||||||
actionBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') selectionStore.exit();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head><title>Trash | Tanabata</title></svelte:head>
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<header>
|
|
||||||
<button class="back-btn" onclick={() => { selectionStore.exit(); goto('/files'); }}>
|
|
||||||
← Files
|
|
||||||
</button>
|
|
||||||
<span class="title">Trash</span>
|
|
||||||
<button
|
|
||||||
class="select-btn"
|
|
||||||
class:active={$selectionActive}
|
|
||||||
onclick={() => ($selectionActive ? selectionStore.exit() : selectionStore.enter())}
|
|
||||||
>
|
|
||||||
{$selectionActive ? 'Cancel' : 'Select'}
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main bind:this={scrollContainer}>
|
|
||||||
{#if error}
|
|
||||||
<p class="error" role="alert">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
{#each files as file, i (file.id)}
|
|
||||||
<FileCard
|
|
||||||
{file}
|
|
||||||
index={i}
|
|
||||||
selected={$selectionStore.ids.has(file.id ?? '')}
|
|
||||||
selectionMode={$selectionActive}
|
|
||||||
onTap={(e) => handleTap(file, i, e)}
|
|
||||||
onLongPress={(pt) => handleLongPress(file, i, pt)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<InfiniteScroll {loading} {hasMore} onLoadMore={loadMore} />
|
|
||||||
|
|
||||||
{#if !loading && !hasMore && files.length === 0}
|
|
||||||
<div class="empty">Trash is empty.</div>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if $selectionActive}
|
|
||||||
<div class="sel-bar" role="toolbar" aria-label="Trash selection actions">
|
|
||||||
<button class="sel-count" onclick={() => selectionStore.exit()} title="Clear selection">
|
|
||||||
<span class="sel-num">{$selectionCount}</span>
|
|
||||||
<span class="sel-label">selected</span>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
||||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="sel-spacer"></div>
|
|
||||||
<button class="sel-action restore" onclick={() => (confirmRestore = true)} disabled={actionBusy}>
|
|
||||||
Restore
|
|
||||||
</button>
|
|
||||||
<button class="sel-action perm-delete" onclick={() => (confirmPermDelete = true)} disabled={actionBusy}>
|
|
||||||
Delete permanently
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if confirmRestore}
|
|
||||||
<ConfirmDialog
|
|
||||||
message={`Restore ${$selectionStore.ids.size} file(s)?`}
|
|
||||||
confirmLabel="Restore"
|
|
||||||
onConfirm={restoreSelected}
|
|
||||||
onCancel={() => (confirmRestore = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if confirmPermDelete}
|
|
||||||
<ConfirmDialog
|
|
||||||
message={`Permanently delete ${$selectionStore.ids.size} file(s)? This cannot be undone.`}
|
|
||||||
confirmLabel="Delete permanently"
|
|
||||||
danger
|
|
||||||
onConfirm={permDeleteSelected}
|
|
||||||
onCancel={() => (confirmPermDelete = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn:hover { color: var(--color-accent); }
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-btn {
|
|
||||||
margin-left: auto;
|
|
||||||
height: 30px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-btn:hover { color: var(--color-text-primary); border-color: var(--color-accent); }
|
|
||||||
|
|
||||||
.select-btn.active {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
|
||||||
color: var(--color-accent);
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 10px 10px calc(60px + 10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid::after {
|
|
||||||
content: '';
|
|
||||||
flex: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: var(--color-danger);
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
padding: 60px 20px;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Trash selection bar ---- */
|
|
||||||
.sel-bar {
|
|
||||||
position: fixed;
|
|
||||||
left: 10px;
|
|
||||||
right: 10px;
|
|
||||||
bottom: 65px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background-color: var(--color-bg-secondary);
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
|
|
||||||
padding: 12px 14px;
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
animation: slide-up 0.18s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slide-up {
|
|
||||||
from { transform: translateY(12px); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.sel-count {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sel-count:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sel-num {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sel-label { font-size: 0.85rem; }
|
|
||||||
|
|
||||||
.sel-spacer { flex: 1; }
|
|
||||||
|
|
||||||
.sel-action {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sel-action:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
|
|
||||||
.sel-action.restore {
|
|
||||||
color: #7ECBA1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sel-action.restore:hover:not(:disabled) {
|
|
||||||
background-color: color-mix(in srgb, #7ECBA1 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sel-action.perm-delete {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sel-action.perm-delete:hover:not(:disabled) {
|
|
||||||
background-color: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user