diff --git a/CLAUDE.md b/CLAUDE.md index 888b5d8..000a252 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,16 +13,18 @@ Monorepo: `backend/` (Go) + `frontend/` (SvelteKit). ## Key documents (read before coding) -- `openapi.yaml` — full REST API specification (36 paths, 58 operations) +- `openapi.yaml` — full REST API specification (44 paths, 67 operations) +- `docs/REQUIREMENTS.md` — product requirements (functional + non-functional) +- `docs/ARCHITECTURE.md` — system architecture overview (layers, data flow, decisions) - `docs/GO_PROJECT_STRUCTURE.md` — backend architecture, layer rules, DI pattern - `docs/FRONTEND_STRUCTURE.md` — frontend architecture, CSS approach, API client -- `docs/Описание.md` — product requirements in Russian -- `backend/migrations/001_init.sql` — database schema (4 schemas, 16 tables) +- `backend/migrations/` — database schema as goose migrations (4 schemas, 19 tables) ## Design reference Visual design tokens for the frontend (carried over from the previous Python/Flask version): + - Color palette: #312F45 (bg), #9592B5 (accent), #444455 (tag default), #111118 (elevated) - Font: Epilogue (variable weight) - Dark theme is primary @@ -32,6 +34,7 @@ Python/Flask version): - Floating selection bar for multi-select ## Backend commands + ```bash cd backend go run ./cmd/server # run dev server @@ -39,6 +42,7 @@ go test ./... # run all tests ``` ## Frontend commands + ```bash cd frontend npm run dev # vite dev server diff --git a/README.md b/README.md index 0e15cbc..4a2e35d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ built SPA on one port. ## Documentation - [`openapi.yaml`](openapi.yaml) — full REST API specification +- [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md) — product requirements +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — system architecture overview - [`docs/DEPLOY.md`](docs/DEPLOY.md) — production deploy (Gitea Actions → host) - [`docs/GO_PROJECT_STRUCTURE.md`](docs/GO_PROJECT_STRUCTURE.md) — backend architecture - [`docs/FRONTEND_STRUCTURE.md`](docs/FRONTEND_STRUCTURE.md) — frontend architecture diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6d5b3ed --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,180 @@ +# Tanabata File Manager — Architecture + +System-level overview of Tanabata File Manager (TFM). For the product-level +requirements see [REQUIREMENTS.md](REQUIREMENTS.md); for per-side detail see +[GO_PROJECT_STRUCTURE.md](GO_PROJECT_STRUCTURE.md) (backend) and +[FRONTEND_STRUCTURE.md](FRONTEND_STRUCTURE.md) (frontend). The full HTTP contract +lives in [`openapi.yaml`](../openapi.yaml). + +## System Context + +TFM is a multi-user, tag-based web file manager for images and video. It is a +single deployable unit — one Docker image that serves both the REST API and the +built single-page app on one port — plus a PostgreSQL database. + +``` + ┌───────────────────────────────────────────┐ + Browser / installed │ Reverse proxy (nginx, TLS) │ + PWA (desktop/mobile) │ host: 443 → 127.0.0.1:${APP_PORT} │ + │ HTTPS └─────────────────────┬─────────────────────┘ + └─────────────────────────────────────┼──────────────► 127.0.0.1:42776 + │ + ┌─────────────────────────▼────────────────────────┐ + │ Tanabata container (single image) │ + │ │ + │ Go server (Gin) │ + │ ├─ /api/v1/* REST API │ + │ ├─ /health liveness │ + │ └─ /* static SPA + index.html │ + │ fallback │ + │ │ + │ Disk: /data/files (originals, name = UUID) │ + │ /data/thumbs (thumbnail/preview cache) │ + │ /data/import (server-side import drop) │ + └─────────────────────────┬────────────────────────┘ + │ pgx (private network) + ┌─────────▼─────────┐ + │ PostgreSQL 14+ │ + │ (bundled or host)│ + └───────────────────┘ +``` + +Optional companion process: a one-shot **dedup CLI** (same image, different +entrypoint) that backfills perceptual hashes and rebuilds the duplicate-pairs +table. It is not a daemon — it is run on demand. + +## Components + +| Component | Tech | Responsibility | +| -------------- | -------------------------------------------------------------------------- | -------------------------------------------------------- | +| Frontend (SPA) | SvelteKit (adapter-static, `ssr=false`), Svelte 5, Tailwind v4, TypeScript | UI, client routing, PWA/offline, calls the REST API | +| API server | Go + Gin, Clean Architecture | REST API, auth, ACL, business logic, thumbnailing, audit | +| Database | PostgreSQL 14+ (pgx v5, goose) | All structured data across 4 schemas / 19 tables | +| File storage | Local disk, flat, keyed by UUID | Originals + a regenerable thumbnail/preview cache | +| dedup CLI | Go (same image) | Offline perceptual-hash backfill + pairs rescan | +| Reverse proxy | nginx (host, not shipped) | TLS termination, large-body/streaming config | + +## Backend Architecture (Clean Architecture) + +Dependencies point inward; no layer imports a layer above it. + +``` +handler → service → port (interfaces) ← db/postgres, storage, imagehash + ↓ + domain (entities, value objects, errors) — stdlib only +``` + +- **domain** — entities and errors, zero internal imports. +- **port** — interfaces (repositories, `FileStorage`, `Transactor`). +- **service** — use cases; the only place business rules live. +- **handler** — Gin HTTP layer; maps domain errors to HTTP status codes. +- **db/postgres**, **storage**, **imagehash** — adapters implementing the ports. + +Wiring is manual in `cmd/server/main.go` (no DI framework). See +[GO_PROJECT_STRUCTURE.md](GO_PROJECT_STRUCTURE.md) for the file-by-file layout, +the transaction/context patterns, and the DI sketch. + +## Request Flow (typical authenticated call) + +1. The SPA sends `Authorization: Bearer ` to `/api/v1/...`. +2. Gin middleware runs: security headers → (for `/auth`) per-IP rate limiter → + auth middleware validates the JWT and puts `(userID, isAdmin, sessionID)` + into the request context. +3. The handler parses/validates input and calls a service method + (`ctx` first arg). +4. The service enforces ACL via `ACLService`, performs the use case — composing + repository calls inside a `Transactor.WithTx` when several writes must be + atomic — and writes an audit entry. +5. Repositories run SQL through pgx (pool or the tx carried in `ctx`). +6. The handler serializes the result; domain errors are mapped to + `{ code, message, details? }` with the right HTTP status. + +## Cross-Cutting Concerns + +### Authentication & sessions + +JWT bearer auth. A short-lived **access token** (15 min default) authorizes API +calls; a long-lived **refresh token** (30 days default) rotates on use and is +stored as a hash in `activity.sessions`. A separate **content token** (6 h +default) is a single-file capability embedded in media URLs so long video keeps +streaming past access-token expiry. The `/auth` endpoints are rate-limited per +client IP. + +### Authorization (ACL) + +Private-by-default. Admins see everything; otherwise access requires a `public` +flag, creator ownership, or an explicit grant in `acl.permissions` (read / edit). +All checks are centralized in `ACLService` and applied before reads and writes. + +### File storage + +Originals are stored flat under `FILES_PATH`, each named by its file UUID (no +directory tree, no original-name collisions). Thumbnails and previews are a +**regenerable cache** under `THUMBS_CACHE_PATH`: still images via vipsthumbnail +(shrink-on-load) with a pure-Go `imaging` fallback, video frames via ffmpeg; +metadata/EXIF via exiftool with a pure-Go fallback. Uploads are rejected unless +their sniffed MIME type is whitelisted in `core.mime_types`. + +### Near-duplicate detection + +Images are dHash-ed (64-bit perceptual hash) inline on upload; video hashes are +backfilled by the dedup CLI. A rescan rebuilds `data.duplicate_pairs` using a +BK-tree over Hamming distance (within `DUPLICATE_HASH_THRESHOLD`), and the API +groups pairs into connected-component clusters. Dismissed pairs are remembered so +they stop resurfacing. See the duplicate sections in +[GO_PROJECT_STRUCTURE.md](GO_PROJECT_STRUCTURE.md). + +### Audit logging + +User-visible actions (file/tag/category/pool CRUD, relations, ACL changes, +auth, session termination, admin user actions) are recorded in +`activity.audit_log` against a seeded set of action types. + +### Frontend / PWA + +Pure client-side SPA: static assets served by the Go binary, with `index.html` +as the fallback for client routes. Installable PWA with a service worker for +app-shell caching and optional offline viewing of pinned files. See +[FRONTEND_STRUCTURE.md](FRONTEND_STRUCTURE.md). + +## Data Model + +PostgreSQL, four schemas (see `backend/migrations/`): + +- **core** — users, MIME whitelist, object types. +- **data** — categories, tags, tag rules, files, file–tag, pools, file–pool, + duplicate pairs, duplicate dismissals. +- **acl** — per-object permission grants. +- **activity** — sessions, file/pool views, tag uses, audit log, action types. + +Migrations are goose files embedded via `go:embed` and applied automatically on +server startup, so a fresh database bootstraps itself. + +## Deployment + +- **One image, one port.** The multi-stage `Dockerfile` builds the SPA (Node + stage) and the static Go binary (Go stage), then ships an Alpine runtime with + vips-tools / ffmpeg / exiftool and a non-root user. The server serves both the + API and the SPA on port **42776** (the sum of the code points of 七夕). +- **Compose.** `docker-compose.yml` runs the app plus, optionally, a bundled + PostgreSQL (`with-db` profile); a host Postgres is supported by leaving the + profile empty. The app is published on loopback only and expects a host + reverse proxy; the DB sits on a private `internal` network with no route + off-host. The dedup CLI is a `tools`-profile, run-on-demand service. +- **Config.** All runtime config is environment variables, fully documented in + [`.env.example`](../.env.example) (1:1 with `config.Config`). Secrets + (`JWT_SECRET`, `ADMIN_PASSWORD`, `DATABASE_URL`) are never baked into the image. +- **First run.** Migrations auto-apply and the initial admin is bootstrapped + from `ADMIN_USERNAME` / `ADMIN_PASSWORD`, so setup is: fill `.env`, + `docker compose up`. + +See [DEPLOY.md](DEPLOY.md) for the production deploy (Gitea Actions → host) and +the reverse-proxy notes in [README.md](../README.md). + +## Design Constraints & Future Direction + +- **DDD / Clean Architecture** on the server keeps business rules independent of + Gin and pgx. +- **PostgreSQL-specific adapters are isolated** behind the `port` interfaces (the + filter DSL → SQL translation lives in `db/postgres`), leaving room for other + database engines in a future version without touching the service layer. diff --git a/docs/FRONTEND_STRUCTURE.md b/docs/FRONTEND_STRUCTURE.md index a67a13e..f0d2734 100644 --- a/docs/FRONTEND_STRUCTURE.md +++ b/docs/FRONTEND_STRUCTURE.md @@ -1,13 +1,21 @@ # Tanabata File Manager — Frontend Structure +> Frontend counterpart of [ARCHITECTURE.md](ARCHITECTURE.md). This document +> details the SvelteKit layout, the CSS approach, and the API client. + ## Stack -- **Framework**: SvelteKit (SPA mode, `ssr: false`) -- **Language**: TypeScript -- **CSS**: Tailwind CSS + CSS custom properties (hybrid) -- **API types**: Auto-generated via openapi-typescript -- **PWA**: Service worker + web manifest +- **Framework**: SvelteKit in **SPA mode** (`adapter-static`, `ssr = false`), + Svelte 5 (runes) +- **Language**: TypeScript (strict) +- **Build**: Vite 7 +- **CSS**: Tailwind CSS **v4** via `@tailwindcss/vite` + CSS custom properties + (`@theme` in `app.css`) — no `tailwind.config.*` / `postcss.config.*` file +- **API types**: auto-generated via `openapi-typescript` (`src/lib/api/schema.ts`) +- **PWA**: service worker + web manifest - **Font**: Epilogue (variable weight) +- **Dev**: `vite-mock-plugin.ts` serves a mock API so the UI can run without the + Go backend - **Package manager**: npm ## SPA mode — why SvelteKit without the server @@ -19,7 +27,7 @@ static assets, and the only backend is the Go API. SvelteKit is used here purely as an SPA framework: file-based routing, the client router, and build tooling. -**SvelteKit features we *do* use:** +**SvelteKit features we _do_ use:** - File-based routing with nested layouts (`admin/` has its own guard) and dynamic segments (`[id]`). @@ -30,11 +38,11 @@ tooling. over the still-mounted list via shallow routing, so the browser back button dismisses it without reloading the grid. This is the single biggest reason we stay on SvelteKit rather than a plain router. -- `load` functions, used *only* as client-side route guards (auth redirect, +- `load` functions, used _only_ as client-side route guards (auth redirect, admin redirect, `/` → `/files`). - `$lib` alias, generated `./$types`, Vite/HMR integration. -**SvelteKit features we deliberately do *not* use** (the "server half"): +**SvelteKit features we deliberately do _not_ use** (the "server half"): - SSR / hydration. - `+page.server.ts`, `+server.ts` endpoints, form actions — all data goes @@ -43,7 +51,7 @@ tooling. `hooks.client.ts` files exist. **Decision: stay on SvelteKit, do not migrate to a bare Svelte + router SPA.** -The project already *is* an SPA, so there is no runtime gain from switching +The project already _is_ an SPA, so there is no runtime gain from switching (adapter-static tree-shakes the unused server bits; the client-runtime size difference is negligible). A migration would mean re-implementing nested layouts, guards, dynamic params, and — most painfully — shallow routing / @@ -55,15 +63,7 @@ expect SSR, endpoints, or hooks to do anything here; that is intentional. ``` tanabata/ ├── backend/ ← Go project (go.mod in here) -│ ├── cmd/ -│ ├── internal/ -│ ├── migrations/ -│ ├── go.mod -│ └── go.sum -│ ├── frontend/ ← SvelteKit project (package.json in here) -│ └── (see below) -│ ├── openapi.yaml ← Shared API contract (root level) ├── docker-compose.yml ├── Dockerfile @@ -71,352 +71,206 @@ tanabata/ └── README.md ``` -`openapi.yaml` lives at repository root — both backend and frontend -reference it. The frontend generates types from it; the backend -validates its handlers against it. +`openapi.yaml` lives at repository root — both backend and frontend reference +it. The frontend generates types from it; the backend implements it. ## Frontend Directory Layout ``` frontend/ ├── package.json -├── svelte.config.js -├── vite.config.ts +├── svelte.config.js # adapter-static, fallback: index.html +├── vite.config.ts # plugins: tailwindcss(), sveltekit(), mockApiPlugin() +├── vite-mock-plugin.ts # dev-only mock API (run the UI without the Go backend) ├── tsconfig.json -├── tailwind.config.ts -├── postcss.config.js │ -├── src/ -│ ├── app.html # Shell HTML (PWA meta, font preload) -│ ├── app.css # Tailwind directives + CSS custom properties -│ │ # (no hooks.* — see "SPA mode" above) -│ │ -│ ├── lib/ # Shared code ($lib/ alias) -│ │ │ -│ │ ├── api/ # API client layer -│ │ │ ├── client.ts # Base fetch wrapper: auth headers, token refresh, -│ │ │ │ # error parsing, base URL -│ │ │ ├── files.ts # listFiles, getFile, uploadFile, deleteFile, etc. -│ │ │ ├── tags.ts # listTags, createTag, getTag, updateTag, etc. -│ │ │ ├── categories.ts # Category API functions -│ │ │ ├── pools.ts # Pool API functions -│ │ │ ├── auth.ts # login, logout, refresh, listSessions -│ │ │ ├── acl.ts # getPermissions, setPermissions -│ │ │ ├── users.ts # getMe, updateMe, admin user CRUD -│ │ │ ├── audit.ts # queryAuditLog -│ │ │ ├── schema.ts # AUTO-GENERATED from openapi.yaml (do not edit) -│ │ │ └── types.ts # Friendly type aliases: -│ │ │ # export type File = components["schemas"]["File"] -│ │ │ # export type Tag = components["schemas"]["Tag"] -│ │ │ -│ │ ├── components/ # Reusable UI components -│ │ │ │ -│ │ │ ├── layout/ # App shell -│ │ │ │ ├── Navbar.svelte # Bottom navigation bar (mobile-first) -│ │ │ │ ├── Header.svelte # Section header with sorting controls -│ │ │ │ ├── SelectionBar.svelte # Floating bar for multi-select actions -│ │ │ │ └── Loader.svelte # Full-screen loading overlay -│ │ │ │ -│ │ │ ├── file/ # File-related components -│ │ │ │ ├── FileGrid.svelte # Thumbnail grid with infinite scroll -│ │ │ │ ├── FileCard.svelte # Single thumbnail (160×160, selectable) -│ │ │ │ ├── FileViewer.svelte # Full-screen preview with prev/next navigation -│ │ │ │ ├── FileUpload.svelte # Upload form + drag-and-drop zone -│ │ │ │ ├── FileDetail.svelte # Metadata editor (notes, datetime, tags) -│ │ │ │ └── FilterBar.svelte # DSL filter builder UI -│ │ │ │ -│ │ │ ├── tag/ # Tag-related components -│ │ │ │ ├── TagBadge.svelte # Colored pill with tag name -│ │ │ │ ├── TagPicker.svelte # Searchable tag selector (add/remove) -│ │ │ │ ├── TagList.svelte # Tag grid for section view -│ │ │ │ └── TagRuleEditor.svelte # Auto-tag rule management -│ │ │ │ -│ │ │ ├── pool/ # Pool-related components -│ │ │ │ ├── PoolCard.svelte # Pool preview card -│ │ │ │ ├── PoolFileList.svelte # Ordered file list with drag reorder -│ │ │ │ └── PoolDetail.svelte # Pool metadata editor -│ │ │ │ -│ │ │ ├── acl/ # Access control components -│ │ │ │ └── PermissionEditor.svelte # User permission grid -│ │ │ │ -│ │ │ └── common/ # Shared primitives -│ │ │ ├── Button.svelte -│ │ │ ├── Modal.svelte -│ │ │ ├── ConfirmDialog.svelte -│ │ │ ├── Toast.svelte -│ │ │ ├── InfiniteScroll.svelte -│ │ │ ├── Pagination.svelte -│ │ │ ├── SortDropdown.svelte -│ │ │ ├── SearchInput.svelte -│ │ │ ├── ColorPicker.svelte -│ │ │ ├── Checkbox.svelte # Three-state: checked, unchecked, partial -│ │ │ └── EmptyState.svelte -│ │ │ -│ │ ├── stores/ # Svelte stores (global state) -│ │ │ ├── auth.ts # Current user, JWT tokens, isAuthenticated -│ │ │ ├── selection.ts # Selected item IDs, selection mode toggle -│ │ │ ├── sorting.ts # Per-section sort key + order (persisted to localStorage) -│ │ │ ├── theme.ts # Dark/light mode (persisted, respects prefers-color-scheme) -│ │ │ └── toast.ts # Notification queue (success, error, info) -│ │ │ -│ │ └── utils/ # Pure helper functions -│ │ ├── format.ts # formatDate, formatFileSize, formatDuration -│ │ ├── dsl.ts # Filter DSL builder: UI state → query string -│ │ ├── pwa.ts # PWA reset, cache clear, update prompt -│ │ └── keyboard.ts # Keyboard shortcut helpers (Ctrl+A, Escape, etc.) -│ │ -│ ├── routes/ # SvelteKit file-based routing -│ │ │ -│ │ ├── +layout.svelte # Root layout: Navbar, theme wrapper, toast container -│ │ ├── +layout.ts # Root load: auth guard → redirect to /login if no token -│ │ │ -│ │ ├── +page.svelte # / → redirect to /files -│ │ │ -│ │ ├── login/ -│ │ │ └── +page.svelte # Login form (decorative Tanabata images) -│ │ │ -│ │ ├── files/ -│ │ │ ├── +page.svelte # File grid: filter bar, sort, multi-select, upload -│ │ │ ├── +page.ts # Load: initial file list (cursor page) -│ │ │ ├── [id]/ -│ │ │ │ ├── +page.svelte # File view: preview, metadata, tags, ACL -│ │ │ │ └── +page.ts # Load: file detail + tags -│ │ │ └── trash/ -│ │ │ ├── +page.svelte # Trash: restore / permanent delete -│ │ │ └── +page.ts -│ │ │ -│ │ ├── tags/ -│ │ │ ├── +page.svelte # Tag list: search, sort, multi-select -│ │ │ ├── +page.ts -│ │ │ ├── new/ -│ │ │ │ └── +page.svelte # Create tag form -│ │ │ └── [id]/ -│ │ │ ├── +page.svelte # Tag detail: edit, category, rules, parent tags -│ │ │ └── +page.ts -│ │ │ -│ │ ├── categories/ -│ │ │ ├── +page.svelte # Category list -│ │ │ ├── +page.ts -│ │ │ ├── new/ -│ │ │ │ └── +page.svelte -│ │ │ └── [id]/ -│ │ │ ├── +page.svelte # Category detail: edit, view tags -│ │ │ └── +page.ts -│ │ │ -│ │ ├── pools/ -│ │ │ ├── +page.svelte # Pool list -│ │ │ ├── +page.ts -│ │ │ ├── new/ -│ │ │ │ └── +page.svelte -│ │ │ └── [id]/ -│ │ │ ├── +page.svelte # Pool detail: files (reorderable), filter, edit -│ │ │ └── +page.ts -│ │ │ -│ │ ├── settings/ -│ │ │ ├── +page.svelte # Profile: name, password, active sessions -│ │ │ └── +page.ts -│ │ │ -│ │ └── admin/ -│ │ ├── +layout.svelte # Admin layout: restrict to is_admin -│ │ ├── users/ -│ │ │ ├── +page.svelte # User management list -│ │ │ ├── +page.ts -│ │ │ └── [id]/ -│ │ │ ├── +page.svelte # User detail: role, block/unblock -│ │ │ └── +page.ts -│ │ └── audit/ -│ │ ├── +page.svelte # Audit log with filters -│ │ └── +page.ts -│ │ -│ └── service-worker.ts # PWA: offline cache for pinned files, app shell caching +├── static/ # Copied verbatim into the build +│ ├── manifest.webmanifest # PWA manifest +│ ├── browserconfig.xml +│ ├── robots.txt +│ ├── favicon.ico +│ ├── fonts/ +│ │ └── Epilogue-VariableFont_wght.ttf +│ └── images/ # PWA icons, section icons (svg), login decorations │ -└── static/ - ├── favicon.png - ├── favicon.ico - ├── manifest.webmanifest # PWA manifest (name, icons, theme_color) - ├── images/ - │ ├── tanabata-left.png # Login page decorations (from current design) - │ ├── tanabata-right.png - │ └── icons/ # PWA icons (192×192, 512×512, etc.) - └── fonts/ - └── Epilogue-VariableFont_wght.ttf +└── src/ + ├── app.html # Shell HTML (PWA meta, font preload) + ├── app.css # `@import 'tailwindcss'` + `@theme` custom properties + ├── app.d.ts # Ambient types + ├── service-worker.ts # PWA: app-shell + pinned-file offline cache + │ + ├── lib/ # Shared code ($lib alias) + │ ├── index.ts + │ │ + │ ├── api/ # API client layer + │ │ ├── client.ts # fetch wrapper: bearer auth, 401 refresh+retry, error parsing, + │ │ │ # upload-with-progress (XHR), NDJSON streaming; exports `api` + │ │ ├── auth.ts # login, refresh, logout, sessions + │ │ ├── tags.ts # tag + tag-rule calls + │ │ ├── categories.ts # category calls + │ │ ├── duplicates.ts # duplicate list / dismiss / resolve + │ │ ├── schema.ts # AUTO-GENERATED from openapi.yaml (gitignored; do not edit) + │ │ └── types.ts # Friendly aliases: components['schemas'][...] + │ │ + │ ├── components/ + │ │ ├── layout/ + │ │ │ ├── Header.svelte # Section header with sorting controls + │ │ │ ├── SelectionBar.svelte # Floating bar for multi-select actions + │ │ │ └── KeyboardHelp.svelte # Keyboard-shortcut overlay + │ │ │ + │ │ ├── file/ + │ │ │ ├── FileCard.svelte # Single thumbnail (160×160, selectable) + │ │ │ ├── Thumb.svelte # Lazy-loaded thumbnail (IntersectionObserver) + │ │ │ ├── FileViewer.svelte # Full-screen viewer with prev/next + │ │ │ ├── FileUpload.svelte # Upload form + drag-and-drop + │ │ │ ├── FilterBar.svelte # DSL filter builder UI + │ │ │ ├── MetadataEditor.svelte # Notes / datetime / metadata (nested) editor + │ │ │ ├── TagPicker.svelte # Searchable tag selector (add/remove) + │ │ │ ├── PoolPicker.svelte # Add-to-pool dialog + │ │ │ ├── BulkTagEditor.svelte # Multi-select tag add/remove + │ │ │ └── DuplicateMergeDialog.svelte # Field-by-field duplicate resolution + │ │ │ + │ │ ├── tag/ + │ │ │ ├── TagBadge.svelte # Colored pill + │ │ │ └── TagRuleEditor.svelte # Auto-tag rule management + │ │ │ + │ │ └── common/ + │ │ ├── ConfirmDialog.svelte + │ │ └── InfiniteScroll.svelte # Below-the-fold lazy loading on scroll + │ │ + │ ├── stores/ # Svelte stores (global state) + │ │ ├── auth.ts # Current user + JWT tokens (persisted, cross-tab sync) + │ │ ├── selection.ts # Selected item IDs, selection mode + │ │ ├── sorting.ts # Per-section sort key + order (persisted) + │ │ ├── theme.ts # Dark/light theme (persisted) + │ │ ├── appSettings.ts # Misc client-side settings + │ │ ├── listScroll.ts # Restore list scroll position after overlay/back + │ │ └── sectionCache.ts # Cached list snapshots, invalidated on mutation + │ │ + │ └── utils/ # Pure helpers + │ ├── dsl.ts # Filter DSL builder: UI state → query string + │ ├── metadata.ts # Nested metadata <-> editor rows + │ ├── pwa.ts # PWA reset / update prompt + │ └── rovingGrid.svelte.ts # Roving-tabindex keyboard grid navigation + │ + └── routes/ # SvelteKit file-based routing (guards only in load) + ├── +layout.svelte # Root layout: nav, theme + ├── +layout.ts # ssr=false; root auth guard + ├── +page.svelte / +page.ts # / → redirect to /files + ├── login/+page.svelte + ├── files/ + │ ├── +page.svelte / +page.ts # Grid: filter, sort, multi-select, upload + │ ├── [id]/+page.svelte # File view (also opened as shallow-routing overlay) + │ ├── duplicates/+page.svelte # Duplicate clusters + │ └── trash/+page.svelte # Trash: restore / permanent delete + ├── tags/ { +page.svelte, new/, [id]/ } + ├── categories/ { +page.svelte, new/, [id]/ } + ├── pools/ { +page.svelte, new/, [id]/ } + ├── settings/+page.svelte # Profile: name, password, sessions, import path + └── admin/ + ├── +layout.svelte / +layout.ts # Restrict to admins + ├── users/{ +page.svelte, [id]/ } + └── audit/+page.svelte ``` ## Key Architecture Decisions -### CSS Hybrid: Tailwind + Custom Properties +### CSS: Tailwind v4 + Custom Properties -Theme colors defined as CSS custom properties in `app.css`: +Tailwind v4 is configured **in CSS**, not in a JS config file. `app.css` imports +Tailwind and declares the theme tokens as CSS custom properties inside `@theme`; +Tailwind then generates utilities (`bg-bg-primary`, `text-text-primary`, +`font-sans`, …) from those tokens automatically. ```css -@tailwind base; -@tailwind components; -@tailwind utilities; +/* src/app.css */ +@import "tailwindcss"; -:root { - --color-bg-primary: #312F45; - --color-bg-secondary: #181721; - --color-bg-elevated: #111118; - --color-accent: #9592B5; - --color-accent-hover: #7D7AA4; - --color-text-primary: #f0f0f0; - --color-text-muted: #9999AD; - --color-danger: #DB6060; - --color-info: #4DC7ED; - --color-warning: #F5E872; - --color-tag-default: #444455; -} +@theme { + --color-bg-primary: #312f45; + --color-bg-secondary: #181721; + --color-bg-elevated: #111118; + --color-accent: #9592b5; + --color-accent-hover: #7d7aa4; + --color-text-primary: #f0f0f0; + --color-tag-default: #444455; + /* … info / danger / warning / success / nav tokens … */ -:root[data-theme="light"] { - --color-bg-primary: #f5f5f5; - --color-bg-secondary: #ffffff; - /* ... */ + --font-sans: "Epilogue", sans-serif; } ``` -Tailwind references them in `tailwind.config.ts`: - -```ts -export default { - theme: { - extend: { - colors: { - bg: { - primary: 'var(--color-bg-primary)', - secondary: 'var(--color-bg-secondary)', - elevated: 'var(--color-bg-elevated)', - }, - accent: { - DEFAULT: 'var(--color-accent)', - hover: 'var(--color-accent-hover)', - }, - // ... - }, - fontFamily: { - sans: ['Epilogue', 'sans-serif'], - }, - }, - }, - darkMode: 'class', // controlled via data-theme attribute -}; -``` - +Dark theme is primary; the light theme overrides the same custom properties. Usage in components: `
`. Complex cases use scoped `