25 Commits

Author SHA1 Message Date
H1K0 135c71ae4d fix(frontend): theme-aware footer navbar colors
- Add --color-nav-bg and --color-nav-active CSS variables
- Dark: semi-transparent purple-dark tone (rgba 52,50,73 / 0.45 bg)
- Light: light semi-transparent background, accent-tinted active highlight
- Footer background and active nav item now use variables instead of
  hardcoded dark values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 01:10:12 +03:00
H1K0 d38e54e307 feat(frontend): use reference icons for PWA manifest and favicons
- Copy all icon PNGs from docs/reference (android, apple, ms, favicon sizes)
- Copy favicon.ico and browserconfig.xml
- Manifest: full icon set (36–310px), background/theme #312F45
- app.html: favicon links, full apple-touch-icon set, MS tile metas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 01:04:36 +03:00
H1K0 c6e91c2eaf feat(frontend): add PWA support (service worker, manifest, pwa util)
- src/service-worker.ts: cache-first app shell (build + static assets),
  network-only for /api/, offline fallback to SPA shell
- static/manifest.webmanifest: name/short_name Tanabata, theme #312F45,
  standalone display, start_url /files, icon paths for 192/512/maskable
- src/lib/utils/pwa.ts: resetPwa() — unregisters SW + clears all caches
- app.html: link manifest, theme-color meta, Apple PWA metas
- settings page: refactored to use resetPwa() from utils

Note: add /static/images/icon-192.png, icon-512.png, icon-maskable-512.png
for full installability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 01:02:53 +03:00
H1K0 d6e9223f61 feat(frontend): implement trash view with restore and permanent delete
- New /files/trash page: same grid as files view, deleted files only
- Tap selects (no detail page for deleted files), long-press drag-selects
- Trash selection bar: Restore (bulk) and Delete permanently (bulk, confirmed)
- Trash icon added to files header, navigates to /files/trash
- Mock: MOCK_TRASH with 6 pre-seeded files; bulk/delete now moves to trash;
  handlers for POST /files/{id}/restore and DELETE /files/{id}/permanent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:56:55 +03:00
