diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 650292d..a6df5d5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -59,17 +59,17 @@ func main() { } // 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) + 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) + poolRepo := postgres.NewPoolRepo(pool) + transactor := postgres.NewTransactor(pool) // Services authSvc := service.NewAuthService( @@ -79,12 +79,12 @@ func main() { cfg.JWTAccessTTL, cfg.JWTRefreshTTL, ) - aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor) - auditSvc := service.NewAuditService(auditRepo) - tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor) + aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor) + 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( + poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc) + fileSvc := service.NewFileService( fileRepo, mimeRepo, diskStorage, @@ -103,15 +103,15 @@ func main() { } // Handlers - authMiddleware := handler.NewAuthMiddleware(authSvc) - authHandler := handler.NewAuthHandler(authSvc) - fileHandler := handler.NewFileHandler(fileSvc, tagSvc, cfg.MaxUploadBytes) - tagHandler := handler.NewTagHandler(tagSvc, fileSvc) + authMiddleware := handler.NewAuthMiddleware(authSvc) + authHandler := handler.NewAuthHandler(authSvc) + fileHandler := handler.NewFileHandler(fileSvc, tagSvc, cfg.MaxUploadBytes) + 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) + poolHandler := handler.NewPoolHandler(poolSvc) + userHandler := handler.NewUserHandler(userSvc) + aclHandler := handler.NewACLHandler(aclSvc) + auditHandler := handler.NewAuditHandler(auditSvc) r := handler.NewRouter( authMiddleware, authHandler, diff --git a/backend/internal/db/postgres/category_repo.go b/backend/internal/db/postgres/category_repo.go index 509ac2a..e4325c2 100644 --- a/backend/internal/db/postgres/category_repo.go +++ b/backend/internal/db/postgres/category_repo.go @@ -300,4 +300,4 @@ func (r *CategoryRepo) Delete(ctx context.Context, id uuid.UUID) error { return domain.ErrNotFound } return nil -} \ No newline at end of file +} diff --git a/backend/internal/db/postgres/file_repo.go b/backend/internal/db/postgres/file_repo.go index 3a227c9..816d90f 100644 --- a/backend/internal/db/postgres/file_repo.go +++ b/backend/internal/db/postgres/file_repo.go @@ -142,7 +142,7 @@ func makeCursor(r fileRow, sort, order string) fileCursor { } case "mime": val = r.MIMEType - // "created": val is empty; f.id is the sort key. + // "created": val is empty; f.id is the sort key. } return fileCursor{Sort: sort, Order: order, ID: r.ID.String(), Val: val} } @@ -569,7 +569,7 @@ func (r *FileRepo) List(ctx context.Context, params domain.FileListParams) (*dom cursorVal = av.OriginalName case "mime": cursorVal = av.MIMEType - // "created": cursorVal stays ""; cursorID is the sort key. + // "created": cursorVal stays ""; cursorID is the sort key. } hasCursor = true isAnchor = true diff --git a/backend/internal/db/postgres/filter_parser.go b/backend/internal/db/postgres/filter_parser.go index f3f87f8..a3adf50 100644 --- a/backend/internal/db/postgres/filter_parser.go +++ b/backend/internal/db/postgres/filter_parser.go @@ -15,7 +15,7 @@ import ( type filterTokenKind int const ( - ftkAnd filterTokenKind = iota + ftkAnd filterTokenKind = iota ftkOr ftkNot ftkLParen @@ -44,9 +44,9 @@ 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 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) { diff --git a/backend/internal/db/postgres/pool_repo.go b/backend/internal/db/postgres/pool_repo.go index e1fc848..c49ed99 100644 --- a/backend/internal/db/postgres/pool_repo.go +++ b/backend/internal/db/postgres/pool_repo.go @@ -712,4 +712,4 @@ func (r *PoolRepo) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid } return r.reassignPositions(ctx, q, poolID, ordered) -} \ No newline at end of file +} diff --git a/backend/internal/db/postgres/tag_repo.go b/backend/internal/db/postgres/tag_repo.go index 742e552..838b4b2 100644 --- a/backend/internal/db/postgres/tag_repo.go +++ b/backend/internal/db/postgres/tag_repo.go @@ -23,16 +23,16 @@ import ( type tagRow struct { ID uuid.UUID `db:"id"` - Name string `db:"name"` - Notes *string `db:"notes"` - Color *string `db:"color"` + 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"` + 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 { @@ -43,8 +43,8 @@ type tagRowWithTotal struct { 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"` + ThenTagName string `db:"then_tag_name"` + IsActive bool `db:"is_active"` } // --------------------------------------------------------------------------- @@ -637,4 +637,4 @@ WHERE when_tag_id = $1 AND then_tag_id = $2` return domain.ErrNotFound } return nil -} \ No newline at end of file +} diff --git a/backend/internal/domain/acl.go b/backend/internal/domain/acl.go index 0a660d8..7ca2a9c 100644 --- a/backend/internal/domain/acl.go +++ b/backend/internal/domain/acl.go @@ -10,10 +10,10 @@ type ObjectType struct { // Permission represents a per-object access entry for a user. type Permission struct { - UserID int16 - UserName string // denormalized - ObjectTypeID int16 - ObjectID uuid.UUID - CanView bool - CanEdit bool + UserID int16 + UserName string // denormalized + ObjectTypeID int16 + ObjectID uuid.UUID + CanView bool + CanEdit bool } diff --git a/backend/internal/domain/audit.go b/backend/internal/domain/audit.go index faeb469..896624e 100644 --- a/backend/internal/domain/audit.go +++ b/backend/internal/domain/audit.go @@ -15,14 +15,14 @@ type ActionType struct { // AuditEntry is a single audit log record. type AuditEntry struct { - ID int64 - UserID int16 - UserName string // denormalized - Action string // action type name, e.g. "file_create" - ObjectType *string - ObjectID *uuid.UUID - Details json.RawMessage - PerformedAt time.Time + ID int64 + UserID int16 + UserName string // denormalized + Action string // action type name, e.g. "file_create" + ObjectType *string + ObjectID *uuid.UUID + Details json.RawMessage + PerformedAt time.Time } // AuditPage is an offset-based page of audit log entries. diff --git a/backend/internal/handler/acl_handler.go b/backend/internal/handler/acl_handler.go index 5f2aa1f..19512be 100644 --- a/backend/internal/handler/acl_handler.go +++ b/backend/internal/handler/acl_handler.go @@ -143,4 +143,4 @@ func (h *ACLHandler) SetPermissions(c *gin.Context) { out[i] = toPermissionJSON(p) } respondJSON(c, http.StatusOK, out) -} \ No newline at end of file +} diff --git a/backend/internal/handler/audit_handler.go b/backend/internal/handler/audit_handler.go index 6dad2fc..3ce9e47 100644 --- a/backend/internal/handler/audit_handler.go +++ b/backend/internal/handler/audit_handler.go @@ -117,4 +117,4 @@ func (h *AuditHandler) List(c *gin.Context) { "offset": page.Offset, "limit": page.Limit, }) -} \ No newline at end of file +} diff --git a/backend/internal/handler/category_handler.go b/backend/internal/handler/category_handler.go index 46f0f30..d3b42f7 100644 --- a/backend/internal/handler/category_handler.go +++ b/backend/internal/handler/category_handler.go @@ -232,4 +232,4 @@ func (h *CategoryHandler) ListTags(c *gin.Context) { "offset": page.Offset, "limit": page.Limit, }) -} \ No newline at end of file +} diff --git a/backend/internal/handler/file_handler.go b/backend/internal/handler/file_handler.go index e273402..9b3bb77 100644 --- a/backend/internal/handler/file_handler.go +++ b/backend/internal/handler/file_handler.go @@ -89,16 +89,16 @@ type fileJSON struct { func toTagJSON(t domain.Tag) tagJSON { j := tagJSON{ - ID: t.ID.String(), - Name: t.Name, - Notes: t.Notes, - Color: t.Color, + 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), + CreatorID: t.CreatorID, + CreatorName: t.CreatorName, + IsPublic: t.IsPublic, + CreatedAt: t.CreatedAt.Format(time.RFC3339), } if t.CategoryID != nil { s := t.CategoryID.String() @@ -672,4 +672,4 @@ func parseUUIDs(strs []string) ([]uuid.UUID, error) { ids = append(ids, id) } return ids, nil -} \ No newline at end of file +} diff --git a/backend/internal/handler/pool_handler.go b/backend/internal/handler/pool_handler.go index b9722e1..c55593e 100644 --- a/backend/internal/handler/pool_handler.go +++ b/backend/internal/handler/pool_handler.go @@ -353,4 +353,4 @@ func (h *PoolHandler) Reorder(c *gin.Context) { return } c.Status(http.StatusNoContent) -} \ No newline at end of file +} diff --git a/backend/internal/handler/tag_handler.go b/backend/internal/handler/tag_handler.go index d25a9e9..eb44eca 100644 --- a/backend/internal/handler/tag_handler.go +++ b/backend/internal/handler/tag_handler.go @@ -106,11 +106,11 @@ func (h *TagHandler) List(c *gin.Context) { 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"` + 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) @@ -549,4 +549,4 @@ func (h *TagHandler) FileRemoveTag(c *gin.Context) { // Helpers // --------------------------------------------------------------------------- -func ptr(s string) *string { return &s } \ No newline at end of file +func ptr(s string) *string { return &s } diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 18d4580..b7eb7a6 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -255,4 +255,4 @@ func (h *UserHandler) Delete(c *gin.Context) { return } c.Status(http.StatusNoContent) -} \ No newline at end of file +} diff --git a/backend/internal/integration/server_test.go b/backend/internal/integration/server_test.go index eb58397..08326dd 100644 --- a/backend/internal/integration/server_test.go +++ b/backend/internal/integration/server_test.go @@ -111,42 +111,42 @@ func setupSuite(t *testing.T) *harness { 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) + 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) + 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, fileRepo, tagRepo, categoryRepo, poolRepo, transactor) - auditSvc := service.NewAuditService(auditRepo) - tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor) + authSvc := service.NewAuthService(userRepo, sessionRepo, "test-secret", 15*time.Minute, 720*time.Hour) + aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor) + 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, sessionRepo, auditSvc) + poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc) + fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, filesDir) + userSvc := service.NewUserService(userRepo, sessionRepo, auditSvc) // Bootstrap the admin account the suite logs in with (replaces the old // hardcoded seed credentials). require.NoError(t, userSvc.EnsureAdmin(ctx, "admin", "admin")) // --- Handlers ------------------------------------------------------------ - authMiddleware := handler.NewAuthMiddleware(authSvc) - authHandler := handler.NewAuthHandler(authSvc) - fileHandler := handler.NewFileHandler(fileSvc, tagSvc, 500<<20) - tagHandler := handler.NewTagHandler(tagSvc, fileSvc) + authMiddleware := handler.NewAuthMiddleware(authSvc) + authHandler := handler.NewAuthHandler(authSvc) + fileHandler := handler.NewFileHandler(fileSvc, tagSvc, 500<<20) + 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) + poolHandler := handler.NewPoolHandler(poolSvc) + userHandler := handler.NewUserHandler(userSvc) + aclHandler := handler.NewACLHandler(aclSvc) + auditHandler := handler.NewAuditHandler(auditSvc) r := handler.NewRouter( authMiddleware, authHandler, @@ -289,7 +289,7 @@ func TestFullFlow(t *testing.T) { // 3. Log in as alice // ========================================================================= aliceToken := h.login("alice", "alicepass") - bobToken := h.login("bob", "bobpass") + bobToken := h.login("bob", "bobpass") // ========================================================================= // 4. Alice uploads a private JPEG @@ -619,7 +619,7 @@ func TestTagRuleActivateApplyToExisting(t *testing.T) { // 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, + "is_active": true, "apply_to_existing": false, }, tok) require.Equal(t, http.StatusOK, resp.StatusCode, resp.String()) @@ -634,7 +634,7 @@ func TestTagRuleActivateApplyToExisting(t *testing.T) { // 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, + "is_active": true, "apply_to_existing": true, }, tok) require.Equal(t, http.StatusOK, resp.StatusCode, resp.String()) @@ -1023,4 +1023,4 @@ func writeFile(t *testing.T, dir, name string, content []byte) string { var ( _ = freePort _ = writeFile -) \ No newline at end of file +) diff --git a/backend/internal/service/category_service.go b/backend/internal/service/category_service.go index 31c9809..9a20667 100644 --- a/backend/internal/service/category_service.go +++ b/backend/internal/service/category_service.go @@ -10,14 +10,14 @@ import ( "tanabata/backend/internal/port" ) -const categoryObjectType = "category" -const categoryObjectTypeID int16 = 3 // third row in 007_seed_data.sql object_types +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 + Color *string // nil = no change; pointer to empty string = clear Metadata json.RawMessage IsPublic *bool } @@ -176,4 +176,4 @@ func (s *CategoryService) Delete(ctx context.Context, id uuid.UUID) error { func (s *CategoryService) ListTags(ctx context.Context, categoryID uuid.UUID, params port.OffsetParams) (*domain.TagOffsetPage, error) { params.ViewerID, params.ViewerIsAdmin, _ = domain.UserFromContext(ctx) return s.tags.ListByCategory(ctx, categoryID, params) -} \ No newline at end of file +} diff --git a/backend/internal/service/file_service.go b/backend/internal/service/file_service.go index 05acd10..35ba5ed 100644 --- a/backend/internal/service/file_service.go +++ b/backend/internal/service/file_service.go @@ -627,4 +627,4 @@ func extractEXIFWithDatetime(data []byte) (json.RawMessage, *time.Time) { dt = &t } return json.RawMessage(b), dt -} \ No newline at end of file +} diff --git a/backend/internal/service/pool_service.go b/backend/internal/service/pool_service.go index d1890f0..850e26f 100644 --- a/backend/internal/service/pool_service.go +++ b/backend/internal/service/pool_service.go @@ -10,7 +10,7 @@ import ( "tanabata/backend/internal/port" ) -const poolObjectType = "pool" +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. @@ -238,4 +238,4 @@ func (s *PoolService) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []u return err } return s.pools.Reorder(ctx, poolID, fileIDs) -} \ No newline at end of file +} diff --git a/backend/internal/service/tag_service.go b/backend/internal/service/tag_service.go index 195d6fd..61bffee 100644 --- a/backend/internal/service/tag_service.go +++ b/backend/internal/service/tag_service.go @@ -10,15 +10,15 @@ import ( "tanabata/backend/internal/port" ) -const tagObjectType = "tag" -const tagObjectTypeID int16 = 2 // second row in 007_seed_data.sql object_types +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 + Color *string // nil = no change; pointer to empty string = clear + CategoryID *uuid.UUID // nil = no change; Nil UUID = unassign Metadata json.RawMessage IsPublic *bool } @@ -422,4 +422,4 @@ func (s *TagService) expandTagSet(ctx context.Context, seeds []uuid.UUID) ([]uui } return queue, nil -} \ No newline at end of file +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 0123888..28c2020 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -189,4 +189,4 @@ func (s *UserService) Delete(ctx context.Context, id int16) error { } _ = s.audit.Log(ctx, "user_delete", nil, nil, map[string]any{"target_user_id": id}) return nil -} \ No newline at end of file +} diff --git a/backend/internal/storage/disk.go b/backend/internal/storage/disk.go index 346a577..8cb5c07 100644 --- a/backend/internal/storage/disk.go +++ b/backend/internal/storage/disk.go @@ -5,12 +5,12 @@ import ( "bytes" "context" "fmt" + _ "golang.org/x/image/webp" // register WebP decoder "image" "image/color" + _ "image/gif" // register GIF decoder "image/jpeg" - _ "image/gif" // register GIF decoder - _ "image/png" // register PNG decoder - _ "golang.org/x/image/webp" // register WebP decoder + _ "image/png" // register PNG decoder "io" "os" "os/exec" diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..b9d37c8 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,11 @@ +# Build output and dependencies +.svelte-kit +build +node_modules +package-lock.json + +# Generated from openapi.yaml — formatting would be overwritten on regen +src/lib/api/schema.ts + +# Static assets (fonts, icons, manifest, robots) +static diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..3f7802c --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,15 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3b222a2..47c0d29 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,8 @@ "@tailwindcss/vite": "^4.2.2", "@types/node": "^25.5.2", "openapi-typescript": "^7.13.0", + "prettier": "^3.8.4", + "prettier-plugin-svelte": "^4.1.0", "svelte": "^5.54.0", "svelte-check": "^4.4.2", "tailwindcss": "^4.2.2", @@ -2210,6 +2212,36 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-4.1.0.tgz", + "integrity": "sha512-YZkhA2Q9oOerFFG9tq+2f98WYT7Z2JgrybJrAyrB78jpsH9i/DdgplXemehuFPgsldetFNCcR/yCcYlDjPy94Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^5.0.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 36f00ee..88bba31 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,9 @@ "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" + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.0", @@ -20,6 +22,8 @@ "@tailwindcss/vite": "^4.2.2", "@types/node": "^25.5.2", "openapi-typescript": "^7.13.0", + "prettier": "^3.8.4", + "prettier-plugin-svelte": "^4.1.0", "svelte": "^5.54.0", "svelte-check": "^4.4.2", "tailwindcss": "^4.2.2", diff --git a/frontend/src/app.css b/frontend/src/app.css index 29fbb88..b6e8988 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,42 +1,42 @@ @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); + --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; + --font-sans: 'Epilogue', sans-serif; } -:root[data-theme="light"] { - /* Muted, faintly lavender-tinted surfaces — not a glaring near-white, the same +:root[data-theme='light'] { + /* Muted, faintly lavender-tinted surfaces — not a glaring near-white, the same way the dark theme's background isn't pure black. Page sits on the dimmest surface; sheets are brighter to pop, chips a touch darker for definition. */ - --color-bg-primary: #e4e2ec; - --color-bg-secondary: #f2f1f6; - --color-bg-elevated: #d8d6e2; - --color-accent: #6B68A0; - --color-accent-hover: #5A578F; - --color-text-primary: #111118; - --color-text-muted: #555566; - --color-tag-default: #cbcad9; - --color-nav-bg: rgba(228, 226, 236, 0.85); - --color-nav-active: rgba(90, 87, 143, 0.22); + --color-bg-primary: #e4e2ec; + --color-bg-secondary: #f2f1f6; + --color-bg-elevated: #d8d6e2; + --color-accent: #6b68a0; + --color-accent-hover: #5a578f; + --color-text-primary: #111118; + --color-text-muted: #555566; + --color-tag-default: #cbcad9; + --color-nav-bg: rgba(228, 226, 236, 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; + font-family: 'Epilogue'; + src: url('/fonts/Epilogue-VariableFont_wght.ttf') format('truetype'); + font-weight: 100 900; + font-display: swap; } diff --git a/frontend/src/app.html b/frontend/src/app.html index 8ed6dab..eb0bf1c 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -16,16 +16,22 @@ - - - - + + + + - + %sveltekit.head% diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index 530a870..b461956 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -8,7 +8,7 @@ export async function login(name: string, password: string): Promise { authStore.update((s) => ({ ...s, accessToken: tokens.access_token ?? null, - refreshToken: tokens.refresh_token ?? null, + refreshToken: tokens.refresh_token ?? null })); } @@ -28,7 +28,7 @@ export async function refresh(): Promise { authStore.update((s) => ({ ...s, accessToken: tokens.access_token ?? null, - refreshToken: tokens.refresh_token ?? null, + refreshToken: tokens.refresh_token ?? null })); } @@ -42,4 +42,4 @@ export function listSessions(params?: { offset?: number; limit?: number }): Prom export function terminateSession(sessionId: number): Promise { return api.delete(`/auth/sessions/${sessionId}`); -} \ No newline at end of file +} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index a8d635a..d1a0cd5 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -18,7 +18,7 @@ export class ApiError extends Error { public readonly status: number, public readonly code: string, message: string, - public readonly details?: Array<{ field?: string; message?: string }>, + public readonly details?: Array<{ field?: string; message?: string }> ) { super(message); this.name = 'ApiError'; @@ -38,7 +38,7 @@ async function refreshTokens(): Promise { const res = await fetch(`${BASE}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh_token: refreshToken }), + body: JSON.stringify({ refresh_token: refreshToken }) }); if (!res.ok) { @@ -50,7 +50,7 @@ async function refreshTokens(): Promise { authStore.update((s) => ({ ...s, accessToken: data.access_token ?? null, - refreshToken: data.refresh_token ?? null, + refreshToken: data.refresh_token ?? null })); } @@ -64,7 +64,7 @@ function buildHeaders(init: RequestInit | undefined, accessToken: string | null) async function request(path: string, init?: RequestInit): Promise { let res = await fetch(BASE + path, { ...init, - headers: buildHeaders(init, get(authStore).accessToken), + headers: buildHeaders(init, get(authStore).accessToken) }); if (res.status === 401) { @@ -81,18 +81,27 @@ async function request(path: string, init?: RequestInit): Promise { res = await fetch(BASE + path, { ...init, - headers: buildHeaders(init, get(authStore).accessToken), + headers: buildHeaders(init, get(authStore).accessToken) }); } if (!res.ok) { - let body: { code?: string; message?: string; details?: Array<{ field?: string; message?: string }> } = {}; + 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); + throw new ApiError( + res.status, + body.code ?? 'error', + body.message ?? res.statusText, + body.details + ); } if (res.status === 204) return undefined as T; @@ -103,7 +112,7 @@ async function request(path: string, init?: RequestInit): Promise { export function uploadWithProgress( path: string, formData: FormData, - onProgress: (pct: number) => void, + onProgress: (pct: number) => void ): Promise { return new Promise((resolve, reject) => { const token = get(authStore).accessToken; @@ -126,7 +135,9 @@ export function uploadWithProgress( let body: { code?: string; message?: string } = {}; try { body = JSON.parse(xhr.responseText); - } catch { /* ignore */ } + } catch { + /* ignore */ + } reject(new ApiError(xhr.status, body.code ?? 'error', body.message ?? xhr.statusText)); } }; @@ -146,5 +157,5 @@ export const api = { request(path, { method: 'PUT', body: JSON.stringify(body) }), delete: (path: string) => request(path, { method: 'DELETE' }), upload: (path: string, formData: FormData) => - request(path, { method: 'POST', body: formData }), -}; \ No newline at end of file + request(path, { method: 'POST', body: formData }) +}; diff --git a/frontend/src/lib/components/common/ConfirmDialog.svelte b/frontend/src/lib/components/common/ConfirmDialog.svelte index 8e9dafd..acc9b5e 100644 --- a/frontend/src/lib/components/common/ConfirmDialog.svelte +++ b/frontend/src/lib/components/common/ConfirmDialog.svelte @@ -120,4 +120,4 @@ .btn.confirm.danger:hover { background-color: color-mix(in srgb, var(--color-danger) 80%, #fff); } - \ No newline at end of file + diff --git a/frontend/src/lib/components/common/InfiniteScroll.svelte b/frontend/src/lib/components/common/InfiniteScroll.svelte index 53c7b39..1a389a9 100644 --- a/frontend/src/lib/components/common/InfiniteScroll.svelte +++ b/frontend/src/lib/components/common/InfiniteScroll.svelte @@ -21,9 +21,7 @@ function nearViewport(): boolean { if (!sentinel) return false; const rect = sentinel.getBoundingClientRect(); - return edge === 'bottom' - ? rect.top <= window.innerHeight + MARGIN - : rect.bottom >= -MARGIN; + return edge === 'bottom' ? rect.top <= window.innerHeight + MARGIN : rect.bottom >= -MARGIN; } function maybeLoad() { @@ -100,6 +98,8 @@ } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } diff --git a/frontend/src/lib/components/file/BulkTagEditor.svelte b/frontend/src/lib/components/file/BulkTagEditor.svelte index a94cf52..a4d82a9 100644 --- a/frontend/src/lib/components/file/BulkTagEditor.svelte +++ b/frontend/src/lib/components/file/BulkTagEditor.svelte @@ -33,8 +33,8 @@ api.get('/tags?limit=200&sort=name&order=asc'), api.post<{ common_tag_ids: string[]; partial_tag_ids: string[] }>( '/files/bulk/common-tags', - { file_ids: fileIds }, - ), + { file_ids: fileIds } + ) ]); allTags = tagsRes.items ?? []; commonIds = new Set(commonRes.common_tag_ids ?? []); @@ -53,16 +53,16 @@ allTags.filter( (t) => assignedIds.has(t.id ?? '') && - (!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())), - ), + (!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())), - ), + (!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())) + ) ); function tagStyle(tag: Tag) { @@ -132,8 +132,10 @@ 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'} + 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} @@ -159,7 +161,12 @@ {#if search} {/if} @@ -347,4 +354,4 @@ color: var(--color-text-muted); margin: 0; } - \ No newline at end of file + diff --git a/frontend/src/lib/components/file/FileCard.svelte b/frontend/src/lib/components/file/FileCard.svelte index 504025d..9ed3196 100644 --- a/frontend/src/lib/components/file/FileCard.svelte +++ b/frontend/src/lib/components/file/FileCard.svelte @@ -22,7 +22,7 @@ selected = false, selectionMode = false, onTap, - onLongPress, + onLongPress }: Props = $props(); let imgSrc = $state(null); @@ -34,7 +34,7 @@ let cancelled = false; fetch(`/api/v1/files/${file.id}/thumbnail`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, + headers: token ? { Authorization: `Bearer ${token}` } : {} }) .then((res) => (res.ok ? res.blob() : null)) .then((blob) => { @@ -111,7 +111,10 @@ data-file-index={index} onpointerdown={onPointerDown} onpointermove={onPointerMoveInternal} - onpointerup={() => { cancelPress(); didLongPress = false; }} + onpointerup={() => { + cancelPress(); + didLongPress = false; + }} onpointerleave={cancelPress} oncontextmenu={(e) => e.preventDefault()} onclick={onClick} @@ -128,14 +131,27 @@ {#if selected} {:else if selectionMode} {/if} @@ -207,7 +223,11 @@ } @keyframes shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } } - \ No newline at end of file + diff --git a/frontend/src/lib/components/file/FileUpload.svelte b/frontend/src/lib/components/file/FileUpload.svelte index 578b1b3..38557ea 100644 --- a/frontend/src/lib/components/file/FileUpload.svelte +++ b/frontend/src/lib/components/file/FileUpload.svelte @@ -50,13 +50,11 @@ id: uid(), name: f.name, progress: 0, - status: 'uploading', + status: 'uploading' })); queue = [...queue, ...items]; - await Promise.all( - files.map((file, i) => uploadOne(file, items[i].id)), - ); + await Promise.all(files.map((file, i) => uploadOne(file, items[i].id))); } async function uploadOne(file: globalThis.File, itemId: string) { @@ -64,10 +62,8 @@ fd.append('file', file); try { - const result = await uploadWithProgress( - '/files', - fd, - (pct) => updateItem(itemId, { progress: pct }), + const result = await uploadWithProgress('/files', fd, (pct) => + updateItem(itemId, { progress: pct }) ); updateItem(itemId, { status: 'done', progress: 100 }); onUploaded(result); @@ -144,8 +140,14 @@ @@ -155,7 +153,12 @@ {#if search} {/if} @@ -326,4 +329,4 @@ color: var(--color-danger); margin: 0; } - \ No newline at end of file + diff --git a/frontend/src/lib/stores/appSettings.ts b/frontend/src/lib/stores/appSettings.ts index 5decb7c..833e9ea 100644 --- a/frontend/src/lib/stores/appSettings.ts +++ b/frontend/src/lib/stores/appSettings.ts @@ -8,7 +8,7 @@ export interface AppSettings { const DEFAULTS: AppSettings = { fileLoadLimit: 100, - tagRuleApplyToExisting: false, + tagRuleApplyToExisting: false }; function load(): AppSettings { @@ -25,4 +25,4 @@ export const appSettings = writable(load()); appSettings.subscribe((v) => { if (browser) localStorage.setItem('app-settings', JSON.stringify(v)); -}); \ No newline at end of file +}); diff --git a/frontend/src/lib/stores/auth.ts b/frontend/src/lib/stores/auth.ts index c2bf415..38b1844 100644 --- a/frontend/src/lib/stores/auth.ts +++ b/frontend/src/lib/stores/auth.ts @@ -31,4 +31,4 @@ authStore.subscribe((state) => { } }); -export const isAuthenticated = derived(authStore, ($auth) => !!$auth.accessToken); \ No newline at end of file +export const isAuthenticated = derived(authStore, ($auth) => !!$auth.accessToken); diff --git a/frontend/src/lib/stores/selection.ts b/frontend/src/lib/stores/selection.ts index 88da804..7622f37 100644 --- a/frontend/src/lib/stores/selection.ts +++ b/frontend/src/lib/stores/selection.ts @@ -8,7 +8,7 @@ interface SelectionState { function createSelectionStore() { const { subscribe, update, set } = writable({ active: false, - ids: new Set(), + ids: new Set() }); return { @@ -55,11 +55,11 @@ function createSelectionStore() { 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); \ No newline at end of file +export const selectionActive = derived(selectionStore, ($s) => $s.active); diff --git a/frontend/src/lib/stores/sorting.ts b/frontend/src/lib/stores/sorting.ts index be478ef..a38fcb9 100644 --- a/frontend/src/lib/stores/sorting.ts +++ b/frontend/src/lib/stores/sorting.ts @@ -29,30 +29,30 @@ function makeSortStore(key: string, defaults: SortState) { }, toggleOrder() { store.update((s) => ({ ...s, order: s.order === 'asc' ? 'desc' : 'asc' })); - }, + } }; } export const fileSorting = makeSortStore('sort:files', { sort: 'created', - order: 'desc', + order: 'desc' }); export const tagSorting = makeSortStore('sort:tags', { sort: 'created', - order: 'desc', + order: 'desc' }); export type CategorySortField = 'name' | 'color' | 'created'; export const categorySorting = makeSortStore('sort:categories', { sort: 'name', - order: 'asc', + order: 'asc' }); export type PoolSortField = 'name' | 'created'; export const poolSorting = makeSortStore('sort:pools', { sort: 'created', - order: 'desc', -}); \ No newline at end of file + order: 'desc' +}); diff --git a/frontend/src/lib/utils/dsl.ts b/frontend/src/lib/utils/dsl.ts index b4f9c49..6406be3 100644 --- a/frontend/src/lib/utils/dsl.ts +++ b/frontend/src/lib/utils/dsl.ts @@ -36,4 +36,4 @@ export function tokenLabel(token: string, tagNames: Map): string return tagNames.get(id) ?? token; } return token; -} \ No newline at end of file +} diff --git a/frontend/src/lib/utils/pwa.ts b/frontend/src/lib/utils/pwa.ts index fcedfda..7975f90 100644 --- a/frontend/src/lib/utils/pwa.ts +++ b/frontend/src/lib/utils/pwa.ts @@ -14,4 +14,4 @@ export async function resetPwa(): Promise { // Hard reload so the page (and a fresh service worker, if still installed) // re-fetches everything from the network instead of the now-cleared cache. location.reload(); -} \ No newline at end of file +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 7a83253..8f8e6a8 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -9,28 +9,28 @@ { href: '/categories', label: 'Categories', - match: '/categories', + match: '/categories' }, { href: '/tags', label: 'Tags', - match: '/tags', + match: '/tags' }, { href: '/files', label: 'Files', - match: '/files', + match: '/files' }, { href: '/pools', label: 'Pools', - match: '/pools', + match: '/pools' }, { href: '/settings', label: 'Settings', - match: '/settings', - }, + match: '/settings' + } ]; const isLogin = $derived($page.url.pathname === '/login'); @@ -45,30 +45,114 @@ {@const active = $page.url.pathname.startsWith(item.match)} {#if item.label === 'Categories'} - @@ -110,7 +194,9 @@ border-radius: 10px; color: var(--color-text-muted); text-decoration: none; - transition: background-color 0.15s, color 0.15s; + transition: + background-color 0.15s, + color 0.15s; } .nav:hover, @@ -124,4 +210,4 @@ height: 28px; width: auto; } - \ No newline at end of file + diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index 740acf0..2d32d3b 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -6,7 +6,7 @@ const tabs = [ { href: '/admin/users', label: 'Users' }, - { href: '/admin/audit', label: 'Audit log' }, + { href: '/admin/audit', label: 'Audit log' } ]; @@ -14,17 +14,21 @@ @@ -112,4 +116,4 @@ min-height: 0; overflow-y: auto; } - \ No newline at end of file + diff --git a/frontend/src/routes/admin/+layout.ts b/frontend/src/routes/admin/+layout.ts index 7f1ee99..0831e80 100644 --- a/frontend/src/routes/admin/+layout.ts +++ b/frontend/src/routes/admin/+layout.ts @@ -9,4 +9,4 @@ export const load = () => { if (!user?.isAdmin) { redirect(307, '/files'); } -}; \ No newline at end of file +}; diff --git a/frontend/src/routes/admin/audit/+page.svelte b/frontend/src/routes/admin/audit/+page.svelte index c7ec654..a97cbf1 100644 --- a/frontend/src/routes/admin/audit/+page.svelte +++ b/frontend/src/routes/admin/audit/+page.svelte @@ -6,42 +6,42 @@ const OBJECT_TYPES = ['file', 'tag', 'category', 'pool']; const ACTION_LABELS: Record = { // Auth - user_login: 'User logged in', - user_logout: 'User logged out', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + 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', + session_terminate: 'Session terminated' }; // ---- Filters ---- @@ -55,7 +55,7 @@ // ---- Data ---- let entries = $state([]); let total = $state(0); - let page = $state(0); // 0-based + let page = $state(0); // 0-based let loading = $state(false); let error = $state(''); let initialLoaded = $state(false); @@ -65,14 +65,23 @@ // ---- Users for filter dropdown ---- let allUsers = $state([]); $effect(() => { - api.get('/users?limit=200').then((r) => { allUsers = r.items ?? []; }).catch(() => {}); + api + .get('/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[]); + 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 filterKey = $derived( + `${filterUserId}|${filterAction}|${filterObjectType}|${filterObjectId}|${filterFrom}|${filterTo}` + ); let prevFilterKey = $state(''); $effect(() => { @@ -94,12 +103,12 @@ 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()); + 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(`/audit?${params}`); entries = res.items ?? []; @@ -122,8 +131,12 @@ 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', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' }); } @@ -211,60 +224,74 @@ {:else}
-
- - - - - - - - - - - - {#each entries as e (e.id)} +
+
TimeUserActionObjectID
+ - - - - - + + + + + - {/each} + + + {#each entries as e (e.id)} + + + + + + + + {/each} - {#if loading} - - - - {/if} + {#if loading} + + + + {/if} - {#if !loading && initialLoaded && entries.length === 0} - - - - {/if} - -
{formatTs(e.performed_at)}{e.user_name ?? '—'} - - {actionLabel(e.action)} - - {e.object_type ?? '—'}{shortId(e.object_id)}TimeUserActionObjectID
{formatTs(e.performed_at)}{e.user_name ?? '—'} + + {actionLabel(e.action)} + + {e.object_type ?? '—'}{shortId(e.object_id)}
- -
+ +
No entries match the current filters.
-
- - {#if totalPages > 1} - - {/if} + + {#if totalPages > 1} + + {/if}
{/if} @@ -428,10 +455,22 @@ 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); } + .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); @@ -466,7 +505,11 @@ animation: spin 0.7s linear infinite; } - @keyframes spin { to { transform: rotate(360deg); } } + @keyframes spin { + to { + transform: rotate(360deg); + } + } .pagination { display: flex; @@ -510,4 +553,4 @@ color: var(--color-danger); margin: 0; } - \ No newline at end of file + diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte index 3afc5e8..cafa3f7 100644 --- a/frontend/src/routes/admin/users/+page.svelte +++ b/frontend/src/routes/admin/users/+page.svelte @@ -46,7 +46,7 @@ name: newName.trim(), password: newPassword.trim(), can_create: newCanCreate, - is_admin: newIsAdmin, + is_admin: newIsAdmin }); users = [u, ...users]; total++; @@ -73,7 +73,9 @@ } } - $effect(() => { void load(); }); + $effect(() => { + void load(); + }); Users — Admin | Tanabata @@ -90,8 +92,20 @@
{#if createError}{/if}
- - + +
@@ -113,10 +117,12 @@

Can upload files and create tags, pools, categories.

+ class="toggle" + class:on={canCreate} + role="switch" + aria-checked={canCreate} + onclick={() => (canCreate = !canCreate)}>
@@ -129,10 +135,13 @@

Blocked users cannot log in.

+ class="toggle" + class:on={isBlocked} + class:danger={isBlocked} + role="switch" + aria-checked={isBlocked} + onclick={() => (isBlocked = !isBlocked)}> @@ -140,7 +149,11 @@ - @@ -179,7 +192,9 @@ font-family: inherit; } - .back-link:hover { color: var(--color-accent); } + .back-link:hover { + color: var(--color-accent); + } .card { background-color: var(--color-bg-elevated); @@ -257,8 +272,12 @@ transition: background-color 0.15s; } - .toggle.on { background-color: var(--color-accent); } - .toggle.on.danger { background-color: var(--color-danger); } + .toggle.on { + background-color: var(--color-accent); + } + .toggle.on.danger { + background-color: var(--color-danger); + } .toggle .thumb { position: absolute; @@ -271,7 +290,9 @@ transition: transform 0.15s; } - .toggle.on .thumb { transform: translateX(18px); } + .toggle.on .thumb { + transform: translateX(18px); + } .action-row { display: flex; @@ -290,7 +311,10 @@ border: none; } - .btn:disabled { opacity: 0.5; cursor: default; } + .btn:disabled { + opacity: 0.5; + cursor: default; + } .btn.primary { background-color: var(--color-accent); @@ -316,8 +340,12 @@ margin: 0; } - .msg.error { color: var(--color-danger); } - .msg.success { color: #7ECBA1; } + .msg.error { + color: var(--color-danger); + } + .msg.success { + color: #7ecba1; + } .loading { display: flex; @@ -335,5 +363,9 @@ animation: spin 0.7s linear infinite; } - @keyframes spin { to { transform: rotate(360deg); } } - \ No newline at end of file + @keyframes spin { + to { + transform: rotate(360deg); + } + } + diff --git a/frontend/src/routes/categories/+page.svelte b/frontend/src/routes/categories/+page.svelte index 00eaaea..b30d659 100644 --- a/frontend/src/routes/categories/+page.svelte +++ b/frontend/src/routes/categories/+page.svelte @@ -10,7 +10,7 @@ const SORT_OPTIONS: { value: CategorySortField; label: string }[] = [ { value: 'name', label: 'Name' }, { value: 'color', label: 'Color' }, - { value: 'created', label: 'Created' }, + { value: 'created', label: 'Created' } ]; let categories = $state([]); @@ -49,7 +49,7 @@ limit: String(LIMIT), offset: String(offset), sort: sortState.sort, - order: sortState.order, + order: sortState.order }); if (search.trim()) params.set('search', search.trim()); const page = await api.get(`/categories?${params}`); @@ -79,7 +79,10 @@ +
@@ -173,7 +194,12 @@ -
@@ -225,73 +251,134 @@ {/if} \ No newline at end of file + .error { + color: var(--color-danger); + font-size: 0.875rem; + margin: 0; + } + diff --git a/frontend/src/routes/categories/new/+page.svelte b/frontend/src/routes/categories/new/+page.svelte index 6fd0527..8f54e83 100644 --- a/frontend/src/routes/categories/new/+page.svelte +++ b/frontend/src/routes/categories/new/+page.svelte @@ -18,7 +18,7 @@ name: name.trim(), notes: notes.trim() || null, color: color.slice(1), - is_public: isPublic, + is_public: isPublic }); goto('/categories'); } catch (e) { @@ -37,7 +37,13 @@

New Category

@@ -48,7 +54,13 @@ {/if} -
{ e.preventDefault(); void submit(); }}> + { + e.preventDefault(); + void submit(); + }} + >
@@ -70,7 +82,13 @@
- +
@@ -96,58 +114,110 @@
\ No newline at end of file + .error { + color: var(--color-danger); + font-size: 0.875rem; + } + diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte index 22cddaf..0c20714 100644 --- a/frontend/src/routes/files/+page.svelte +++ b/frontend/src/routes/files/+page.svelte @@ -76,7 +76,7 @@ { value: 'created', label: 'Created' }, { value: 'content_datetime', label: 'Date taken' }, { value: 'original_name', label: 'Name' }, - { value: 'mime', label: 'Type' }, + { value: 'mime', label: 'Type' } ]; let files = $state([]); @@ -181,7 +181,7 @@ const p = new URLSearchParams({ limit: String(LIMIT), sort: sortState.sort, - order: sortState.order, + order: sortState.order }); if (filterParam) p.set('filter', filterParam); return p; @@ -335,7 +335,7 @@ let activeIdx = $derived(activeFileId ? files.findIndex((f) => f.id === activeFileId) : -1); let viewerPrevId = $derived(activeIdx > 0 ? (files[activeIdx - 1]?.id ?? null) : null); let viewerNextId = $derived( - activeIdx >= 0 && activeIdx < files.length - 1 ? (files[activeIdx + 1]?.id ?? null) : null, + activeIdx >= 0 && activeIdx < files.length - 1 ? (files[activeIdx + 1]?.id ?? null) : null ); // Paging near the end of the loaded grid: pull the next page by cursor so the @@ -469,11 +469,7 @@ /> {#if filterOpen} - (filterOpen = false)} - /> + (filterOpen = false)} /> {/if} @@ -535,10 +531,19 @@ {#if filterOpen} - (filterOpen = false)} - /> + (filterOpen = false)} /> {/if} @@ -585,10 +643,10 @@ ondragend={canReorder ? onDragEnd : undefined} > handleTap(file, i, e)} onLongPress={() => handleLongPress(file, i)} /> @@ -625,11 +683,22 @@ {#if selectionMode && !addMode} @@ -190,69 +214,134 @@ {/if} \ No newline at end of file + .error { + color: var(--color-danger); + font-size: 0.875rem; + margin: 0; + } + diff --git a/frontend/src/routes/tags/new/+page.svelte b/frontend/src/routes/tags/new/+page.svelte index 03919f9..6bff57c 100644 --- a/frontend/src/routes/tags/new/+page.svelte +++ b/frontend/src/routes/tags/new/+page.svelte @@ -28,7 +28,7 @@ notes: notes.trim() || null, color: color.slice(1), // strip # category_id: categoryId || null, - is_public: isPublic, + is_public: isPublic }); goto('/tags'); } catch (e) { @@ -47,7 +47,13 @@

