Frontend counterpart of ARCHITECTURE.md. This document details the SvelteKit layout, the CSS approach, and the API client.
adapter-static, ssr = false),
Svelte 5 (runes)@tailwindcss/vite + CSS custom properties
(@theme in app.css) β no tailwind.config.* / postcss.config.* fileopenapi-typescript (src/lib/api/schema.ts)vite-mock-plugin.ts serves a mock API so the UI can run without the
Go backendThis 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:
admin/ has its own guard) and
dynamic segments ([id]).goto, the page store/state, afterNavigate,
navigating.pushState/replaceState + page.state. The
Immich-style file viewer in files/ and pools/[id]/ opens as an overlay
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,
admin redirect, / β /files).$lib alias, generated ./$types, Vite/HMR integration.SvelteKit features we deliberately do not use (the βserver halfβ):
+page.server.ts, +server.ts endpoints, form actions β all data goes
through the Go API via the $lib/api client.hooks.*, prerendering, server-only modules β no hooks.server.ts /
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 (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.
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/
βββ 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
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.
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.
// 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"];
// β¦
// 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.
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).