Add catch-all routes so unknown pages 404 inside the (app) layout and unknown /api URLs return a JSON 404 instead of redirecting to the login page. Restore the full root error page.
114 lines
13 KiB
Markdown
114 lines
13 KiB
Markdown
# Musify: web-player
|
|
|
|
Musify's public web app (`web-player/` folder). Design and UI recipes live in this file and in `src/routes/layout.css`.
|
|
|
|
## Stack
|
|
|
|
SvelteKit 2 (`adapter-node`), Svelte 5 **runes**, Tailwind 4, TypeScript strict, Zitadel OIDC auth (`openid-client` + `jose`).
|
|
|
|
There's no `svelte.config.js` (config is inline in `vite.config.ts`) and no `tailwind.config.js` (config lives in `src/routes/layout.css`).
|
|
|
|
## Commands
|
|
|
|
```sh
|
|
npm run dev # dev server (port 5173)
|
|
npm run build
|
|
npm run check # svelte-check, must exit 0 errors before closing a change
|
|
npm run format # prettier --write .
|
|
npm run test # vitest run (tests live next to the file they test)
|
|
npm run lint # prettier --check . && eslint . && npm run lint:tokens && npm run test
|
|
npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below)
|
|
```
|
|
|
|
## Rules
|
|
|
|
- **No comments** in code unless essential.
|
|
- `npm run lint` and `npm run check` must exit clean before closing a change.
|
|
- **Imports:** always `$lib/...`, never `../` (ESLint enforces it). Non-null assertions are forbidden too.
|
|
- **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed.
|
|
- **Server only code goes under `$lib/server/`**, and it's never imported from client code.
|
|
- SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop.
|
|
- Links to `+server.ts` endpoints (`/auth/login`, `/auth/logout`) carry `data-sveltekit-reload`.
|
|
- Reuse design system tokens and recipes before writing new CSS. If something repeats, extract it to `$lib/components/ui/`.
|
|
|
|
## Folder structure
|
|
|
|
- `src/lib/types.ts` holds the domain types (`Track`, `Album`, `Playlist`, `Mix`, `ListeningStats`, `Paged<T>`). Pages and the player only ever see these, never raw API DTOs.
|
|
- `src/lib/player/` is the player: `player.svelte.ts` (state and orchestration), `queue.ts` (pure queue operations), `stream.ts`, `mediaSession.ts`, `progressClock.ts`, `listenTracker.ts`, `recentlyPlayed.ts` (merges `player.recentlyPlayed` with the server history), and `actions.ts` (`playAllOrToggle`, `playShuffled`, `isQueueCurrent`).
|
|
- `src/lib/state/` holds reactive rune state: `panels.svelte.ts` (queue panel, create playlist modal), `history.svelte.ts` (previous page for `BackLink`), `menu.svelte.ts` (`createMenu<T>()`), `pagedList.svelte.ts` (`createPagedList`), `dialog.svelte.ts`, `liked.svelte.ts` (the user's liked tracks), `toggle.svelte.ts` (`createToggle`), and `scroll.ts` (`restoreScroll`). Nothing in `state/` imports from `components/`.
|
|
- `src/lib/theme/` holds the theme: `accent.svelte.ts` (accent hue per track and the hue fade), `mode.svelte.ts`, `palette.ts`, `tokens.ts`, and `color.ts` (`ACCENT_HUE`). The player never imports from `theme/`: `(app)/+layout.svelte` follows `player.currentId` and calls `accent.follow`.
|
|
- `src/lib/data/` holds client side domain helpers: albums, genres, search. It never imports from `player/`.
|
|
- `src/lib/utils/` holds generic utilities: `format.ts`, `collections.ts`, `hrefs.ts` (search, genre and cover URLs), `menuPosition.ts`, `storage.ts`, `transitions.ts`.
|
|
- `src/lib/actions/` holds Svelte actions: `clickOutside`, `onEscape`, `pressable`.
|
|
- `src/lib/validation.ts` holds the form limits (`LIMITS`) shared by client forms and server parsers.
|
|
- `src/lib/server/` is server only code (see below).
|
|
- `src/lib/components/shell/` holds the app shell: Sidebar, TopBar, TabsBar, MobileHeader, AccountMenu, and `navLinks.ts`.
|
|
- `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls.
|
|
- `src/lib/components/music/` holds the components that connect the UI to app state (the player, album data): TrackList, PlayAllButton, TrackContextMenu, AlbumContextMenu.
|
|
- `src/lib/components/ui/` holds the design system, which is presentation only. ESLint forbids importing `$lib/player/*`, `$lib/data/*`, `$lib/server/*`, `components/music`, `components/shell` and `components/player` from it; it can use `$lib/state/*`, `$lib/utils/*`, `$lib/actions/*`, `$lib/theme/*` and `$lib/types`. It is split into five categories:
|
|
- `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).
|
|
- `overlay/` for anything that floats above the content (Modal, ContextMenu, GlassMenu, ConfirmDialog, and so on).
|
|
- `media/` for stateless music display components that receive props (Artwork, MediaCard, ListRow, PlayButton, and so on).
|
|
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone).
|
|
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading).
|
|
- The root `+layout.svelte` only holds the globals (fonts, theme, `DialogHost`). Everything the user sees after signing in lives under `src/routes/(app)/`, including Home (`(app)/+page.svelte`); `(app)/+layout.server.ts` guards the group and loads the shell data, and `(app)/+error.svelte` renders inside the shell. Unknown URLs hit the catch-all `(app)/[...rest]/` (it throws a 404) so they also get the shell (unknown `/api/...` URLs hit `api/[...rest]/`, a JSON 404); the root `+error.svelte` only covers errors outside the group or in the group layout load. Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`).
|
|
|
|
## Naming conventions and glossary
|
|
|
|
- Variant maps are module level constants in upper case (`SIZE`, `VARIANT`).
|
|
- Errors: destructure as `const { data, error } = ...` and name the caught value `err` in `catch (err)`.
|
|
- Play handlers: `playFrom(index)` and `playAll()`. Form actions: `create`, `edit`, `delete`, `addTrack`, `removeTrack`.
|
|
- UI copy: "Playlist" (never "Lista"), "Mezcla" (never "Mix"), "Modo claro". Total durations always go through `fmtDurationLong`.
|
|
|
|
## Styles
|
|
|
|
The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utility` recipes) and in `src/lib/theme/` (`theme.css` for the static neutrals, `tokens.ts` for the dynamic tokens that depend on the user's hue). `scripts/check-tokens.mjs` (run as `npm run lint:tokens`, part of `lint`) fails on a stock radius, an `h-N w-N` pair, fractional quarter spacing, a literal hex/`rgba()`, or an arbitrary `-[…]` value outside its whitelist, so check that script before adding one.
|
|
|
|
- **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`.
|
|
- **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl/2xl/3xl`; covers: `size-cover-xs` through `size-cover-hero`).
|
|
- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`.
|
|
- **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`).
|
|
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
|
|
- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token.
|
|
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
|
|
- **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` utility.
|
|
|
|
## App shell
|
|
|
|
The shell in `src/routes/(app)/+layout.svelte` is a fixed `h-dvh` frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (`app-scroll`) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over `app-backdrop`. Because the window never scrolls, scroll reset/restore on navigation is handled by `restoreScroll()` in `$lib/state/scroll.ts`; don't use `window.scrollTo`/`scrollY`.
|
|
|
|
## Motion
|
|
|
|
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover.
|
|
|
|
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
|
|
|
|
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in the root `+layout.svelte` (a random 2 to 4 seconds per song change), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`.
|
|
|
|
## Auth
|
|
|
|
`hooks.server.ts` validates the encrypted session cookie (`mf_session`), refreshes the `access_token`, and fills `locals.user` and `locals.accessToken`. To protect a route, check `locals.user` in its `load` and redirect to `/auth?returnTo=…`. When talking to the backend, send `locals.accessToken` as a Bearer token to `API_BASE_URL`.
|
|
|
|
Environment variables live in `.env` (see `.env.example`): `ZITADEL_*`, `AUTH_REDIRECT_URI`, `AUTH_POST_LOGOUT_URI`, `SESSION_SECRET`, `API_BASE_URL`.
|
|
|
|
## Backend API
|
|
|
|
Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action use the client in `$lib/server/api.ts`:
|
|
|
|
```ts
|
|
const api = apiFor(event); // or apiFor({ fetch, locals }); adds the bearer token when there is a session
|
|
const { data, error } = await api.GET('/tracks');
|
|
```
|
|
|
|
It returns `{ data, error }` (ErrorOr style) and never throws except on network errors. The types live in `src/lib/api/schema.d.ts` and get regenerated with `npm run gen:api` (this needs the dev API running at `API_BASE_URL`, which only exposes `/openapi/v1.json` in Development).
|
|
|
|
Server helpers:
|
|
|
|
- `api.ts` holds the client and error handling: `apiFor`, `unwrapOrError`, `failOnError` (turns an API error into a `fail(...)`), `requireData` (also narrows the response data) and `apiErrorDetail`.
|
|
- `guards.ts` holds `requireUser`, `optionalUser` and `loginRedirect`. Keep them in pages and actions even though the `(app)` layout also redirects: page loads run in parallel with the layout load, and form actions skip it.
|
|
- `mappers.ts` (`toTrack`, `toAlbum`, `toPlaylist`, `toMix`, `toPage`, `toCount`, `toListeningStats`, ...) turn DTOs into the domain types. Every load and `/api` endpoint goes through them, so `Number()` conversions on DTOs never reach routes or the client.
|
|
- `actions/` holds `authedAction(handler)` (returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler) plus the shared `follow` and `playlist` actions.
|
|
- `forms/` holds `fields.ts` (`formString`, `formFile`) and the form parsers (`parseAlbumForm`, `parsePlaylistForm`, `parseTrackUploadForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload.
|
|
- `genres.ts` caches the genre list; `playbackConfig.ts` caches the anonymous listening setting.
|
|
- Page sizes come from `$lib/config`; do not hardcode them.
|