tanabata

Tanabata File Manager β€” Frontend Structure

Frontend counterpart of ARCHITECTURE.md. This document details the SvelteKit layout, the CSS approach, and the API client.

Stack

SPA mode β€” why SvelteKit without the server

This frontend runs as a pure client-side SPA: adapter-static with fallback: 'index.html' and ssr = false globally (see src/routes/+layout.ts). There is no Node server in production β€” the build is 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 deliberately do not use (the β€œserver half”):

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 (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 / history-state overlays by hand, for zero benefit. New contributors should not expect SSR, endpoints, or hooks to do anything here; that is intentional.

Monorepo Layout

tanabata/
β”œβ”€β”€ backend/                    ← Go project (go.mod in here)
β”œβ”€β”€ frontend/                   ← SvelteKit project (package.json in here)
β”œβ”€β”€ 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 implements it.

Frontend Directory Layout

frontend/
β”œβ”€β”€ package.json
β”œβ”€β”€ 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
β”‚
β”œβ”€β”€ 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
β”‚
└── 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: Tailwind v4 + Custom Properties

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.

/* src/app.css */
@import "tailwindcss";

@theme {
	--color-bg-primary: #312f45;
	--color-bg-secondary: #181721;
	--color-bg-elevated: #111118;
	--color-accent: #9592b5;
	--color-accent-hover: #7d7aa4;
	--color-text-primary: #f0f0f0;
	--color-tag-default: #444455;
	/* … info / danger / warning / success / nav tokens … */

	--font-sans: "Epilogue", sans-serif;
}

Dark theme is primary; the light theme overrides the same custom properties. Usage in components: <div class="bg-bg-primary text-text-primary rounded-xl p-4">. Complex cases use scoped <style> inside .svelte files.

API Client Pattern

src/lib/api/client.ts is a thin fetch wrapper exporting a generic api object. It attaches the bearer token, transparently handles a single 401 refresh-and-retry (deduplicating concurrent refreshes and syncing rotated tokens across tabs), parses { code, message, details } errors into ApiError, and invalidates cached list snapshots on mutation. It also provides uploadWithProgress (XHR, for upload progress) and postStream (NDJSON, for the live import progress).

// $lib/api/client.ts (shape)
export const api = {
	get: <T>(path) => request<T>(path),
	post: <T>(path, body?) =>
		request<T>(path, { method: "POST", body: JSON.stringify(body) }),
	patch: <T>(path, body?) =>
		request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
	put: <T>(path, body?) =>
		request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
	delete: <T>(path) => request<T>(path, { method: "DELETE" }),
	upload: <T>(path, fd) => request<T>(path, { method: "POST", body: fd }),
};

Resource modules (auth.ts, tags.ts, categories.ts, duplicates.ts) wrap api with typed helpers. Endpoints without a dedicated module (files, pools, users, acl, audit) are called through api.* directly from their route components.

Type Generation

// package.json
{
	"scripts": {
		"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
		"dev": "npm run generate:types && vite dev",
		"build": "npm run generate:types && vite build"
	}
}

schema.ts is generated (and gitignored) β€” never edit it by hand. types.ts re-exports friendly aliases:

import type { components } from "./schema";
export type File = components["schemas"]["File"];
export type Tag = components["schemas"]["Tag"];
// …

SPA Mode (build)

// svelte.config.js
import adapter from "@sveltejs/adapter-static";
export default { kit: { adapter: adapter({ fallback: "index.html" }) } };

The Go backend serves index.html for all non-API routes (SPA fallback, see handler/static.go). In development the Vite dev server serves the UI and the mock plugin (or a proxied Go backend) answers /api.

PWA

service-worker.ts handles app-shell caching (HTML/CSS/JS/fonts) and optional user-pinned file caching for offline viewing; utils/pwa.ts exposes the reset / update flow (clear caches and reload from the server, keeping pinned files).