From 780f85de5927e9b93af47b4da364180ccc2a5f14 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Wed, 1 Apr 2026 16:17:37 +0300 Subject: [PATCH] chore: initial project structure --- CLAUDE.md | 55 + backend/migrations/001_init.sql | 425 +++++++ docs/FRONTEND_STRUCTURE.md | 383 ++++++ docs/GO_PROJECT_STRUCTURE.md | 320 +++++ docs/Описание.md | 148 +++ openapi.yaml | 2033 +++++++++++++++++++++++++++++++ 6 files changed, 3364 insertions(+) create mode 100644 CLAUDE.md create mode 100644 backend/migrations/001_init.sql create mode 100644 docs/FRONTEND_STRUCTURE.md create mode 100644 docs/GO_PROJECT_STRUCTURE.md create mode 100644 docs/Описание.md create mode 100644 openapi.yaml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..44bcb71 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# Tanabata File Manager + +Multi-user, tag-based web file manager for images and video. + +## Architecture + +Monorepo: `backend/` (Go) + `frontend/` (SvelteKit). + +- Backend: Go + Gin + pgx v5 + goose migrations. Clean Architecture. +- Frontend: SvelteKit SPA + Tailwind CSS + CSS custom properties. +- DB: PostgreSQL 14+. +- Auth: JWT Bearer tokens. + +## Key documents (read before coding) + +- `openapi.yaml` — full REST API specification (36 paths, 58 operations) +- `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) + +## Design reference + +The `docs/reference/` directory contains the previous Python/Flask version. +Use its visual design as the basis for the new frontend: +- Color palette: #312F45 (bg), #9592B5 (accent), #444455 (tag default), #111118 (elevated) +- Font: Epilogue (variable weight) +- Dark theme is primary +- Mobile-first layout with bottom navbar +- 160×160 thumbnail grid for files +- Colored tag pills +- Floating selection bar for multi-select + +## Backend commands +```bash +cd backend +go run ./cmd/server # run dev server +go test ./... # run all tests +``` + +## Frontend commands +```bash +cd frontend +npm run dev # vite dev server +npm run build # production build +npm run generate:types # regenerate API types from openapi.yaml +``` + +## Conventions + +- Go: gofmt, no global state, context.Context as first param in all service methods +- TypeScript: strict mode, named exports +- SQL: snake_case, all migrations via goose +- API errors: { code, message, details? } +- Git: conventional commits (feat:, fix:, docs:, refactor:) diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..409e55e --- /dev/null +++ b/backend/migrations/001_init.sql @@ -0,0 +1,425 @@ +-- ============================================================================= +-- Tanabata File Manager — Database Schema v2 +-- ============================================================================= +-- PostgreSQL 14+ +-- +-- Design decisions: +-- • Business logic lives in Go (DDD), no stored procedures +-- • UUID v7 for entity PKs (created_at extracted from UUID, no separate column) +-- • ACL: is_public flag on objects + acl.permissions table for granular control +-- • Schemas: core, data, acl, activity +-- • Flat pools (no hierarchy) +-- • Soft delete for files only (trash/recycle bin) +-- • phash field for future duplicate detection +-- • metadata jsonb on all entities +-- • Unified audit log with reference tables instead of enums +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- Extensions +-- --------------------------------------------------------------------------- + +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- --------------------------------------------------------------------------- +-- Schemas +-- --------------------------------------------------------------------------- + +CREATE SCHEMA IF NOT EXISTS core; +CREATE SCHEMA IF NOT EXISTS data; +CREATE SCHEMA IF NOT EXISTS acl; +CREATE SCHEMA IF NOT EXISTS activity; + +-- --------------------------------------------------------------------------- +-- Utility functions +-- --------------------------------------------------------------------------- + +-- UUID v7 generator +CREATE OR REPLACE FUNCTION public.uuid_v7(cts timestamptz DEFAULT clock_timestamp()) +RETURNS uuid LANGUAGE plpgsql AS $$ +DECLARE + state text = current_setting('uuidv7.old_tp', true); + old_tp text = split_part(state, ':', 1); + base int = coalesce(nullif(split_part(state, ':', 4), '')::int, (random()*16777215/2-1)::int); + tp text; + entropy text; + seq text = base; + seqn int = split_part(state, ':', 2); + ver text = coalesce(split_part(state, ':', 3), to_hex(8+(random()*3)::int)); +BEGIN + base = (random()*16777215/2-1)::int; + tp = lpad(to_hex(floor(extract(epoch from cts)*1000)::int8), 12, '0') || '7'; + IF tp IS DISTINCT FROM old_tp THEN + old_tp = tp; + ver = to_hex(8+(random()*3)::int); + base = (random()*16777215/2-1)::int; + seqn = base; + ELSE + seqn = seqn + (random()*1000)::int; + END IF; + PERFORM set_config('uuidv7.old_tp', old_tp||':'||seqn||':'||ver||':'||base, false); + entropy = md5(gen_random_uuid()::text); + seq = lpad(to_hex(seqn), 6, '0'); + RETURN (tp || substring(seq from 1 for 3) || ver || substring(seq from 4 for 3) || + substring(entropy from 1 for 12))::uuid; +END; +$$; + +-- Extract timestamp from UUID v7 +CREATE OR REPLACE FUNCTION public.uuid_extract_timestamp(uuid_val uuid) +RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ + SELECT to_timestamp( + ('x' || left(replace(uuid_val::text, '-', ''), 12))::bit(48)::bigint / 1000.0 + ); +$$; + +-- ============================================================================= +-- SCHEMA: core +-- ============================================================================= + +-- Users +CREATE TABLE core.users ( + id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar(32) NOT NULL, + password text NOT NULL, -- bcrypt hash via pgcrypto + is_admin boolean NOT NULL DEFAULT false, + can_create boolean NOT NULL DEFAULT false, + + CONSTRAINT uni__users__name UNIQUE (name) +); + +-- MIME types (whitelist of supported file types) +CREATE TABLE core.mime_types ( + id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar(127) NOT NULL, + extension varchar(16) NOT NULL, + + CONSTRAINT uni__mime_types__name UNIQUE (name) +); + +-- Object types (file, tag, category, pool — used in ACL and audit log) +CREATE TABLE core.object_types ( + id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar(32) NOT NULL, + + CONSTRAINT uni__object_types__name UNIQUE (name) +); + +-- ============================================================================= +-- SCHEMA: data +-- ============================================================================= + +-- Categories (logical grouping of tags) +CREATE TABLE data.categories ( + id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY, + name varchar(256) NOT NULL, + notes text, + color char(6), + metadata jsonb, + creator_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + is_public boolean NOT NULL DEFAULT false, + + CONSTRAINT uni__categories__name UNIQUE (name), + CONSTRAINT chk__categories__color_hex + CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$') +); + +-- Tags +CREATE TABLE data.tags ( + id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY, + name varchar(256) NOT NULL, + notes text, + color char(6), + category_id uuid REFERENCES data.categories(id) + ON UPDATE CASCADE ON DELETE SET NULL, + metadata jsonb, + creator_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + is_public boolean NOT NULL DEFAULT false, + + CONSTRAINT uni__tags__name UNIQUE (name), + CONSTRAINT chk__tags__color_hex + CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$') +); + +-- Tag rules (when when_tag is added to a file, then_tag is also added) +CREATE TABLE data.tag_rules ( + when_tag_id uuid NOT NULL REFERENCES data.tags(id) + ON UPDATE CASCADE ON DELETE CASCADE, + then_tag_id uuid NOT NULL REFERENCES data.tags(id) + ON UPDATE CASCADE ON DELETE CASCADE, + is_active boolean NOT NULL DEFAULT true, + + PRIMARY KEY (when_tag_id, then_tag_id) +); + +-- Files +CREATE TABLE data.files ( + id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY, + original_name varchar(256), -- original filename at upload time + mime_id smallint NOT NULL REFERENCES core.mime_types(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + content_datetime timestamptz NOT NULL DEFAULT clock_timestamp(), -- content datetime (e.g. photo taken) + notes text, + metadata jsonb, -- user-editable key-value data + exif jsonb NOT NULL DEFAULT '{}'::jsonb, -- EXIF data extracted at upload (immutable) + phash bigint, -- perceptual hash for duplicate detection (future) + creator_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + is_public boolean NOT NULL DEFAULT false, + is_deleted boolean NOT NULL DEFAULT false -- soft delete (trash) +); + +-- File ↔ Tag (many-to-many) +CREATE TABLE data.file_tag ( + file_id uuid NOT NULL REFERENCES data.files(id) + ON UPDATE CASCADE ON DELETE CASCADE, + tag_id uuid NOT NULL REFERENCES data.tags(id) + ON UPDATE CASCADE ON DELETE CASCADE, + + PRIMARY KEY (file_id, tag_id) +); + +-- Pools (ordered collections of files) +CREATE TABLE data.pools ( + id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY, + name varchar(256) NOT NULL, + notes text, + metadata jsonb, + creator_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + is_public boolean NOT NULL DEFAULT false, + + CONSTRAINT uni__pools__name UNIQUE (name) +); + +-- File ↔ Pool (many-to-many, with ordering) +-- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow +-- insertions without renumbering. Compact when gaps get too small. +CREATE TABLE data.file_pool ( + file_id uuid NOT NULL REFERENCES data.files(id) + ON UPDATE CASCADE ON DELETE CASCADE, + pool_id uuid NOT NULL REFERENCES data.pools(id) + ON UPDATE CASCADE ON DELETE CASCADE, + position integer NOT NULL DEFAULT 0, + + PRIMARY KEY (file_id, pool_id) +); + +-- ============================================================================= +-- SCHEMA: acl +-- ============================================================================= + +-- Granular permissions +-- If is_public=true on the object, it is accessible to everyone (ACL ignored). +-- If is_public=false, only creator and users with can_view=true see it. +-- Admins bypass all ACL checks. +CREATE TABLE acl.permissions ( + user_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE CASCADE, + object_type_id smallint NOT NULL REFERENCES core.object_types(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + object_id uuid NOT NULL, + can_view boolean NOT NULL DEFAULT true, + can_edit boolean NOT NULL DEFAULT false, + + PRIMARY KEY (user_id, object_type_id, object_id) +); + +-- ============================================================================= +-- SCHEMA: activity +-- ============================================================================= + +-- Action types (reference table for audit log) +CREATE TABLE activity.action_types ( + id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar(64) NOT NULL, + + CONSTRAINT uni__action_types__name UNIQUE (name) +); + +-- Sessions +CREATE TABLE activity.sessions ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + token_hash text NOT NULL, -- hashed session token + user_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE CASCADE, + user_agent varchar(256) NOT NULL, + started_at timestamptz NOT NULL DEFAULT statement_timestamp(), + expires_at timestamptz, + last_activity timestamptz NOT NULL DEFAULT statement_timestamp(), + + CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash) +); + +-- File views (analytics) +CREATE TABLE activity.file_views ( + file_id uuid NOT NULL REFERENCES data.files(id) + ON UPDATE CASCADE ON DELETE CASCADE, + user_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE CASCADE, + viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(), + + PRIMARY KEY (file_id, viewed_at, user_id) +); + +-- Pool views (analytics) +CREATE TABLE activity.pool_views ( + pool_id uuid NOT NULL REFERENCES data.pools(id) + ON UPDATE CASCADE ON DELETE CASCADE, + user_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE CASCADE, + viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(), + + PRIMARY KEY (pool_id, viewed_at, user_id) +); + +-- Tag usage tracking (when tag is used as filter) +CREATE TABLE activity.tag_uses ( + tag_id uuid NOT NULL REFERENCES data.tags(id) + ON UPDATE CASCADE ON DELETE CASCADE, + user_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE CASCADE, + used_at timestamptz NOT NULL DEFAULT statement_timestamp(), + is_included boolean NOT NULL, -- true=included in filter, false=excluded + + PRIMARY KEY (tag_id, used_at, user_id) +); + +-- Audit log (unified journal for all user actions) +CREATE TABLE activity.audit_log ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id smallint NOT NULL REFERENCES core.users(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + action_type_id smallint NOT NULL REFERENCES activity.action_types(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + object_type_id smallint REFERENCES core.object_types(id) + ON UPDATE CASCADE ON DELETE RESTRICT, + object_id uuid, + details jsonb, -- action-specific payload + performed_at timestamptz NOT NULL DEFAULT statement_timestamp() +); + +-- ============================================================================= +-- SEED DATA +-- ============================================================================= + +-- Object types +INSERT INTO core.object_types (name) VALUES + ('file'), ('tag'), ('category'), ('pool'); + +-- Action types +INSERT INTO activity.action_types (name) VALUES + -- Auth + ('user_login'), ('user_logout'), + -- Files + ('file_create'), ('file_edit'), ('file_delete'), ('file_restore'), + ('file_permanent_delete'), ('file_replace'), + -- Tags + ('tag_create'), ('tag_edit'), ('tag_delete'), + -- Categories + ('category_create'), ('category_edit'), ('category_delete'), + -- Pools + ('pool_create'), ('pool_edit'), ('pool_delete'), + -- Relations + ('file_tag_add'), ('file_tag_remove'), + ('file_pool_add'), ('file_pool_remove'), + -- ACL + ('acl_change'), + -- Admin + ('user_create'), ('user_delete'), ('user_block'), ('user_unblock'), + ('user_role_change'), + -- Sessions + ('session_terminate'); + +-- ============================================================================= +-- INDEXES +-- ============================================================================= + +-- core +CREATE INDEX idx__users__name ON core.users USING hash (name); + +-- data.categories +CREATE INDEX idx__categories__creator_id ON data.categories USING hash (creator_id); + +-- data.tags +CREATE INDEX idx__tags__category_id ON data.tags USING hash (category_id); +CREATE INDEX idx__tags__creator_id ON data.tags USING hash (creator_id); + +-- data.tag_rules +CREATE INDEX idx__tag_rules__when ON data.tag_rules USING hash (when_tag_id); +CREATE INDEX idx__tag_rules__then ON data.tag_rules USING hash (then_tag_id); + +-- data.files +CREATE INDEX idx__files__mime_id ON data.files USING hash (mime_id); +CREATE INDEX idx__files__creator_id ON data.files USING hash (creator_id); +CREATE INDEX idx__files__content_datetime ON data.files USING btree (content_datetime DESC NULLS LAST); +CREATE INDEX idx__files__is_deleted ON data.files USING btree (is_deleted) WHERE is_deleted = true; +CREATE INDEX idx__files__phash ON data.files USING btree (phash) WHERE phash IS NOT NULL; + +-- data.file_tag +CREATE INDEX idx__file_tag__tag_id ON data.file_tag USING hash (tag_id); +CREATE INDEX idx__file_tag__file_id ON data.file_tag USING hash (file_id); + +-- data.pools +CREATE INDEX idx__pools__creator_id ON data.pools USING hash (creator_id); + +-- data.file_pool +CREATE INDEX idx__file_pool__pool_id ON data.file_pool USING hash (pool_id); +CREATE INDEX idx__file_pool__file_id ON data.file_pool USING hash (file_id); + +-- acl.permissions +CREATE INDEX idx__acl__object ON acl.permissions USING btree (object_type_id, object_id); +CREATE INDEX idx__acl__user ON acl.permissions USING hash (user_id); + +-- activity.sessions +CREATE INDEX idx__sessions__user_id ON activity.sessions USING hash (user_id); +CREATE INDEX idx__sessions__token_hash ON activity.sessions USING hash (token_hash); + +-- activity.file_views +CREATE INDEX idx__file_views__user_id ON activity.file_views USING hash (user_id); + +-- activity.pool_views +CREATE INDEX idx__pool_views__user_id ON activity.pool_views USING hash (user_id); + +-- activity.tag_uses +CREATE INDEX idx__tag_uses__user_id ON activity.tag_uses USING hash (user_id); + +-- activity.audit_log +CREATE INDEX idx__audit_log__user_id ON activity.audit_log USING hash (user_id); +CREATE INDEX idx__audit_log__action_type_id ON activity.audit_log USING hash (action_type_id); +CREATE INDEX idx__audit_log__object ON activity.audit_log USING btree (object_type_id, object_id) + WHERE object_id IS NOT NULL; +CREATE INDEX idx__audit_log__performed_at ON activity.audit_log USING btree (performed_at DESC); + +-- ============================================================================= +-- COMMENTS +-- ============================================================================= + +COMMENT ON TABLE core.users IS 'Application users'; +COMMENT ON TABLE core.mime_types IS 'Whitelist of supported MIME types'; +COMMENT ON TABLE core.object_types IS 'Reference: entity types for ACL and audit log'; +COMMENT ON TABLE data.categories IS 'Logical grouping of tags'; +COMMENT ON TABLE data.tags IS 'File labels/tags'; +COMMENT ON TABLE data.tag_rules IS 'Auto-tagging rules: when when_tag is assigned, then_tag follows'; +COMMENT ON TABLE data.files IS 'Managed files; actual content stored on disk as {id}.{ext}'; +COMMENT ON TABLE data.file_tag IS 'Many-to-many: files <-> tags'; +COMMENT ON TABLE data.pools IS 'Ordered collections of files'; +COMMENT ON TABLE data.file_pool IS 'Many-to-many: files <-> pools, with ordering'; +COMMENT ON TABLE acl.permissions IS 'Per-object permissions (used when is_public=false)'; +COMMENT ON TABLE activity.action_types IS 'Reference: types of auditable user actions'; +COMMENT ON TABLE activity.sessions IS 'Active user sessions'; +COMMENT ON TABLE activity.file_views IS 'File view history'; +COMMENT ON TABLE activity.pool_views IS 'Pool view history'; +COMMENT ON TABLE activity.tag_uses IS 'Tag usage in filters'; +COMMENT ON TABLE activity.audit_log IS 'Unified audit trail for all user actions'; + +COMMENT ON COLUMN data.files.original_name IS 'Original filename at upload time'; +COMMENT ON COLUMN data.files.content_datetime IS 'Content datetime (e.g. when photo was taken); falls back to EXIF DateTimeOriginal'; +COMMENT ON COLUMN data.files.metadata IS 'User-editable key-value metadata'; +COMMENT ON COLUMN data.files.exif IS 'EXIF data extracted at upload time (immutable, system-managed)'; +COMMENT ON COLUMN data.files.phash IS 'Perceptual hash for image/video duplicate detection'; +COMMENT ON COLUMN data.files.is_deleted IS 'Soft-deleted files (trash); true = in recycle bin'; +COMMENT ON COLUMN data.file_pool.position IS 'Manual ordering within pool; uses gapped integers'; diff --git a/docs/FRONTEND_STRUCTURE.md b/docs/FRONTEND_STRUCTURE.md new file mode 100644 index 0000000..12d6c71 --- /dev/null +++ b/docs/FRONTEND_STRUCTURE.md @@ -0,0 +1,383 @@ +# Tanabata File Manager — Frontend Structure + +## 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 +- **Font**: Epilogue (variable weight) +- **Package manager**: npm + +## Monorepo Layout + +``` +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 +├── .env.example +└── 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. + +## Frontend Directory Layout + +``` +frontend/ +├── package.json +├── svelte.config.js +├── vite.config.ts +├── tsconfig.json +├── tailwind.config.ts +├── postcss.config.js +│ +├── src/ +│ ├── app.html # Shell HTML (PWA meta, font preload) +│ ├── app.css # Tailwind directives + CSS custom properties +│ ├── hooks.server.ts # Server hooks (not used in SPA mode) +│ ├── hooks.client.ts # Client hooks (global error handling) +│ │ +│ ├── 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/ + ├── 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 +``` + +## Key Architecture Decisions + +### CSS Hybrid: Tailwind + Custom Properties + +Theme colors defined as CSS custom properties in `app.css`: + +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +: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; +} + +:root[data-theme="light"] { + --color-bg-primary: #f5f5f5; + --color-bg-secondary: #ffffff; + /* ... */ +} +``` + +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 +}; +``` + +Usage in components: `
`. +Complex cases use scoped `