H1K0 004ff0b45e fix(frontend): admin section fixes (pagination, actions, navbar)
- Audit log: replace load-more with page-based pagination
- Audit log: add all 29 action types to the dropdown
- Audit log: fix pagination bar hidden behind footer
- Root layout: hide footer navbar on /admin/* routes
- Users pages: fix curly-quote parse error in ConfirmDialog messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:56:43 +03:00
H1K0 6e052efebf feat(frontend): implement admin section (users + audit log)
- Layout guard redirecting non-admins to /files
- User list page with create form and delete confirmation
- User detail page with role/permission toggles and delete
- Audit log page with filters (user, action, object type, ID, date range)
- Mock data: 5 test users, 80 audit entries, full CRUD handlers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:27:44 +03:00
H1K0 70cbb45b01 fix(frontend): auto-fill viewport on file list load
After each batch, check if the scroll container is still shorter than
the viewport (scrollHeight <= clientHeight) and keep loading until the
scrollbar appears or there are no more files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:06:45 +03:00
H1K0 012c6f9c48 feat(frontend): add configurable app settings (file load limit, tag rule apply_to_existing)
- Add appSettings store (localStorage-backed) with two settings:
  fileLoadLimit (default 100) and tagRuleApplyToExisting (default false)
- Settings page: new Behaviour section with numeric input for files per
  page (10–500) and an on/off toggle for retroactive tag rule application
- files/+page.svelte: derive LIMIT from appSettings.fileLoadLimit so
  changes take effect immediately without reload
- TagRuleEditor: pass apply_to_existing from appSettings when activating
  a rule via PATCH (only sent on activation, not deactivation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:00:55 +03:00
H1K0 8cfcd39ab6 feat(backend): apply tag rules retroactively to existing files on activation
Extend PATCH /tags/{id}/rules/{then_id} to accept apply_to_existing bool.
When a rule is activated with apply_to_existing=true, a single recursive
CTE retroactively inserts the full transitive expansion of then_tag into
data.file_tag for all files already carrying when_tag:

  WITH RECURSIVE expansion(tag_id) AS (
      SELECT then_tag_id
      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 ... ON CONFLICT DO NOTHING

Changes:
- port/repository.go: add applyToExisting param to TagRuleRepo.SetActive
- db/postgres/tag_repo.go: implement recursive CTE retroactive apply
- service/tag_service.go: thread applyToExisting through SetRuleActive
- handler/tag_handler.go: parse apply_to_existing from PATCH body
- openapi.yaml: document apply_to_existing on PATCH endpoint
- integration test: add TestTagRuleActivateApplyToExisting covering
  no-op when false, direct+transitive apply when true

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:00:45 +03:00
H1K0 6da25dc696 feat(frontend): implement settings page
- Profile editor: name and optional password change with confirm field,
  saves via PATCH /users/me and updates auth store
- Appearance: theme toggle button (dark/light) with sun/moon icon
- App cache: PWA reset — unregisters service workers and clears caches
- Sessions: list active sessions with parsed user agent, start date,
  expiry, current badge, and terminate button per session
- Add mock handlers: PATCH /users/me, DELETE /auth/sessions/{id}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:37:44 +03:00
H1K0 9b1aa40522 feat(frontend): implement bulk tag editing for multi-file selection
- Add BulkTagEditor component: loads common/partial tags via
  POST /files/bulk/common-tags and applies changes via POST /files/bulk/tags
- Common tags shown solid with × to remove from all files
- Partial tags shown with dashed border and ~ indicator; clicking promotes
  to common (adds to files that are missing it)
- Wire "Edit tags" button in SelectionBar to a bottom sheet with the editor
- Add mock handlers for /files/bulk/common-tags and /files/bulk/tags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 08:40:53 +03:00
H1K0 d79e76e9b7 feat(frontend): implement pool views and add-to-pool from file list
- Add /pools list page with search, sort, load-more pagination
- Add /pools/new create form (name, notes, public toggle)
- Add /pools/[id] detail page: metadata editing, ordered file grid,
  drag-to-reorder, filter bar, file selection/removal, add-files overlay
- Add pool sort store (poolSorting) to sorting.ts
- Wire "Add to pool" button in SelectionBar: bottom-sheet pool picker
  loads pool list, user picks one, selected files are POSTed to pool
- Add full pool mock API handlers in vite-mock-plugin.ts (CRUD + file
  management: add, remove, reorder with cursor pagination)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 08:31:06 +03:00
H1K0 1f591f3a3f feat(frontend): replace JS confirm() with native dialog component
- ConfirmDialog: centered <dialog> with backdrop blur, cancel + confirm (danger variant)
- tags/[id]: delete tag uses ConfirmDialog
- categories/[id]: delete category uses ConfirmDialog
- files: bulk delete calls POST /files/bulk/delete, removes files from list,
  text updated to "Move to trash" (soft delete)
- mock: add POST /files/bulk/delete handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:48:21 +03:00
H1K0 1931adcd38 feat(frontend): implement category list, create, and edit pages
- /categories: list with colored pills, search + clear, sort/order controls
- /categories/new: create form with name, color picker, notes, is_public
- /categories/[id]: edit form + tags-in-category section with load more
- sorting.ts: add categorySorting store (name/color/created, persisted)
- mock: category CRUD, GET /categories/{id}/tags, search/sort/offset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:38:52 +03:00
H1K0 21f3acadf0 feat: add PATCH /tags/{id}/rules/{then_id} to activate/deactivate rules
- openapi.yaml: new PATCH endpoint with is_active body, returns TagRule
- backend/service: SetRuleActive calls repo.SetActive then returns updated rule
- backend/handler: PatchRule validates body and delegates to service
- backend/router: register PATCH /:tag_id/rules/:then_tag_id
- frontend: TagRuleEditor uses PATCH instead of delete+recreate
- mock: handle PATCH /tags/{id}/rules/{then_id}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:31:12 +03:00
H1K0 871250345a feat(frontend): add activate/deactivate toggle for tag rules
- Toggle button (filled/hollow circle) on each rule row
- Inactive rules dim to 45% opacity
- Toggle via delete + recreate with new is_active value
- Mock: track is_active per rule (Map instead of Set)
- Show all available tags by default in add-rule picker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:26:07 +03:00
H1K0 6e24060d99 feat(frontend): add clear button to TagPicker search input
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:16:49 +03:00
H1K0 f7d7e8ce37 feat(frontend): implement tag list, create, and edit pages
- /tags: list with search + clear button, sort/order controls, offset pagination
  Fix infinite requests when search matches no tags (track initialLoaded flag)
- /tags/new: create form with name, notes, color picker, category, is_public
- /tags/[id]: edit form + TagRuleEditor for implied-tag rules + delete
- TagBadge: colored pill with optional onclick and size prop
- TagRuleEditor: manage implied-tag rules (search to add, × to remove)
- Mock: tag/category CRUD, rules CRUD, search/sort, 5 mock categories

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:14:04 +03:00
H1K0 b9cace2997 feat(frontend): implement file upload with drag-and-drop and per-file progress
- client.ts: add uploadWithProgress() using XHR for upload progress events
- FileUpload.svelte: drag-drop zone wrapper, multi-file queue with individual
  progress bars, success/error status, MIME rejection message, dismiss panel
- Header.svelte: optional onUpload prop renders upload icon button
- files/+page.svelte: wire upload button, prepend uploaded files to grid
- vite-mock-plugin.ts: handle POST /files, unshift new file into mock array
- Fix crypto.randomUUID() crash on non-secure HTTP context (use Date.now + Math.random)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:02:26 +03:00
H1K0 a5b610d472 feat(frontend): implement file viewer page with metadata editing and tag picker
- files/[id]/+page.svelte: full-screen preview (100dvh), sticky top bar,
  prev/next nav via anchor API, notes/datetime/is_public editing, TagPicker,
  EXIF display, keyboard navigation (←/→/Esc)
- TagPicker.svelte: assigned tags with remove, searchable available tags to add
- Fix infinite request loop: previewSrc read inside $effect tracked as dependency;
  wrapped in untrack() to prevent re-triggering on blob URL assignment
- vite-mock-plugin: add GET/PATCH /files/{id}, preview endpoint, tags CRUD,
  anchor-based pagination, in-memory mutable state for file overrides and tags
- files/+page.svelte: migrate from deprecated $app/stores to $app/state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:55:04 +03:00
H1K0 84c47d0282 feat(frontend): expand mock tags to 207 entries for filter bar testing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:35:05 +03:00
H1K0 6fa340b17c feat(frontend): make header and filter bar sticky on scroll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:32:35 +03:00
H1K0 aebf7127af feat(frontend): implement file selection with long-press, shift+click, and touch drag
- selection.ts: store with select/deselect/toggle/enter/exit, derived count and active
- FileCard: long-press (400ms) enters selection mode, shows check overlay, blocks context menu
- Header: Select/Cancel button toggles selection mode
- SelectionBar: floating bar above navbar with count, Edit tags, Add to pool, Delete
- Shift+click range-selects between last and current index (desktop)
- Touch drag-to-select/deselect after long-press; non-passive touchmove blocks scroll only during drag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:30:26 +03:00
H1K0 63ea1a4d6a feat(frontend): make filter expression tokens draggable for reordering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:57:45 +03:00
H1K0 27d8215a0a feat(frontend): add header, filter bar, and sorting store for files page
- sorting.ts: per-section sort store (sort field + order) persisted to localStorage
- dsl.ts: build/parse DSL filter strings ({t=uuid,&,|,!,...})
- Header.svelte: sort dropdown, asc/desc toggle, filter toggle button
- FilterBar.svelte: tag token picker with operator buttons, search, apply/reset
- files/+page.svelte: wired header + filter bar, resets pagination on sort/filter change
- vite-mock-plugin.ts: added 5 mock tags for filter bar development

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:47:18 +03:00
75 changed files with 10770 additions and 44 deletions
+30 -3
View File
@@ -574,19 +574,46 @@ JOIN data.tags t ON t.id = ins.then_tag_id`
return &result, nil
}
func (r *TagRuleRepo) SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active bool) error {
const query = `
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, query, whenTagID, thenTagID, active)
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
}
+1
View File
@@ -93,6 +93,7 @@ func NewRouter(
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)
}
+39
View File
@@ -354,6 +354,45 @@ func (h *TagHandler) CreateRule(c *gin.Context) {
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
// ---------------------------------------------------------------------------
@@ -552,6 +552,90 @@ func TestPoolReorder(t *testing.T) {
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() {
+4 -2
View File
@@ -83,8 +83,10 @@ 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.
SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active bool) 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
}
+19
View File
@@ -191,6 +191,25 @@ func (s *TagService) CreateRule(ctx context.Context, whenTagID, thenTagID uuid.U
})
}
// 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)
+4
View File
@@ -12,6 +12,8 @@
--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;
}
@@ -25,6 +27,8 @@
--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 {
+22
View File
@@ -3,6 +3,28 @@
<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>
+37
View File
@@ -89,6 +89,43 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return res.json();
}
/** Upload with XHR so we can track progress via onProgress(0100). */
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) =>
@@ -0,0 +1,123 @@
<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>
@@ -0,0 +1,350 @@
<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>
@@ -3,12 +3,27 @@
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;
onclick?: (file: File) => void;
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, onclick }: Props = $props();
let {
file,
index,
selected = false,
selectionMode = false,
onTap,
onLongPress,
}: Props = $props();
let imgSrc = $state<string | null>(null);
let failed = $state(false);
@@ -40,8 +55,51 @@
};
});
function handleClick() {
onclick?.(file);
// --- 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>
@@ -49,17 +107,38 @@
<div
class="card"
class:loaded={!!imgSrc}
onclick={handleClick}
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" />
<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>
@@ -73,6 +152,8 @@
cursor: pointer;
background-color: var(--color-bg-elevated);
flex-shrink: 0;
user-select: none;
-webkit-user-select: none;
}
.thumb {
@@ -114,6 +195,17 @@
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; }
@@ -0,0 +1,351 @@
<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>
@@ -0,0 +1,329 @@
<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>
@@ -0,0 +1,242 @@
<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>
@@ -0,0 +1,176 @@
<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>
@@ -0,0 +1,138 @@
<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>
@@ -0,0 +1,56 @@
<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>
@@ -0,0 +1,329 @@
<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>
+28
View File
@@ -0,0 +1,28 @@
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));
});
+65
View File
@@ -0,0 +1,65 @@
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);
+58
View File
@@ -0,0 +1,58 @@
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',
});
+39
View File
@@ -0,0 +1,39 @@
/**
* 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;
}
+14
View File
@@ -0,0 +1,14 @@
/**
* 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)));
}
}
+4 -3
View File
@@ -34,11 +34,12 @@
];
const isLogin = $derived($page.url.pathname === '/login');
const isAdmin = $derived($page.url.pathname.startsWith('/admin'));
</script>
{@render children()}
{#if !isLogin}
{#if !isLogin && !isAdmin}
<footer>
{#each navItems as item}
{@const active = $page.url.pathname.startsWith(item.match)}
@@ -94,7 +95,7 @@
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: rgba(0, 0, 0, 0.45);
background-color: var(--color-nav-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 100;
@@ -114,7 +115,7 @@
.nav:hover,
.nav.curr {
background-color: #343249;
background-color: var(--color-nav-active);
color: var(--color-text-primary);
}
+115
View File
@@ -0,0 +1,115 @@
<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>
+12
View File
@@ -0,0 +1,12 @@
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');
}
};
@@ -0,0 +1,513 @@
<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>
@@ -0,0 +1,415 @@
<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 &ldquo;{confirmDeleteUser.name}&rdquo;? 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>
@@ -0,0 +1,339 @@
<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 &ldquo;{user.name}&rdquo;? 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>
+389
View File
@@ -0,0 +1,389 @@
<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>
@@ -0,0 +1,401 @@
<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>
@@ -0,0 +1,199 @@
<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>
+459 -12
View File
@@ -1,38 +1,231 @@
<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 type { File, FileCursorPage } from '$lib/api/types';
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';
const LIMIT = 50;
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) });
const params = new URLSearchParams({
limit: String(LIMIT),
sort: sortState.sort,
order: sortState.order,
});
if (nextCursor) params.set('cursor', nextCursor);
const page = await api.get<FileCursorPage>(`/files?${params}`);
files = [...files, ...(page.items ?? [])];
nextCursor = page.next_cursor ?? null;
hasMore = !!page.next_cursor;
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>
@@ -40,14 +233,42 @@
</svelte:head>
<div class="page">
<main>
<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 (file.id)}
<FileCard {file} />
{#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>
@@ -57,8 +278,97 @@
<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;
@@ -100,4 +410,141 @@
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>
+588
View File
@@ -0,0 +1,588 @@
<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>
@@ -0,0 +1,405 @@
<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>
+452
View File
@@ -0,0 +1,452 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api/client';
import { poolSorting, type PoolSortField } from '$lib/stores/sorting';
import type { Pool, PoolOffsetPage } from '$lib/api/types';
const LIMIT = 50;
const SORT_OPTIONS: { value: PoolSortField; label: string }[] = [
{ value: 'name', label: 'Name' },
{ value: 'created', label: 'Created' },
];
let pools = $state<Pool[]>([]);
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($poolSorting);
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
let prevKey = $state('');
$effect(() => {
if (resetKey !== prevKey) {
prevKey = resetKey;
pools = [];
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<PoolOffsetPage>(`/pools?${params}`);
pools = offset === 0 ? (page.items ?? []) : [...pools, ...(page.items ?? [])];
total = page.total ?? 0;
offset = pools.length;
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to load pools';
} finally {
loading = false;
initialLoaded = true;
}
}
let hasMore = $derived(pools.length < total);
function formatCount(n: number): string {
return n === 1 ? '1 file' : `${n} files`;
}
</script>
<svelte:head>
<title>Pools | Tanabata</title>
</svelte:head>
<div class="page">
<header class="top-bar">
<h1 class="page-title">Pools</h1>
<div class="controls">
<select
class="sort-select"
value={sortState.sort}
onchange={(e) => poolSorting.setSort((e.currentTarget as HTMLSelectElement).value as PoolSortField)}
>
{#each SORT_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<button
class="icon-btn"
onclick={() => poolSorting.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('/pools/new')}>+ New</button>
</div>
</header>
<div class="search-bar">
<div class="search-wrap">
<input
class="search-input"
type="search"
placeholder="Search pools…"
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="pool-list">
{#each pools as pool (pool.id)}
<button class="pool-card" onclick={() => goto(`/pools/${pool.id}`)}>
<div class="pool-icon" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect x="2" y="2" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.7"/>
<rect x="11" y="2" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.5"/>
<rect x="2" y="11" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.5"/>
<rect x="11" y="11" width="7" height="7" rx="1.5" fill="currentColor" opacity="0.3"/>
</svg>
</div>
<div class="pool-info">
<span class="pool-name">{pool.name}</span>
<span class="pool-meta">
{formatCount(pool.file_count ?? 0)}
{#if pool.creator_name}· {pool.creator_name}{/if}
{#if pool.is_public}<span class="badge-public">public</span>{/if}
</span>
</div>
<svg class="chevron" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</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 && pools.length === 0}
<div class="empty">
{search ? 'No pools match your search.' : 'No pools yet.'}
{#if !search}
<a href="/pools/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);
}
.pool-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.pool-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
font-family: inherit;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background-color 0.15s;
}
.pool-card:hover {
border-color: color-mix(in srgb, var(--color-accent) 40%, transparent);
background-color: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-elevated));
}
.pool-icon {
color: var(--color-accent);
flex-shrink: 0;
display: flex;
}
.pool-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.pool-name {
font-size: 0.95rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pool-meta {
font-size: 0.78rem;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: 5px;
}
.badge-public {
display: inline-block;
padding: 1px 5px;
border-radius: 4px;
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
color: var(--color-accent);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
}
.chevron {
color: var(--color-text-muted);
flex-shrink: 0;
opacity: 0.5;
}
.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>
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api/client';
import type { Pool } from '$lib/api/types';
let name = $state('');
let notes = $state('');
let isPublic = $state(false);
let saving = $state(false);
let error = $state('');
async function submit() {
if (!name.trim() || saving) return;
saving = true;
error = '';
try {
const pool = await api.post<Pool>('/pools', {
name: name.trim(),
notes: notes.trim() || null,
is_public: isPublic,
});
goto(`/pools/${pool.id}`);
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to create pool';
saving = false;
}
}
</script>
<svelte:head>
<title>New Pool | Tanabata</title>
</svelte:head>
<div class="page">
<header class="top-bar">
<button class="back-btn" onclick={() => goto('/pools')} 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 Pool</h1>
</header>
<main>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<form class="form" onsubmit={(e) => { e.preventDefault(); void submit(); }}>
<div class="field">
<label class="label" for="name">Name <span class="required">*</span></label>
<input
id="name"
class="input"
type="text"
bind:value={name}
required
placeholder="Pool name"
autocomplete="off"
/>
</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 pool'}
</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; }
.field { display: flex; flex-direction: column; gap: 5px; }
.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); }
.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 {
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; margin: 0 0 8px; }
</style>
+600
View File
@@ -0,0 +1,600 @@
<script lang="ts">
import { api, ApiError } from '$lib/api/client';
import { authStore } from '$lib/stores/auth';
import { themeStore, toggleTheme } from '$lib/stores/theme';
import { appSettings } from '$lib/stores/appSettings';
import { resetPwa as doPwaReset } from '$lib/utils/pwa';
import type { User, Session, SessionList } from '$lib/api/types';
// ---- Profile ----
let userName = $state($authStore.user?.name ?? '');
let password = $state('');
let passwordConfirm = $state('');
let profileSaving = $state(false);
let profileSuccess = $state(false);
let profileError = $state('');
async function saveProfile() {
profileError = '';
profileSuccess = false;
if (!userName.trim()) {
profileError = 'Name is required';
return;
}
if (password && password !== passwordConfirm) {
profileError = 'Passwords do not match';
return;
}
profileSaving = true;
try {
const body: Record<string, string> = { name: userName.trim() };
if (password) body.password = password;
const updated = await api.patch<User>('/users/me', body);
authStore.update((s) => ({
...s,
user: s.user ? { ...s.user, name: updated.name ?? s.user.name } : s.user,
}));
password = '';
passwordConfirm = '';
profileSuccess = true;
setTimeout(() => (profileSuccess = false), 3000);
} catch (e) {
profileError = e instanceof ApiError ? e.message : 'Failed to save';
} finally {
profileSaving = false;
}
}
// ---- Sessions ----
let sessions = $state<Session[]>([]);
let sessionsTotal = $state(0);
let sessionsLoading = $state(true);
let sessionsError = $state('');
let terminatingIds = $state(new Set<number>());
async function loadSessions() {
sessionsLoading = true;
sessionsError = '';
try {
const res = await api.get<SessionList>('/auth/sessions');
sessions = res.items ?? [];
sessionsTotal = res.total ?? sessions.length;
} catch (e) {
sessionsError = e instanceof ApiError ? e.message : 'Failed to load sessions';
} finally {
sessionsLoading = false;
}
}
async function terminateSession(id: number) {
terminatingIds = new Set([...terminatingIds, id]);
try {
await api.delete(`/auth/sessions/${id}`);
sessions = sessions.filter((s) => s.id !== id);
sessionsTotal = Math.max(0, sessionsTotal - 1);
} catch {
// silently ignore
} finally {
terminatingIds.delete(id);
terminatingIds = new Set(terminatingIds);
}
}
$effect(() => {
void loadSessions();
});
// ---- PWA reset ----
let pwaResetting = $state(false);
let pwaSuccess = $state(false);
async function resetPwa() {
pwaResetting = true;
pwaSuccess = false;
try {
await doPwaReset();
pwaSuccess = true;
setTimeout(() => (pwaSuccess = false), 3000);
} finally {
pwaResetting = false;
}
}
// ---- Helpers ----
function formatDate(iso: string | null | undefined): 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',
});
}
function shortUserAgent(ua: string | null | undefined): string {
if (!ua) return 'Unknown';
// Extract browser + OS from UA string
const browser =
ua.match(/\b(Chrome|Firefox|Safari|Edge|Opera|Brave)\/[\d.]+/)?.[0] ??
ua.match(/\b(MSIE|Trident)\b/)?.[0] ??
ua.slice(0, 40);
const os =
ua.match(/\((Windows[^;)]*|Mac OS X [^;)]*|Linux[^;)]*|Android [^;)]*|iOS [^;)]*)/)?.[1] ?? '';
return os ? `${browser} · ${os}` : browser;
}
</script>
<svelte:head>
<title>Settings | Tanabata</title>
</svelte:head>
<div class="page">
<!-- ====== Profile ====== -->
<section class="card">
<h2 class="section-title">Profile</h2>
{#if profileError}
<p class="msg error" role="alert">{profileError}</p>
{/if}
{#if profileSuccess}
<p class="msg success" role="status">Saved.</p>
{/if}
<div class="field">
<label class="label" for="username">Username</label>
<input
id="username"
class="input"
type="text"
bind:value={userName}
required
autocomplete="username"
placeholder="Your display name"
/>
</div>
<div class="field">
<label class="label" for="password">New password</label>
<input
id="password"
class="input"
type="password"
bind:value={password}
autocomplete="new-password"
placeholder="Leave blank to keep current"
/>
</div>
{#if password}
<div class="field">
<label class="label" for="password-confirm">Confirm password</label>
<input
id="password-confirm"
class="input"
type="password"
bind:value={passwordConfirm}
autocomplete="new-password"
placeholder="Repeat new password"
/>
</div>
{/if}
<div class="row-actions">
<button
class="btn primary"
onclick={saveProfile}
disabled={profileSaving || !userName.trim()}
>
{profileSaving ? 'Saving…' : 'Save changes'}
</button>
</div>
</section>
<!-- ====== Appearance ====== -->
<section class="card">
<h2 class="section-title">Appearance</h2>
<div class="toggle-row">
<span class="toggle-label">
{$themeStore === 'light' ? 'Light theme' : 'Dark theme'}
</span>
<button
class="theme-toggle"
onclick={toggleTheme}
title="Toggle theme"
aria-label="Toggle theme"
>
{#if $themeStore === 'light'}
<!-- Sun icon -->
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<circle cx="9" cy="9" r="3.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M9 1v2M9 15v2M1 9h2M15 9h2M3.22 3.22l1.41 1.41M13.36 13.36l1.42 1.42M3.22 14.78l1.41-1.41M13.36 4.64l1.42-1.42" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
Switch to dark
{:else}
<!-- Moon icon -->
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M15 11.5A7 7 0 0 1 6.5 3a7.001 7.001 0 1 0 8.5 8.5z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
</svg>
Switch to light
{/if}
</button>
</div>
</section>
<!-- ====== PWA ====== -->
<section class="card">
<h2 class="section-title">App cache</h2>
<p class="hint-text">Clear service worker and cached assets. Useful if the app feels stale after an update.</p>
{#if pwaSuccess}
<p class="msg success" role="status">Cache cleared. Reload the page to fetch fresh assets.</p>
{/if}
<div class="row-actions">
<button class="btn danger-outline" onclick={resetPwa} disabled={pwaResetting}>
{pwaResetting ? 'Clearing…' : 'Clear PWA cache'}
</button>
</div>
</section>
<!-- ====== App settings ====== -->
<section class="card">
<h2 class="section-title">Behaviour</h2>
<div class="field">
<label class="label" for="file-limit">Files per page</label>
<p class="hint-text">How many files to load in one batch when scrolling the file list.</p>
<input
id="file-limit"
class="input input-narrow"
type="number"
min="10"
max="500"
step="1"
value={$appSettings.fileLoadLimit}
oninput={(e) => {
const v = parseInt((e.currentTarget as HTMLInputElement).value, 10);
if (!isNaN(v) && v >= 10 && v <= 500)
appSettings.update((s) => ({ ...s, fileLoadLimit: v }));
}}
/>
</div>
<div class="toggle-row">
<div>
<span class="toggle-label">Apply activated tag rules to existing files</span>
<p class="hint-text">When a tag rule is activated, automatically add the implied tag to all files that already have the source tag.</p>
</div>
<button
class="toggle"
class:on={$appSettings.tagRuleApplyToExisting}
role="switch"
aria-checked={$appSettings.tagRuleApplyToExisting}
aria-label="Apply activated tag rules to existing files"
onclick={() => appSettings.update((s) => ({ ...s, tagRuleApplyToExisting: !s.tagRuleApplyToExisting }))}
>
<span class="thumb"></span>
</button>
</div>
</section>
<!-- ====== Sessions ====== -->
<section class="card">
<h2 class="section-title">
Active sessions
{#if sessionsTotal > 0}<span class="count">({sessionsTotal})</span>{/if}
</h2>
{#if sessionsError}
<p class="msg error" role="alert">{sessionsError}</p>
{:else if sessionsLoading}
<p class="msg muted">Loading…</p>
{:else if sessions.length === 0}
<p class="msg muted">No active sessions.</p>
{:else}
<ul class="sessions-list">
{#each sessions as session (session.id)}
<li class="session-item" class:current={session.is_current}>
<div class="session-info">
<span class="session-ua">{shortUserAgent(session.user_agent)}</span>
{#if session.is_current}
<span class="current-badge">current</span>
{/if}
<span class="session-meta">
Started {formatDate(session.started_at)}
{#if session.expires_at}· Expires {formatDate(session.expires_at)}{/if}
</span>
</div>
{#if !session.is_current}
<button
class="terminate-btn"
onclick={() => session.id != null && terminateSession(session.id)}
disabled={terminatingIds.has(session.id ?? -1)}
aria-label="Terminate session"
>
{terminatingIds.has(session.id ?? -1) ? '…' : 'End'}
</button>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
</div>
<style>
.page {
flex: 1;
overflow-y: auto;
padding: 16px 12px calc(70px + 16px);
display: flex;
flex-direction: column;
gap: 14px;
max-width: 600px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.card {
background-color: var(--color-bg-elevated);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.section-title {
font-size: 0.9rem;
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
margin: 0;
}
.count {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
}
.label {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.input {
height: 36px;
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.9rem;
font-family: inherit;
outline: none;
}
.input:focus {
border-color: var(--color-accent);
}
.row-actions {
display: flex;
gap: 8px;
}
.btn {
height: 34px;
padding: 0 16px;
border-radius: 7px;
border: none;
font-size: 0.875rem;
font-family: inherit;
font-weight: 600;
cursor: pointer;
}
.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;
padding: 6px 0;
}
.msg.error { color: var(--color-danger); }
.msg.success { color: #7ECBA1; }
.msg.muted { color: var(--color-text-muted); }
/* ---- Appearance toggle ---- */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.toggle-label {
font-size: 0.9rem;
}
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 7px;
height: 34px;
padding: 0 14px;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 0.85rem;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
}
.theme-toggle:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.input-narrow {
max-width: 100px;
}
/* On/off toggle switch */
.toggle {
flex-shrink: 0;
position: relative;
width: 42px;
height: 24px;
border-radius: 12px;
border: none;
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-primary));
cursor: pointer;
padding: 0;
transition: background-color 0.15s;
}
.toggle.on {
background-color: var(--color-accent);
}
.toggle .thumb {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: #fff;
transition: transform 0.15s;
}
.toggle.on .thumb {
transform: translateX(18px);
}
/* ---- PWA ---- */
.hint-text {
font-size: 0.82rem;
color: var(--color-text-muted);
margin: 0;
line-height: 1.5;
}
/* ---- Sessions ---- */
.sessions-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.session-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 12%, transparent);
}
.session-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.session-item.current {
background: none;
}
.session-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.session-ua {
font-size: 0.875rem;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-accent);
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
border-radius: 4px;
padding: 1px 6px;
width: fit-content;
}
.session-meta {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.terminate-btn {
flex-shrink: 0;
height: 28px;
padding: 0 12px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-danger) 50%, transparent);
background: none;
color: var(--color-danger);
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
}
.terminate-btn:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
}
.terminate-btn:disabled {
opacity: 0.45;
cursor: default;
}
</style>
+374
View File
@@ -0,0 +1,374 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api/client';
import { tagSorting, type TagSortField } from '$lib/stores/sorting';
import TagBadge from '$lib/components/tag/TagBadge.svelte';
import type { Tag, TagOffsetPage } from '$lib/api/types';
const LIMIT = 100;
const SORT_OPTIONS = [
{ value: 'name', label: 'Name' },
{ value: 'created', label: 'Created' },
{ value: 'color', label: 'Color' },
{ value: 'category_name', label: 'Category' },
];
let tags = $state<Tag[]>([]);
let total = $state(0);
let offset = $state(0);
let loading = $state(false);
let initialLoaded = $state(false); // true once first page loaded for current key
let error = $state('');
let search = $state('');
let searchDebounce: ReturnType<typeof setTimeout>;
let sortState = $derived($tagSorting);
// Reset + reload on sort or search change
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
let prevKey = $state('');
$effect(() => {
if (resetKey !== prevKey) {
prevKey = resetKey;
tags = [];
offset = 0;
total = 0;
initialLoaded = false;
}
});
// Trigger load after reset (only once per key)
$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<TagOffsetPage>(`/tags?${params}`);
tags = offset === 0 ? (page.items ?? []) : [...tags, ...(page.items ?? [])];
total = page.total ?? 0;
offset = tags.length;
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to load tags';
} finally {
loading = false;
initialLoaded = true;
}
}
function onSearch(e: Event) {
search = (e.currentTarget as HTMLInputElement).value;
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {}, 0); // reactive reset already handles it
}
let hasMore = $derived(tags.length < total);
</script>
<svelte:head>
<title>Tags | Tanabata</title>
</svelte:head>
<div class="page">
<header class="top-bar">
<h1 class="page-title">Tags</h1>
<div class="controls">
<select
class="sort-select"
value={sortState.sort}
onchange={(e) => tagSorting.setSort((e.currentTarget as HTMLSelectElement).value as TagSortField)}
>
{#each SORT_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<button
class="icon-btn"
onclick={() => tagSorting.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('/tags/new')}>+ New</button>
</div>
</header>
<div class="search-bar">
<div class="search-wrap">
<input
class="search-input"
type="search"
placeholder="Search tags…"
value={search}
oninput={onSearch}
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="tag-grid">
{#each tags as tag (tag.id)}
<TagBadge {tag} onclick={() => goto(`/tags/${tag.id}`)} />
{/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 && tags.length === 0}
<div class="empty">
{search ? 'No tags match your search.' : 'No tags yet.'}
{#if !search}
<a href="/tags/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);
}
.tag-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
}
.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>
+328
View File
@@ -0,0 +1,328 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { api, ApiError } from '$lib/api/client';
import type { Category, CategoryOffsetPage, Tag, TagRule } from '$lib/api/types';
import TagRuleEditor from '$lib/components/tag/TagRuleEditor.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
let tagId = $derived(page.params.id);
let tag = $state<Tag | null>(null);
let categories = $state<Category[]>([]);
let rules = $state<TagRule[]>([]);
let name = $state('');
let notes = $state('');
let color = $state('#444455');
let categoryId = $state('');
let isPublic = $state(false);
let saving = $state(false);
let deleting = $state(false);
let loadError = $state('');
let saveError = $state('');
let confirmDelete = $state(false);
let loaded = $state(false);
$effect(() => {
const id = tagId;
loaded = false;
loadError = '';
void Promise.all([
api.get<Tag>(`/tags/${id}`),
api.get<CategoryOffsetPage>('/categories?limit=200&sort=name&order=asc'),
api.get<TagRule[]>(`/tags/${id}/rules`).catch(() => [] as TagRule[]),
]).then(([t, cats, r]) => {
tag = t;
categories = cats.items ?? [];
rules = r;
name = t.name ?? '';
notes = t.notes ?? '';
color = t.color ? `#${t.color}` : '#444455';
categoryId = t.category_id ?? '';
isPublic = t.is_public ?? false;
loaded = true;
}).catch((e) => {
loadError = e instanceof ApiError ? e.message : 'Failed to load tag';
});
});
async function save() {
if (!name.trim() || saving) return;
saving = true;
saveError = '';
try {
await api.patch(`/tags/${tagId}`, {
name: name.trim(),
notes: notes.trim() || null,
color: color.slice(1),
category_id: categoryId || null,
is_public: isPublic,
});
goto('/tags');
} catch (e) {
saveError = e instanceof ApiError ? e.message : 'Failed to save tag';
} finally {
saving = false;
}
}
async function doDeleteTag() {
confirmDelete = false;
deleting = true;
try {
await api.delete(`/tags/${tagId}`);
goto('/tags');
} catch (e) {
saveError = e instanceof ApiError ? e.message : 'Failed to delete tag';
deleting = false;
}
}
</script>
<svelte:head>
<title>{tag?.name ?? 'Tag'} | Tanabata</title>
</svelte:head>
<div class="page">
<header class="top-bar">
<button class="back-btn" onclick={() => goto('/tags')} 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">{tag?.name ?? 'Tag'}</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="Tag 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="field">
<label class="label" for="category">Category</label>
<select id="category" class="input" bind:value={categoryId}>
<option value="">— None —</option>
{#each categories as cat (cat.id)}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</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>
<!-- Tag rules -->
<section class="section">
<h2 class="section-title">Implied tags</h2>
<TagRuleEditor {tagId} {rules} onRulesChange={(r) => (rules = r)} />
</section>
{/if}
</main>
</div>
{#if confirmDelete}
<ConfirmDialog
message={`Delete tag "${name}"? This cannot be undone.`}
confirmLabel="Delete"
danger
onConfirm={doDeleteTag}
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); }
select.input { cursor: pointer; color-scheme: dark; }
.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);
}
.error { color: var(--color-danger); font-size: 0.875rem; margin: 0; }
</style>
+221
View File
@@ -0,0 +1,221 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api/client';
import type { Category, CategoryOffsetPage } from '$lib/api/types';
let name = $state('');
let notes = $state('');
let color = $state('#444455');
let categoryId = $state('');
let isPublic = $state(false);
let saving = $state(false);
let error = $state('');
let categories = $state<Category[]>([]);
$effect(() => {
api.get<CategoryOffsetPage>('/categories?limit=200&sort=name&order=asc').then((p) => {
categories = p.items ?? [];
});
});
async function submit() {
if (!name.trim() || saving) return;
saving = true;
error = '';
try {
await api.post('/tags', {
name: name.trim(),
notes: notes.trim() || null,
color: color.slice(1), // strip #
category_id: categoryId || null,
is_public: isPublic,
});
goto('/tags');
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to create tag';
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>New Tag | Tanabata</title>
</svelte:head>
<div class="page">
<header class="top-bar">
<button class="back-btn" onclick={() => goto('/tags')} 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 Tag</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="Tag 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="field">
<label class="label" for="category">Category</label>
<select id="category" class="input" bind:value={categoryId}>
<option value="">— None —</option>
{#each categories as cat (cat.id)}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</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 tag'}
</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); }
select.input { cursor: pointer; color-scheme: dark; }
.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>
+68
View File
@@ -0,0 +1,68 @@
/// <reference types="@sveltejs/kit" />
/// <reference lib="webworker" />
import { build, files, version } from '$service-worker';
declare const self: ServiceWorkerGlobalScope;
// Cache name is versioned so a new deploy invalidates the old shell.
const CACHE = `app-shell-${version}`;
// App shell: all Vite-emitted JS/CSS chunks + static assets (fonts, icons, manifest).
const SHELL = [...build, ...files];
// ---- Install: pre-cache the app shell ----
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(SHELL))
);
// Activate immediately without waiting for old tabs to close.
self.skipWaiting();
});
// ---- 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)))
)
);
self.clients.claim();
});
// ---- Fetch: cache-first for shell assets, network-only for API ----
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Only handle same-origin GET requests.
if (request.method !== 'GET' || url.origin !== self.location.origin) return;
// API and authentication calls must always go to the network.
if (url.pathname.startsWith('/api/')) return;
event.respondWith(respond(request));
});
async function respond(request: Request): Promise<Response> {
const cache = await caches.open(CACHE);
// Shell assets are pre-cached — serve from cache immediately.
const cached = await cache.match(request);
if (cached) return cached;
// Everything else (navigation, dynamic routes): network first.
try {
const response = await fetch(request);
// Cache successful responses for navigation so the app works offline.
if (response.status === 200) {
cache.put(request, response.clone());
}
return response;
} catch {
// Offline fallback: return the cached SPA shell for navigation requests.
const fallback = await cache.match('/');
if (fallback) return fallback;
return new Response('Offline', { status: 503 });
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="/images/ms-icon-70x70.png" />
<square150x150logo src="/images/ms-icon-150x150.png" />
<square310x310logo src="/images/ms-icon-310x310.png" />
<TileColor>#615880</TileColor>
</tile>
</msapplication>
</browserconfig>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

+55
View File
@@ -0,0 +1,55 @@
{
"name": "Tanabata File Manager",
"short_name": "Tanabata",
"lang": "en-US",
"description": "Multi-user tag-based file manager",
"start_url": "/files",
"scope": "/",
"display": "standalone",
"background_color": "#312F45",
"theme_color": "#312F45",
"icons": [
{
"src": "/images/android-icon-36x36.png",
"sizes": "36x36",
"type": "image/png"
},
{
"src": "/images/android-icon-48x48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "/images/android-icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/images/android-icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/images/android-icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/images/android-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/apple-icon-180x180.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/images/ms-icon-310x310.png",
"sizes": "310x310",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+869 -6
View File
@@ -50,6 +50,56 @@ const ME = {
is_blocked: false,
};
type MockUser = {
id: number;
name: string;
is_admin: boolean;
can_create: boolean;
is_blocked: boolean;
};
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 },
];
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',
];
const AUDIT_OBJECT_TYPES = ['file', 'tag', 'pool', 'category'];
type MockAuditEntry = {
id: number;
user_id: number;
user_name: string;
action: string;
object_type: string | null;
object_id: string | null;
details: Record<string, unknown> | null;
performed_at: string;
};
const mockAuditLog: MockAuditEntry[] = Array.from({ length: 80 }, (_, i) => {
const user = mockUsersArr[i % mockUsersArr.length];
const action = AUDIT_ACTIONS[i % AUDIT_ACTIONS.length];
const objType = AUDIT_OBJECT_TYPES[i % AUDIT_OBJECT_TYPES.length];
return {
id: i + 1,
user_id: user.id,
user_name: user.name,
action,
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(),
};
});
const THUMB_COLORS = [
'#9592B5', '#4DC7ED', '#DB6060', '#F5E872', '#7ECBA1',
'#E08C5A', '#A67CB8', '#5A9ED4', '#C4A44A', '#6DB89E',
@@ -64,6 +114,31 @@ function mockThumbSvg(id: string): string {
</svg>`;
}
// 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 mi = i % mimes.length;
const id = `00000000-0000-7000-8000-trash${String(i + 1).padStart(7, '0')}`;
return {
id,
original_name: `deleted-${String(i + 1).padStart(3, '0')}.${exts[mi]}`,
mime_type: mimes[mi],
mime_extension: exts[mi],
content_datetime: new Date(Date.now() - (i + 80) * 3_600_000).toISOString(),
notes: null,
metadata: null,
exif: {},
phash: null,
creator_id: 1,
creator_name: 'admin',
is_public: false,
is_deleted: true,
created_at: new Date(Date.now() - (i + 80) * 3_600_000).toISOString(),
position: 0,
};
});
const MOCK_FILES = Array.from({ length: 75 }, (_, i) => {
const mimes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'];
const exts = ['jpg', 'png', 'webp', 'mp4' ];
@@ -87,6 +162,164 @@ const MOCK_FILES = Array.from({ length: 75 }, (_, i) => {
};
});
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',
];
const TAG_COLORS = [
'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() },
];
// Assign some tags to categories
const CATEGORY_ASSIGNMENTS: Record<string, string> = {};
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))
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))
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))
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))
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[4].id; // Color
});
function getCategoryForId(catId: string | null) {
if (!catId) return null;
return MOCK_CATEGORIES.find((c) => c.id === catId) ?? null;
}
type MockTag = {
id: string;
name: string;
color: string;
notes: string | null;
category_id: string | null;
category_name: string | null;
category_color: string | null;
is_public: boolean;
created_at: string;
};
const mockTagsArr: MockTag[] = TAG_NAMES.map((name, i) => {
const catId = CATEGORY_ASSIGNMENTS[name] ?? null;
const cat = getCategoryForId(catId);
return {
id: `00000000-0000-7000-8001-${String(i + 1).padStart(12, '0')}`,
name,
color: TAG_COLORS[i % TAG_COLORS.length],
notes: null,
category_id: catId,
category_name: cat?.name ?? null,
category_color: cat?.color ?? null,
is_public: false,
created_at: new Date(Date.now() - i * 3_600_000).toISOString(),
};
});
// Backwards-compatible reference for existing file-tag lookups
const MOCK_TAGS = mockTagsArr;
// Tag rules: Map<tagId, Map<thenTagId, is_active>>
const tagRules = new Map<string, Map<string, boolean>>();
// Mutable in-memory state for file metadata and tags
const fileOverrides = new Map<string, Partial<typeof MOCK_FILES[0]>>();
const fileTags = new Map<string, Set<string>>(); // fileId → Set<tagId>
type MockPool = {
id: string;
name: string;
notes: string | null;
is_public: boolean;
file_count: number;
creator_id: number;
creator_name: string;
created_at: string;
};
type PoolFile = {
id: string;
original_name: string;
mime_type: string;
mime_extension: string;
content_datetime: string;
notes: string | null;
metadata: null;
exif: Record<string, unknown>;
phash: null;
creator_id: number;
creator_name: string;
is_public: boolean;
is_deleted: boolean;
created_at: string;
position: number;
};
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() },
];
// Pool files: Map<poolId, PoolFile[]> ordered by position
const poolFilesMap = new Map<string, PoolFile[]>();
// 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 }));
poolFilesMap.set(mockPoolsArr[0].id, p1Files);
poolFilesMap.set(mockPoolsArr[1].id, p2Files);
mockPoolsArr[0].file_count = p1Files.length;
mockPoolsArr[1].file_count = p2Files.length;
}
seedPoolFiles();
function getMockFile(id: string) {
const base = MOCK_FILES.find((f) => f.id === id);
if (!base) return null;
return { ...base, ...(fileOverrides.get(id) ?? {}) };
}
export function mockApiPlugin(): Plugin {
return {
name: 'mock-api',
@@ -142,6 +375,19 @@ export function mockApiPlugin(): Plugin {
return json(res, 200, ME);
}
// PATCH /users/me
if (method === 'PATCH' && path === '/users/me') {
const body = (await readBody(req)) as { name?: string; password?: string };
if (body.name) ME.name = body.name;
return json(res, 200, ME);
}
// DELETE /auth/sessions/{id}
const sessionDelMatch = path.match(/^\/auth\/sessions\/(\d+)$/);
if (method === 'DELETE' && sessionDelMatch) {
return noContent(res);
}
// GET /files/{id}/thumbnail
const thumbMatch = path.match(/^\/files\/([^/]+)\/thumbnail$/);
if (method === 'GET' && thumbMatch) {
@@ -150,33 +396,650 @@ export function mockApiPlugin(): Plugin {
return res.end(svg);
}
// GET /files (cursor pagination — page through MOCK_FILES in chunks of 50)
// GET /files/{id}/preview (same SVG, just bigger)
const previewMatch = path.match(/^\/files\/([^/]+)\/preview$/);
if (method === 'GET' && previewMatch) {
const id = previewMatch[1];
const color = THUMB_COLORS[id.charCodeAt(id.length - 1) % THUMB_COLORS.length];
const label = id.slice(-4);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600">
<rect width="800" height="600" fill="${color}"/>
<text x="400" y="315" text-anchor="middle" font-family="monospace" font-size="48" fill="rgba(0,0,0,0.35)">${label}</text>
</svg>`;
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(svg) });
return res.end(svg);
}
// GET /files/{id}/tags
const fileTagsGetMatch = path.match(/^\/files\/([^/]+)\/tags$/);
if (method === 'GET' && fileTagsGetMatch) {
const ids = fileTags.get(fileTagsGetMatch[1]) ?? new Set<string>();
return json(res, 200, MOCK_TAGS.filter((t) => ids.has(t.id)));
}
// PUT /files/{id}/tags/{tag_id} — add tag
const fileTagPutMatch = path.match(/^\/files\/([^/]+)\/tags\/([^/]+)$/);
if (method === 'PUT' && fileTagPutMatch) {
const [, fid, tid] = fileTagPutMatch;
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)));
}
// DELETE /files/{id}/tags/{tag_id} — remove tag
const fileTagDelMatch = path.match(/^\/files\/([^/]+)\/tags\/([^/]+)$/);
if (method === 'DELETE' && fileTagDelMatch) {
const [, fid, tid] = fileTagDelMatch;
fileTags.get(fid)?.delete(tid);
return noContent(res);
}
// GET /files/{id} — single file
const fileGetMatch = path.match(/^\/files\/([^/]+)$/);
if (method === 'GET' && fileGetMatch) {
const f = getMockFile(fileGetMatch[1]);
if (!f) return json(res, 404, { code: 'not_found', message: 'File not found' });
return json(res, 200, f);
}
// PATCH /files/{id} — update metadata
const filePatchMatch = path.match(/^\/files\/([^/]+)$/);
if (method === 'PATCH' && filePatchMatch) {
const id = filePatchMatch[1];
const base = getMockFile(id);
if (!base) return json(res, 404, { code: 'not_found', message: 'File not found' });
const body = (await readBody(req)) as Record<string, unknown>;
fileOverrides.set(id, { ...(fileOverrides.get(id) ?? {}), ...body });
return json(res, 200, getMockFile(id));
}
// POST /files/bulk/common-tags
if (method === 'POST' && path === '/files/bulk/common-tags') {
const body = (await readBody(req)) as { file_ids?: string[] };
const ids = body.file_ids ?? [];
if (ids.length === 0) return json(res, 200, { common_tag_ids: [], partial_tag_ids: [] });
const sets = ids.map((fid) => fileTags.get(fid) ?? new Set<string>());
const allTagIds = new Set<string>();
sets.forEach((s) => s.forEach((t) => allTagIds.add(t)));
const common: string[] = [];
const partial: string[] = [];
allTagIds.forEach((tid) => {
if (sets.every((s) => s.has(tid))) common.push(tid);
else partial.push(tid);
});
return json(res, 200, { common_tag_ids: common, partial_tag_ids: partial });
}
// 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 fileIds = body.file_ids ?? [];
const tagIds = body.tag_ids ?? [];
const action = body.action ?? 'add';
for (const fid of fileIds) {
if (!fileTags.has(fid)) fileTags.set(fid, new Set());
const set = fileTags.get(fid)!;
for (const tid of tagIds) {
if (action === 'add') set.add(tid);
else set.delete(tid);
}
}
return json(res, 200, { applied_tag_ids: action === 'add' ? tagIds : [] });
}
// POST /files/bulk/delete — soft delete (move to trash)
if (method === 'POST' && path === '/files/bulk/delete') {
const body = (await readBody(req)) as { file_ids?: string[] };
const ids = new Set(body.file_ids ?? []);
for (let i = MOCK_FILES.length - 1; i >= 0; i--) {
if (ids.has(MOCK_FILES[i].id)) {
const [f] = MOCK_FILES.splice(i, 1);
MOCK_TRASH.unshift({ ...f, is_deleted: true });
}
}
return noContent(res);
}
// POST /files/{id}/restore
const fileRestoreMatch = path.match(/^\/files\/([^/]+)\/restore$/);
if (method === 'POST' && fileRestoreMatch) {
const id = fileRestoreMatch[1];
const idx = MOCK_TRASH.findIndex((f) => f.id === id);
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'File not in trash' });
const [f] = MOCK_TRASH.splice(idx, 1);
const restored = { ...f, is_deleted: false };
MOCK_FILES.unshift(restored);
fileOverrides.delete(id);
return json(res, 200, restored);
}
// DELETE /files/{id}/permanent
const filePermMatch = path.match(/^\/files\/([^/]+)\/permanent$/);
if (method === 'DELETE' && filePermMatch) {
const id = filePermMatch[1];
const idx = MOCK_TRASH.findIndex((f) => f.id === id);
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'File not in trash' });
MOCK_TRASH.splice(idx, 1);
fileOverrides.delete(id);
return noContent(res);
}
// POST /files — upload (mock: drain body, return a new fake file)
if (method === 'POST' && path === '/files') {
// Drain the multipart body without parsing it
await new Promise<void>((resolve) => {
req.on('data', () => {});
req.on('end', resolve);
});
const idx = MOCK_FILES.length;
const id = `00000000-0000-7000-8000-${String(Date.now()).slice(-12)}`;
const ct = req.headers['content-type'] ?? '';
// Extract filename from Content-Disposition if present (best-effort)
const nameMatch = ct.match(/name="([^"]+)"/);
const newFile = {
id,
original_name: nameMatch ? nameMatch[1] : `upload-${idx + 1}.jpg`,
mime_type: 'image/jpeg',
mime_extension: 'jpg',
content_datetime: new Date().toISOString(),
notes: null,
metadata: null,
exif: {},
phash: null,
creator_id: 1,
creator_name: 'admin',
is_public: false,
is_deleted: false,
created_at: new Date().toISOString(),
};
MOCK_FILES.unshift(newFile);
return json(res, 201, newFile);
}
// GET /files (cursor pagination + anchor support + trash)
if (method === 'GET' && path === '/files') {
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const trashMode = qs.get('trash') === 'true';
if (trashMode) {
const cursor = qs.get('cursor');
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
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;
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
}
const anchor = qs.get('anchor');
const cursor = qs.get('cursor');
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
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' });
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;
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;
? Buffer.from(String(nextOffset)).toString('base64') : null;
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
}
// GET /tags/{id}/rules
const tagRulesGetMatch = path.match(/^\/tags\/([^/]+)\/rules$/);
if (method === 'GET' && tagRulesGetMatch) {
const tid = tagRulesGetMatch[1];
const ruleMap = tagRules.get(tid) ?? new Map<string, boolean>();
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 json(res, 200, items);
}
// POST /tags/{id}/rules
const tagRulesPostMatch = path.match(/^\/tags\/([^/]+)\/rules$/);
if (method === 'POST' && tagRulesPostMatch) {
const tid = tagRulesPostMatch[1];
const body = (await readBody(req)) as Record<string, unknown>;
const thenId = body.then_tag_id as string;
const isActive = body.is_active !== false;
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 });
}
// PATCH /tags/{id}/rules/{then_id} — activate / deactivate
const tagRulesPatchMatch = path.match(/^\/tags\/([^/]+)\/rules\/([^/]+)$/);
if (method === 'PATCH' && tagRulesPatchMatch) {
const [, tid, thenId] = tagRulesPatchMatch;
const body = (await readBody(req)) as Record<string, unknown>;
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' });
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 });
}
// DELETE /tags/{id}/rules/{then_id}
const tagRulesDelMatch = path.match(/^\/tags\/([^/]+)\/rules\/([^/]+)$/);
if (method === 'DELETE' && tagRulesDelMatch) {
const [, tid, thenId] = tagRulesDelMatch;
tagRules.get(tid)?.delete(thenId);
return noContent(res);
}
// GET /tags/{id}
const tagGetMatch = path.match(/^\/tags\/([^/]+)$/);
if (method === 'GET' && tagGetMatch) {
const t = MOCK_TAGS.find((x) => x.id === tagGetMatch[1]);
if (!t) return json(res, 404, { code: 'not_found', message: 'Tag not found' });
return json(res, 200, t);
}
// PATCH /tags/{id}
const tagPatchMatch = path.match(/^\/tags\/([^/]+)$/);
if (method === 'PATCH' && tagPatchMatch) {
const idx = MOCK_TAGS.findIndex((x) => x.id === tagPatchMatch[1]);
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'Tag not found' });
const body = (await readBody(req)) as Partial<MockTag>;
const catId = body.category_id ?? MOCK_TAGS[idx].category_id;
const cat = getCategoryForId(catId);
Object.assign(MOCK_TAGS[idx], {
...body,
category_name: cat?.name ?? null,
category_color: cat?.color ?? null,
});
return json(res, 200, MOCK_TAGS[idx]);
}
// DELETE /tags/{id}
const tagDelMatch = path.match(/^\/tags\/([^/]+)$/);
if (method === 'DELETE' && tagDelMatch) {
const idx = MOCK_TAGS.findIndex((x) => x.id === tagDelMatch[1]);
if (idx >= 0) MOCK_TAGS.splice(idx, 1);
return noContent(res);
}
// GET /tags
if (method === 'GET' && path === '/tags') {
return json(res, 200, { items: [], total: 0, offset: 0, limit: 50 });
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const search = qs.get('search')?.toLowerCase() ?? '';
const sort = qs.get('sort') ?? 'name';
const order = qs.get('order') ?? 'asc';
const limit = Math.min(Number(qs.get('limit') ?? 100), 500);
const offset = Number(qs.get('offset') ?? 0);
let filtered = search
? MOCK_TAGS.filter((t) => t.name.toLowerCase().includes(search))
: [...MOCK_TAGS];
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; }
const cmp = av.localeCompare(bv);
return order === 'desc' ? -cmp : cmp;
});
const items = filtered.slice(offset, offset + limit);
return json(res, 200, { items, total: filtered.length, offset, limit });
}
// POST /tags
if (method === 'POST' && path === '/tags') {
const body = (await readBody(req)) as Partial<MockTag>;
const catId = body.category_id ?? null;
const cat = getCategoryForId(catId);
const newTag: MockTag = {
id: `00000000-0000-7000-8001-${String(Date.now()).slice(-12)}`,
name: body.name ?? 'Unnamed',
color: body.color ?? '444455',
notes: body.notes ?? null,
category_id: catId,
category_name: cat?.name ?? null,
category_color: cat?.color ?? null,
is_public: body.is_public ?? false,
created_at: new Date().toISOString(),
};
MOCK_TAGS.unshift(newTag);
return json(res, 201, newTag);
}
// GET /categories/{id}/tags
const catTagsMatch = path.match(/^\/categories\/([^/]+)\/tags$/);
if (method === 'GET' && catTagsMatch) {
const catId = catTagsMatch[1];
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const limit = Math.min(Number(qs.get('limit') ?? 100), 500);
const offset = Number(qs.get('offset') ?? 0);
const all = MOCK_TAGS.filter((t) => t.category_id === catId);
all.sort((a, b) => a.name.localeCompare(b.name));
const items = all.slice(offset, offset + limit);
return json(res, 200, { items, total: all.length, offset, limit });
}
// GET /categories/{id}
const catGetMatch = path.match(/^\/categories\/([^/]+)$/);
if (method === 'GET' && catGetMatch) {
const cat = MOCK_CATEGORIES.find((c) => c.id === catGetMatch[1]);
if (!cat) return json(res, 404, { code: 'not_found', message: 'Category not found' });
return json(res, 200, cat);
}
// PATCH /categories/{id}
const catPatchMatch = path.match(/^\/categories\/([^/]+)$/);
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<typeof MOCK_CATEGORIES[0]>;
Object.assign(MOCK_CATEGORIES[idx], body);
// Sync category_name/color on affected tags
const cat = MOCK_CATEGORIES[idx];
for (const t of MOCK_TAGS) {
if (t.category_id === cat.id) {
t.category_name = cat.name;
t.category_color = cat.color;
}
}
return json(res, 200, MOCK_CATEGORIES[idx]);
}
// DELETE /categories/{id}
const catDelMatch = path.match(/^\/categories\/([^/]+)$/);
if (method === 'DELETE' && catDelMatch) {
const idx = MOCK_CATEGORIES.findIndex((c) => c.id === catDelMatch[1]);
if (idx >= 0) {
const catId = MOCK_CATEGORIES[idx].id;
MOCK_CATEGORIES.splice(idx, 1);
for (const t of MOCK_TAGS) {
if (t.category_id === catId) {
t.category_id = null;
t.category_name = null;
t.category_color = null;
}
}
}
return noContent(res);
}
// GET /categories
if (method === 'GET' && path === '/categories') {
return json(res, 200, { items: [], total: 0, offset: 0, limit: 50 });
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const search = qs.get('search')?.toLowerCase() ?? '';
const sort = qs.get('sort') ?? 'name';
const order = qs.get('order') ?? 'asc';
const limit = Math.min(Number(qs.get('limit') ?? 50), 500);
const offset = Number(qs.get('offset') ?? 0);
let filtered = search
? MOCK_CATEGORIES.filter((c) => c.name.toLowerCase().includes(search))
: [...MOCK_CATEGORIES];
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; }
const cmp = av.localeCompare(bv);
return order === 'desc' ? -cmp : cmp;
});
const items = filtered.slice(offset, offset + limit);
return json(res, 200, { items, total: filtered.length, offset, limit });
}
// POST /categories
if (method === 'POST' && path === '/categories') {
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(),
};
MOCK_CATEGORIES.unshift(newCat);
return json(res, 201, newCat);
}
// GET /pools/{id}/files
const poolFilesGetMatch = path.match(/^\/pools\/([^/]+)\/files$/);
if (method === 'GET' && poolFilesGetMatch) {
const pid = poolFilesGetMatch[1];
if (!mockPoolsArr.find((p) => p.id === pid))
return json(res, 404, { code: 'not_found', message: 'Pool not found' });
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
const cursor = qs.get('cursor');
const files = poolFilesMap.get(pid) ?? [];
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;
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
}
// POST /pools/{id}/files/remove
const poolFilesRemoveMatch = path.match(/^\/pools\/([^/]+)\/files\/remove$/);
if (method === 'POST' && poolFilesRemoveMatch) {
const pid = poolFilesRemoveMatch[1];
const pool = mockPoolsArr.find((p) => p.id === pid);
if (!pool) return json(res, 404, { code: 'not_found', message: 'Pool not found' });
const body = (await readBody(req)) as { file_ids?: string[] };
const toRemove = new Set(body.file_ids ?? []);
const files = poolFilesMap.get(pid) ?? [];
const updated = files.filter((f) => !toRemove.has(f.id));
// Reassign positions
updated.forEach((f, i) => { f.position = i + 1; });
poolFilesMap.set(pid, updated);
pool.file_count = updated.length;
return noContent(res);
}
// PUT /pools/{id}/files/reorder
const poolReorderMatch = path.match(/^\/pools\/([^/]+)\/files\/reorder$/);
if (method === 'PUT' && poolReorderMatch) {
const pid = poolReorderMatch[1];
const pool = mockPoolsArr.find((p) => p.id === pid);
if (!pool) return json(res, 404, { code: 'not_found', message: 'Pool not found' });
const body = (await readBody(req)) as { file_ids?: string[] };
const order = body.file_ids ?? [];
const files = poolFilesMap.get(pid) ?? [];
const byId = new Map(files.map((f) => [f.id, f]));
const reordered: PoolFile[] = [];
for (const id of order) {
const f = byId.get(id);
if (f) reordered.push(f);
}
reordered.forEach((f, i) => { f.position = i + 1; });
poolFilesMap.set(pid, reordered);
return noContent(res);
}
// POST /pools/{id}/files — add files
const poolFilesAddMatch = path.match(/^\/pools\/([^/]+)\/files$/);
if (method === 'POST' && poolFilesAddMatch) {
const pid = poolFilesAddMatch[1];
const pool = mockPoolsArr.find((p) => p.id === pid);
if (!pool) return json(res, 404, { code: 'not_found', message: 'Pool not found' });
const body = (await readBody(req)) as { file_ids?: string[] };
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 ?? [])) {
if (existing.has(fid)) continue;
const base = MOCK_FILES.find((f) => f.id === fid);
if (!base) continue;
pos++;
files.push({ ...base, metadata: null, exif: {}, phash: null, position: pos });
existing.add(fid);
}
poolFilesMap.set(pid, files);
pool.file_count = files.length;
return noContent(res);
}
// GET /pools/{id}
const poolGetMatch = path.match(/^\/pools\/([^/]+)$/);
if (method === 'GET' && poolGetMatch) {
const pool = mockPoolsArr.find((p) => p.id === poolGetMatch[1]);
if (!pool) return json(res, 404, { code: 'not_found', message: 'Pool not found' });
return json(res, 200, pool);
}
// PATCH /pools/{id}
const poolPatchMatch = path.match(/^\/pools\/([^/]+)$/);
if (method === 'PATCH' && poolPatchMatch) {
const pool = mockPoolsArr.find((p) => p.id === poolPatchMatch[1]);
if (!pool) return json(res, 404, { code: 'not_found', message: 'Pool not found' });
const body = (await readBody(req)) as Partial<MockPool>;
Object.assign(pool, body);
return json(res, 200, pool);
}
// DELETE /pools/{id}
const poolDelMatch = path.match(/^\/pools\/([^/]+)$/);
if (method === 'DELETE' && poolDelMatch) {
const idx = mockPoolsArr.findIndex((p) => p.id === poolDelMatch[1]);
if (idx >= 0) {
poolFilesMap.delete(mockPoolsArr[idx].id);
mockPoolsArr.splice(idx, 1);
}
return noContent(res);
}
// GET /pools
if (method === 'GET' && path === '/pools') {
return json(res, 200, { items: [], total: 0, offset: 0, limit: 50 });
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const search = qs.get('search')?.toLowerCase() ?? '';
const sort = qs.get('sort') ?? 'created';
const order = qs.get('order') ?? 'desc';
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
const offset = Number(qs.get('offset') ?? 0);
let filtered = search
? mockPoolsArr.filter((p) => p.name.toLowerCase().includes(search))
: [...mockPoolsArr];
filtered.sort((a, b) => {
const av = sort === 'name' ? a.name : a.created_at;
const bv = sort === 'name' ? b.name : b.created_at;
const cmp = av.localeCompare(bv);
return order === 'desc' ? -cmp : cmp;
});
const items = filtered.slice(offset, offset + limit);
return json(res, 200, { items, total: filtered.length, offset, limit });
}
// POST /pools
if (method === 'POST' && path === '/pools') {
const body = (await readBody(req)) as Partial<MockPool>;
const newPool: MockPool = {
id: `00000000-0000-7000-8003-${String(Date.now()).slice(-12)}`,
name: body.name ?? 'Unnamed',
notes: body.notes ?? null,
is_public: body.is_public ?? false,
file_count: 0,
creator_id: 1,
creator_name: 'admin',
created_at: new Date().toISOString(),
};
mockPoolsArr.unshift(newPool);
return json(res, 201, newPool);
}
// GET /users/{id}
const userGetMatch = path.match(/^\/users\/(\d+)$/);
if (method === 'GET' && userGetMatch) {
const u = mockUsersArr.find((x) => x.id === Number(userGetMatch[1]));
if (!u) return json(res, 404, { code: 'not_found', message: 'User not found' });
return json(res, 200, u);
}
// PATCH /users/{id}
const userPatchMatch = path.match(/^\/users\/(\d+)$/);
if (method === 'PATCH' && userPatchMatch) {
const u = mockUsersArr.find((x) => x.id === Number(userPatchMatch[1]));
if (!u) return json(res, 404, { code: 'not_found', message: 'User not found' });
const body = (await readBody(req)) as Partial<MockUser>;
Object.assign(u, body);
return json(res, 200, u);
}
// DELETE /users/{id}
const userDelMatch = path.match(/^\/users\/(\d+)$/);
if (method === 'DELETE' && userDelMatch) {
const idx = mockUsersArr.findIndex((x) => x.id === Number(userDelMatch[1]));
if (idx >= 0) mockUsersArr.splice(idx, 1);
return noContent(res);
}
// GET /users
if (method === 'GET' && path === '/users') {
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
const offset = Number(qs.get('offset') ?? 0);
const items = mockUsersArr.slice(offset, offset + limit);
return json(res, 200, { items, total: mockUsersArr.length, offset, limit });
}
// POST /users
if (method === 'POST' && path === '/users') {
const body = (await readBody(req)) as Partial<MockUser> & { password?: string };
const newUser: MockUser = {
id: Math.max(...mockUsersArr.map((u) => u.id)) + 1,
name: body.name ?? 'unnamed',
is_admin: body.is_admin ?? false,
can_create: body.can_create ?? false,
is_blocked: false,
};
mockUsersArr.push(newUser);
return json(res, 201, newUser);
}
// GET /audit
if (method === 'GET' && path === '/audit') {
const qs = new URLSearchParams(url.split('?')[1] ?? '');
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
const offset = Number(qs.get('offset') ?? 0);
const filterUserId = qs.get('user_id') ? Number(qs.get('user_id')) : null;
const filterAction = qs.get('action') ?? '';
const filterObjectType = qs.get('object_type') ?? '';
const filterObjectId = qs.get('object_id') ?? '';
const filterFrom = qs.get('from') ? new Date(qs.get('from')!).getTime() : null;
const filterTo = qs.get('to') ? new Date(qs.get('to')!).getTime() : null;
let filtered = mockAuditLog.filter((e) => {
if (filterUserId !== null && e.user_id !== filterUserId) return false;
if (filterAction && e.action !== filterAction) return false;
if (filterObjectType && e.object_type !== filterObjectType) return false;
if (filterObjectId && e.object_id !== filterObjectId) return false;
const t = new Date(e.performed_at).getTime();
if (filterFrom !== null && t < filterFrom) return false;
if (filterTo !== null && t > filterTo) return false;
return true;
});
const items = filtered.slice(offset, offset + limit);
return json(res, 200, { items, total: filtered.length, offset, limit });
}
// Fallback: 404
+35
View File
@@ -825,6 +825,41 @@ paths:
$ref: '#/components/schemas/TagRule'
/tags/{tag_id}/rules/{then_tag_id}:
patch:
tags: [Tags]
summary: Update a tag rule (activate / deactivate)
parameters:
- $ref: '#/components/parameters/tag_id'
- name: then_tag_id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [is_active]
properties:
is_active:
type: boolean
apply_to_existing:
type: boolean
default: false
description: When activating, apply rule retroactively to files already tagged
responses:
'200':
description: Rule updated
content:
application/json:
schema:
$ref: '#/components/schemas/TagRule'
'404':
$ref: '#/components/responses/NotFound'
delete:
tags: [Tags]
summary: Remove a tag rule