docs: add web UI scaffold design spec
First pass of the web UI design doc from the M1 close-out brainstorm. Covers the stack (SvelteKit static + Go embed), auth model (cookie + bearer from one endpoint), /api/* surface, shell layout (sidebar + bottom player), first-cut feature scope, and how later web UI work threads into M2–M5 alongside each backend milestone instead of landing as a post-hoc M6. Also ignores the local .superpowers/ brainstorming companion cache. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -25,3 +25,6 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
|
||||||
|
# Superpowers brainstorming companion sessions
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# Web UI Scaffold — Design
|
||||||
|
|
||||||
|
**Status:** Approved (brainstorm), ready for implementation plan
|
||||||
|
**Date:** 2026-04-20
|
||||||
|
**Milestone:** M1.5 (new) — scaffold for the web UI that M2–M5 layer features onto
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Minstrel needs a built-in web UI with feature parity to the planned Flutter client. The M1 Subsonic baseline is verified end-to-end via third-party clients (Feishin), but Minstrel's own UI surface is currently empty. This spec describes the scaffold: the stack, the packaging, the auth model, the API surface the SPA consumes, the shell layout, and the first-cut feature set.
|
||||||
|
|
||||||
|
Features that depend on backend work not yet built (events, recommendations, radio, Lidarr) are explicitly deferred and will land alongside their respective backend milestones. The web UI is not a late-stage M6 milestone — it advances through M2–M5 in step with the server.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- OIDC / SSO login (deferred until after a solid UI exists; possibly post-v1).
|
||||||
|
- Light-theme support (dark theme only for first cut; light mode follows).
|
||||||
|
- Mobile-first responsive layouts (desktop-first; the web UI is expected to be *more dense* than the Flutter client, which will own the mobile experience).
|
||||||
|
- SSR / server-rendered initial HTML (pure SPA — SvelteKit static adapter).
|
||||||
|
- Reusing the `/rest/*` Subsonic surface from the SPA.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Framework:** SvelteKit + TypeScript + Vite.
|
||||||
|
- **Adapter:** `@sveltejs/adapter-static`, building to `web/build/` as fully-static HTML/CSS/JS.
|
||||||
|
- **SPA fallback:** `adapter-static` configured with `fallback: 'index.html'` so deep links (`/artists/abc`) load the SPA shell and client-side routing resolves the path.
|
||||||
|
- **Styling:** Tailwind CSS + component-scoped Svelte styles. No UI kit — hand-rolled components kept minimal.
|
||||||
|
- **State:** Svelte stores. No external state library.
|
||||||
|
- **HTTP:** Native `fetch`; a thin wrapper (`web/src/lib/api.ts`) adds credentials, parses errors, returns typed responses.
|
||||||
|
|
||||||
|
### Why Svelte over React/Solid
|
||||||
|
|
||||||
|
- Compiled output is small and fast. Matters when rendering large library views (paginated at first; virtualization if we hit perf walls later).
|
||||||
|
- Built-in routing / stores / forms mean fewer library decisions for a solo project.
|
||||||
|
- The trade-off (smaller ecosystem) is acceptable because the components we need — list views, player UI, form controls — are trivial to build or exist as libraries.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
- **Single binary, single image.** SvelteKit builds to static assets; Go embeds `web/build/` via `//go:embed` and serves from root (`/`) with SPA fallback (unmatched paths return `index.html`).
|
||||||
|
- **Dockerfile:** multi-stage.
|
||||||
|
1. `FROM node:<lts> AS web` — `npm ci`, `npm run build`, produces `web/build/`.
|
||||||
|
2. `FROM golang:<ver> AS go` — `go build`, with `web/build/` copied in before build so `//go:embed` picks it up.
|
||||||
|
3. `FROM debian:stable-slim` (or existing base) — copy binary, ffmpeg, run.
|
||||||
|
- Release artifact remains a single container image. `docker compose up` stays one service for the app.
|
||||||
|
|
||||||
|
## Dev workflow
|
||||||
|
|
||||||
|
- Two processes during development:
|
||||||
|
1. `docker compose up` — Go server on `:4533`, Postgres, scanner.
|
||||||
|
2. `cd web && npm run dev` — Vite dev server on `:5173` with HMR.
|
||||||
|
- Vite dev server proxies `/api/*` and `/rest/*` to `http://localhost:4533`. The SPA is loaded from `:5173` in dev; cookies on `:5173` work because the proxy rewrites the origin.
|
||||||
|
- Production build: `npm run build` runs in CI / Docker build; static assets get embedded.
|
||||||
|
|
||||||
|
## API surface
|
||||||
|
|
||||||
|
### Principle
|
||||||
|
|
||||||
|
- `/rest/*` is the Subsonic-compatibility surface for third-party clients. It keeps salted-md5 + apiKey auth, Subsonic envelope, XML/JSON shape. **The SPA does not call `/rest/*`.**
|
||||||
|
- `/api/*` is the native surface for Minstrel's own clients (web SPA now, Flutter later). Clean JSON, cookie-or-bearer auth, designed for modern consumers.
|
||||||
|
|
||||||
|
### First-cut endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/api/auth/login` | Verify credentials. Returns `{token, user}`, sets `httpOnly` session cookie. |
|
||||||
|
| POST | `/api/auth/logout` | Invalidate session cookie and the bearer token associated with the caller's session. |
|
||||||
|
| GET | `/api/me` | Return the authenticated user (username, is_admin, etc.). |
|
||||||
|
| GET | `/api/artists` | Paginated artist list with sort keys. |
|
||||||
|
| GET | `/api/artists/{id}` | Artist with albums array. |
|
||||||
|
| GET | `/api/albums/{id}` | Album with tracks array. |
|
||||||
|
| GET | `/api/tracks/{id}` | Single track (used for now-playing detail / direct-link). |
|
||||||
|
| GET | `/api/search?q=` | Unified search returning `{artists, albums, tracks}` arrays. |
|
||||||
|
| GET | `/api/stream/{trackId}` | Audio stream with `Accept-Ranges: bytes` for scrubbing. Cookie-auth works because `<audio src>` sends cookies; bearer works for non-browser clients. |
|
||||||
|
| GET | `/api/cover/{albumId}` | Album cover art, sized via `?size=` query. |
|
||||||
|
|
||||||
|
### Implementation approach
|
||||||
|
|
||||||
|
- Handlers are thin — argument parsing, call to existing sqlc queries in `internal/db/dbq/*`, JSON marshalling.
|
||||||
|
- Shared types go in `internal/api/types.go` (request/response shapes). These map 1:1 to TypeScript types in `web/src/lib/types.ts` (kept hand-synced; codegen deferred).
|
||||||
|
- Errors follow `{error: {code, message}}` with standard HTTP status codes. No Subsonic-style "status=failed" envelope on `/api/*`.
|
||||||
|
|
||||||
|
### Deferred endpoints (land with their backend milestone)
|
||||||
|
|
||||||
|
- M2: `POST /api/events/{play|skip|complete}`, `POST /api/tracks/{id}/star`, `DELETE /api/tracks/{id}/star`, `GET /api/me/recently-played`.
|
||||||
|
- M3: `GET /api/shuffle`, `POST /api/tracks/{id}/contextual-like`, related artist/session queries.
|
||||||
|
- M4: `POST /api/radio`, `GET /api/radio/{id}`, ListenBrainz settings.
|
||||||
|
- M5: Lidarr proxy + quarantine endpoints.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### Login flow
|
||||||
|
|
||||||
|
1. SPA `POST /api/auth/login` with `{username, password}`.
|
||||||
|
2. Server verifies against `password_hash`, creates a session row (opaque 32-byte random token), returns `{token, user}`.
|
||||||
|
3. Server also sets `Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strict; Path=/`.
|
||||||
|
4. SPA stores the user object in a Svelte store; the cookie travels automatically. **SPA does not persist the bearer token** — it's returned only so Flutter can consume the same endpoint later.
|
||||||
|
|
||||||
|
### Middleware
|
||||||
|
|
||||||
|
- `internal/auth.RequireUser` middleware resolves the caller in this order:
|
||||||
|
1. `Cookie: session=<token>` → sessions table lookup.
|
||||||
|
2. `Authorization: Bearer <token>` → sessions table lookup.
|
||||||
|
3. Legacy Subsonic params (only within `/rest/*` — unchanged from today).
|
||||||
|
- On success, user is placed in request context (same pattern as `UserFromContext` used in `internal/subsonic`).
|
||||||
|
- Unauthenticated requests to `/api/*` → `401 {error: {code: "unauthenticated", message: "..."}}`.
|
||||||
|
|
||||||
|
### Sessions table
|
||||||
|
|
||||||
|
- New migration: `sessions(id uuid pk, user_id uuid fk, token_hash bytea, created_at, last_seen_at, user_agent text)`.
|
||||||
|
- Token is hashed in DB (sha256) so a DB leak doesn't grant active sessions.
|
||||||
|
- Logout deletes the session row; cookie is expired via `Set-Cookie: session=; Max-Age=0`.
|
||||||
|
|
||||||
|
### Why this design
|
||||||
|
|
||||||
|
- Web is cookie-safe (no token in JS / localStorage → no XSS extraction).
|
||||||
|
- Flutter later uses the same `POST /api/auth/login` endpoint; it ignores the cookie and sends `Authorization: Bearer` on every call.
|
||||||
|
- OIDC, when it eventually lands, plugs in at the same login-success point: the callback handler mints the same `{token + cookie}` response. The rest of the app doesn't change.
|
||||||
|
|
||||||
|
## Shell layout
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
| SIDEBAR | MAIN CONTENT |
|
||||||
|
| | |
|
||||||
|
| Minstrel | (route content — artists list, |
|
||||||
|
| | album grid, search results, |
|
||||||
|
| ▸ Home | artist detail, settings, etc.) |
|
||||||
|
| ▸ Library | |
|
||||||
|
| ▸ Search | |
|
||||||
|
| ▸ Recent | |
|
||||||
|
| ▸ Settings | |
|
||||||
|
| | |
|
||||||
|
| — Admin — | |
|
||||||
|
| ▸ Users | |
|
||||||
|
| ▸ Scan | |
|
||||||
|
| ▸ Quarantine | |
|
||||||
|
| | |
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
| PLAYER BAR: art · title / artist · controls · scrub · time |
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
- `Sidebar.svelte` — brand, primary nav, admin section (items render as disabled until their backend exists).
|
||||||
|
- `PlayerBar.svelte` — persistent across route changes. Owns a single `<audio>` element via `bind:this`. Reads from and writes to the `player` Svelte store.
|
||||||
|
- `RouteShell.svelte` — root layout. Mounts sidebar + outlet + player bar. Subscribes `player` store to `<audio>` element events (`timeupdate`, `ended`, `error`).
|
||||||
|
- Routes: `/login`, `/` (home), `/library`, `/artists/[id]`, `/albums/[id]`, `/search`, `/settings`, `/admin/*`.
|
||||||
|
|
||||||
|
### Player state (Svelte store)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PlayerState {
|
||||||
|
queue: Track[];
|
||||||
|
queueIndex: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTime: number; // seconds
|
||||||
|
duration: number; // seconds
|
||||||
|
volume: number; // 0..1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Queue operations: `enqueue`, `playNow`, `prev`, `next`, `seek`, `setVolume`.
|
||||||
|
- Store methods kick the `<audio>` element via imperative calls (`audio.play()`, `audio.currentTime = …`).
|
||||||
|
- The store is the only place that writes player state; components read via `$player` subscriptions.
|
||||||
|
|
||||||
|
### Theme
|
||||||
|
|
||||||
|
- Dark-only for first cut. Colors via CSS custom properties in `:root`. Palette: neutral dark grays (`#14161a` / `#1a1d22` / `#2a2f36`), light text (`#cdd3db` / `#e8ecf2`), accent color picked during implementation — scaffold works with any single-hue accent.
|
||||||
|
- Layout convention: left sidebar width `240px`, player bar height `72px`, content scrolls independently of shell.
|
||||||
|
|
||||||
|
### Keyboard shortcuts
|
||||||
|
|
||||||
|
- `Space` — play/pause (blocked when focus is in an input).
|
||||||
|
- `ArrowLeft` / `ArrowRight` — skip back / forward 5s.
|
||||||
|
- `Shift+ArrowLeft` / `Shift+ArrowRight` — prev / next track.
|
||||||
|
- `/` — focus search input.
|
||||||
|
|
||||||
|
## First-cut feature scope
|
||||||
|
|
||||||
|
- Login / logout.
|
||||||
|
- Artist list (sortable, searchable within-list).
|
||||||
|
- Artist detail → albums grid.
|
||||||
|
- Album detail → track list with inline play buttons.
|
||||||
|
- Global search: `?q=` against tracks + albums + artists, results grouped.
|
||||||
|
- Player: queue, play/pause/seek/prev/next, volume, keyboard shortcuts, auto-advance on `ended`.
|
||||||
|
- Now-playing: persistent player bar always visible; clicking it opens an expanded queue panel.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
Tracked in Fable milestones 27–30 (M2–M5). Each milestone adds the corresponding UI alongside its backend work.
|
||||||
|
|
||||||
|
- **M2:** Star/unstar UI, recently-played view, event reporting from player (play/skip/complete at the appropriate thresholds).
|
||||||
|
- **M3:** Smart-shuffle entry points, contextual-like controls.
|
||||||
|
- **M4:** Radio seed / start UI, ListenBrainz account connection in Settings.
|
||||||
|
- **M5:** Lidarr quarantine admin, "suggested additions" panel on radio.
|
||||||
|
- **Post-v1:** Stats dashboards, user management UI, OIDC login, light theme, mobile-responsive polish.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Svelte (Vitest):** component tests for non-trivial units only — `player` store (queue/seek/advance logic), `api.ts` (error parsing / auth header injection), auth store. Skip snapshot tests and boilerplate component rendering.
|
||||||
|
- **Go (`internal/api`):** handler-level tests per the existing pattern in `internal/subsonic/*_test.go` — httptest ResponseRecorder, context-injected user, JSON decode of response body.
|
||||||
|
- **No E2E / Playwright** for first cut. If a full-stack smoke test becomes valuable, add one headless Playwright scenario (login → play a track) later.
|
||||||
|
|
||||||
|
## Repo structure
|
||||||
|
|
||||||
|
```
|
||||||
|
minstrel/
|
||||||
|
├── cmd/minstrel/ # Go binary (unchanged)
|
||||||
|
├── internal/
|
||||||
|
│ ├── api/ # NEW: /api/* handlers + shared types
|
||||||
|
│ │ ├── auth.go
|
||||||
|
│ │ ├── library.go
|
||||||
|
│ │ ├── search.go
|
||||||
|
│ │ ├── stream.go
|
||||||
|
│ │ ├── types.go
|
||||||
|
│ │ └── *_test.go
|
||||||
|
│ ├── auth/ # existing; add session token logic
|
||||||
|
│ ├── subsonic/ # unchanged
|
||||||
|
│ ├── library/ # unchanged
|
||||||
|
│ ├── db/ # + migration for sessions
|
||||||
|
│ └── server/ # Router() wires /api/* alongside /rest/*
|
||||||
|
├── web/ # NEW: SvelteKit project
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── lib/
|
||||||
|
│ │ │ ├── api.ts # fetch wrapper
|
||||||
|
│ │ │ ├── stores/
|
||||||
|
│ │ │ │ ├── player.ts
|
||||||
|
│ │ │ │ └── auth.ts
|
||||||
|
│ │ │ ├── components/ # Sidebar, PlayerBar, etc.
|
||||||
|
│ │ │ └── types.ts # TypeScript mirrors of internal/api/types.go
|
||||||
|
│ │ ├── routes/
|
||||||
|
│ │ │ ├── +layout.svelte
|
||||||
|
│ │ │ ├── +page.svelte # Home
|
||||||
|
│ │ │ ├── login/+page.svelte
|
||||||
|
│ │ │ ├── artists/+page.svelte
|
||||||
|
│ │ │ ├── artists/[id]/+page.svelte
|
||||||
|
│ │ │ ├── albums/[id]/+page.svelte
|
||||||
|
│ │ │ ├── search/+page.svelte
|
||||||
|
│ │ │ └── settings/+page.svelte
|
||||||
|
│ │ └── app.html
|
||||||
|
│ ├── static/
|
||||||
|
│ ├── svelte.config.js # adapter-static + fallback
|
||||||
|
│ ├── vite.config.ts # /api + /rest proxy for dev
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── tsconfig.json
|
||||||
|
├── docs/
|
||||||
|
├── Dockerfile # multi-stage: node → go → slim
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open questions resolved
|
||||||
|
|
||||||
|
- **Framework:** Svelte (B in brainstorm).
|
||||||
|
- **Packaging:** Single binary with embedded static (A in brainstorm).
|
||||||
|
- **Auth:** Cookie + bearer from same endpoint (C in brainstorm).
|
||||||
|
- **API shape:** New `/api/*` surface (B in brainstorm).
|
||||||
|
- **Scope:** Rich target, scaffold first, features woven through M2–M5 (C in brainstorm with sequencing plan).
|
||||||
|
- **Layout:** Left sidebar + bottom persistent player (A in brainstorm).
|
||||||
|
|
||||||
|
## Risks / decisions to revisit during implementation
|
||||||
|
|
||||||
|
- **Cover art sizing.** `/rest/getCoverArt` does thumbnail scaling via ffmpeg (or similar). `/api/cover/{id}?size=` may need the same logic; could call the existing Subsonic helper internally rather than re-implementing.
|
||||||
|
- **TypeScript / Go type sync.** Manual hand-sync is fine at first-cut scale (maybe 10 types). If it becomes a churn point, introduce codegen (oapi-codegen / sqlc-like generator) — not first-cut scope.
|
||||||
|
- **Stream auth with `<audio>` elements.** Cookies carry automatically. Verify Chromium, Firefox, Safari all forward the cookie on `<audio src>` requests — we believe they do; flag if not.
|
||||||
|
- **Keyboard shortcut collisions.** Space-bar for play/pause conflicts with page scroll. Only capture when body isn't scrolled-to-focus-element; easy to get wrong. Write a small test when implementing.
|
||||||
Reference in New Issue
Block a user