From c041daf649cd7675a371aa5a6c051619aaf75630 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 11:54:38 +0200 Subject: [PATCH] web-player: add lint guardrails for imports, non-null assertions, unused props and icon sizes; refresh docs --- web-player/CLAUDE.md | 47 ++++++--- web-player/docs/paginas.md | 147 +++++++--------------------- web-player/eslint.config.js | 13 +++ web-player/scripts/check-tokens.mjs | 15 +++ 4 files changed, 97 insertions(+), 125 deletions(-) diff --git a/web-player/CLAUDE.md b/web-player/CLAUDE.md index 4b8700d..1345cec 100644 --- a/web-player/CLAUDE.md +++ b/web-player/CLAUDE.md @@ -15,7 +15,8 @@ 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 lint # prettier --check . && eslint . && npm run lint:tokens +npm run test # vitest run (pure functions, mappers, form parsers) +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) ``` @@ -23,6 +24,7 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail - **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. @@ -31,18 +33,30 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail ## Folder structure -- `src/lib/state/` holds reactive rune based state (`*.svelte.ts` files that don't have a more specific home). Player state stays in `$lib/player/`, theme mode in `$lib/theme/`. -- `src/lib/data/` holds client side domain helpers: albums, collections, genres, mixes, recentlyPlayed, search. -- `src/lib/utils/` holds generic utilities with no domain knowledge, like `format.ts`. -- `src/lib/components/layout/` holds the app chrome: Sidebar, TopBar, TabsBar, MobileHeader. +- `src/lib/types.ts` holds the domain types (`Track`, `Album`, `Playlist`, `Mix`, `Paged`). 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`, `liked.svelte.ts`, 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()`), `pagedList.svelte.ts` (`createPagedList`), `dialog.svelte.ts`, and `scroll.ts`. 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`. +- `src/lib/data/` holds client side domain helpers: albums, genres, recentlyPlayed, search. +- `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/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/layout/` holds the app chrome: Sidebar, TopBar, TabsBar, MobileHeader, and `navLinks.ts`. - `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls. - `src/lib/components/ui/` holds the design system components, 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, AccountMenu, and so on). - - `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, and so on). + - `overlay/` for anything that floats above the content (Modal, ContextMenu, TrackContextMenu, AlbumContextMenu, AccountMenu, and so on). + - `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, PlayAllButton, and so on). - `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone). - `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome. -- 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`). +- Everything the user sees after signing in lives under `src/routes/(app)/`, including Home (`(app)/+page.svelte`). 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 @@ -77,12 +91,19 @@ Environment variables live in `.env` (see `.env.example`): `ZITADEL_*`, `AUTH_RE ## Backend API -Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action: +Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action use the helpers in `$lib/server/api.ts`: ```ts -import { createApiClient } from '$lib/server/api'; -const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); -const { data, error: err } = await api.GET('/tracks'); +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. 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). +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: + +- `mappers.ts` (`toTrack`, `toAlbum`, `toPlaylist`, `toMix`, `toPage`, ...) turn DTOs into the domain types. Every load and `/api` endpoint that returns tracks, albums or playlists goes through them, so `Number()` conversions never reach the client. +- `authedAction(handler)` wraps a form action: it returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler. Use `formString`/`formFile` to read fields, `failOnError(result, message)` to turn an API error into a `fail(...)`, and `requireData(result, message)` when you also need the response data narrowed. +- `forms/` holds 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. diff --git a/web-player/docs/paginas.md b/web-player/docs/paginas.md index 095282c..8d88aa9 100644 --- a/web-player/docs/paginas.md +++ b/web-player/docs/paginas.md @@ -1,128 +1,51 @@ # Musify, page guide -Living document describing what each page of the web frontend should contain. It works as a map for prioritizing features. The design system (tokens, recipes, components) lives in the `musify-web` skill; here we describe **content and structure**, not styling. - -Status legend: โœ… done ยท ๐ŸŸก partial / mockup ยท โฌœ pending. - -> **Note (app redesign):** the `Musify.dc.html` prototype is being ported over (an app with a sidebar, views, and a persistent player with a dynamic accent per track). The marketing landing page was removed. The sections below still describe the older design and are pending an update to match the app views (Home, Search, Library, Upload, Import, Downloads, Playlists). The **player** is already built as components in `$lib/components/player/`, with its theme in `$lib/theme/` (`theme.css` for the palette, `color.ts` for the accent and gradient). - -## Home (app) `/` ๐ŸŸก - -- **Hero**: a greeting plus "Your music. No limits." with a color wash taken from the current track's hue. -- **Quick access, meaning recently played** (`GET /users/{id}/listening-history` merged with `player.recentlyPlayed` via `mergeRecentlyPlayed`). Empty state with an icon and an "Explore" CTA. -- **Your playlists**: mosaic cards for the user's playlists (sample data for now). Has an empty state. -- **Resume on load**: the root layout fetches `GET /users/{id}/last-listened-track` and hydrates the player (`player.hydrate`) with it, paused, so the player bar isn't empty on a fresh session. Only applies when nothing is already loaded (won't override an in-progress session). - -### Pending backend work (ASP.NET) for Home - -- **Playlists by last listen**: add `lastPlayedAt` to the playlist (the schema currently only has `createdAt`/`updatedAt`) and allow sorting `GET /playlists/users/{userId}` by that field (for example `?sort=lastPlayed`), to show "your most recently played" playlists. -- Tracks will need an **artist** and a **color/hue** (or a real cover to extract one from) for the artwork and the dynamic accent. Right now `TrackApplicationResponse` carries neither artist nor color. +Living document describing what each page of the web frontend contains. The design system (tokens, recipes, components) lives in the `musify-web` skill and in `CLAUDE.md`; here we describe **content and structure**, not styling. ## Route structure -Routes are grouped to separate the public experience from the authenticated one (the groups don't change the URL): +Everything the user sees after signing in lives under the `(app)` group (the group doesn't change the URL). `auth/` and `api/` sit outside it. ``` src/routes/ - (marketing)/ Public, no session required - +page.svelte / Landing โœ… - (app)/ The app experience - explore/ /explore Explore / search โœ… - upload/ /upload Upload music ๐ŸŸก mockup - library/ /library Your library โฌœ - playlists/ /playlists Your playlists โฌœ - [id]/ /playlists/:id Playlist detail โฌœ - auth auth/login auth/logout auth/callback Auth (page + endpoints) โœ… - api/tracks/[id]/stream Stream proxy โœ… + (app)/ + +page.svelte / Home + explore/ /explore Search and genre discovery + liked/ /liked Liked songs + library/ /library Your uploaded songs + upload/ /upload Upload a song + albums/ /albums Your albums + [id]/ /albums/:id Album detail + playlists/ /playlists Your playlists + [id]/ /playlists/:id Playlist detail + mixes/[id]/ /mixes/:id Generated mix + user/[id]/ /user/:id Public profile + [list=followList]/ /user/:id/followers|following + auth/ auth/login auth/logout auth/callback Login screen and OIDC endpoints + api/ Same origin JSON endpoints used by the client ``` -Global components (present on every page): **Navbar**, **Footer**, and **PlayerBar** (the fixed playback bar, shown while something is playing). +Global chrome on every app page: sidebar (desktop) or tab bar (mobile), top bar with search and account menu, the persistent player dock, and the queue panel. ---- +## Pages -## 1. Landing `/` โœ… (marketing) +- **Home `/`**: a greeting, weekly listening stats, "continue listening" (recently played merged with the in-session history), the featured playlist with its first tracks (play and shuffle load the full list on demand), the user's mixes and playlists. Anonymous visitors are redirected to `/explore`. +- **Explore `/explore`**: genre tiles when there is no query, otherwise search results grouped by songs, albums, users and playlists with filter chips and counts taken from the API totals. Songs paginate with infinite scroll. Track and album context menus offer play next, add to queue and add to playlist. +- **Liked `/liked`**: the songs the user liked, newest first, with unlike per row. +- **Library `/library`**: the songs the user uploaded, with a confirmed delete. +- **Upload `/upload`**: audio, cover, title, genres (incompatible genres are blocked) and explicit flag. The server reserves presigned URLs, uploads both files and creates the track; processing happens asynchronously in the backend. +- **Albums `/albums`, `/albums/:id`**: list and create albums. The owner can edit, delete and add or remove tracks from their library. +- **Playlists `/playlists`, `/playlists/:id`**: list and create playlists. The owner can edit, delete and remove tracks; everyone else gets a read only view. +- **Mix `/mixes/:id`**: a generated mix with play, shuffle and a track menu. +- **Profile `/user/:id`**: public playlists, follow button and links to the followers and following lists, which respect the profile's privacy. +- **Auth `/auth`**: the login screen. Its buttons lead to `/auth/login`, which starts the Zitadel OIDC flow; `?mode=register` opens the sign up variant and `?returnTo=` (local paths only) sends the user back afterwards. `/auth/logout` ends the session and `/auth/callback` creates the encrypted `mf_session` cookie. -The entry page for logged out visitors. Its job is to sell the product and drive people to sign up or explore. +## Data flow -- **Hero**: a status badge (beta), a title, a subtitle, a primary CTA (Start for free, leading to sign up) and a secondary one (Explore songs). -- **Features**: three cards covering listening, playlists, and uploading. -- **Suggested next steps**: a screenshots or demo section, social proof, a short FAQ. +Each page loads its data in `+page.server.ts` through the typed API client, and normalizes it with the mappers in `$lib/server/mappers.ts` so the client only ever sees the domain types in `$lib/types.ts`. Mutations are form actions built with `authedAction`. Client side pagination goes through the `/api/*` endpoints. -## 2. Login / Auth โœ… (endpoints) +## Pending backend work -`/auth` is Musify's login screen; its buttons lead to `/auth/login`, which kicks off the Zitadel OIDC flow and redirects. `?mode=register` starts on the sign up variant, and `?returnTo=` sends the user back to the route they came from once they're in. `/auth/logout` ends the session. `/auth/callback` exchanges the code and creates the encrypted `mf_session` cookie. The credentials UI itself is served by Zitadel, not by Musify. - -## 3. Explore / Search `/explore` โœ… (app) - -Discovery and search over the catalog. - -- **Search box**: a text input (`?q=`) that filters by name on the backend. -- **Results count** and a **track grid** (2 to 4 columns): cover, title, date, and a play/pause button on hover that queues and plays through the global player. -- **Pagination**: previous/next (`?page=`, 24 per page). -- **Empty state**: no results, or an empty catalog. -- **Suggested next steps**: filters (most recent, by user), loading skeletons, a real track cover (the listing API doesn't currently return the image key). - -## 4. Upload music `/upload` ๐ŸŸก mockup (app) - -Adding a new song. The real backend flow, for when it gets wired up: - -1. `POST /tracks/upload-urls` returns presigned URLs for cover and audio, plus `intentId`s. -2. `PUT` each file to its presigned URL (a direct upload to storage). -3. `POST /tracks` with `title`, `pictureIntentId`, `audioIntentId`. -4. The backend processes the audio asynchronously (transcoding it to DASH). - -What the page needs: - -- **Audio zone**: drag and drop or a file picker, showing the file's name and size, with type (audio) and size validation. -- **Cover**: an image picker with a square preview. -- **Metadata**: title (required), with room for future fields like a description. -- **Visible pipeline**: the Upload, Process, Publish steps and their status. -- **Progress bar** for the upload and the processing state. -- **States**: empty, file selected, uploading, processing, published, error. -- **Requires a session** (once wired up: redirect to `/auth?returnTo=/upload`). - -## 5. Your library `/library` โฌœ (app) - -The authenticated user's personal panel, everything of theirs in one place. - -- **Tabs / sections**: "Your songs" (`GET /tracks/users/{userId}`) and "Your playlists" (`GET /playlists/users/{userId}`). -- **Your songs**: a list with play, plus management actions (delete via `DELETE /tracks/{id}`, add to a playlist). -- **Your playlists**: a grid of covers with a "Create playlist" button. -- **Empty state** with a CTA to upload or create. -- **Requires a session.** - -## 6. Playlists `/playlists` โฌœ (app) - -A listing of the user's playlists. - -- **Grid** of playlists (cover, name, track count), linking to the detail page. -- **Create playlist**: a modal or page with a name, description, and cover (`POST /playlists`, cover via `POST /playlists/upload-picture`). -- **Empty state** with a CTA. -- **Requires a session.** - -## 7. Playlist detail `/playlists/[id]` โฌœ (app) - -- **Header**: a large cover, name, description, track count, and a "Play all" button. -- **Track list** (`GET /playlists/{id}/tracks`, paginated): index, title, date, play, and remove from the playlist (`DELETE /playlists/{id}/tracks/{trackId}`). -- **Owner actions**: edit (`PUT`), delete (`DELETE`) the playlist, reorder (a future addition). -- **Add tracks**: from explore or the library (`POST /playlists/{id}/tracks/{trackId}`). - -## 8. User profile `/user/[id]` โฌœ (future) - -A public view of a user: avatar, name, their public songs and playlists (`GET /users/{id}`, `GET /tracks/users/{id}`, `GET /playlists/users/{id}`). - ---- - -## Global player (PlayerBar) โœ… - -A fixed bottom bar, visible while something is playing. Shows track info, transport controls (previous, play/pause, next), a progress bar, and volume control. Queues lists from any grid (`player.playQueue`). Plays DASH through the `/api/tracks/:id/stream` proxy, which requests a manifest and a signed ticket from the backend. - -## Available backend (summary) - -| Resource | Endpoints | -| --------- | ----------------------------------------------------------------- | -| Tracks | list/search, detail, by user, stream, create, delete, upload-urls | -| Playlists | CRUD, playlist tracks, add/remove track, upload-picture | -| Users | list/search, detail, create | - -Typed client in `$lib/server/api.ts` (`openapi-fetch`, ErrorOr style). Use it inside `load`/server actions, never from the client. Types live in `src/lib/api/schema.d.ts`. +- Sorting playlists by last listen (`lastPlayedAt`) for a "recently played playlists" shelf. +- A bulk endpoint to add several tracks to a playlist at once (adding an album currently posts one track at a time). +- An endpoint returning the most listened tracks, to bring back a "popular" section on Home. diff --git a/web-player/eslint.config.js b/web-player/eslint.config.js index eeaed68..8a7a022 100644 --- a/web-player/eslint.config.js +++ b/web-player/eslint.config.js @@ -19,6 +19,19 @@ export default ts.config( 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } ], + '@typescript-eslint/no-non-null-assertion': 'error', + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['../*'], + message: 'Use $lib/... instead of relative parent imports.' + } + ] + } + ], + 'svelte/no-unused-props': 'error', 'svelte/no-navigation-without-resolve': 'off', 'svelte/prefer-svelte-reactivity': 'off' } diff --git a/web-player/scripts/check-tokens.mjs b/web-player/scripts/check-tokens.mjs index 759a57c..19a9164 100644 --- a/web-player/scripts/check-tokens.mjs +++ b/web-player/scripts/check-tokens.mjs @@ -24,12 +24,16 @@ const PAIRED_HW = /\b(?:([a-z0-9-]+:))?h-([\d.]+)\s+\1w-\2\b/g; const QUARTER_STEP_SPACING = /\b[a-z-]+-\d+\.(25|75)\b/g; const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g; const RGBA_LITERAL = /\brgba?\(/g; +const LUCIDE_IMPORT = /import (\w+) from '@lucide\/svelte\/icons\/[\w-]+'/g; +const ICON_PIXEL_SIZE = /\bsize=\{\d+\}/; +const ICON_RAW_SIZE = /\bclass="[^"]*\bsize-\d[\w.]*/; let violations = []; for (const file of files) { const content = readFileSync(file, 'utf8'); const lines = content.split('\n'); + const icons = [...content.matchAll(LUCIDE_IMPORT)].map((m) => m[1]); lines.forEach((line, i) => { const lineNo = i + 1; @@ -40,6 +44,17 @@ for (const file of files) { } } + for (const icon of icons) { + const tag = line.match(new RegExp(`<${icon}\s[^>]*`)); + if (!tag) continue; + if (ICON_PIXEL_SIZE.test(tag[0])) { + violations.push(`${file}:${lineNo} โ€” icono con size={N}: usa una clase size-icon-*`); + } + if (ICON_RAW_SIZE.test(tag[0])) { + violations.push(`${file}:${lineNo} โ€” icono con size-N: usa una clase size-icon-*`); + } + } + for (const match of line.matchAll(RAW_ROUNDED)) { violations.push(`${file}:${lineNo} โ€” radio de Tailwind sin tokenizar: \`${match[0]}\``); }