New Tag

@@ -58,7 +64,13 @@ {/if} - { e.preventDefault(); void submit(); }}> + { + e.preventDefault(); + void submit(); + }} + >
@@ -80,7 +92,13 @@
- +
@@ -116,58 +134,110 @@
\ No newline at end of file + .error { + color: var(--color-danger); + font-size: 0.875rem; + } + diff --git a/frontend/src/service-worker.ts b/frontend/src/service-worker.ts index 20af3bb..d453629 100644 --- a/frontend/src/service-worker.ts +++ b/frontend/src/service-worker.ts @@ -16,9 +16,7 @@ const SHELL = ['/', ...build, ...files]; // ---- Install: pre-cache the app shell ---- self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(CACHE).then((cache) => cache.addAll(SHELL)) - ); + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL))); // Activate immediately without waiting for old tabs to close. self.skipWaiting(); }); @@ -26,9 +24,9 @@ self.addEventListener('install', (event) => { // ---- Activate: remove stale caches from previous versions ---- self.addEventListener('activate', (event) => { event.waitUntil( - caches.keys().then((keys) => - Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) - ) + caches + .keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) ); self.clients.claim(); }); @@ -68,4 +66,4 @@ async function respond(request: Request): Promise { if (fallback) return fallback; return new Response('Offline', { status: 503 }); } -} \ No newline at end of file +} diff --git a/frontend/vite-mock-plugin.ts b/frontend/vite-mock-plugin.ts index 4ed1ec3..562b454 100644 --- a/frontend/vite-mock-plugin.ts +++ b/frontend/vite-mock-plugin.ts @@ -23,7 +23,7 @@ function json(res: ServerResponse, status: number, body: unknown) { const payload = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload), + 'Content-Length': Buffer.byteLength(payload) }); res.end(payload); } @@ -39,7 +39,7 @@ const MOCK_REFRESH_TOKEN = 'mock-refresh-token'; const TOKEN_PAIR = { access_token: MOCK_ACCESS_TOKEN, refresh_token: MOCK_REFRESH_TOKEN, - expires_in: 900, + expires_in: 900 }; const ME = { @@ -47,7 +47,7 @@ const ME = { name: 'admin', is_admin: true, can_create: true, - is_blocked: false, + is_blocked: false }; type MockUser = { @@ -59,17 +59,27 @@ type MockUser = { }; const mockUsersArr: MockUser[] = [ - { id: 1, name: 'admin', is_admin: true, can_create: true, is_blocked: false }, - { id: 2, name: 'alice', is_admin: false, can_create: true, is_blocked: false }, - { id: 3, name: 'bob', is_admin: false, can_create: true, is_blocked: false }, - { id: 4, name: 'charlie', is_admin: false, can_create: false, is_blocked: true }, - { id: 5, name: 'diana', is_admin: false, can_create: true, is_blocked: false }, + { id: 1, name: 'admin', is_admin: true, can_create: true, is_blocked: false }, + { id: 2, name: 'alice', is_admin: false, can_create: true, is_blocked: false }, + { id: 3, name: 'bob', is_admin: false, can_create: true, is_blocked: false }, + { id: 4, name: 'charlie', is_admin: false, can_create: false, is_blocked: true }, + { id: 5, name: 'diana', is_admin: false, can_create: true, is_blocked: false } ]; const AUDIT_ACTIONS = [ - 'file_create', 'file_edit', 'file_delete', 'file_tag_add', 'file_tag_remove', - 'tag_create', 'tag_edit', 'tag_delete', 'pool_create', 'pool_edit', 'pool_delete', - 'category_create', 'category_edit', + 'file_create', + 'file_edit', + 'file_delete', + 'file_tag_add', + 'file_tag_remove', + 'tag_create', + 'tag_edit', + 'tag_delete', + 'pool_create', + 'pool_edit', + 'pool_delete', + 'category_create', + 'category_edit' ]; const AUDIT_OBJECT_TYPES = ['file', 'tag', 'pool', 'category']; @@ -96,13 +106,21 @@ const mockAuditLog: MockAuditEntry[] = Array.from({ length: 80 }, (_, i) => { object_type: objType, object_id: `00000000-0000-7000-8000-${String(i + 1).padStart(12, '0')}`, details: null, - performed_at: new Date(Date.now() - i * 1_800_000).toISOString(), + performed_at: new Date(Date.now() - i * 1_800_000).toISOString() }; }); const THUMB_COLORS = [ - '#9592B5', '#4DC7ED', '#DB6060', '#F5E872', '#7ECBA1', - '#E08C5A', '#A67CB8', '#5A9ED4', '#C4A44A', '#6DB89E', + '#9592B5', + '#4DC7ED', + '#DB6060', + '#F5E872', + '#7ECBA1', + '#E08C5A', + '#A67CB8', + '#5A9ED4', + '#C4A44A', + '#6DB89E' ]; function mockThumbSvg(id: string): string { @@ -135,7 +153,7 @@ type MockFile = { // Trash — pre-seeded with a few deleted files const MOCK_TRASH: MockFile[] = Array.from({ length: 6 }, (_, i) => { const mimes = ['image/jpeg', 'image/png', 'image/webp']; - const exts = ['jpg', 'png', 'webp' ]; + const exts = ['jpg', 'png', 'webp']; const mi = i % mimes.length; const id = `00000000-0000-7000-8000-trash${String(i + 1).padStart(7, '0')}`; return { @@ -153,13 +171,13 @@ const MOCK_TRASH: MockFile[] = Array.from({ length: 6 }, (_, i) => { is_public: false, is_deleted: true, created_at: new Date(Date.now() - (i + 80) * 3_600_000).toISOString(), - position: 0, + position: 0 }; }); const MOCK_FILES: MockFile[] = Array.from({ length: 500 }, (_, i) => { const mimes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4']; - const exts = ['jpg', 'png', 'webp', 'mp4' ]; + const exts = ['jpg', 'png', 'webp', 'mp4']; const mi = i % mimes.length; const id = `00000000-0000-7000-8000-${String(i + 1).padStart(12, '0')}`; return { @@ -176,67 +194,343 @@ const MOCK_FILES: MockFile[] = Array.from({ length: 500 }, (_, i) => { creator_name: 'admin', is_public: false, is_deleted: false, - created_at: new Date(Date.now() - i * 3_600_000).toISOString(), + created_at: new Date(Date.now() - i * 3_600_000).toISOString() }; }); const TAG_NAMES = [ - 'nature', 'portrait', 'travel', 'architecture', 'food', 'street', 'macro', - 'landscape', 'wildlife', 'urban', 'abstract', 'black-and-white', 'night', - 'golden-hour', 'blue-hour', 'aerial', 'underwater', 'infrared', 'long-exposure', - 'panorama', 'astrophotography', 'documentary', 'editorial', 'fashion', 'wedding', - 'newborn', 'maternity', 'family', 'pet', 'sport', 'concert', 'theatre', - 'interior', 'exterior', 'product', 'still-life', 'automotive', 'aviation', - 'marine', 'industrial', 'medical', 'scientific', 'satellite', 'drone', - 'film', 'analog', 'polaroid', 'tilt-shift', 'fisheye', 'telephoto', - 'wide-angle', 'bokeh', 'silhouette', 'reflection', 'shadow', 'texture', - 'pattern', 'color', 'minimal', 'surreal', 'conceptual', 'fine-art', - 'photojournalism', 'war', 'protest', 'people', 'crowd', 'solitude', - 'children', 'elderly', 'culture', 'tradition', 'festival', 'religion', - 'asia', 'europe', 'africa', 'americas', 'oceania', 'arctic', 'desert', - 'forest', 'mountain', 'ocean', 'lake', 'river', 'waterfall', 'cave', - 'volcano', 'canyon', 'glacier', 'field', 'garden', 'park', 'city', - 'village', 'ruins', 'bridge', 'road', 'railway', 'harbor', 'airport', - 'market', 'cafe', 'restaurant', 'bar', 'museum', 'library', 'school', - 'hospital', 'church', 'mosque', 'temple', 'shrine', 'cemetery', 'stadium', - 'spring', 'summer', 'autumn', 'winter', 'rain', 'snow', 'fog', 'storm', - 'sunrise', 'sunset', 'cloudy', 'clear', 'rainbow', 'lightning', 'wind', - 'cat', 'dog', 'bird', 'horse', 'fish', 'insect', 'reptile', 'mammal', - 'flower', 'tree', 'grass', 'moss', 'mushroom', 'fruit', 'vegetable', - 'fire', 'water', 'earth', 'air', 'smoke', 'ice', 'stone', 'wood', 'metal', - 'glass', 'fabric', 'paper', 'plastic', 'ceramic', 'leather', 'concrete', - 'red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'pink', - 'brown', 'white', 'grey', 'dark', 'bright', 'pastel', 'vivid', 'muted', - 'raw', 'edited', 'hdr', 'composite', 'retouched', 'unedited', 'scanned', - 'selfie', 'candid', 'posed', 'staged', 'spontaneous', 'planned', 'series', + 'nature', + 'portrait', + 'travel', + 'architecture', + 'food', + 'street', + 'macro', + 'landscape', + 'wildlife', + 'urban', + 'abstract', + 'black-and-white', + 'night', + 'golden-hour', + 'blue-hour', + 'aerial', + 'underwater', + 'infrared', + 'long-exposure', + 'panorama', + 'astrophotography', + 'documentary', + 'editorial', + 'fashion', + 'wedding', + 'newborn', + 'maternity', + 'family', + 'pet', + 'sport', + 'concert', + 'theatre', + 'interior', + 'exterior', + 'product', + 'still-life', + 'automotive', + 'aviation', + 'marine', + 'industrial', + 'medical', + 'scientific', + 'satellite', + 'drone', + 'film', + 'analog', + 'polaroid', + 'tilt-shift', + 'fisheye', + 'telephoto', + 'wide-angle', + 'bokeh', + 'silhouette', + 'reflection', + 'shadow', + 'texture', + 'pattern', + 'color', + 'minimal', + 'surreal', + 'conceptual', + 'fine-art', + 'photojournalism', + 'war', + 'protest', + 'people', + 'crowd', + 'solitude', + 'children', + 'elderly', + 'culture', + 'tradition', + 'festival', + 'religion', + 'asia', + 'europe', + 'africa', + 'americas', + 'oceania', + 'arctic', + 'desert', + 'forest', + 'mountain', + 'ocean', + 'lake', + 'river', + 'waterfall', + 'cave', + 'volcano', + 'canyon', + 'glacier', + 'field', + 'garden', + 'park', + 'city', + 'village', + 'ruins', + 'bridge', + 'road', + 'railway', + 'harbor', + 'airport', + 'market', + 'cafe', + 'restaurant', + 'bar', + 'museum', + 'library', + 'school', + 'hospital', + 'church', + 'mosque', + 'temple', + 'shrine', + 'cemetery', + 'stadium', + 'spring', + 'summer', + 'autumn', + 'winter', + 'rain', + 'snow', + 'fog', + 'storm', + 'sunrise', + 'sunset', + 'cloudy', + 'clear', + 'rainbow', + 'lightning', + 'wind', + 'cat', + 'dog', + 'bird', + 'horse', + 'fish', + 'insect', + 'reptile', + 'mammal', + 'flower', + 'tree', + 'grass', + 'moss', + 'mushroom', + 'fruit', + 'vegetable', + 'fire', + 'water', + 'earth', + 'air', + 'smoke', + 'ice', + 'stone', + 'wood', + 'metal', + 'glass', + 'fabric', + 'paper', + 'plastic', + 'ceramic', + 'leather', + 'concrete', + 'red', + 'orange', + 'yellow', + 'green', + 'cyan', + 'blue', + 'purple', + 'pink', + 'brown', + 'white', + 'grey', + 'dark', + 'bright', + 'pastel', + 'vivid', + 'muted', + 'raw', + 'edited', + 'hdr', + 'composite', + 'retouched', + 'unedited', + 'scanned', + 'selfie', + 'candid', + 'posed', + 'staged', + 'spontaneous', + 'planned', + 'series' ]; const TAG_COLORS = [ - '7ECBA1', '9592B5', '4DC7ED', 'E08C5A', 'DB6060', - 'F5E872', 'A67CB8', '5A9ED4', 'C4A44A', '6DB89E', - 'E07090', '70B0E0', 'C0A060', '80C080', 'D080B0', + '7ECBA1', + '9592B5', + '4DC7ED', + 'E08C5A', + 'DB6060', + 'F5E872', + 'A67CB8', + '5A9ED4', + 'C4A44A', + '6DB89E', + 'E07090', + '70B0E0', + 'C0A060', + '80C080', + 'D080B0' ]; const MOCK_CATEGORIES = [ - { id: '00000000-0000-7000-8002-000000000001', name: 'Style', color: '9592B5', notes: null, created_at: new Date().toISOString() }, - { id: '00000000-0000-7000-8002-000000000002', name: 'Subject', color: '4DC7ED', notes: null, created_at: new Date().toISOString() }, - { id: '00000000-0000-7000-8002-000000000003', name: 'Location', color: '7ECBA1', notes: null, created_at: new Date().toISOString() }, - { id: '00000000-0000-7000-8002-000000000004', name: 'Season', color: 'E08C5A', notes: null, created_at: new Date().toISOString() }, - { id: '00000000-0000-7000-8002-000000000005', name: 'Color', color: 'DB6060', notes: null, created_at: new Date().toISOString() }, + { + id: '00000000-0000-7000-8002-000000000001', + name: 'Style', + color: '9592B5', + notes: null, + created_at: new Date().toISOString() + }, + { + id: '00000000-0000-7000-8002-000000000002', + name: 'Subject', + color: '4DC7ED', + notes: null, + created_at: new Date().toISOString() + }, + { + id: '00000000-0000-7000-8002-000000000003', + name: 'Location', + color: '7ECBA1', + notes: null, + created_at: new Date().toISOString() + }, + { + id: '00000000-0000-7000-8002-000000000004', + name: 'Season', + color: 'E08C5A', + notes: null, + created_at: new Date().toISOString() + }, + { + id: '00000000-0000-7000-8002-000000000005', + name: 'Color', + color: 'DB6060', + notes: null, + created_at: new Date().toISOString() + } ]; // Assign some tags to categories const CATEGORY_ASSIGNMENTS: Record = {}; TAG_NAMES.forEach((name, i) => { - if (['film', 'analog', 'polaroid', 'bokeh', 'silhouette', 'long-exposure', 'tilt-shift', 'fisheye', 'telephoto', 'wide-angle', 'macro', 'infrared', 'hdr', 'composite'].includes(name)) + if ( + [ + 'film', + 'analog', + 'polaroid', + 'bokeh', + 'silhouette', + 'long-exposure', + 'tilt-shift', + 'fisheye', + 'telephoto', + 'wide-angle', + 'macro', + 'infrared', + 'hdr', + 'composite' + ].includes(name) + ) CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[0].id; // Style - else if (['portrait', 'wildlife', 'people', 'children', 'elderly', 'cat', 'dog', 'bird', 'horse', 'flower', 'tree', 'insect', 'reptile', 'mammal'].includes(name)) + else if ( + [ + 'portrait', + 'wildlife', + 'people', + 'children', + 'elderly', + 'cat', + 'dog', + 'bird', + 'horse', + 'flower', + 'tree', + 'insect', + 'reptile', + 'mammal' + ].includes(name) + ) CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[1].id; // Subject - else if (['asia', 'europe', 'africa', 'americas', 'oceania', 'arctic', 'desert', 'forest', 'mountain', 'ocean', 'lake', 'river', 'city', 'village'].includes(name)) + else if ( + [ + 'asia', + 'europe', + 'africa', + 'americas', + 'oceania', + 'arctic', + 'desert', + 'forest', + 'mountain', + 'ocean', + 'lake', + 'river', + 'city', + 'village' + ].includes(name) + ) CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[2].id; // Location else if (['spring', 'summer', 'autumn', 'winter'].includes(name)) CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[3].id; // Season - else if (['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'pink', 'brown', 'white', 'grey', 'dark', 'bright', 'pastel', 'vivid', 'muted'].includes(name)) + else if ( + [ + 'red', + 'orange', + 'yellow', + 'green', + 'cyan', + 'blue', + 'purple', + 'pink', + 'brown', + 'white', + 'grey', + 'dark', + 'bright', + 'pastel', + 'vivid', + 'muted' + ].includes(name) + ) CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[4].id; // Color }); @@ -269,7 +563,7 @@ const mockTagsArr: MockTag[] = TAG_NAMES.map((name, i) => { category_name: cat?.name ?? null, category_color: cat?.color ?? null, is_public: false, - created_at: new Date(Date.now() - i * 3_600_000).toISOString(), + created_at: new Date(Date.now() - i * 3_600_000).toISOString() }; }); @@ -280,7 +574,7 @@ const MOCK_TAGS = mockTagsArr; const tagRules = new Map>(); // Mutable in-memory state for file metadata and tags -const fileOverrides = new Map>(); +const fileOverrides = new Map>(); const fileTags = new Map>(); // fileId → Set type MockPool = { @@ -313,9 +607,36 @@ type PoolFile = { }; const mockPoolsArr: MockPool[] = [ - { id: '00000000-0000-7000-8003-000000000001', name: 'Best of 2024', notes: 'Top picks from last year', is_public: false, file_count: 0, creator_id: 1, creator_name: 'admin', created_at: new Date(Date.now() - 10 * 86400000).toISOString() }, - { id: '00000000-0000-7000-8003-000000000002', name: 'Portfolio', notes: null, is_public: true, file_count: 0, creator_id: 1, creator_name: 'admin', created_at: new Date(Date.now() - 5 * 86400000).toISOString() }, - { id: '00000000-0000-7000-8003-000000000003', name: 'Work in Progress', notes: 'Drafts and experiments', is_public: false, file_count: 0, creator_id: 1, creator_name: 'admin', created_at: new Date(Date.now() - 2 * 86400000).toISOString() }, + { + id: '00000000-0000-7000-8003-000000000001', + name: 'Best of 2024', + notes: 'Top picks from last year', + is_public: false, + file_count: 0, + creator_id: 1, + creator_name: 'admin', + created_at: new Date(Date.now() - 10 * 86400000).toISOString() + }, + { + id: '00000000-0000-7000-8003-000000000002', + name: 'Portfolio', + notes: null, + is_public: true, + file_count: 0, + creator_id: 1, + creator_name: 'admin', + created_at: new Date(Date.now() - 5 * 86400000).toISOString() + }, + { + id: '00000000-0000-7000-8003-000000000003', + name: 'Work in Progress', + notes: 'Drafts and experiments', + is_public: false, + file_count: 0, + creator_id: 1, + creator_name: 'admin', + created_at: new Date(Date.now() - 2 * 86400000).toISOString() + } ]; // Pool files: Map ordered by position @@ -323,8 +644,20 @@ const poolFilesMap = new Map(); // Seed some files into first two pools function seedPoolFiles() { - const p1Files: PoolFile[] = MOCK_FILES.slice(0, 8).map((f, i) => ({ ...f, metadata: null, exif: {}, phash: null, position: i + 1 })); - const p2Files: PoolFile[] = MOCK_FILES.slice(5, 14).map((f, i) => ({ ...f, metadata: null, exif: {}, phash: null, position: i + 1 })); + const p1Files: PoolFile[] = MOCK_FILES.slice(0, 8).map((f, i) => ({ + ...f, + metadata: null, + exif: {}, + phash: null, + position: i + 1 + })); + const p2Files: PoolFile[] = MOCK_FILES.slice(5, 14).map((f, i) => ({ + ...f, + metadata: null, + exif: {}, + phash: null, + position: i + 1 + })); poolFilesMap.set(mockPoolsArr[0].id, p1Files); poolFilesMap.set(mockPoolsArr[1].id, p2Files); mockPoolsArr[0].file_count = p1Files.length; @@ -381,10 +714,10 @@ export function mockApiPlugin(): Plugin { started_at: new Date().toISOString(), expires_at: null, last_activity: new Date().toISOString(), - is_current: true, - }, + is_current: true + } ], - total: 1, + total: 1 }); } @@ -410,7 +743,10 @@ export function mockApiPlugin(): Plugin { const thumbMatch = path.match(/^\/files\/([^/]+)\/thumbnail$/); if (method === 'GET' && thumbMatch) { const svg = mockThumbSvg(thumbMatch[1]); - res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(svg) }); + res.writeHead(200, { + 'Content-Type': 'image/svg+xml', + 'Content-Length': Buffer.byteLength(svg) + }); return res.end(svg); } @@ -424,7 +760,10 @@ export function mockApiPlugin(): Plugin { ${label} `; - res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(svg) }); + res.writeHead(200, { + 'Content-Type': 'image/svg+xml', + 'Content-Length': Buffer.byteLength(svg) + }); return res.end(svg); } @@ -432,7 +771,11 @@ export function mockApiPlugin(): Plugin { const fileTagsGetMatch = path.match(/^\/files\/([^/]+)\/tags$/); if (method === 'GET' && fileTagsGetMatch) { const ids = fileTags.get(fileTagsGetMatch[1]) ?? new Set(); - return json(res, 200, MOCK_TAGS.filter((t) => ids.has(t.id))); + return json( + res, + 200, + MOCK_TAGS.filter((t) => ids.has(t.id)) + ); } // PUT /files/{id}/tags/{tag_id} — add tag @@ -442,7 +785,11 @@ export function mockApiPlugin(): Plugin { if (!fileTags.has(fid)) fileTags.set(fid, new Set()); fileTags.get(fid)!.add(tid); const ids = fileTags.get(fid)!; - return json(res, 200, MOCK_TAGS.filter((t) => ids.has(t.id))); + return json( + res, + 200, + MOCK_TAGS.filter((t) => ids.has(t.id)) + ); } // DELETE /files/{id}/tags/{tag_id} — remove tag @@ -491,7 +838,11 @@ export function mockApiPlugin(): Plugin { // POST /files/bulk/tags if (method === 'POST' && path === '/files/bulk/tags') { - const body = (await readBody(req)) as { file_ids?: string[]; action?: string; tag_ids?: string[] }; + const body = (await readBody(req)) as { + file_ids?: string[]; + action?: string; + tag_ids?: string[]; + }; const fileIds = body.file_ids ?? []; const tagIds = body.tag_ids ?? []; const action = body.action ?? 'add'; @@ -569,7 +920,7 @@ export function mockApiPlugin(): Plugin { creator_name: 'admin', is_public: false, is_deleted: false, - created_at: new Date().toISOString(), + created_at: new Date().toISOString() }; MOCK_FILES.unshift(newFile); return json(res, 201, newFile); @@ -585,8 +936,10 @@ export function mockApiPlugin(): Plugin { const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0; const slice = MOCK_TRASH.slice(offset, offset + limit); const nextOffset = offset + slice.length; - const next_cursor = nextOffset < MOCK_TRASH.length - ? Buffer.from(String(nextOffset)).toString('base64') : null; + const next_cursor = + nextOffset < MOCK_TRASH.length + ? Buffer.from(String(nextOffset)).toString('base64') + : null; return json(res, 200, { items: slice, next_cursor, prev_cursor: null }); } const anchor = qs.get('anchor'); @@ -596,13 +949,15 @@ export function mockApiPlugin(): Plugin { if (anchor) { // Anchor mode: return the anchor file surrounded by neighbors const anchorIdx = MOCK_FILES.findIndex((f) => f.id === anchor); - if (anchorIdx < 0) return json(res, 404, { code: 'not_found', message: 'Anchor not found' }); + if (anchorIdx < 0) + return json(res, 404, { code: 'not_found', message: 'Anchor not found' }); const from = Math.max(0, anchorIdx - Math.floor(limit / 2)); const slice = MOCK_FILES.slice(from, from + limit); - const next_cursor = from + slice.length < MOCK_FILES.length - ? Buffer.from(String(from + slice.length)).toString('base64') : null; - const prev_cursor = from > 0 - ? Buffer.from(String(from)).toString('base64') : null; + const next_cursor = + from + slice.length < MOCK_FILES.length + ? Buffer.from(String(from + slice.length)).toString('base64') + : null; + const prev_cursor = from > 0 ? Buffer.from(String(from)).toString('base64') : null; return json(res, 200, { items: slice, next_cursor, prev_cursor }); } @@ -612,18 +967,18 @@ export function mockApiPlugin(): Plugin { const end = Number(Buffer.from(cursor, 'base64').toString()); const start = Math.max(0, end - limit); const slice = MOCK_FILES.slice(start, end); - const prev_cursor = start > 0 - ? Buffer.from(String(start)).toString('base64') : null; + const prev_cursor = start > 0 ? Buffer.from(String(start)).toString('base64') : null; const next_cursor = Buffer.from(String(end)).toString('base64'); return json(res, 200, { items: slice, next_cursor, prev_cursor }); } const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0; const slice = MOCK_FILES.slice(offset, offset + limit); const nextOffset = offset + slice.length; - const next_cursor = nextOffset < MOCK_FILES.length - ? Buffer.from(String(nextOffset)).toString('base64') : null; - const prev_cursor = offset > 0 - ? Buffer.from(String(offset)).toString('base64') : null; + const next_cursor = + nextOffset < MOCK_FILES.length + ? Buffer.from(String(nextOffset)).toString('base64') + : null; + const prev_cursor = offset > 0 ? Buffer.from(String(offset)).toString('base64') : null; return json(res, 200, { items: slice, next_cursor, prev_cursor }); } @@ -634,7 +989,12 @@ export function mockApiPlugin(): Plugin { const ruleMap = tagRules.get(tid) ?? new Map(); const items = [...ruleMap.entries()].map(([thenId, isActive]) => { const t = MOCK_TAGS.find((x) => x.id === thenId); - return { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive }; + return { + tag_id: tid, + then_tag_id: thenId, + then_tag_name: t?.name ?? null, + is_active: isActive + }; }); return json(res, 200, items); } @@ -649,7 +1009,12 @@ export function mockApiPlugin(): Plugin { if (!tagRules.has(tid)) tagRules.set(tid, new Map()); tagRules.get(tid)!.set(thenId, isActive); const t = MOCK_TAGS.find((x) => x.id === thenId); - return json(res, 201, { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive }); + return json(res, 201, { + tag_id: tid, + then_tag_id: thenId, + then_tag_name: t?.name ?? null, + is_active: isActive + }); } // PATCH /tags/{id}/rules/{then_id} — activate / deactivate @@ -659,10 +1024,16 @@ export function mockApiPlugin(): Plugin { const body = (await readBody(req)) as Record; const isActive = body.is_active as boolean; const ruleMap = tagRules.get(tid); - if (!ruleMap?.has(thenId)) return json(res, 404, { code: 'not_found', message: 'Rule not found' }); + if (!ruleMap?.has(thenId)) + return json(res, 404, { code: 'not_found', message: 'Rule not found' }); ruleMap.set(thenId, isActive); const t = MOCK_TAGS.find((x) => x.id === thenId); - return json(res, 200, { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive }); + return json(res, 200, { + tag_id: tid, + then_tag_id: thenId, + then_tag_name: t?.name ?? null, + is_active: isActive + }); } // DELETE /tags/{id}/rules/{then_id} @@ -692,7 +1063,7 @@ export function mockApiPlugin(): Plugin { Object.assign(MOCK_TAGS[idx], { ...body, category_name: cat?.name ?? null, - category_color: cat?.color ?? null, + category_color: cat?.color ?? null }); return json(res, 200, MOCK_TAGS[idx]); } @@ -720,10 +1091,19 @@ export function mockApiPlugin(): Plugin { filtered.sort((a, b) => { let av: string, bv: string; - if (sort === 'color') { av = a.color; bv = b.color; } - else if (sort === 'category_name') { av = a.category_name ?? ''; bv = b.category_name ?? ''; } - else if (sort === 'created') { av = a.created_at; bv = b.created_at; } - else { av = a.name; bv = b.name; } + if (sort === 'color') { + av = a.color; + bv = b.color; + } else if (sort === 'category_name') { + av = a.category_name ?? ''; + bv = b.category_name ?? ''; + } else if (sort === 'created') { + av = a.created_at; + bv = b.created_at; + } else { + av = a.name; + bv = b.name; + } const cmp = av.localeCompare(bv); return order === 'desc' ? -cmp : cmp; }); @@ -746,7 +1126,7 @@ export function mockApiPlugin(): Plugin { category_name: cat?.name ?? null, category_color: cat?.color ?? null, is_public: body.is_public ?? false, - created_at: new Date().toISOString(), + created_at: new Date().toISOString() }; MOCK_TAGS.unshift(newTag); return json(res, 201, newTag); @@ -778,7 +1158,7 @@ export function mockApiPlugin(): Plugin { if (method === 'PATCH' && catPatchMatch) { const idx = MOCK_CATEGORIES.findIndex((c) => c.id === catPatchMatch[1]); if (idx < 0) return json(res, 404, { code: 'not_found', message: 'Category not found' }); - const body = (await readBody(req)) as Partial; + const body = (await readBody(req)) as Partial<(typeof MOCK_CATEGORIES)[0]>; Object.assign(MOCK_CATEGORIES[idx], body); // Sync category_name/color on affected tags const cat = MOCK_CATEGORIES[idx]; @@ -824,9 +1204,16 @@ export function mockApiPlugin(): Plugin { filtered.sort((a, b) => { let av: string, bv: string; - if (sort === 'color') { av = a.color; bv = b.color; } - else if (sort === 'created') { av = a.created_at; bv = b.created_at; } - else { av = a.name; bv = b.name; } + if (sort === 'color') { + av = a.color; + bv = b.color; + } else if (sort === 'created') { + av = a.created_at; + bv = b.created_at; + } else { + av = a.name; + bv = b.name; + } const cmp = av.localeCompare(bv); return order === 'desc' ? -cmp : cmp; }); @@ -837,13 +1224,13 @@ export function mockApiPlugin(): Plugin { // POST /categories if (method === 'POST' && path === '/categories') { - const body = (await readBody(req)) as Partial; + const body = (await readBody(req)) as Partial<(typeof MOCK_CATEGORIES)[0]>; const newCat = { id: `00000000-0000-7000-8002-${String(Date.now()).slice(-12)}`, name: body.name ?? 'Unnamed', color: body.color ?? '9592B5', notes: body.notes ?? null, - created_at: new Date().toISOString(), + created_at: new Date().toISOString() }; MOCK_CATEGORIES.unshift(newCat); return json(res, 201, newCat); @@ -862,8 +1249,8 @@ export function mockApiPlugin(): Plugin { const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0; const slice = files.slice(offset, offset + limit); const nextOffset = offset + slice.length; - const next_cursor = nextOffset < files.length - ? Buffer.from(String(nextOffset)).toString('base64') : null; + const next_cursor = + nextOffset < files.length ? Buffer.from(String(nextOffset)).toString('base64') : null; return json(res, 200, { items: slice, next_cursor, prev_cursor: null }); } @@ -878,7 +1265,9 @@ export function mockApiPlugin(): Plugin { const files = poolFilesMap.get(pid) ?? []; const updated = files.filter((f) => !toRemove.has(f.id)); // Reassign positions - updated.forEach((f, i) => { f.position = i + 1; }); + updated.forEach((f, i) => { + f.position = i + 1; + }); poolFilesMap.set(pid, updated); pool.file_count = updated.length; return noContent(res); @@ -899,7 +1288,9 @@ export function mockApiPlugin(): Plugin { const f = byId.get(id); if (f) reordered.push(f); } - reordered.forEach((f, i) => { f.position = i + 1; }); + reordered.forEach((f, i) => { + f.position = i + 1; + }); poolFilesMap.set(pid, reordered); return noContent(res); } @@ -914,7 +1305,7 @@ export function mockApiPlugin(): Plugin { const files = poolFilesMap.get(pid) ?? []; const existing = new Set(files.map((f) => f.id)); let pos = files.length; - for (const fid of (body.file_ids ?? [])) { + for (const fid of body.file_ids ?? []) { if (existing.has(fid)) continue; const base = MOCK_FILES.find((f) => f.id === fid); if (!base) continue; @@ -991,7 +1382,7 @@ export function mockApiPlugin(): Plugin { file_count: 0, creator_id: 1, creator_name: 'admin', - created_at: new Date().toISOString(), + created_at: new Date().toISOString() }; mockPoolsArr.unshift(newPool); return json(res, 201, newPool); @@ -1040,7 +1431,7 @@ export function mockApiPlugin(): Plugin { name: body.name ?? 'unnamed', is_admin: body.is_admin ?? false, can_create: body.can_create ?? false, - is_blocked: false, + is_blocked: false }; mockUsersArr.push(newUser); return json(res, 201, newUser); @@ -1074,8 +1465,11 @@ export function mockApiPlugin(): Plugin { } // Fallback: 404 - return json(res, 404, { code: 'not_found', message: `Mock: no handler for ${method} ${path}` }); + return json(res, 404, { + code: 'not_found', + message: `Mock: no handler for ${method} ${path}` + }); }); - }, + } }; -} \ No newline at end of file +}