Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e85af2b3b | ||
|
|
2d23eb87c6 | ||
|
|
e5ce63a192 | ||
|
|
b5c9658d65 | ||
|
|
ba0a2373b5 | ||
|
|
b0163f6a4d | ||
|
|
0a192645d6 | ||
|
|
351b333529 | ||
|
|
155f0e2b6a | ||
|
|
a54d1ef077 | ||
|
|
2361b88e0a | ||
|
|
e7c2fd5571 |
+23
-19
@@ -1,6 +1,6 @@
|
||||
# Musify: web-player
|
||||
|
||||
Musify's public web app (`web-player/` folder). For design and UI recipes, use the **`musify-web`** skill.
|
||||
Musify's public web app (`web-player/` folder). Design and UI recipes live in this file and in `src/routes/layout.css`.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -15,7 +15,7 @@ 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 (pure functions, mappers, form parsers)
|
||||
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)
|
||||
```
|
||||
@@ -33,23 +33,25 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
|
||||
|
||||
## Folder structure
|
||||
|
||||
- `src/lib/types.ts` holds the domain types (`Track`, `Album`, `Playlist`, `Mix`, `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`, `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<T>()`), `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/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/layout/` holds the app chrome: Sidebar, TopBar, TabsBar, MobileHeader, and `navLinks.ts`.
|
||||
- `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/ui/` holds the design system components, split into five categories:
|
||||
- `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, TrackContextMenu, AlbumContextMenu, AccountMenu, and so on).
|
||||
- `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, PlayAllButton, 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). This is different from `components/layout/` above, which is the global app chrome.
|
||||
- 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`).
|
||||
- `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
|
||||
|
||||
@@ -73,7 +75,7 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili
|
||||
|
||||
## App shell
|
||||
|
||||
The shell in `src/routes/+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/navigation.svelte.ts`; don't use `window.scrollTo`/`scrollY`.
|
||||
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
|
||||
|
||||
@@ -81,7 +83,7 @@ Every animation follows one scale of three durations and one easing curve, all d
|
||||
|
||||
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 `+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`.
|
||||
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
|
||||
|
||||
@@ -91,7 +93,7 @@ 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 use the helpers in `$lib/server/api.ts`:
|
||||
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
|
||||
@@ -102,8 +104,10 @@ It returns `{ data, error }` (ErrorOr style) and never throws except on network
|
||||
|
||||
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.
|
||||
- `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.
|
||||
|
||||
@@ -31,5 +31,4 @@ Built and run as a container by the production stack — see
|
||||
[../deploy/README.md](../deploy/README.md). There the same variables come from
|
||||
`deploy/.env.prod` through compose instead of from `.env`.
|
||||
|
||||
Conventions and the design system are documented in [CLAUDE.md](CLAUDE.md) and
|
||||
in the `musify-web` skill.
|
||||
Conventions and the design system are documented in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
# Plan de refactor tras la auditoría del frontend
|
||||
|
||||
> **Cerrado.** Todas las partes están hechas; el plan activo es `frontend-structure-plan.md`.
|
||||
|
||||
Plan de trabajo derivado de la auditoría del `web-player` (2026-09-24). Cada parte es un PR independiente, revisable por separado, que deja `npm run check` y `npm run lint` en verde. Antes de empezar una parte, mira el **Registro** del final.
|
||||
|
||||
Leyenda de tamaño: **S** (< 1 h) · **M** (medio día) · **L** (1 día o más). Las partes marcadas con 🔗 dependen de cambios en `Musify.Api`.
|
||||
|
||||
## Orden y dependencias
|
||||
|
||||
```
|
||||
P0 Red de seguridad
|
||||
└─ P1 Seguridad ─ P2 Reproductor ─ P3 Bugs de páginas ─ P4 Código muerto
|
||||
└─ P5 Modelo Track único
|
||||
├─ P6 Capa de servidor ─ P7 Formularios y validación
|
||||
│ └─ P8 Rendimiento 🔗
|
||||
└─ P9 Responsabilidades ─ P10 Componentes y páginas
|
||||
└─ P11 Consistencia ─ P12 Guardarraíles y docs
|
||||
```
|
||||
|
||||
P1 a P4 son arreglos acotados y se pueden mergear en cualquier orden. De P5 en adelante el orden importa: cada parte simplifica la siguiente.
|
||||
|
||||
---
|
||||
|
||||
## P0 · Red de seguridad (M)
|
||||
|
||||
Objetivo: tener tests de las funciones puras antes de tocar lógica.
|
||||
|
||||
- Añadir Vitest (`vitest`, script `npm run test`, incluido en `lint` o en CI).
|
||||
- Tests para lo que ya es puro: `fmtTime`, `fmtDurationLong`, `plural`, `albumMeta` (`utils/format.ts`), `findTopResult` (`data/search.ts`), `findConflict`, `genreHue` (`data/genres.ts`), `pickGreeting` (`server/greeting.ts`), `parseAlbumForm` (`server/albumForm.ts`), `appendUnique`, `shuffle`.
|
||||
- Extraer las operaciones de cola de `PlayerState` a funciones puras en `player/queue.ts` (`insertNext`, `append`, `move`, `nextIndex`, `previousIndex`) y testearlas. `PlayerState` pasa a llamarlas. Esto prepara P2.
|
||||
|
||||
**Cierre:** `npm run test` en verde y cubre las funciones anteriores.
|
||||
|
||||
---
|
||||
|
||||
## P1 · Seguridad (S)
|
||||
|
||||
1. **Redirección abierta con `returnTo`.**
|
||||
- Nueva función `safeReturnTo(value: string | null | undefined): string` en `$lib/server/auth.ts`: acepta solo rutas que empiecen por `/` y no por `//` ni `/\`; si no, devuelve `/`.
|
||||
- Aplicarla en `routes/auth/+page.server.ts` (ambos usos), en `routes/auth/login/+server.ts` (antes de guardar la cookie) y en `routes/auth/callback/+server.ts` (al leer la cookie).
|
||||
2. **`SESSION_SECRET` obligatorio.** En `$lib/server/config.ts`, `sessionSecret` pasa por `required()` y exige una longitud mínima de 32 caracteres. Actualizar el comentario de `.env.example`.
|
||||
3. **Validar las entradas de los endpoints `/api`.**
|
||||
- `api/likes/+server.ts`: no reenviar el body tal cual; leer `trackId`, comprobar que es un string no vacío y devolver 400 si no.
|
||||
- `api/listens/[id]/progress/+server.ts`: envolver `request.json()` para devolver 400 con JSON inválido en vez de 500.
|
||||
- `server/image.ts`: `encodeURIComponent(id)` al construir la URL de upstream.
|
||||
- `api/tracks/+server.ts`: acotar `pageSize` con un máximo (`MAX_PAGE_SIZE` en `config.ts`).
|
||||
|
||||
**Cierre:** `/auth?returnTo=https://example.com` acaba en `/`; arrancar sin `SESSION_SECRET` falla con un mensaje claro; tests de `safeReturnTo`.
|
||||
|
||||
---
|
||||
|
||||
## P2 · Reproductor y cola (M)
|
||||
|
||||
1. **Duplicados en la cola** (`player.svelte.ts` `playNext`/`appendToQueue`, `Queue.svelte`).
|
||||
- `playNext` mueve la pista si ya estaba en la cola, en vez de duplicarla.
|
||||
- `appendToQueue` omite las pistas que ya están pendientes.
|
||||
- Con esto las claves `(track.id)` vuelven a ser únicas y `#index()` es fiable.
|
||||
2. **Fin de cola.** Con repetir desactivado, al terminar la última pista el reproductor se detiene en vez de volver al principio. Si más adelante se quiere "repetir todo", `repeat` pasará a `'off' | 'all' | 'one'` (queda fuera de este plan).
|
||||
3. **La cola se abre sola** (`+layout.svelte:44`). Abrirla solo la primera vez que empieza a sonar algo en la sesión (`currentId` pasa de `null` a un valor) y no volver a abrirla si el usuario la cierra.
|
||||
4. **Rollback de "Me gusta"** (`liked.svelte.ts:69`). En caso de error, revertir solo la entrada de ese id, no sustituir el mapa entero.
|
||||
5. **localStorage seguro.**
|
||||
- Nuevo `$lib/utils/storage.ts` con `readStorage(key, fallback)` y `writeStorage(key, value)` envueltos en try/catch.
|
||||
- Volumen: validar y acotar a 0–100 (hoy puede quedar `NaN`).
|
||||
- Unificar claves: `musify.player.volume`, `musify.player.muted`, `musify.theme`.
|
||||
- Persistir el volumen siempre, no solo cuando existe el elemento `<audio>`.
|
||||
|
||||
**Cierre:** añadir la misma pista dos veces a la cola no rompe nada; la cola termina; tests de `player/queue.ts` actualizados; prueba manual en el Browser pane.
|
||||
|
||||
---
|
||||
|
||||
## P3 · Bugs de páginas y datos (M)
|
||||
|
||||
1. **Permisos en la playlist** (`playlists/[id]`).
|
||||
- El `load` devuelve `isOwner`, comparando el propietario de la playlist con `locals.user.sub`. Si la API no expone el propietario, añadirlo al DTO 🔗.
|
||||
- Mostrar Editar, Eliminar y "Quitar" solo al propietario. Añadir `BackLink` como en el álbum y la mezcla, y `section: '/playlists'` cuando sea propia.
|
||||
2. **Una sola fuente para las playlists del usuario.**
|
||||
- `+layout.server.ts` carga las playlists del usuario con `PLAYLIST_PICKER_PAGE_SIZE` y devuelve también `totalItemCount`.
|
||||
- La barra lateral muestra las 8 primeras y usa el total real en el contador.
|
||||
- Los menús contextuales (inicio, explorar, mezcla) usan esa lista desde `page.data` y así explorar y mezcla dejan de pedirla aparte.
|
||||
3. **Playlist destacada de inicio.**
|
||||
- Nuevo endpoint `api/playlists/[id]/tracks/+server.ts`, análogo al de álbumes. "Reproducir" y "Aleatorio" cargan la lista completa en ese momento.
|
||||
- La meta muestra el total real. La duración se quita hasta que la API la devuelva (P8).
|
||||
- La promesa se resuelve con `{#await}` en vez de con un `$effect` que copia a estado.
|
||||
4. **Borrar canción de la biblioteca con confirmación.** `ConfirmDialog` acepta `fields` (inputs ocultos) y `library` lo usa, igual que álbumes y playlists.
|
||||
5. **Explorar.**
|
||||
- Usar `plural()` para el número de canciones de un álbum.
|
||||
- Contadores de chips y "N coincidencias" a partir de `totalItemCount` de cada respuesta, no de lo cargado.
|
||||
6. **Enlace `/terms`** en la subida: quitar el enlace hasta que exista una página de términos (o crearla, si se decide).
|
||||
7. **`playbackConfig.ts`:** no guardar en caché cuando la respuesta trae `error`.
|
||||
8. **`ImageDropzone`:** liberar la URL anterior con `URL.revokeObjectURL` al cambiar de archivo y al desmontar.
|
||||
9. **Textos.**
|
||||
- "Populares esta semana" duplica "Continuar escuchando": quitar la sección hasta que haya un endpoint real de más escuchadas (o renombrarla a "Escuchado recientemente").
|
||||
- `+error.svelte`: quitar la promesa de modo sin conexión.
|
||||
- "Retomalo" → "Retómalo"; "Modo blanco" → "Modo claro".
|
||||
|
||||
**Cierre:** un no propietario no ve acciones de edición; la barra lateral cuenta bien; la destacada reproduce la lista completa.
|
||||
|
||||
---
|
||||
|
||||
## P4 · Código muerto (S)
|
||||
|
||||
Borrar sin sustituto:
|
||||
|
||||
- `player.svelte.ts`: `playPlaylist`, `playlistTracks`, `playlists`, interfaz `Playlist`, getter `isPlaying`, `playNextItem` (usar `playNext([item])`).
|
||||
- `theme/palette.ts`: `Accent.gradient` (y con ello el tipo `Accent`, que queda en `string`).
|
||||
- `IconButton`: el mapa `TEXT` y la prop `tone` pasan a `plain` o por defecto; `muted` y `subtle` son idénticos.
|
||||
- `Artwork`: prop `fallback` y sus ramas `gradient` y `none`.
|
||||
- `MediaIdentity.badge`, `FollowButton.userId`, `EqBars.size` y `EqBars.barWidth`.
|
||||
- Acciones `follow` y `unfollow` en `explore/+page.server.ts` y en `user/[id]/[list=followList]/+page.server.ts`, con el `form?.message` que solo servía para ellas.
|
||||
- Utilidades `media-grid` y `text-count` de `layout.css` (y su mención en `CLAUDE.md`).
|
||||
- `TrackList.svelte`: el `use:enhance` redundante (queda `use:enhance`) y los `stopPropagation` de la línea 141.
|
||||
- `+page.server.ts` (inicio): el `redirect` previo a `requireUser`. `+layout.server.ts`: la rama `accountUrl = null`.
|
||||
- Clases que no hacen nada: `hover:text-fg-2` sobre `text-fg-2` (SegmentedControl, Sidebar, ArtistLink) y el `hidden sm:flex` anidado de `PlayerBar`.
|
||||
- Separadores vacíos (`<span></span>`, `<div class="flex-1">`) en `CoverForm` y `TopBar`: sustituir por `ml-auto`.
|
||||
- `sessionCookieOptions` deja de exportarse.
|
||||
|
||||
**Cierre:** `grep` de cada símbolo sin resultados; la app se comporta igual.
|
||||
|
||||
---
|
||||
|
||||
## P5 · Modelo `Track` único (L)
|
||||
|
||||
Objetivo: una sola forma de pista, normalizada en la frontera del servidor. Es el cambio que más código quita.
|
||||
|
||||
1. **Tipos de dominio** en `$lib/types.ts`:
|
||||
```ts
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
ownerUserId: string | null;
|
||||
explicit: boolean;
|
||||
duration: number;
|
||||
listensCount: number;
|
||||
}
|
||||
export interface LikedTrack extends Track {
|
||||
likedAt: number;
|
||||
}
|
||||
```
|
||||
Más `Album` y `Playlist` normalizados (`trackCount: number`, `releaseYear: number | null`, ids como `string`).
|
||||
2. **Mappers de servidor** en `$lib/server/mappers.ts`: `toTrack`, `toAlbum`, `toPlaylist`, `toMixTrack` (sustituye a `mixItemTrack`). Hacen todas las conversiones `Number()` y `String()`. Todos los `load` y los endpoints `/api` que devuelven pistas los usan.
|
||||
3. **El reproductor usa `Track`.**
|
||||
- Se eliminan `PlayerTrack`, `QueueItem`, `ApiTrackLike`, `LikeToggleInput` y `TrackListTrack` (que pasa a ser `Track & { date?: string | number }`).
|
||||
- Se eliminan `toQueueItems`, `toQueueItem`, `trackFromQueueItem` y `toTrack` del cliente.
|
||||
- `current` pasa a ser `Track | null` en vez del centinela `EMPTY` con `id: ''`.
|
||||
4. **Páginas.** Quitar todos los `Number(...)`, los `?? undefined` y los mapeos intermedios (`listTracks`, `songRows`, `spotlightRows`, `popularTracks`…).
|
||||
|
||||
**Cierre:** `grep -rn "Number(" src/routes --include=*.svelte` vacío; `isExplicit` solo aparece en `mappers.ts`; tests de los mappers.
|
||||
|
||||
---
|
||||
|
||||
## P6 · Capa de servidor (M)
|
||||
|
||||
1. **Helpers en `$lib/server/api.ts`.**
|
||||
- `apiFor(event)` sustituye los 15 `createApiClient({ fetch, accessToken: locals.accessToken ?? undefined })`.
|
||||
- Exportar `type ApiClient` (hoy se redefine en `follows.ts`).
|
||||
- `loginRedirect(url)`, usado por `requireUser`, `optionalUser` y `+layout.server.ts`.
|
||||
2. **Envoltorio de acciones** `authedAction(handler)`: comprueba el token (401 si falta), crea `api`, lee `form` y pasa `{ api, form, params, locals }`. Sustituye el bloque `requireAccessTokenAction` + `typeof` + `formData` + `createApiClient` de las ~10 acciones.
|
||||
3. **Lectura de formularios:** `formString(form, key)` y `formFile(form, key)` (devuelve `null` si el archivo viene vacío). Sustituyen los 17 `String(form.get(...) ?? '')` y los `instanceof File && size > 0`.
|
||||
4. **Nombres.**
|
||||
- `unwrapOrFail` → `failOnError` (no desenvuelve nada).
|
||||
- Desestructurar siempre como `const { data, error } = ...` y usar `catch (err)`.
|
||||
- La acción `rename` de playlist pasa a `edit` y devuelve `{ edited: true }`, como la de álbum.
|
||||
5. **Sin aserciones no nulas:** quitar `urlsResult.data!` y `trackResult.data!` de `upload/+page.server.ts` usando el patrón de `failOnError` que estrecha el tipo.
|
||||
6. **`+layout.server.ts`:** una sola función para las tres cargas con fallback, sin `try/catch` duplicado (openapi-fetch solo lanza con error de red).
|
||||
7. **Tamaños de página:** todos los `pageSize` salen de `config.ts` (quitar `50`, `200`, `500` sueltos y el `PAGE_SIZE` local de `library`). Quitar el alias `EXPLORE_PAGE_SIZE as PAGE_SIZE`.
|
||||
|
||||
**Cierre:** ninguna acción repite la plantilla de autenticación; `grep "createApiClient(" src/routes` vacío.
|
||||
|
||||
---
|
||||
|
||||
## P7 · Formularios y validación compartida (M)
|
||||
|
||||
1. **Límites en un único sitio:** `$lib/validation.ts` (compartido entre cliente y servidor):
|
||||
```ts
|
||||
export const LIMITS = {
|
||||
playlistName: 100,
|
||||
playlistDescription: 300,
|
||||
albumTitle: 200,
|
||||
albumDescription: 256,
|
||||
trackTitle: 100
|
||||
} as const;
|
||||
```
|
||||
Antes de fijarlos, comprobar los validadores de `Musify.Api` y usar sus valores. `ALBUM_EARLIEST_YEAR` se mueve aquí.
|
||||
2. **Parsers de servidor** en `$lib/server/forms/`: `parseAlbumForm` (existente), `parsePlaylistForm` (nuevo, sustituye el código duplicado de `create` y `edit`), `parseTrackUploadForm` (sacado de `upload/+page.server.ts`). Todos validan con `LIMITS`.
|
||||
3. **Portadas:** `uploadOptionalCover(api, endpoint, file)` sustituye los 4 bloques `if (cover ...) uploadPresignedImage(...)`.
|
||||
4. **Formularios de cliente:** `AlbumForm`, `PlaylistForm` y la subida usan `LIMITS` en `maxlength` y en el contador. Añadir el contador que falta en `AlbumForm` para que los tres se comporten igual.
|
||||
5. `formatSize` de la subida pasa a `utils/format.ts`.
|
||||
|
||||
**Cierre:** cambiar un límite en `LIMITS` actualiza cliente y servidor; tests de los parsers.
|
||||
|
||||
---
|
||||
|
||||
## P8 · Rendimiento 🔗 (M, parte en backend)
|
||||
|
||||
1. **Backend:** añadir `trackCount` y `durationSeconds` a la respuesta de playlist.
|
||||
2. **Frontend:**
|
||||
- `playlists/+page.server.ts` deja de pedir hasta 500 pistas por playlist.
|
||||
- `user/[id]/+page.server.ts` deja de hacer una petición por playlist.
|
||||
- La destacada de inicio recupera la duración real.
|
||||
3. **Explorar:** cachear `/genres` en `$lib/server/genres.ts` (como `playbackConfig`, con TTL de unos minutos) y lanzar todas las peticiones en paralelo en vez de esperar primero a los géneros.
|
||||
4. **Añadir álbum a playlist** (`playlistActions.ts`): hoy hace un POST por pista en serie y se corta en 200. Pedir un endpoint de inserción múltiple 🔗; mientras tanto, paralelizar con un límite de concurrencia.
|
||||
|
||||
**Cierre:** la página de playlists hace una sola petición a la API; el perfil hace dos.
|
||||
|
||||
---
|
||||
|
||||
## P9 · Separación de responsabilidades en `lib/` (L)
|
||||
|
||||
1. **Dividir `player.svelte.ts`:**
|
||||
- `player/queue.ts`: operaciones de cola (de P0).
|
||||
- `player/stream.ts`: `resolveStream(id)` (fetch de `/stream`, ticket, `listenId`) y `readErrorMessage`.
|
||||
- `player/mediaSession.ts`: registro de acciones y sincronización de metadatos y estado.
|
||||
- `player/progressClock.ts`: el bucle `requestAnimationFrame` de progreso suave.
|
||||
- `player/actions.ts`: `playAllOrToggle`, `playShuffled`, `isQueueCurrent`.
|
||||
- `player/player.svelte.ts`: solo el estado y la orquestación.
|
||||
2. **Acento como número.**
|
||||
- Nuevo `theme/accent.svelte.ts`: caché, `extractAccent` y el tono actual como `number`.
|
||||
- Ahí mismo vive el fundido de tono que hoy está en `+layout.svelte` (`animateThemeHue()`).
|
||||
- Se elimina `parseHue` (ya no hay que leer el tono de una cadena `oklch(...)`).
|
||||
3. **Dividir `state/navigation.svelte.ts`:**
|
||||
- `state/history.svelte.ts`: `trackNavigation`, `previousPage`.
|
||||
- `state/scroll.ts`: `restoreScroll`.
|
||||
- `utils/hrefs.ts`: `searchHref`, `genreHref` y las URLs de portada (`trackCover(id, size)`, `playlistCover(playlist, size)` con el `v=updatedAt`, `albumCover(id, size)`), que hoy se escriben a mano en más de 15 sitios.
|
||||
- `components/layout/navLinks.ts`: `appNavLinks` e `isNavActive` (sustituye el `isActive` duplicado de Sidebar y TabsBar).
|
||||
4. **Menús.** `contextMenuPosition` y `MenuPosition` pasan a `utils/menuPosition.ts`, sin `preventDefault()` dentro (lo hace quien llama). `state/menus.svelte.ts` deja de importar de un componente.
|
||||
5. **Movimientos de archivos:**
|
||||
- `data/collections.ts` → `utils/collections.ts`.
|
||||
- `player/queuePanel.svelte.ts` y `state/playlists.svelte.ts` → `state/panels.svelte.ts` (`queuePanel`, `createPlaylistModal`).
|
||||
- `data/search.ts`: claves internas (`tracks`, `albums`, `playlists`, `users`) y etiquetas aparte en un mapa de textos.
|
||||
6. Tipar `+layout.svelte` con `interface Props`.
|
||||
|
||||
**Cierre:** `player.svelte.ts` por debajo de ~250 líneas; ningún archivo de `state/` importa de `components/`.
|
||||
|
||||
---
|
||||
|
||||
## P10 · Componentes extraídos y páginas más finas (L)
|
||||
|
||||
1. **Menú contextual.**
|
||||
- `state/menu.svelte.ts` con `createMenu<T>()` genérico: sustituye `createTrackMenu` y el `albumMenu` hecho a mano de explorar.
|
||||
- Componente `TrackContextMenu` que encapsula el bloque de 15 líneas repetido en inicio, explorar y mezcla. `AlbumContextMenu` para explorar.
|
||||
2. **`PlayAllButton {items}`** con el "Reproducir/Pausar" de álbum, playlist, mezcla, me gusta y la portada de inicio.
|
||||
3. **`createPagedList(fetcher)`** en `state/pagedList.svelte.ts` para la paginación infinita de explorar y de seguidores (estado, `appendUnique`, `hasMore` y un estado de error visible en vez de tragarse el fallo).
|
||||
4. **Primitivas.**
|
||||
- `Button`, `IconButton`, `Chip` y `MenuItem` usan `<svelte:element this={href ? 'a' : 'button'}>` en vez de duplicar las dos ramas.
|
||||
- Acción `use:onEscape` para `Modal`, `ContextMenu` y `AccountMenu`.
|
||||
- `AccountMenu` incluye su propio disparador con el `Avatar`; `TopBar` y `MobileHeader` dejan de duplicarlo.
|
||||
- `Artwork` y `Avatar` usan `{#key src}` en vez del `$effect` que reinicia `failed`.
|
||||
5. **Páginas.**
|
||||
- Quitar los alias `const x = $derived(data.x)` y las funciones de una línea (`playSpotlight`, `shuffleSpotlight`, `openContextMenu`, `stepClass`…).
|
||||
- Sustituir los `$effect` que copian `data` a estado local por `$derived` escribible cuando sea posible.
|
||||
- Navegación con enlaces reales: `ListRow href` en vez de `onclick={() => goto(...)}`, y la destacada de inicio como `<a>`.
|
||||
- `upload`: el indicador de pasos solo refleja estados reales (o se simplifica a "subiendo / listo").
|
||||
|
||||
**Cierre:** ninguna página repite el bloque del menú ni el botón de reproducir; las tarjetas se pueden abrir con clic central.
|
||||
|
||||
---
|
||||
|
||||
## P11 · Consistencia (M, mecánico)
|
||||
|
||||
1. **Imports:** siempre `$lib/...`, nunca `../`. Se hace cumplir en P12.
|
||||
2. **Convenciones de nombres** (se documentan en `CLAUDE.md`):
|
||||
- Mapas de variantes en constantes de módulo en mayúsculas (`SIZE`, `VARIANT`).
|
||||
- Errores: `error` al desestructurar y `err` en `catch`.
|
||||
- Manejadores de reproducción: `playFrom(index)`, `playAll()`.
|
||||
- Acciones: `create`, `edit`, `delete`, `addTrack`, `removeTrack`.
|
||||
3. **Glosario de interfaz:** "Playlist" (nunca "Lista"), "Mezcla" (nunca "Mix"), "Modo claro". Duraciones totales siempre con `fmtDurationLong`.
|
||||
4. **Tokens.**
|
||||
- Iconos siempre con `size-icon-*`; añadir `size-icon-2xs` si hace falta para los de 14 px.
|
||||
- Nada de `size={16}` en iconos Lucide.
|
||||
- No usar tokens de icono como espaciado (`pb-icon-md`, `gap-icon-md`).
|
||||
- Anchos siempre con clase (`w-(--mf-sidebar-w)`), no con `style`.
|
||||
- Nueva utilidad `focus-ring` usada por `Button`, `IconButton`, `Chip` y los enlaces interactivos.
|
||||
5. **Rutas:** mover `routes/+page.svelte` y `+page.server.ts` (inicio) dentro de `(app)`, para que el grupo tenga sentido: todo lo de la app dentro y `auth/` fuera.
|
||||
|
||||
**Cierre:** `npm run lint` en verde con las reglas nuevas de P12.
|
||||
|
||||
---
|
||||
|
||||
## P12 · Guardarraíles y documentación (S)
|
||||
|
||||
1. **ESLint:**
|
||||
- `no-restricted-imports` que prohíba `../*`.
|
||||
- `@typescript-eslint/no-non-null-assertion`.
|
||||
- `svelte/no-unused-props`.
|
||||
2. **`scripts/check-tokens.mjs`:** fallar con `size={N}` en iconos y con `size-N` sobre componentes Lucide.
|
||||
3. **Opcional:** `knip` para detectar exportaciones y archivos sin uso.
|
||||
4. **Documentación:**
|
||||
- `CLAUDE.md`: nueva estructura de carpetas, helpers de servidor (`apiFor`, `authedAction`, mappers), convenciones de nombres y glosario.
|
||||
- `docs/paginas.md`: está desactualizado (menciona el grupo `(marketing)` y la landing, que ya no existen).
|
||||
|
||||
**Cierre:** las reglas nuevas fallan si se reintroduce cualquiera de los patrones eliminados.
|
||||
|
||||
---
|
||||
|
||||
## Registro
|
||||
|
||||
| Parte | Estado | PR / notas |
|
||||
| --------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| P0 Red de seguridad | ✅ | Vitest + 37 tests; `player/queue.ts` extraído. `lint` ahora ejecuta `test` |
|
||||
| P1 Seguridad | ✅ | Hecho en la rama `claude/frontend-audit-plan-4f9432` |
|
||||
| P2 Reproductor y cola | ✅ | Sin probar en navegador (API caída) |
|
||||
| P3 Bugs de páginas | ✅ | Backend: `ownerUserId` en playlist. Sin probar en navegador |
|
||||
| P4 Código muerto | ✅ | Se mantiene el redirect a `/explore` de inicio (cubre escucha anónima) |
|
||||
| P5 Modelo Track único | ✅ | `Paged<T>`, `Mix`, `PlaylistSummary` añadidos. Sin probar en navegador |
|
||||
| P6 Capa de servidor | ✅ | `failOnError` + `requireData`; `parseAlbumForm` usa `formString` |
|
||||
| P7 Formularios y validación | ✅ | Límites según las columnas de BD del backend (playlist 50/256, pista 200) |
|
||||
| P8 Rendimiento 🔗 | ✅ | Backend: `trackCount` y `durationSeconds` en playlist. Añadir álbum usa `POST /playlists/{id}/albums/{albumId}` (bulk) |
|
||||
| P9 Responsabilidades | ✅ | `player.svelte.ts` queda en ~320 líneas |
|
||||
| P10 Componentes y páginas | ✅ | Quedan algunos alias `$derived(data.x)`; `{#key}` sustituido por `failedSrc` |
|
||||
| P11 Consistencia | ✅ | |
|
||||
| P12 Guardarraíles y docs | ✅ | `knip` no añadido (opcional) |
|
||||
|
||||
Leyenda: ⬜ pendiente · 🟡 en curso · ✅ hecho.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Plan de estructura del frontend
|
||||
|
||||
Plan derivado de la revisión de estructura del `web-player` (2026-09-24), hecha después de cerrar `frontend-audit-plan.md` (P0–P12). Cada parte es un PR independiente que deja `npm run check` y `npm run lint` en verde y no cambia el comportamiento visible. Antes de empezar una parte, mira el **Registro** del final.
|
||||
|
||||
Leyenda de tamaño: **S** (< 1 h) · **M** (medio día) · **L** (1 día o más).
|
||||
|
||||
## Orden y dependencias
|
||||
|
||||
```
|
||||
E1 Layout del grupo (app)
|
||||
└─ E2 Carpeta shell
|
||||
└─ E3 Frontera de ui/ ─ E6 Explorar en componentes
|
||||
|
||||
E4 Ubicación de estados (independiente)
|
||||
E5 Capa de servidor (independiente)
|
||||
E7 Acento fuera del reproductor (después de E1)
|
||||
|
||||
E9 Tests (antes o junto con E1 y E5)
|
||||
|
||||
E8 Documentación (al final)
|
||||
```
|
||||
|
||||
E1 → E2 → E3 van en orden porque tocan los mismos imports. E4 y E5 se pueden hacer en cualquier momento. Conviene hacer E9 primero, o al menos sus puntos 1 y 2, para que E1 y E5 muevan el código de sesión con tests que detecten si algo se rompe.
|
||||
|
||||
---
|
||||
|
||||
## E1 · Layout del grupo `(app)` (M)
|
||||
|
||||
Problema: el grupo `(app)` no tiene `+layout`. El shell y las cargas de la app están en la raíz, que distingue la pantalla de login a mano (`isAuthPage` en `+layout.svelte`, `PUBLIC_PATHS` en `+layout.server.ts`). Por eso `/auth` también consulta `playbackConfig`, y `+error.svelte` se pinta fuera del shell (un 404 dentro de la app pierde la barra lateral y el reproductor).
|
||||
|
||||
1. **`routes/+layout.svelte`** se queda con lo global: fuentes, `theme.css`, `layout.css`, favicon, el `<style>` inicial del tema, `animateThemeHue` y `<DialogHost />`. Renderiza `children` sin envoltorio.
|
||||
2. **`routes/+layout.server.ts`** desaparece (o queda vacío). Si alguna ruta fuera de `(app)` necesita `user`, lo lee de `locals` en su propio `load`.
|
||||
3. **Nuevo `routes/(app)/+layout.server.ts`** con todo lo que hoy hay en la raíz: `getAllowAnonymousListening`, el guard (`loginRedirect` si no hay usuario ni escucha anónima), `accountUrl`, playlists del usuario, me gusta y última pista. Sin `PUBLIC_PATHS`.
|
||||
4. **Nuevo `routes/(app)/+layout.svelte`** con el shell: backdrop, `Sidebar`, `MobileHeader`, `TopBar`, `<main>` con el `{#key}`, `Queue`, `PlayerDock`, el modal de crear playlist, la hidratación de `liked` y `player`, la apertura automática de la cola y el atajo de la barra espaciadora. Desaparecen `isAuthPage` y la rama `!data.user && !data.allowAnonymousListening`, porque el guard ya ha redirigido.
|
||||
5. **Nuevo `routes/(app)/+error.svelte`** (el contenido del actual) para que los errores dentro de la app se vean con el shell. El `+error.svelte` de la raíz se queda como fallback mínimo para errores fuera del grupo.
|
||||
6. **No quitar** los `requireUser`/`optionalUser` de las páginas ni `authedAction`. Los `load` de página se ejecutan en paralelo con los del layout, y las acciones de formulario no ejecutan el `load` del layout, así que el guard del layout no basta.
|
||||
|
||||
**Cierre:** `grep -rn "isAuthPage\|PUBLIC_PATHS" src` vacío; `/auth` no llama a la API; un 404 en `/albums/xxx` se ve dentro del shell; prueba manual con sesión, sin sesión y con escucha anónima.
|
||||
|
||||
---
|
||||
|
||||
## E2 · Carpeta `shell` (S)
|
||||
|
||||
Problema: hay dos carpetas `layout` con significados distintos (`components/layout/` es el shell de la app y `components/ui/layout/` son piezas para montar páginas). `AccountMenu` está en `ui/overlay/`, pero solo lo usan `TopBar` y `MobileHeader`.
|
||||
|
||||
1. Renombrar `components/layout/` a `components/shell/` (`Sidebar`, `TopBar`, `TabsBar`, `MobileHeader`, `navLinks.ts`).
|
||||
2. Mover `components/ui/overlay/AccountMenu.svelte` a `components/shell/AccountMenu.svelte`.
|
||||
3. Actualizar imports en `(app)/+layout.svelte` y `components/player/PlayerDock.svelte`.
|
||||
|
||||
**Cierre:** `grep -rn "components/layout/\|overlay/AccountMenu" src` vacío.
|
||||
|
||||
---
|
||||
|
||||
## E3 · Frontera de `components/ui/` (M)
|
||||
|
||||
Problema: `ui/` se presenta como el design system, pero cuatro componentes dependen del estado global de la app. `TrackList` y `PlayAllButton` importan `player`. `TrackContextMenu` y `AlbumContextMenu` importan `player`, y `AlbumContextMenu` también importa `$lib/data/albums`. El resto de `ui/media/` son componentes de presentación sin estado (reciben props), y así deben quedarse.
|
||||
|
||||
1. **Nueva carpeta `components/music/`** para los componentes que conectan la interfaz con el estado de la app:
|
||||
- `ui/media/TrackList.svelte` → `music/TrackList.svelte`
|
||||
- `ui/media/PlayAllButton.svelte` → `music/PlayAllButton.svelte`
|
||||
- `ui/overlay/TrackContextMenu.svelte` → `music/TrackContextMenu.svelte`
|
||||
- `ui/overlay/AlbumContextMenu.svelte` → `music/AlbumContextMenu.svelte`
|
||||
2. **Regla de ESLint:** un bloque con `files: ['src/lib/components/ui/**']` y `no-restricted-imports` que prohíba `$lib/player/*`, `$lib/data/*`, `$lib/server/*`, `$lib/components/music/*`, `$lib/components/shell/*` y `$lib/components/player/*`. Hay que repetir en ese bloque el patrón `../*` del bloque general, porque la configuración de un override sustituye a la del bloque general, no se suma.
|
||||
3. Comprobar que la regla pasa sin excepciones. `ui/` puede seguir importando `$lib/state/*` (historial, diálogos), `$lib/utils/*`, `$lib/actions/*`, `$lib/theme/*` y `$lib/types`.
|
||||
|
||||
**Cierre:** `npm run lint` en verde; si un componente de `ui/` importa `player`, el lint falla.
|
||||
|
||||
---
|
||||
|
||||
## E4 · Ubicación de estados (S)
|
||||
|
||||
1. `player/liked.svelte.ts` → `state/liked.svelte.ts`. Guarda los me gusta del usuario, no el estado del reproductor, y lo usan `Sidebar`, `TrackInfo`, el layout y las páginas.
|
||||
2. `data/recentlyPlayed.ts` → `player/recentlyPlayed.ts`. Solo mezcla `player.recentlyPlayed` con el historial, y hoy es el único archivo de `data/` que depende del reproductor.
|
||||
|
||||
**Cierre:** `grep -rn "player/liked\|data/recentlyPlayed" src` vacío; `data/` no importa de `player/`.
|
||||
|
||||
---
|
||||
|
||||
## E5 · Capa de servidor (S–M)
|
||||
|
||||
Problema: `server/api.ts` mezcla el cliente de la API, los guards de sesión, la lectura de formularios y el envoltorio de acciones. Los parsers de `server/forms/` importan `api.ts` solo para usar `formString`/`formFile`.
|
||||
|
||||
1. **`server/api.ts`** se queda con el cliente y el manejo de errores: `createApiClient`, `ApiClient`, `apiFor`, `unwrapOrError`, `apiErrorDetail`, `failOnError`, `requireData`.
|
||||
2. **Nuevo `server/guards.ts`:** `loginRedirect`, `requireUser`, `optionalUser` (14 imports en `routes/` más `(app)/+layout.server.ts`).
|
||||
3. **Nuevo `server/forms/fields.ts`:** `formString`, `formFile`. Los parsers de `forms/` lo importan desde ahí.
|
||||
4. **Nueva carpeta `server/actions/`:**
|
||||
- `authedAction.ts` con `authedAction` y `ActionContext`.
|
||||
- `followActions.ts` → `actions/follow.ts`.
|
||||
- `playlistActions.ts` → `actions/playlist.ts`.
|
||||
|
||||
**Cierre:** `server/forms/` no importa `server/api`; `api.ts` no importa `redirect` ni `RequestEvent` salvo para `apiFor`.
|
||||
|
||||
---
|
||||
|
||||
## E6 · Explorar en componentes (M)
|
||||
|
||||
Problema: `(app)/explore/+page.svelte` (393 líneas) resuelve tres vistas en una: la rejilla de géneros, la vista de un género y los resultados de búsqueda (mejor resultado y cuatro grupos).
|
||||
|
||||
1. Sacar componentes junto a la página (SvelteKit ignora los archivos sin `+` dentro de `routes/`):
|
||||
- `explore/GenreGrid.svelte`: la rejilla de géneros y el estado vacío.
|
||||
- `explore/TopResult.svelte`: el snippet `topResultBody` y `playTopResult`.
|
||||
- `explore/SearchResults.svelte`: los cuatro grupos, `capped`, `showGroup` y el `InfiniteScroll` de pistas.
|
||||
2. `+page.svelte` queda como selector de vista: cabecera, chips, mensaje de "sin resultados" y los dos menús contextuales.
|
||||
3. El estado compartido (`createPagedList`, `trackMenu`, `albumMenu`) se crea en la página y se pasa por props; los componentes no leen `page.data`.
|
||||
|
||||
**Cierre:** `explore/+page.svelte` por debajo de ~150 líneas; ninguna vista nueva supera las ~150.
|
||||
|
||||
---
|
||||
|
||||
## E7 · Acento fuera del reproductor (S)
|
||||
|
||||
Problema: `player.svelte.ts` importa `theme/accent.svelte` y en `#announce` llama a `accent.follow(...)`, así que el estado del reproductor depende del tema visual.
|
||||
|
||||
1. Quitar `accent` y `trackCover` de `player.svelte.ts`; `#announce` solo actualiza `mediaSession`.
|
||||
2. En `(app)/+layout.svelte`, un `$effect` que siga a `player.currentId` y llame a `accent.follow(trackCover(id, 'small'), () => player.currentId === id)`.
|
||||
3. Comprobar que la hidratación de la última pista también actualiza el acento (el efecto depende de `currentId`, así que debería).
|
||||
|
||||
**Cierre:** `player/` no importa de `theme/`; el color cambia al cambiar de canción y al cargar la página con una última pista.
|
||||
|
||||
---
|
||||
|
||||
## E8 · Documentación y restos (S)
|
||||
|
||||
1. **`CLAUDE.md`:**
|
||||
- Estructura de carpetas con `components/shell/`, `components/music/`, la regla de frontera de `ui/`, `state/liked.svelte.ts`, `server/guards.ts`, `server/forms/fields.ts` y `server/actions/`.
|
||||
- Documentar lo que falta: `lib/actions/` (`clickOutside`, `onEscape`, `pressable`), `state/toggle.svelte.ts` y `theme/color.ts`.
|
||||
- Corregir la referencia a `$lib/state/navigation.svelte.ts` en "App shell" (ahora es `restoreScroll` en `$lib/state/scroll.ts`) y describir el shell en `(app)/+layout.svelte`.
|
||||
- La skill `musify-web` que cita la introducción no está en `web-player/.claude/skills`: añadirla o quitar la referencia.
|
||||
2. **Conversiones fuera de los mappers.** La regla de P5 dice que los `Number(...)` sobre DTOs solo se hacen en `server/mappers.ts`, pero quedan varios en `routes/`:
|
||||
- `(app)/+page.server.ts`: las estadísticas de escucha (nuevo `toListeningStats`, con test) y `totalItemCount` de las playlists.
|
||||
- `(app)/explore/+page.server.ts`: `albumsTotal` y `usersTotal`.
|
||||
- `(app)/user/[id]/+page.server.ts`: `followersCount`.
|
||||
- La carga de playlists del layout (que tras E1 está en `(app)/+layout.server.ts`): `totalItemCount`.
|
||||
|
||||
Los totales pueden salir de `toPage(...).totalItemCount` o de un helper `toCount` en los mappers.
|
||||
|
||||
3. **Planes:** marcar `frontend-audit-plan.md` como cerrado (o archivarlo) y dejar este como el plan activo.
|
||||
|
||||
**Cierre:** cada ruta citada en `CLAUDE.md` existe; `grep -rn "Number(" src/routes` solo devuelve lecturas de parámetros o formularios.
|
||||
|
||||
---
|
||||
|
||||
## E9 · Tests de los arreglos y del código con efectos (M)
|
||||
|
||||
Los tests se quedan junto al archivo que prueban (`x.ts` + `x.test.ts`); no se crea una carpeta `tests/`. Hoy cubren bien la lógica pura (formatos, mappers, parsers, cola, búsqueda), pero no el código con fetch, estado o tiempo, ni los arreglos de seguridad de P1 y P2, que pueden volver sin que nada falle.
|
||||
|
||||
1. **Sesión y guards.**
|
||||
- `server/auth.ts`: `encodeSession` → `decodeSession` devuelve la misma sesión; un token manipulado, cifrado con otra clave o vacío devuelve `null`. Para el secreto, usar `vi.mock('$env/dynamic/private')` con un `SESSION_SECRET` de prueba, no el `.env` local (en CI no existe).
|
||||
- Guards (`requireUser`, `optionalUser`, `loginRedirect`; en `server/guards.ts` tras E5): redirigen a `/auth?returnTo=…` con la ruta y la query codificadas, y dejan pasar cuando toca.
|
||||
- Tras E1, el `load` de `(app)/+layout.server.ts`: redirige sin sesión ni escucha anónima, devuelve datos vacíos en modo anónimo y no llama a la API sin usuario.
|
||||
2. **Endpoints `/api` (arreglos de P1).** Llamar al `RequestHandler` con un evento falso y un `fetch` simulado:
|
||||
- `api/likes`: 401 sin token, 400 con `trackId` ausente, vacío o no string, 502 si la API falla.
|
||||
- `api/listens/[id]/progress`: 400 con JSON inválido, con `playedSeconds` negativo, no finito o no numérico; 404 si la API devuelve 404.
|
||||
- `api/tracks`: `pageSize` se limita a `MAX_PAGE_SIZE` y `pageNumber` inválido pasa a 1.
|
||||
3. **Rollback de "Me gusta" (`liked.svelte.ts`, arreglo de P2).** Con `fetch` simulado que falla: se revierte solo esa pista y se conservan los cambios hechos mientras tanto en otras. También los casos de dar y quitar el me gusta, y que `hydrate` solo se aplica una vez.
|
||||
4. **`ListenTracker`.** Con `vi.useFakeTimers()` y `fetch` simulado:
|
||||
- Suma solo los avances de hasta 2,5 s; un salto mayor (seek) o hacia atrás no cuenta.
|
||||
- `interrupt()` hace que el siguiente `tick` no sume.
|
||||
- Envía a los 30 s y en `flush()`; no reenvía si no hay progreso nuevo.
|
||||
- Si el envío falla, el siguiente `flush()` vuelve a enviar el total.
|
||||
- `begin()` envía lo pendiente de la escucha anterior antes de empezar la nueva.
|
||||
5. **`createPagedList`.** Añade la página siguiente sin duplicados, actualiza `hasMore`, ignora un `loadMore` con otro en curso y, si falla, marca `error` y conserva los elementos. `reset` limpia el error.
|
||||
6. **`apiErrorDetail`.** Mensajes de `errors` (varios campos, arrays), después `detail`, después `title`, y `undefined` para valores que no son objeto o sin texto.
|
||||
|
||||
Fuera de alcance, a propósito: tests de componentes (sobre todo presentación, ya vigilada por `check-tokens`), utilidades triviales (`storage`, `menuPosition`, `navLinks`), `mediaSession`, `progressClock`, `palette` (necesita canvas) y `player.svelte.ts` entero (depende de `<audio>`; su lógica está en `queue.ts`, que ya tiene tests). E2E con Playwright queda para cuando CI pueda levantar el backend y Zitadel.
|
||||
|
||||
**Cierre:** cada punto anterior tiene su `*.test.ts` junto al módulo; `npm run test` en verde sin `.env` local; volver a introducir cualquiera de los bugs de P1/P2 cubiertos hace fallar un test.
|
||||
|
||||
---
|
||||
|
||||
## Registro
|
||||
|
||||
| Parte | Estado | PR / notas |
|
||||
| ------------------------------- | ------ | ---------------------------------------- |
|
||||
| E1 Layout del grupo (app) | ✅ | |
|
||||
| E2 Carpeta shell | ✅ | |
|
||||
| E3 Frontera de ui/ | ✅ | Regla de ESLint sobre `components/ui/**` |
|
||||
| E4 Ubicación de estados | ✅ | |
|
||||
| E5 Capa de servidor | ✅ | |
|
||||
| E6 Explorar en componentes | ✅ | `+page.svelte` queda en ~180 líneas |
|
||||
| E7 Acento fuera del reproductor | ✅ | |
|
||||
| E8 Documentación y restos | ✅ | |
|
||||
| E9 Tests | ✅ | 121 tests, sin `.env` local |
|
||||
|
||||
Leyenda: ⬜ pendiente · 🟡 en curso · ✅ hecho.
|
||||
@@ -48,6 +48,34 @@ export default ts.config(
|
||||
'@typescript-eslint/no-unused-expressions': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['src/lib/components/ui/**'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['../*'],
|
||||
message: 'Use $lib/... instead of relative parent imports.'
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'$lib/player/*',
|
||||
'$lib/data/*',
|
||||
'$lib/server/*',
|
||||
'$lib/components/music/*',
|
||||
'$lib/components/shell/*',
|
||||
'$lib/components/player/*'
|
||||
],
|
||||
message:
|
||||
'components/ui is the presentation layer: it cannot depend on app state, data or other component layers.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'static/', 'src/lib/api/schema.d.ts']
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
import { fetchAlbumTracks } from '$lib/data/albums';
|
||||
import type { Menu } from '$lib/state/menu.svelte';
|
||||
import type { Playlist } from '$lib/types';
|
||||
import ContextMenu from './ContextMenu.svelte';
|
||||
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
menu: Menu<string>;
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import type { Menu } from '$lib/state/menu.svelte';
|
||||
import type { Playlist, Track } from '$lib/types';
|
||||
import ContextMenu from './ContextMenu.svelte';
|
||||
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
menu: Menu<Track>;
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { fmtTime, fmtDate } from '$lib/utils/format';
|
||||
import { pressable } from '$lib/actions/pressable';
|
||||
import MediaIdentity from './MediaIdentity.svelte';
|
||||
import EqBars from './EqBars.svelte';
|
||||
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
|
||||
import EqBars from '$lib/components/ui/media/EqBars.svelte';
|
||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||
|
||||
interface RowAction {
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import PlayerBar from './PlayerBar.svelte';
|
||||
import TabsBar from '$lib/components/layout/TabsBar.svelte';
|
||||
import TabsBar from '$lib/components/shell/TabsBar.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { liked } from '$lib/state/liked.svelte';
|
||||
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
|
||||
import ArtistLink from '$lib/components/ui/media/ArtistLink.svelte';
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import MenuItem from './MenuItem.svelte';
|
||||
import GlassMenu from './GlassMenu.svelte';
|
||||
import MenuItem from '$lib/components/ui/overlay/MenuItem.svelte';
|
||||
import GlassMenu from '$lib/components/ui/overlay/GlassMenu.svelte';
|
||||
import { themeMode } from '$lib/theme/mode.svelte';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import { onEscape } from '$lib/actions/onEscape';
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import LogIn from '@lucide/svelte/icons/log-in';
|
||||
import MenuItem from '$lib/components/ui/overlay/MenuItem.svelte';
|
||||
import AccountMenu from '$lib/components/ui/overlay/AccountMenu.svelte';
|
||||
import AccountMenu from '$lib/components/shell/AccountMenu.svelte';
|
||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||
import Logo from '$lib/components/ui/primitives/Logo.svelte';
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { appNavLinks, isNavActive } from './navLinks';
|
||||
import { playlistCover } from '$lib/utils/hrefs';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { liked } from '$lib/state/liked.svelte';
|
||||
import { createPlaylistModal } from '$lib/state/panels.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import AccountMenu from '$lib/components/ui/overlay/AccountMenu.svelte';
|
||||
import AccountMenu from '$lib/components/shell/AccountMenu.svelte';
|
||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ListenTracker } from './listenTracker';
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function sentSeconds(call: number): number {
|
||||
const init = fetchMock.mock.calls[call][1] as RequestInit;
|
||||
return JSON.parse(init.body as string).playedSeconds;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function startedTracker(id = 'l1') {
|
||||
const tracker = new ListenTracker();
|
||||
tracker.begin(id);
|
||||
tracker.tick(0);
|
||||
return tracker;
|
||||
}
|
||||
|
||||
describe('ListenTracker', () => {
|
||||
it('sums small forward advances', () => {
|
||||
const tracker = startedTracker();
|
||||
tracker.tick(1);
|
||||
tracker.tick(2);
|
||||
tracker.flush();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(sentSeconds(0)).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores seeks forward and backwards', () => {
|
||||
const tracker = startedTracker();
|
||||
tracker.tick(1);
|
||||
tracker.tick(60);
|
||||
tracker.tick(10);
|
||||
tracker.tick(11);
|
||||
tracker.flush();
|
||||
expect(sentSeconds(0)).toBe(2);
|
||||
});
|
||||
|
||||
it('does not count the tick after interrupt()', () => {
|
||||
const tracker = startedTracker();
|
||||
tracker.tick(1);
|
||||
tracker.interrupt();
|
||||
tracker.tick(2);
|
||||
tracker.tick(3);
|
||||
tracker.flush();
|
||||
expect(sentSeconds(0)).toBe(2);
|
||||
});
|
||||
|
||||
it('sends after 30 seconds of wall time', () => {
|
||||
const tracker = startedTracker();
|
||||
tracker.tick(1);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(30_000);
|
||||
tracker.tick(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(sentSeconds(0)).toBe(2);
|
||||
});
|
||||
|
||||
it('does not resend without new progress', () => {
|
||||
const tracker = startedTracker();
|
||||
tracker.tick(1);
|
||||
tracker.flush();
|
||||
tracker.flush();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
tracker.tick(2);
|
||||
tracker.flush();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(sentSeconds(1)).toBe(2);
|
||||
});
|
||||
|
||||
it('sends nothing when there is no listen id', () => {
|
||||
const tracker = new ListenTracker();
|
||||
tracker.begin(null);
|
||||
tracker.tick(0);
|
||||
tracker.tick(1);
|
||||
tracker.flush();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resends the total after a failed request', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
||||
const tracker = startedTracker();
|
||||
tracker.tick(1);
|
||||
tracker.flush();
|
||||
await vi.runAllTimersAsync();
|
||||
tracker.flush();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(sentSeconds(1)).toBe(1);
|
||||
});
|
||||
|
||||
it('flushes the previous listen when a new one begins', () => {
|
||||
const tracker = startedTracker('old');
|
||||
tracker.tick(1);
|
||||
tracker.begin('new');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/listens/old/progress');
|
||||
tracker.flush();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { accent } from '$lib/theme/accent.svelte';
|
||||
import { dialog } from '$lib/state/dialog.svelte';
|
||||
import type { Track } from '$lib/types';
|
||||
import { readStorage, writeStorage } from '$lib/utils/storage';
|
||||
import { trackCover } from '$lib/utils/hrefs';
|
||||
import { ListenTracker } from './listenTracker';
|
||||
import { mediaSession } from './mediaSession';
|
||||
import { ProgressClock } from './progressClock';
|
||||
@@ -140,7 +138,6 @@ class PlayerState {
|
||||
}
|
||||
|
||||
#announce(track: Track) {
|
||||
accent.follow(trackCover(track.id, 'small'), () => this.currentId === track.id);
|
||||
mediaSession.setTrack(track);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { fail, type RequestEvent } from '@sveltejs/kit';
|
||||
import { apiFor, type ApiClient } from '$lib/server/api';
|
||||
|
||||
export interface ActionContext {
|
||||
api: ApiClient;
|
||||
form: FormData;
|
||||
params: Record<string, string>;
|
||||
locals: App.Locals;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
export function authedAction<R>(
|
||||
handler: (context: ActionContext) => R | Promise<R>,
|
||||
unauthorizedMessage = 'Inicia sesión.'
|
||||
) {
|
||||
return async (event: RequestEvent) => {
|
||||
if (!event.locals.accessToken) return fail(401, { message: unauthorizedMessage });
|
||||
const form = await event.request.formData();
|
||||
return handler({
|
||||
api: apiFor(event),
|
||||
form,
|
||||
params: event.params as Record<string, string>,
|
||||
locals: event.locals,
|
||||
url: event.url
|
||||
});
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { authedAction } from '$lib/server/api';
|
||||
import { authedAction } from '$lib/server/actions/authedAction';
|
||||
|
||||
const setFollowing = (follow: boolean) =>
|
||||
authedAction(async ({ api, params }) => {
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { authedAction, formString } from '$lib/server/api';
|
||||
import { formString } from '$lib/server/forms/fields';
|
||||
import { authedAction } from '$lib/server/actions/authedAction';
|
||||
|
||||
export const addTrackAction = authedAction(async ({ api, form, params }) => {
|
||||
const playlistId = formString(form, 'playlistId') || (params.id ?? '');
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { apiErrorDetail } from './api';
|
||||
|
||||
describe('apiErrorDetail', () => {
|
||||
it('joins the validation messages from errors', () => {
|
||||
expect(
|
||||
apiErrorDetail({ errors: { Name: ['Too short.', 'Required.'], Genre: ['Invalid.'] } })
|
||||
).toBe('Too short. Required. Invalid.');
|
||||
});
|
||||
|
||||
it('flattens arrays and ignores non-string entries', () => {
|
||||
expect(apiErrorDetail({ errors: { A: ['one', 2, null], B: 'two' } })).toBe('one two');
|
||||
});
|
||||
|
||||
it('falls back to detail when errors has no text', () => {
|
||||
expect(apiErrorDetail({ errors: {}, detail: 'Bad request.' })).toBe('Bad request.');
|
||||
});
|
||||
|
||||
it('falls back to title after detail', () => {
|
||||
expect(apiErrorDetail({ detail: '', title: 'Conflict' })).toBe('Conflict');
|
||||
expect(apiErrorDetail({ title: 'Conflict' })).toBe('Conflict');
|
||||
});
|
||||
|
||||
it('returns undefined for non-objects and empty objects', () => {
|
||||
expect(apiErrorDetail(undefined)).toBeUndefined();
|
||||
expect(apiErrorDetail(null)).toBeUndefined();
|
||||
expect(apiErrorDetail('boom')).toBeUndefined();
|
||||
expect(apiErrorDetail({})).toBeUndefined();
|
||||
expect(apiErrorDetail({ detail: 3, title: '' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import createClient from 'openapi-fetch';
|
||||
import { error, fail, redirect, type ActionFailure, type RequestEvent } from '@sveltejs/kit';
|
||||
import { error, fail, type ActionFailure, type RequestEvent } from '@sveltejs/kit';
|
||||
import type { paths } from '$lib/api/schema';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import { apiConfig } from '$lib/server/config';
|
||||
|
||||
interface ApiClientOptions {
|
||||
@@ -23,59 +22,6 @@ export function apiFor({ fetch, locals }: Pick<RequestEvent, 'fetch' | 'locals'>
|
||||
return createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
}
|
||||
|
||||
export function loginRedirect(url: URL): never {
|
||||
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
||||
}
|
||||
|
||||
export function requireUser(locals: App.Locals, url: URL): SessionUser {
|
||||
if (!locals.user) loginRedirect(url);
|
||||
return locals.user;
|
||||
}
|
||||
|
||||
export function optionalUser(
|
||||
locals: App.Locals,
|
||||
url: URL,
|
||||
allowAnonymousListening: boolean
|
||||
): SessionUser | null {
|
||||
if (locals.user) return locals.user;
|
||||
if (!allowAnonymousListening) loginRedirect(url);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formString(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? '');
|
||||
}
|
||||
|
||||
export function formFile(form: FormData, key: string): File | null {
|
||||
const value = form.get(key);
|
||||
return value instanceof File && value.size > 0 ? value : null;
|
||||
}
|
||||
|
||||
interface ActionContext {
|
||||
api: ApiClient;
|
||||
form: FormData;
|
||||
params: Record<string, string>;
|
||||
locals: App.Locals;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
export function authedAction<R>(
|
||||
handler: (context: ActionContext) => R | Promise<R>,
|
||||
unauthorizedMessage = 'Inicia sesión.'
|
||||
) {
|
||||
return async (event: RequestEvent) => {
|
||||
if (!event.locals.accessToken) return fail(401, { message: unauthorizedMessage });
|
||||
const form = await event.request.formData();
|
||||
return handler({
|
||||
api: apiFor(event),
|
||||
form,
|
||||
params: event.params as Record<string, string>,
|
||||
locals: event.locals,
|
||||
url: event.url
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function unwrapOrError<T>(
|
||||
result: { data?: T; error?: unknown; response?: Response },
|
||||
message: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formString } from '$lib/server/api';
|
||||
import { formString } from '$lib/server/forms/fields';
|
||||
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
|
||||
|
||||
interface AlbumFormBody {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function formString(form: FormData, key: string): string {
|
||||
return String(form.get(key) ?? '');
|
||||
}
|
||||
|
||||
export function formFile(form: FormData, key: string): File | null {
|
||||
const value = form.get(key);
|
||||
return value instanceof File && value.size > 0 ? value : null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formFile, formString } from '$lib/server/api';
|
||||
import { formFile, formString } from '$lib/server/forms/fields';
|
||||
import { LIMITS } from '$lib/validation';
|
||||
|
||||
interface PlaylistFormBody {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formFile, formString } from '$lib/server/api';
|
||||
import { formFile, formString } from '$lib/server/forms/fields';
|
||||
import { LIMITS } from '$lib/validation';
|
||||
|
||||
interface TrackUploadBody {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loginRedirect, optionalUser, requireUser } from './guards';
|
||||
|
||||
const user = { sub: 'u1', name: 'Ana' };
|
||||
|
||||
function redirectOf(fn: () => unknown): { status: number; location: string } {
|
||||
try {
|
||||
fn();
|
||||
} catch (thrown) {
|
||||
return thrown as { status: number; location: string };
|
||||
}
|
||||
throw new Error('Expected a redirect');
|
||||
}
|
||||
|
||||
describe('loginRedirect', () => {
|
||||
it('redirects to /auth with the encoded path and query', () => {
|
||||
const result = redirectOf(() => loginRedirect(new URL('http://x/albums/1?tab=a&b=c')));
|
||||
expect(result.status).toBe(302);
|
||||
expect(result.location).toBe(`/auth?returnTo=${encodeURIComponent('/albums/1?tab=a&b=c')}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireUser', () => {
|
||||
it('returns the user when signed in', () => {
|
||||
const locals = { user, accessToken: 't' } as App.Locals;
|
||||
expect(requireUser(locals, new URL('http://x/'))).toBe(user);
|
||||
});
|
||||
|
||||
it('redirects when there is no user', () => {
|
||||
const locals = { user: null, accessToken: null } as App.Locals;
|
||||
const result = redirectOf(() => requireUser(locals, new URL('http://x/library')));
|
||||
expect(result.location).toBe('/auth?returnTo=%2Flibrary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('optionalUser', () => {
|
||||
it('returns the user when signed in', () => {
|
||||
const locals = { user, accessToken: 't' } as App.Locals;
|
||||
expect(optionalUser(locals, new URL('http://x/'), false)).toBe(user);
|
||||
});
|
||||
|
||||
it('returns null for anonymous listening', () => {
|
||||
const locals = { user: null, accessToken: null } as App.Locals;
|
||||
expect(optionalUser(locals, new URL('http://x/'), true)).toBeNull();
|
||||
});
|
||||
|
||||
it('redirects anonymous visitors when listening is not allowed', () => {
|
||||
const locals = { user: null, accessToken: null } as App.Locals;
|
||||
const result = redirectOf(() => optionalUser(locals, new URL('http://x/albums/1'), false));
|
||||
expect(result.location).toBe('/auth?returnTo=%2Falbums%2F1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
|
||||
export function loginRedirect(url: URL): never {
|
||||
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
||||
}
|
||||
|
||||
export function requireUser(locals: App.Locals, url: URL): SessionUser {
|
||||
if (!locals.user) loginRedirect(url);
|
||||
return locals.user;
|
||||
}
|
||||
|
||||
export function optionalUser(
|
||||
locals: App.Locals,
|
||||
url: URL,
|
||||
allowAnonymousListening: boolean
|
||||
): SessionUser | null {
|
||||
if (locals.user) return locals.user;
|
||||
if (!allowAnonymousListening) loginRedirect(url);
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { components } from '$lib/api/schema';
|
||||
import { toAlbum, toDatedTrack, toLikedTrack, toMix, toPage, toPlaylist, toTrack } from './mappers';
|
||||
import {
|
||||
toAlbum,
|
||||
toCount,
|
||||
toDatedTrack,
|
||||
toLikedTrack,
|
||||
toListeningStats,
|
||||
toMix,
|
||||
toPage,
|
||||
toPlaylist,
|
||||
toTrack
|
||||
} from './mappers';
|
||||
|
||||
const trackDto: components['schemas']['TrackApplicationResponse'] = {
|
||||
id: 't1',
|
||||
@@ -129,3 +139,29 @@ describe('toPage', () => {
|
||||
).toEqual({ items: [2, 4], pageNumber: 2, hasNextPage: true, totalItemCount: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCount', () => {
|
||||
it('normalizes numbers, numeric strings and missing values', () => {
|
||||
expect(toCount(4)).toBe(4);
|
||||
expect(toCount('12')).toBe(12);
|
||||
expect(toCount(undefined)).toBe(0);
|
||||
expect(toCount(null)).toBe(0);
|
||||
expect(toCount('abc')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toListeningStats', () => {
|
||||
it('converts string counters to numbers', () => {
|
||||
expect(
|
||||
toListeningStats({ tracksThisWeek: '5', secondsThisWeek: 300, streakDays: '2' })
|
||||
).toEqual({ tracksThisWeek: 5, secondsThisWeek: 300, streakDays: 2 });
|
||||
});
|
||||
|
||||
it('falls back to zeros without data', () => {
|
||||
expect(toListeningStats(undefined)).toEqual({
|
||||
tracksThisWeek: 0,
|
||||
secondsThisWeek: 0,
|
||||
streakDays: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { components } from '$lib/api/schema';
|
||||
import type { Album, LikedTrack, Mix, Paged, Playlist, Track } from '$lib/types';
|
||||
import type { Album, LikedTrack, ListeningStats, Mix, Paged, Playlist, Track } from '$lib/types';
|
||||
|
||||
type TrackDto = components['schemas']['TrackApplicationResponse'];
|
||||
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
|
||||
@@ -82,6 +82,20 @@ export function toPlaylist(dto: PlaylistDto): Playlist {
|
||||
};
|
||||
}
|
||||
|
||||
type ListeningStatsDto = components['schemas']['ListeningStatsResponse'];
|
||||
|
||||
export function toCount(value: number | string | null | undefined): number {
|
||||
return Number(value ?? 0) || 0;
|
||||
}
|
||||
|
||||
export function toListeningStats(dto: ListeningStatsDto | null | undefined): ListeningStats {
|
||||
return {
|
||||
tracksThisWeek: toCount(dto?.tracksThisWeek),
|
||||
secondsThisWeek: toCount(dto?.secondsThisWeek),
|
||||
streakDays: toCount(dto?.streakDays)
|
||||
};
|
||||
}
|
||||
|
||||
export function toPage<D, T>(dto: PageDto<D>, map: (item: D) => T): Paged<T> {
|
||||
return {
|
||||
items: dto.items.map(map),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { EncryptJWT } from 'jose';
|
||||
|
||||
const testEnv = vi.hoisted(() => ({ SESSION_SECRET: 'a'.repeat(40) }) as Record<string, string>);
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({ env: testEnv }));
|
||||
|
||||
import { decodeSession, encodeSession, type Session } from './auth';
|
||||
|
||||
const session: Session = {
|
||||
sub: 'user-1',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
picture: '',
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
idToken: 'id',
|
||||
expiresAt: 1_900_000_000
|
||||
};
|
||||
|
||||
async function encryptWith(secret: string, ttlSeconds: number): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
|
||||
return new EncryptJWT({ ...session })
|
||||
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + ttlSeconds)
|
||||
.encrypt(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
describe('encodeSession / decodeSession', () => {
|
||||
it('round-trips a session', async () => {
|
||||
const token = await encodeSession(session);
|
||||
const decoded = await decodeSession(token);
|
||||
expect(decoded).toMatchObject({ sub: session.sub, accessToken: session.accessToken });
|
||||
});
|
||||
|
||||
it('returns null for a missing or empty token', async () => {
|
||||
expect(await decodeSession(undefined)).toBeNull();
|
||||
expect(await decodeSession('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a tampered token', async () => {
|
||||
const token = await encodeSession(session);
|
||||
const tampered = token.slice(0, -4) + (token.endsWith('AAAA') ? 'BBBB' : 'AAAA');
|
||||
expect(await decodeSession(tampered)).toBeNull();
|
||||
expect(await decodeSession('not.a.token')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a token encrypted with another key', async () => {
|
||||
const token = await encryptWith('b'.repeat(40), 3600);
|
||||
expect(await decodeSession(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an expired token', async () => {
|
||||
const token = await encryptWith(testEnv.SESSION_SECRET, -50);
|
||||
expect(await decodeSession(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a token built with the configured key', async () => {
|
||||
const token = await encryptWith(testEnv.SESSION_SECRET, 3600);
|
||||
expect(await decodeSession(token)).toMatchObject({ sub: 'user-1' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { LikedTrack, Track } from '$lib/types';
|
||||
|
||||
function track(id: string): Track {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
artist: 'Artist',
|
||||
duration: 100,
|
||||
explicit: false,
|
||||
listensCount: 0
|
||||
} as unknown as Track;
|
||||
}
|
||||
|
||||
function likedTrack(id: string): LikedTrack {
|
||||
return { ...track(id), likedAt: 1 } as LikedTrack;
|
||||
}
|
||||
|
||||
async function freshStore() {
|
||||
vi.resetModules();
|
||||
return (await import('./liked.svelte')).liked;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('liked store', () => {
|
||||
it('hydrates only once', async () => {
|
||||
const liked = await freshStore();
|
||||
liked.hydrate([likedTrack('a')]);
|
||||
liked.hydrate([likedTrack('b')]);
|
||||
expect(liked.isLiked('a')).toBe(true);
|
||||
expect(liked.isLiked('b')).toBe(false);
|
||||
expect(liked.count).toBe(1);
|
||||
});
|
||||
|
||||
it('likes and unlikes a track', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||
const liked = await freshStore();
|
||||
await liked.toggle(track('a'));
|
||||
expect(liked.isLiked('a')).toBe(true);
|
||||
await liked.toggle(track('a'));
|
||||
expect(liked.isLiked('a')).toBe(false);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reverts a failed like', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||
const liked = await freshStore();
|
||||
await liked.toggle(track('a'));
|
||||
expect(liked.isLiked('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('reverts a failed unlike', async () => {
|
||||
const liked = await freshStore();
|
||||
liked.hydrate([likedTrack('a')]);
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
||||
await liked.toggle(track('a'));
|
||||
expect(liked.isLiked('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps changes made to other tracks while a request fails', async () => {
|
||||
const liked = await freshStore();
|
||||
let rejectFirst: (reason: Error) => void = () => {};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => new Promise((_, reject) => (rejectFirst = reject)))
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const first = liked.toggle(track('a'));
|
||||
await liked.toggle(track('b'));
|
||||
rejectFirst(new Error('offline'));
|
||||
await first;
|
||||
|
||||
expect(liked.isLiked('a')).toBe(false);
|
||||
expect(liked.isLiked('b')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -48,3 +48,5 @@ export function createPagedList<T>(
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type PagedList<T> = ReturnType<typeof createPagedList<T>>;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Paged } from '$lib/types';
|
||||
import { createPagedList } from './pagedList.svelte';
|
||||
|
||||
interface Item {
|
||||
id: number;
|
||||
}
|
||||
|
||||
function page(ids: number[], pageNumber: number, hasNextPage: boolean): Paged<Item> {
|
||||
return { items: ids.map((id) => ({ id })), pageNumber, hasNextPage, totalItemCount: 99 };
|
||||
}
|
||||
|
||||
describe('createPagedList', () => {
|
||||
it('appends the next page without duplicates and updates hasMore', async () => {
|
||||
const fetchPage = vi.fn().mockResolvedValue(page([2, 3], 2, false));
|
||||
const list = createPagedList<Item>(fetchPage, (item) => item.id);
|
||||
list.reset(page([1, 2], 1, true));
|
||||
expect(list.hasMore).toBe(true);
|
||||
|
||||
await list.loadMore();
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledWith(2);
|
||||
expect(list.items.map((item) => item.id)).toEqual([1, 2, 3]);
|
||||
expect(list.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores loadMore while another is in flight', async () => {
|
||||
let resolve: (value: Paged<Item>) => void = () => {};
|
||||
const fetchPage = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise<Paged<Item>>((r) => {
|
||||
resolve = r;
|
||||
})
|
||||
);
|
||||
const list = createPagedList<Item>(fetchPage, (item) => item.id);
|
||||
list.reset(page([1], 1, true));
|
||||
|
||||
const first = list.loadMore();
|
||||
expect(list.loading).toBe(true);
|
||||
await list.loadMore();
|
||||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolve(page([2], 2, false));
|
||||
await first;
|
||||
expect(list.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('flags an error and keeps the items when the fetch fails', async () => {
|
||||
const fetchPage = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const list = createPagedList<Item>(fetchPage, (item) => item.id);
|
||||
list.reset(page([1, 2], 1, true));
|
||||
|
||||
await list.loadMore();
|
||||
|
||||
expect(list.error).toBe(true);
|
||||
expect(list.loading).toBe(false);
|
||||
expect(list.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reset clears the error', async () => {
|
||||
const fetchPage = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const list = createPagedList<Item>(fetchPage, (item) => item.id);
|
||||
list.reset(page([1], 1, true));
|
||||
await list.loadMore();
|
||||
expect(list.error).toBe(true);
|
||||
|
||||
list.reset(page([5], 1, false));
|
||||
|
||||
expect(list.error).toBe(false);
|
||||
expect(list.items).toEqual([{ id: 5 }]);
|
||||
expect(list.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,12 @@ export interface Mix {
|
||||
tracks: Track[];
|
||||
}
|
||||
|
||||
export interface ListeningStats {
|
||||
tracksThisWeek: number;
|
||||
secondsThisWeek: number;
|
||||
streakDays: number;
|
||||
}
|
||||
|
||||
export interface Paged<T> {
|
||||
items: T[];
|
||||
pageNumber: number;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
</script>
|
||||
|
||||
<div class="animate-enter mx-auto my-[14vh] max-w-prose-sm text-center">
|
||||
<h1 class="mb-2.5 text-display-3 text-fg">
|
||||
{page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'}
|
||||
</h1>
|
||||
<p class="mb-6.5 text-body text-fg-2">
|
||||
{page.status === 404
|
||||
? 'Comprueba el enlace o vuelve al inicio.'
|
||||
: 'Algo salió mal al cargar la página. Inténtalo de nuevo en unos momentos.'}
|
||||
</p>
|
||||
<Button href="/" variant="secondary">Volver</Button>
|
||||
</div>
|
||||
+5
-6
@@ -1,12 +1,11 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { getAllowAnonymousListening } from '$lib/server/playbackConfig';
|
||||
import { apiFor, loginRedirect } from '$lib/server/api';
|
||||
import { toLikedTrack, toPlaylist, toTrack } from '$lib/server/mappers';
|
||||
import { apiFor } from '$lib/server/api';
|
||||
import { loginRedirect } from '$lib/server/guards';
|
||||
import { toCount, toLikedTrack, toPlaylist, toTrack } from '$lib/server/mappers';
|
||||
import { authConfig } from '$lib/server/config';
|
||||
import { LIKED_TRACKS_PAGE_SIZE, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/auth']);
|
||||
|
||||
function orFallback<T>(load: Promise<T>, fallback: T): Promise<T> {
|
||||
return load.catch(() => fallback);
|
||||
}
|
||||
@@ -14,7 +13,7 @@ function orFallback<T>(load: Promise<T>, fallback: T): Promise<T> {
|
||||
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
|
||||
const allowAnonymousListening = await getAllowAnonymousListening(fetch);
|
||||
|
||||
if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) {
|
||||
if (!locals.user && !allowAnonymousListening) {
|
||||
loginRedirect(url);
|
||||
}
|
||||
|
||||
@@ -46,7 +45,7 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
|
||||
})
|
||||
.then(({ data }) => ({
|
||||
items: (data?.items ?? []).map(toPlaylist),
|
||||
total: Number(data?.totalItemCount ?? 0)
|
||||
total: toCount(data?.totalItemCount)
|
||||
})),
|
||||
{ items: [], total: 0 }
|
||||
),
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import PlayerDock from '$lib/components/player/PlayerDock.svelte';
|
||||
import Queue from '$lib/components/player/Queue.svelte';
|
||||
import Sidebar from '$lib/components/shell/Sidebar.svelte';
|
||||
import TopBar from '$lib/components/shell/TopBar.svelte';
|
||||
import MobileHeader from '$lib/components/shell/MobileHeader.svelte';
|
||||
import Modal from '$lib/components/ui/overlay/Modal.svelte';
|
||||
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { queuePanel } from '$lib/state/panels.svelte';
|
||||
import { liked } from '$lib/state/liked.svelte';
|
||||
import { createPlaylistModal } from '$lib/state/panels.svelte';
|
||||
import { accent } from '$lib/theme/accent.svelte';
|
||||
import { trackCover } from '$lib/utils/hrefs';
|
||||
import { trackNavigation } from '$lib/state/history.svelte';
|
||||
import { restoreScroll } from '$lib/state/scroll';
|
||||
import { page } from '$app/state';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
data: LayoutData;
|
||||
}
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
let scroller = $state<HTMLElement>();
|
||||
|
||||
trackNavigation();
|
||||
restoreScroll(() => scroller);
|
||||
|
||||
$effect(() => {
|
||||
if (data.likedTracks?.length) liked.hydrate(data.likedTracks);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (data.lastPlayedTrack) player.hydrate(data.lastPlayedTrack);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const id = player.currentId;
|
||||
if (id) accent.follow(trackCover(id, 'small'), () => player.currentId === id);
|
||||
});
|
||||
|
||||
let queueAutoOpened = false;
|
||||
$effect(() => {
|
||||
if (!player.playing || queueAutoOpened) return;
|
||||
queueAutoOpened = true;
|
||||
queuePanel.show();
|
||||
});
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
return ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(target.tagName);
|
||||
}
|
||||
|
||||
function onWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.code !== 'Space' || isTypingTarget(event.target)) return;
|
||||
event.preventDefault();
|
||||
player.toggle();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
<div class="relative flex h-dvh overflow-hidden text-fg antialiased">
|
||||
<div class="app-backdrop"></div>
|
||||
<div class="app-vignette"></div>
|
||||
|
||||
<Sidebar
|
||||
user={data.user}
|
||||
playlists={data.userPlaylists ?? []}
|
||||
playlistCount={data.userPlaylistsTotal ?? 0}
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div bind:this={scroller} class="flex min-w-0 flex-1 app-scroll flex-col">
|
||||
<MobileHeader user={data.user} accountUrl={data.accountUrl} />
|
||||
<TopBar user={data.user} accountUrl={data.accountUrl} />
|
||||
<main class="flex-1">
|
||||
{#key page.url.pathname}
|
||||
<div class="animate-fade">{@render children()}</div>
|
||||
{/key}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Queue />
|
||||
</div>
|
||||
|
||||
<PlayerDock user={data.user} />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={createPlaylistModal.open}
|
||||
onClose={() => createPlaylistModal.close()}
|
||||
title="Crear nueva playlist"
|
||||
>
|
||||
<PlaylistForm
|
||||
action="/playlists?/create"
|
||||
formMessage={page.form?.message}
|
||||
submitLabel="Crear"
|
||||
submittingLabel="Creando…"
|
||||
helperText="Podrás añadir canciones más tarde."
|
||||
onCancel={() => createPlaylistModal.close()}
|
||||
onSuccess={() => createPlaylistModal.close()}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
@@ -1,8 +1,9 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiFor, requireUser } from '$lib/server/api';
|
||||
import { toMix, toPlaylist, toTrack } from '$lib/server/mappers';
|
||||
import { addTrackAction } from '$lib/server/playlistActions';
|
||||
import { apiFor } from '$lib/server/api';
|
||||
import { requireUser } from '$lib/server/guards';
|
||||
import { toCount, toListeningStats, toMix, toPlaylist, toTrack } from '$lib/server/mappers';
|
||||
import { addTrackAction } from '$lib/server/actions/playlist';
|
||||
import { HOME_MIXES_LIMIT, HOME_SHELF_LIMIT, HOME_SPOTLIGHT_TRACKS_LIMIT } from '$lib/config';
|
||||
import { pickGreeting } from '$lib/server/greeting';
|
||||
|
||||
@@ -23,12 +24,8 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
});
|
||||
const listeningStatsPromise = api
|
||||
.GET('/users/{id}/listening-stats', { params: { path: { id: user.sub } } })
|
||||
.then((res) => ({
|
||||
tracksThisWeek: Number(res.data?.tracksThisWeek ?? 0),
|
||||
secondsThisWeek: Number(res.data?.secondsThisWeek ?? 0),
|
||||
streakDays: Number(res.data?.streakDays ?? 0)
|
||||
}))
|
||||
.catch(() => ({ tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 }));
|
||||
.then((res) => toListeningStats(res.data))
|
||||
.catch(() => toListeningStats(null));
|
||||
|
||||
const [playlists, mixes, recentlyPlayed] = await Promise.all([
|
||||
playlistsPromise,
|
||||
@@ -48,7 +45,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
})
|
||||
.then((res) => ({
|
||||
items: (res.data?.items ?? []).map(toTrack),
|
||||
totalCount: Number(res.data?.totalItemCount ?? 0)
|
||||
totalCount: toCount(res.data?.totalItemCount)
|
||||
}))
|
||||
.catch(() => ({ items: [], totalCount: 0 }))
|
||||
: Promise.resolve({ items: [], totalCount: 0 });
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { playShuffled } from '$lib/player/actions';
|
||||
import type { Track } from '$lib/types';
|
||||
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
|
||||
import { mergeRecentlyPlayed } from '$lib/player/recentlyPlayed';
|
||||
import { fetchPlaylistTracks } from '$lib/data/albums';
|
||||
import { fmtTime, fmtDurationLong, plural } from '$lib/utils/format';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
@@ -16,8 +16,8 @@
|
||||
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
|
||||
import TrackContextMenu from '$lib/components/ui/overlay/TrackContextMenu.svelte';
|
||||
import PlayAllButton from '$lib/components/music/PlayAllButton.svelte';
|
||||
import TrackContextMenu from '$lib/components/music/TrackContextMenu.svelte';
|
||||
import { createMenu } from '$lib/state/menu.svelte';
|
||||
import { HOME_CONTINUE_LIMIT } from '$lib/config';
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
error(404, 'Página no encontrada');
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { apiFor, authedAction, formFile, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||
import { requireUser } from '$lib/server/guards';
|
||||
import { formFile } from '$lib/server/forms/fields';
|
||||
import { authedAction } from '$lib/server/actions/authedAction';
|
||||
import { toAlbum, toPage } from '$lib/server/mappers';
|
||||
import { ALBUMS_PAGE_SIZE } from '$lib/config';
|
||||
import { parseAlbumForm } from '$lib/server/forms/albumForm';
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
apiFor,
|
||||
authedAction,
|
||||
failOnError,
|
||||
formFile,
|
||||
formString,
|
||||
optionalUser,
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
import { apiFor, failOnError, unwrapOrError } from '$lib/server/api';
|
||||
import { optionalUser } from '$lib/server/guards';
|
||||
import { formFile, formString } from '$lib/server/forms/fields';
|
||||
import { authedAction } from '$lib/server/actions/authedAction';
|
||||
import { toAlbum, toTrack } from '$lib/server/mappers';
|
||||
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
|
||||
import { parseAlbumForm } from '$lib/server/forms/albumForm';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { albumCover } from '$lib/utils/hrefs';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
|
||||
import PlayAllButton from '$lib/components/music/PlayAllButton.svelte';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
@@ -14,7 +14,7 @@
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
|
||||
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
|
||||
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
||||
import TrackList from '$lib/components/music/TrackList.svelte';
|
||||
import { albumMeta } from '$lib/utils/format';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||
import { getGenres } from '$lib/server/genres';
|
||||
import { toAlbum, toPage, toTrack } from '$lib/server/mappers';
|
||||
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
|
||||
import { toAlbum, toCount, toPage, toTrack } from '$lib/server/mappers';
|
||||
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/actions/playlist';
|
||||
import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
@@ -56,9 +56,9 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
genres,
|
||||
tracks,
|
||||
albums: (albumsRes?.data?.items ?? []).map((item) => toAlbum(item.album)),
|
||||
albumsTotal: Number(albumsRes?.data?.totalItemCount ?? 0),
|
||||
albumsTotal: toCount(albumsRes?.data?.totalItemCount),
|
||||
users: usersRes?.data?.items ?? [],
|
||||
usersTotal: Number(usersRes?.data?.totalItemCount ?? 0),
|
||||
usersTotal: toCount(usersRes?.data?.totalItemCount),
|
||||
viewerId: user?.sub ?? null
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,45 +1,27 @@
|
||||
<script lang="ts">
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import type { Paged, Track } from '$lib/types';
|
||||
import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
|
||||
import { plural } from '$lib/utils/format';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
|
||||
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
||||
import Avatar from '$lib/components/ui/media/Avatar.svelte';
|
||||
import UserRow from '$lib/components/ui/media/UserRow.svelte';
|
||||
import PlayButton from '$lib/components/ui/media/PlayButton.svelte';
|
||||
import EqBars from '$lib/components/ui/media/EqBars.svelte';
|
||||
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||
import Chip from '$lib/components/ui/primitives/Chip.svelte';
|
||||
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
|
||||
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||
import { genreHref, playlistCover } from '$lib/utils/hrefs';
|
||||
import { genreHref } from '$lib/utils/hrefs';
|
||||
import { createMenu } from '$lib/state/menu.svelte';
|
||||
import { createPagedList } from '$lib/state/pagedList.svelte';
|
||||
import TrackContextMenu from '$lib/components/ui/overlay/TrackContextMenu.svelte';
|
||||
import AlbumContextMenu from '$lib/components/ui/overlay/AlbumContextMenu.svelte';
|
||||
import TrackContextMenu from '$lib/components/music/TrackContextMenu.svelte';
|
||||
import AlbumContextMenu from '$lib/components/music/AlbumContextMenu.svelte';
|
||||
import { genreInfo, genreTiles } from '$lib/data/genres';
|
||||
import {
|
||||
type SearchFilter,
|
||||
showGroup,
|
||||
capped,
|
||||
matchPlaylists,
|
||||
SEARCH_LABELS,
|
||||
searchTotal,
|
||||
searchChips,
|
||||
findTopResult
|
||||
} from '$lib/data/search';
|
||||
|
||||
const TOP_RESULT_KIND_LABEL: Record<TopResult['kind'], string> = {
|
||||
track: 'Canción',
|
||||
album: 'Álbum',
|
||||
playlist: 'Playlist',
|
||||
user: 'Usuario'
|
||||
};
|
||||
import GenreGrid from './GenreGrid.svelte';
|
||||
import TopResult from './TopResult.svelte';
|
||||
import SearchResults from './SearchResults.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
@@ -96,18 +78,7 @@
|
||||
const chips = $derived(searchChips(counts));
|
||||
const totalHits = $derived(searchTotal(counts));
|
||||
|
||||
type TopResult = NonNullable<
|
||||
ReturnType<
|
||||
typeof findTopResult<
|
||||
Track,
|
||||
(typeof albums)[number],
|
||||
(typeof playlistMatches)[number],
|
||||
(typeof users)[number]
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
const topResult = $derived<TopResult | null>(
|
||||
const topResult = $derived(
|
||||
sfilter === 'all' && data.query
|
||||
? findTopResult({
|
||||
query: data.query,
|
||||
@@ -118,18 +89,6 @@
|
||||
})
|
||||
: null
|
||||
);
|
||||
|
||||
function playTopResult() {
|
||||
if (topResult?.kind !== 'track') return;
|
||||
player.playOrToggle([topResult.track], 0);
|
||||
}
|
||||
|
||||
const topResultHref = $derived.by(() => {
|
||||
if (!topResult || topResult.kind === 'track') return undefined;
|
||||
if (topResult.kind === 'album') return `/albums/${topResult.album.id}`;
|
||||
if (topResult.kind === 'playlist') return `/playlists/${topResult.playlist.id}`;
|
||||
return `/user/${topResult.user.id}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -174,62 +133,7 @@
|
||||
</div>
|
||||
|
||||
{#if topResult}
|
||||
{#snippet topResultBody()}
|
||||
{#if topResult.kind === 'track'}
|
||||
<Artwork
|
||||
trackIds={[topResult.track.id]}
|
||||
size="xl"
|
||||
alt={topResult.track.title}
|
||||
class="shrink-0"
|
||||
/>
|
||||
{:else if topResult.kind === 'album'}
|
||||
<Artwork trackIds={topResult.album.coverTrackIds} size="xl" class="shrink-0" />
|
||||
{:else if topResult.kind === 'playlist'}
|
||||
<Artwork
|
||||
src={playlistCover(topResult.playlist, 'large')}
|
||||
trackIds={topResult.playlist.coverTrackIds}
|
||||
size="xl"
|
||||
class="shrink-0"
|
||||
/>
|
||||
{:else}
|
||||
<Avatar name={topResult.user.name} src={topResult.user.profilePictureUrl} size="lg" />
|
||||
{/if}
|
||||
|
||||
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3">
|
||||
<div class="text-eyebrow leading-none font-semibold text-fg-2">
|
||||
Mejor resultado · {TOP_RESULT_KIND_LABEL[topResult.kind]}
|
||||
</div>
|
||||
<div
|
||||
class="truncate font-display text-lg leading-none font-medium tracking-display text-fg"
|
||||
>
|
||||
{topResult.kind === 'track'
|
||||
? topResult.track.title
|
||||
: topResult.kind === 'album'
|
||||
? topResult.album.title
|
||||
: topResult.kind === 'playlist'
|
||||
? topResult.playlist.name
|
||||
: topResult.user.name}
|
||||
</div>
|
||||
{#if topResult.kind === 'track' && topResult.track.artist}
|
||||
<div class="truncate text-sm leading-none text-fg-2">
|
||||
{topResult.track.artist}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PlayButton as="span" size="lg" label="Reproducir" class="group-hover/top:brightness-110" />
|
||||
{/snippet}
|
||||
|
||||
<svelte:element
|
||||
this={topResult.kind === 'track' ? 'button' : 'a'}
|
||||
type={topResult.kind === 'track' ? 'button' : undefined}
|
||||
role={topResult.kind === 'track' ? 'button' : 'link'}
|
||||
href={topResult.kind === 'track' ? undefined : topResultHref}
|
||||
onclick={topResult.kind === 'track' ? playTopResult : undefined}
|
||||
class="group/top animate-pop mt-6.5 flex w-full items-center gap-5 rounded-panel-lg bg-surface p-4.5 text-left transition hover:bg-surface-hover"
|
||||
>
|
||||
{@render topResultBody()}
|
||||
</svelte:element>
|
||||
<TopResult result={topResult} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -255,137 +159,22 @@
|
||||
{/if}
|
||||
|
||||
{#if !data.query && !data.genre}
|
||||
<div class="grid-wide">
|
||||
{#each tiles as tile, i (tile.genre)}
|
||||
<a
|
||||
href={genreHref(tile.genre)}
|
||||
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-panel p-4 transition-[filter] genre-tile hover:brightness-110"
|
||||
style="--i:{i}; --tile-hue:{tile.hue}"
|
||||
>
|
||||
<div class="text-on-art">
|
||||
{tile.label}
|
||||
</div>
|
||||
<div class="text-xs text-on-art-2">{tile.tagline}</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{#if tiles.length === 0}
|
||||
<EmptyState icon={Music} description="Todavía no hay géneros. Sube música para empezar." />
|
||||
{/if}
|
||||
<GenreGrid {tiles} />
|
||||
{/if}
|
||||
|
||||
{#if (data.query || data.genre) && !nothingFound}
|
||||
<div class="mt-9 flex flex-col gap-9">
|
||||
{#if showGroup(sfilter, 'tracks')}
|
||||
<div>
|
||||
{#if items.length > 0}
|
||||
<SectionHeading title={SEARCH_LABELS.tracks} count={counts.tracks} />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(sfilter, items) as track, i (track.id)}
|
||||
<li>
|
||||
<ListRow
|
||||
title={track.title}
|
||||
subtitle={track.artist}
|
||||
subtitleHref={track.ownerUserId}
|
||||
explicit={track.explicit}
|
||||
active={player.currentId === track.id}
|
||||
size="lg"
|
||||
trackId={track.id}
|
||||
onclick={() => player.playOrToggle(items, i)}
|
||||
oncontextmenu={(e) => trackMenu.open(e, track)}
|
||||
>
|
||||
{#snippet overlay()}
|
||||
{#if player.currentId === track.id}
|
||||
<EqBars overlay paused={!player.playing} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
|
||||
{fmtTime(track.duration)} · {fmtPlays(track.listensCount)}
|
||||
</span>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if sfilter === 'tracks' && tracksList.hasMore}
|
||||
<InfiniteScroll
|
||||
onLoadMore={() => tracksList.loadMore()}
|
||||
hasMore={tracksList.hasMore}
|
||||
loading={tracksList.loading}
|
||||
error={tracksList.error}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
description={activeGenre
|
||||
? 'Todavía no hay canciones de este género.'
|
||||
: `No hay canciones que coincidan con «${data.query}».`}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasAlbums && showGroup(sfilter, 'albums')}
|
||||
<div>
|
||||
<SectionHeading title={SEARCH_LABELS.albums} count={counts.albums} />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(sfilter, albums) as album (album.id)}
|
||||
<li>
|
||||
<ListRow
|
||||
title={album.title}
|
||||
href="/albums/{album.id}"
|
||||
size="lg"
|
||||
trackIds={album.coverTrackIds}
|
||||
oncontextmenu={(e) => albumMenu.open(e, album.id)}
|
||||
>
|
||||
{#snippet trailing()}
|
||||
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
|
||||
{plural(album.trackCount, 'canción', 'canciones')}
|
||||
</span>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasUsers && showGroup(sfilter, 'users')}
|
||||
<div>
|
||||
<SectionHeading title={SEARCH_LABELS.users} count={counts.users} />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(sfilter, users) as u (u.id)}
|
||||
<li>
|
||||
<UserRow user={u} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasPlaylists && showGroup(sfilter, 'playlists')}
|
||||
<div>
|
||||
<SectionHeading title={SEARCH_LABELS.playlists} count={counts.playlists} />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(sfilter, playlistMatches) as playlist (playlist.id)}
|
||||
<li>
|
||||
<ListRow
|
||||
title={playlist.name}
|
||||
subtitle={playlist.description}
|
||||
href="/playlists/{playlist.id}"
|
||||
size="lg"
|
||||
coverSrc={playlistCover(playlist, 'medium')}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<SearchResults
|
||||
filter={sfilter}
|
||||
{counts}
|
||||
query={data.query}
|
||||
inGenre={!!activeGenre}
|
||||
{tracksList}
|
||||
{albums}
|
||||
playlists={playlistMatches}
|
||||
{users}
|
||||
{trackMenu}
|
||||
{albumMenu}
|
||||
/>
|
||||
{/if}
|
||||
</Page>
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import { genreHref } from '$lib/utils/hrefs';
|
||||
import type { genreTiles } from '$lib/data/genres';
|
||||
|
||||
interface Props {
|
||||
tiles: ReturnType<typeof genreTiles>;
|
||||
}
|
||||
|
||||
let { tiles }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid-wide">
|
||||
{#each tiles as tile, i (tile.genre)}
|
||||
<a
|
||||
href={genreHref(tile.genre)}
|
||||
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-panel p-4 transition-[filter] genre-tile hover:brightness-110"
|
||||
style="--i:{i}; --tile-hue:{tile.hue}"
|
||||
>
|
||||
<div class="text-on-art">
|
||||
{tile.label}
|
||||
</div>
|
||||
<div class="text-xs text-on-art-2">{tile.tagline}</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{#if tiles.length === 0}
|
||||
<EmptyState icon={Music} description="Todavía no hay géneros. Sube música para empezar." />
|
||||
{/if}
|
||||
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import type { Album, Playlist, Track } from '$lib/types';
|
||||
import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
|
||||
import UserRow from '$lib/components/ui/media/UserRow.svelte';
|
||||
import EqBars from '$lib/components/ui/media/EqBars.svelte';
|
||||
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
|
||||
import { playlistCover } from '$lib/utils/hrefs';
|
||||
import type { Menu } from '$lib/state/menu.svelte';
|
||||
import type { PagedList } from '$lib/state/pagedList.svelte';
|
||||
import {
|
||||
type SearchCounts,
|
||||
type SearchFilter,
|
||||
showGroup,
|
||||
capped,
|
||||
SEARCH_LABELS
|
||||
} from '$lib/data/search';
|
||||
|
||||
interface ResultUser {
|
||||
id: string;
|
||||
name: string;
|
||||
profilePictureUrl: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filter: SearchFilter;
|
||||
counts: SearchCounts;
|
||||
query: string;
|
||||
inGenre: boolean;
|
||||
tracksList: PagedList<Track>;
|
||||
albums: Album[];
|
||||
playlists: Playlist[];
|
||||
users: ResultUser[];
|
||||
trackMenu: Menu<Track>;
|
||||
albumMenu: Menu<string>;
|
||||
}
|
||||
|
||||
let {
|
||||
filter,
|
||||
counts,
|
||||
query,
|
||||
inGenre,
|
||||
tracksList,
|
||||
albums,
|
||||
playlists,
|
||||
users,
|
||||
trackMenu,
|
||||
albumMenu
|
||||
}: Props = $props();
|
||||
|
||||
const items = $derived(tracksList.items);
|
||||
</script>
|
||||
|
||||
<div class="mt-9 flex flex-col gap-9">
|
||||
{#if showGroup(filter, 'tracks')}
|
||||
<div>
|
||||
{#if items.length > 0}
|
||||
<SectionHeading title={SEARCH_LABELS.tracks} count={counts.tracks} />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(filter, items) as track, i (track.id)}
|
||||
<li>
|
||||
<ListRow
|
||||
title={track.title}
|
||||
subtitle={track.artist}
|
||||
subtitleHref={track.ownerUserId}
|
||||
explicit={track.explicit}
|
||||
active={player.currentId === track.id}
|
||||
size="lg"
|
||||
trackId={track.id}
|
||||
onclick={() => player.playOrToggle(items, i)}
|
||||
oncontextmenu={(e) => trackMenu.open(e, track)}
|
||||
>
|
||||
{#snippet overlay()}
|
||||
{#if player.currentId === track.id}
|
||||
<EqBars overlay paused={!player.playing} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
|
||||
{fmtTime(track.duration)} · {fmtPlays(track.listensCount)}
|
||||
</span>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if filter === 'tracks' && tracksList.hasMore}
|
||||
<InfiniteScroll
|
||||
onLoadMore={() => tracksList.loadMore()}
|
||||
hasMore={tracksList.hasMore}
|
||||
loading={tracksList.loading}
|
||||
error={tracksList.error}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
description={inGenre
|
||||
? 'Todavía no hay canciones de este género.'
|
||||
: `No hay canciones que coincidan con «${query}».`}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if albums.length > 0 && showGroup(filter, 'albums')}
|
||||
<div>
|
||||
<SectionHeading title={SEARCH_LABELS.albums} count={counts.albums} />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(filter, albums) as album (album.id)}
|
||||
<li>
|
||||
<ListRow
|
||||
title={album.title}
|
||||
href="/albums/{album.id}"
|
||||
size="lg"
|
||||
trackIds={album.coverTrackIds}
|
||||
oncontextmenu={(e) => albumMenu.open(e, album.id)}
|
||||
>
|
||||
{#snippet trailing()}
|
||||
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
|
||||
{plural(album.trackCount, 'canción', 'canciones')}
|
||||
</span>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if users.length > 0 && showGroup(filter, 'users')}
|
||||
<div>
|
||||
<SectionHeading title={SEARCH_LABELS.users} count={counts.users} />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(filter, users) as u (u.id)}
|
||||
<li>
|
||||
<UserRow user={u} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if playlists.length > 0 && showGroup(filter, 'playlists')}
|
||||
<div>
|
||||
<SectionHeading title={SEARCH_LABELS.playlists} count={counts.playlists} />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(filter, playlists) as playlist (playlist.id)}
|
||||
<li>
|
||||
<ListRow
|
||||
title={playlist.name}
|
||||
subtitle={playlist.description}
|
||||
href="/playlists/{playlist.id}"
|
||||
size="lg"
|
||||
coverSrc={playlistCover(playlist, 'medium')}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
||||
import Avatar from '$lib/components/ui/media/Avatar.svelte';
|
||||
import PlayButton from '$lib/components/ui/media/PlayButton.svelte';
|
||||
import { playlistCover } from '$lib/utils/hrefs';
|
||||
import type { Album, Playlist, Track } from '$lib/types';
|
||||
import type { TopResult as TopResultData } from '$lib/data/search';
|
||||
|
||||
interface ResultUser {
|
||||
id: string;
|
||||
name: string;
|
||||
profilePictureUrl: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
result: TopResultData<Track, Album, Playlist, ResultUser>;
|
||||
}
|
||||
|
||||
let { result }: Props = $props();
|
||||
|
||||
const KIND_LABEL: Record<Props['result']['kind'], string> = {
|
||||
track: 'Canción',
|
||||
album: 'Álbum',
|
||||
playlist: 'Playlist',
|
||||
user: 'Usuario'
|
||||
};
|
||||
|
||||
function play() {
|
||||
if (result.kind !== 'track') return;
|
||||
player.playOrToggle([result.track], 0);
|
||||
}
|
||||
|
||||
const href = $derived.by(() => {
|
||||
if (result.kind === 'track') return undefined;
|
||||
if (result.kind === 'album') return `/albums/${result.album.id}`;
|
||||
if (result.kind === 'playlist') return `/playlists/${result.playlist.id}`;
|
||||
return `/user/${result.user.id}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet body()}
|
||||
{#if result.kind === 'track'}
|
||||
<Artwork trackIds={[result.track.id]} size="xl" alt={result.track.title} class="shrink-0" />
|
||||
{:else if result.kind === 'album'}
|
||||
<Artwork trackIds={result.album.coverTrackIds} size="xl" class="shrink-0" />
|
||||
{:else if result.kind === 'playlist'}
|
||||
<Artwork
|
||||
src={playlistCover(result.playlist, 'large')}
|
||||
trackIds={result.playlist.coverTrackIds}
|
||||
size="xl"
|
||||
class="shrink-0"
|
||||
/>
|
||||
{:else}
|
||||
<Avatar name={result.user.name} src={result.user.profilePictureUrl} size="lg" />
|
||||
{/if}
|
||||
|
||||
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3">
|
||||
<div class="text-eyebrow leading-none font-semibold text-fg-2">
|
||||
Mejor resultado · {KIND_LABEL[result.kind]}
|
||||
</div>
|
||||
<div class="truncate font-display text-lg leading-none font-medium tracking-display text-fg">
|
||||
{result.kind === 'track'
|
||||
? result.track.title
|
||||
: result.kind === 'album'
|
||||
? result.album.title
|
||||
: result.kind === 'playlist'
|
||||
? result.playlist.name
|
||||
: result.user.name}
|
||||
</div>
|
||||
{#if result.kind === 'track' && result.track.artist}
|
||||
<div class="truncate text-sm leading-none text-fg-2">
|
||||
{result.track.artist}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PlayButton as="span" size="lg" label="Reproducir" class="group-hover/top:brightness-110" />
|
||||
{/snippet}
|
||||
|
||||
<svelte:element
|
||||
this={result.kind === 'track' ? 'button' : 'a'}
|
||||
type={result.kind === 'track' ? 'button' : undefined}
|
||||
role={result.kind === 'track' ? 'button' : 'link'}
|
||||
{href}
|
||||
onclick={result.kind === 'track' ? play : undefined}
|
||||
class="group/top animate-pop mt-6.5 flex w-full items-center gap-5 rounded-panel-lg bg-surface p-4.5 text-left transition hover:bg-surface-hover"
|
||||
>
|
||||
{@render body()}
|
||||
</svelte:element>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const allowAnonymous = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('$lib/server/playbackConfig', () => ({ getAllowAnonymousListening: allowAnonymous }));
|
||||
vi.mock('$lib/server/config', () => ({
|
||||
authConfig: { issuer: 'https://auth.example.com/' },
|
||||
apiConfig: { baseUrl: 'http://api.test' }
|
||||
}));
|
||||
|
||||
import { load } from './+layout.server';
|
||||
|
||||
type LoadEvent = Parameters<typeof load>[0];
|
||||
|
||||
function event(user: { sub: string; name: string } | null, fetchFn = vi.fn()) {
|
||||
return {
|
||||
locals: { user, accessToken: user ? 'token' : null },
|
||||
url: new URL('http://x/library?q=1'),
|
||||
fetch: fetchFn
|
||||
} as unknown as LoadEvent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
allowAnonymous.mockReset();
|
||||
});
|
||||
|
||||
describe('(app) layout load', () => {
|
||||
it('redirects to login without a session or anonymous listening', async () => {
|
||||
allowAnonymous.mockResolvedValue(false);
|
||||
await expect(load(event(null))).rejects.toMatchObject({
|
||||
status: 302,
|
||||
location: `/auth?returnTo=${encodeURIComponent('/library?q=1')}`
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty data in anonymous mode without calling the API', async () => {
|
||||
allowAnonymous.mockResolvedValue(true);
|
||||
const fetchFn = vi.fn();
|
||||
const data = await load(event(null, fetchFn));
|
||||
expect(data).toMatchObject({
|
||||
user: null,
|
||||
allowAnonymousListening: true,
|
||||
userPlaylists: [],
|
||||
userPlaylistsTotal: 0,
|
||||
likedTracks: [],
|
||||
lastPlayedTrack: null,
|
||||
accountUrl: 'https://auth.example.com/ui/console'
|
||||
});
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to empty data when the API fails for a signed in user', async () => {
|
||||
allowAnonymous.mockResolvedValue(false);
|
||||
const fetchFn = vi.fn().mockRejectedValue(new Error('down'));
|
||||
const data = await load(event({ sub: 'u1', name: 'Ana' }, fetchFn));
|
||||
expect(data).toMatchObject({
|
||||
user: { sub: 'u1' },
|
||||
userPlaylists: [],
|
||||
userPlaylistsTotal: 0,
|
||||
likedTracks: [],
|
||||
lastPlayedTrack: null
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,10 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { toDatedTrack, toPage } from '$lib/server/mappers';
|
||||
import {
|
||||
apiFor,
|
||||
authedAction,
|
||||
failOnError,
|
||||
formString,
|
||||
requireUser,
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
import { apiFor, failOnError, unwrapOrError } from '$lib/server/api';
|
||||
import { requireUser } from '$lib/server/guards';
|
||||
import { formString } from '$lib/server/forms/fields';
|
||||
import { authedAction } from '$lib/server/actions/authedAction';
|
||||
import { LIBRARY_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
|
||||
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
||||
import TrackList from '$lib/components/music/TrackList.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import PlayAllButton from '$lib/components/music/PlayAllButton.svelte';
|
||||
import { liked } from '$lib/state/liked.svelte';
|
||||
import { plural } from '$lib/utils/format';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
||||
import TrackList from '$lib/components/music/TrackList.svelte';
|
||||
|
||||
const tracks = $derived(liked.list);
|
||||
const listTracks = $derived(tracks.map((track) => ({ ...track, date: track.likedAt })));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiFor, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||
import { requireUser } from '$lib/server/guards';
|
||||
import { toMix } from '$lib/server/mappers';
|
||||
import { addTrackAction } from '$lib/server/playlistActions';
|
||||
import { addTrackAction } from '$lib/server/actions/playlist';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
requireUser(locals, url);
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
||||
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
||||
import TrackContextMenu from '$lib/components/ui/overlay/TrackContextMenu.svelte';
|
||||
import TrackList from '$lib/components/music/TrackList.svelte';
|
||||
import TrackContextMenu from '$lib/components/music/TrackContextMenu.svelte';
|
||||
import { createMenu } from '$lib/state/menu.svelte';
|
||||
import type { Track } from '$lib/types';
|
||||
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
|
||||
import PlayAllButton from '$lib/components/music/PlayAllButton.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||
import { requireUser } from '$lib/server/guards';
|
||||
import { authedAction } from '$lib/server/actions/authedAction';
|
||||
import { PLAYLISTS_PAGE_SIZE } from '$lib/config';
|
||||
import { toPlaylist } from '$lib/server/mappers';
|
||||
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
apiFor,
|
||||
authedAction,
|
||||
failOnError,
|
||||
formString,
|
||||
optionalUser,
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
import { apiFor, failOnError, unwrapOrError } from '$lib/server/api';
|
||||
import { optionalUser } from '$lib/server/guards';
|
||||
import { formString } from '$lib/server/forms/fields';
|
||||
import { authedAction } from '$lib/server/actions/authedAction';
|
||||
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||
import { toDatedTrack, toPlaylist } from '$lib/server/mappers';
|
||||
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
|
||||
import PlayAllButton from '$lib/components/music/PlayAllButton.svelte';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||
import Modal from '$lib/components/ui/overlay/Modal.svelte';
|
||||
@@ -13,7 +13,7 @@
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
||||
import TrackList from '$lib/components/music/TrackList.svelte';
|
||||
import { playlistMeta } from '$lib/utils/format';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { apiFor, authedAction, requireData, requireUser } from '$lib/server/api';
|
||||
import { apiFor, requireData } from '$lib/server/api';
|
||||
import { requireUser } from '$lib/server/guards';
|
||||
import { authedAction } from '$lib/server/actions/authedAction';
|
||||
import { parseTrackUploadForm } from '$lib/server/forms/trackUploadForm';
|
||||
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
|
||||
import { findConflict, genreInfo } from '$lib/data/genres';
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
|
||||
import { toPlaylist } from '$lib/server/mappers';
|
||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||
import { optionalUser } from '$lib/server/guards';
|
||||
import { toCount, toPlaylist } from '$lib/server/mappers';
|
||||
import { PROFILE_PLAYLISTS_PAGE_SIZE } from '$lib/config';
|
||||
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
|
||||
import { followUserAction, unfollowUserAction } from '$lib/server/actions/follow';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||
const { allowAnonymousListening } = await parent();
|
||||
@@ -26,7 +27,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
|
||||
|
||||
return {
|
||||
profile,
|
||||
followersCount: Number(profile.followersCount),
|
||||
followersCount: toCount(profile.followersCount),
|
||||
playlists,
|
||||
isOwnProfile,
|
||||
isAnonymous: viewer === null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
|
||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||
import { optionalUser } from '$lib/server/guards';
|
||||
import { toPage } from '$lib/server/mappers';
|
||||
import { fetchFollowList } from '$lib/server/follows';
|
||||
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
</script>
|
||||
|
||||
<div class="animate-enter mx-auto my-[14vh] max-w-prose-sm text-center">
|
||||
<h1 class="mb-2.5 text-display-3 text-fg">
|
||||
{page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'}
|
||||
</h1>
|
||||
<p class="mb-6.5 text-body text-fg-2">
|
||||
{page.status === 404
|
||||
? 'Comprueba el enlace o vuelve al inicio.'
|
||||
: 'Algo salió mal al cargar la página. Inténtalo de nuevo en unos momentos.'}
|
||||
</p>
|
||||
<Button href="/" variant="secondary">Volver</Button>
|
||||
<div class="min-h-screen bg-bg px-6 text-fg antialiased">
|
||||
<div class="animate-enter mx-auto my-[14vh] max-w-prose-sm text-center">
|
||||
<h1 class="mb-2.5 text-display-3">
|
||||
{page.status === 404 ? 'Página no encontrada' : 'Algo salió mal'}
|
||||
</h1>
|
||||
<p class="mb-6.5 text-body text-fg-2">
|
||||
{page.status === 404
|
||||
? 'Comprueba el enlace o vuelve al inicio.'
|
||||
: 'No se pudo cargar la página. Inténtalo de nuevo en unos momentos.'}
|
||||
</p>
|
||||
<Button href="/" variant="secondary">Volver al inicio</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,134 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import '@fontsource-variable/sora/index.css';
|
||||
import '@fontsource-variable/plus-jakarta-sans/index.css';
|
||||
import '$lib/theme/theme.css';
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import PlayerDock from '$lib/components/player/PlayerDock.svelte';
|
||||
import Queue from '$lib/components/player/Queue.svelte';
|
||||
import Sidebar from '$lib/components/layout/Sidebar.svelte';
|
||||
import TopBar from '$lib/components/layout/TopBar.svelte';
|
||||
import MobileHeader from '$lib/components/layout/MobileHeader.svelte';
|
||||
import Modal from '$lib/components/ui/overlay/Modal.svelte';
|
||||
import DialogHost from '$lib/components/ui/overlay/DialogHost.svelte';
|
||||
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { queuePanel } from '$lib/state/panels.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { createPlaylistModal } from '$lib/state/panels.svelte';
|
||||
import { ACCENT_HUE } from '$lib/theme/color';
|
||||
import { buildThemeTokens, tokensToCss } from '$lib/theme/tokens';
|
||||
import { accent, animateThemeHue } from '$lib/theme/accent.svelte';
|
||||
import { themeMode } from '$lib/theme/mode.svelte';
|
||||
import { trackNavigation } from '$lib/state/history.svelte';
|
||||
import { restoreScroll } from '$lib/state/scroll';
|
||||
import { page } from '$app/state';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
data: LayoutData;
|
||||
}
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
let scroller = $state<HTMLElement>();
|
||||
|
||||
trackNavigation();
|
||||
restoreScroll(() => scroller);
|
||||
|
||||
$effect(() => {
|
||||
if (data.likedTracks?.length) liked.hydrate(data.likedTracks);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (data.lastPlayedTrack) player.hydrate(data.lastPlayedTrack);
|
||||
});
|
||||
|
||||
const isAuthPage = $derived(page.url.pathname === '/auth');
|
||||
let { children }: Props = $props();
|
||||
|
||||
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
|
||||
|
||||
let queueAutoOpened = false;
|
||||
$effect(() => {
|
||||
if (!player.playing || queueAutoOpened) return;
|
||||
queueAutoOpened = true;
|
||||
queuePanel.show();
|
||||
});
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
return ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(target.tagName);
|
||||
}
|
||||
|
||||
function onWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.code !== 'Space' || isTypingTarget(event.target)) return;
|
||||
event.preventDefault();
|
||||
player.toggle();
|
||||
}
|
||||
|
||||
$effect(() => animateThemeHue(accent.hue, themeMode.current));
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- CSS generado a partir de números, sin entrada de usuario -->
|
||||
{@html `<style>${initialThemeCss}</style>`}
|
||||
</svelte:head>
|
||||
|
||||
{#if isAuthPage || (!data.user && !data.allowAnonymousListening)}
|
||||
<div class="min-h-screen bg-bg text-fg antialiased">
|
||||
{@render children()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="relative flex h-dvh overflow-hidden text-fg antialiased">
|
||||
<div class="app-backdrop"></div>
|
||||
<div class="app-vignette"></div>
|
||||
|
||||
<Sidebar
|
||||
user={data.user}
|
||||
playlists={data.userPlaylists ?? []}
|
||||
playlistCount={data.userPlaylistsTotal ?? 0}
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div bind:this={scroller} class="flex min-w-0 flex-1 app-scroll flex-col">
|
||||
<MobileHeader user={data.user} accountUrl={data.accountUrl} />
|
||||
<TopBar user={data.user} accountUrl={data.accountUrl} />
|
||||
<main class="flex-1">
|
||||
{#key page.url.pathname}
|
||||
<div class="animate-fade">{@render children()}</div>
|
||||
{/key}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Queue />
|
||||
</div>
|
||||
|
||||
<PlayerDock user={data.user} />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={createPlaylistModal.open}
|
||||
onClose={() => createPlaylistModal.close()}
|
||||
title="Crear nueva playlist"
|
||||
>
|
||||
<PlaylistForm
|
||||
action="/playlists?/create"
|
||||
formMessage={page.form?.message}
|
||||
submitLabel="Crear"
|
||||
submittingLabel="Creando…"
|
||||
helperText="Podrás añadir canciones más tarde."
|
||||
onCancel={() => createPlaylistModal.close()}
|
||||
onSuccess={() => createPlaylistModal.close()}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
{/if}
|
||||
{@render children()}
|
||||
|
||||
<DialogHost />
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const fallback: RequestHandler = () => json({ message: 'No encontrado.' }, { status: 404 });
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/server/config', () => ({ apiConfig: { baseUrl: 'http://api.test' } }));
|
||||
|
||||
import { POST } from './+server';
|
||||
|
||||
type PostEvent = Parameters<typeof POST>[0];
|
||||
|
||||
function event(body: unknown, options: { token?: string | null; api?: Response } = {}) {
|
||||
const { token = 'token', api = Response.json(true) } = options;
|
||||
const fetchFn = vi.fn().mockResolvedValue(api);
|
||||
const request = new Request('http://x/api/likes', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body)
|
||||
});
|
||||
return {
|
||||
event: {
|
||||
request,
|
||||
locals: { accessToken: token, user: null },
|
||||
fetch: fetchFn
|
||||
} as unknown as PostEvent,
|
||||
fetchFn
|
||||
};
|
||||
}
|
||||
|
||||
describe('POST /api/likes', () => {
|
||||
it('returns 401 without a token', async () => {
|
||||
const { event: e, fetchFn } = event({ trackId: 't1' }, { token: null });
|
||||
expect((await POST(e)).status).toBe(401);
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([[{}], [{ trackId: '' }], [{ trackId: 5 }], ['not json']])(
|
||||
'returns 400 for an invalid body %j',
|
||||
async (body) => {
|
||||
const { event: e, fetchFn } = event(body);
|
||||
expect((await POST(e)).status).toBe(400);
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('returns the liked state from the API', async () => {
|
||||
const { event: e } = event({ trackId: 't1' });
|
||||
const res = await POST(e);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ liked: true });
|
||||
});
|
||||
|
||||
it('returns 502 when the API fails', async () => {
|
||||
const { event: e } = event(
|
||||
{ trackId: 't1' },
|
||||
{ api: Response.json({ title: 'boom' }, { status: 500 }) }
|
||||
);
|
||||
expect((await POST(e)).status).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/server/config', () => ({ apiConfig: { baseUrl: 'http://api.test' } }));
|
||||
|
||||
import { PUT } from './+server';
|
||||
|
||||
type PutEvent = Parameters<typeof PUT>[0];
|
||||
|
||||
function event(body: string, api: Response = new Response(null, { status: 204 })) {
|
||||
const fetchFn = vi.fn().mockResolvedValue(api);
|
||||
const request = new Request('http://x/api/listens/l1/progress', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
return {
|
||||
event: {
|
||||
request,
|
||||
params: { id: 'l1' },
|
||||
locals: { accessToken: 'token', user: null },
|
||||
fetch: fetchFn
|
||||
} as unknown as PutEvent,
|
||||
fetchFn
|
||||
};
|
||||
}
|
||||
|
||||
describe('PUT /api/listens/[id]/progress', () => {
|
||||
it('returns 401 without a token', async () => {
|
||||
const { event: e } = event('{"playedSeconds":5}');
|
||||
(e.locals as { accessToken: string | null }).accessToken = null;
|
||||
expect((await PUT(e)).status).toBe(401);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['invalid JSON', 'nope'],
|
||||
['negative seconds', '{"playedSeconds":-1}'],
|
||||
['string seconds', '{"playedSeconds":"5"}'],
|
||||
['missing seconds', '{}'],
|
||||
['null seconds', '{"playedSeconds":null}']
|
||||
])('returns 400 for %s', async (_name, body) => {
|
||||
const { event: e, fetchFn } = event(body);
|
||||
expect((await PUT(e)).status).toBe(400);
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 204 when the API accepts the progress', async () => {
|
||||
const { event: e, fetchFn } = event('{"playedSeconds":12.5}');
|
||||
expect((await PUT(e)).status).toBe(204);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns 404 when the API returns 404', async () => {
|
||||
const { event: e } = event('{"playedSeconds":5}', Response.json({}, { status: 404 }));
|
||||
expect((await PUT(e)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 502 for other API failures', async () => {
|
||||
const { event: e } = event('{"playedSeconds":5}', Response.json({}, { status: 500 }));
|
||||
expect((await PUT(e)).status).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { EXPLORE_PAGE_SIZE, MAX_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
vi.mock('$lib/server/config', () => ({ apiConfig: { baseUrl: 'http://api.test' } }));
|
||||
|
||||
import { GET } from './+server';
|
||||
|
||||
type GetEvent = Parameters<typeof GET>[0];
|
||||
|
||||
async function requestedQuery(search: string) {
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
Response.json({ items: [], pageNumber: 1, hasNextPage: false, totalItemCount: 0 })
|
||||
);
|
||||
const res = await GET({
|
||||
url: new URL(`http://x/api/tracks${search}`),
|
||||
locals: { accessToken: null, user: null },
|
||||
fetch: fetchFn
|
||||
} as unknown as GetEvent);
|
||||
expect(res.status).toBe(200);
|
||||
const request = fetchFn.mock.calls[0][0] as Request;
|
||||
return new URL(request.url).searchParams;
|
||||
}
|
||||
|
||||
describe('GET /api/tracks', () => {
|
||||
it('caps pageSize at MAX_PAGE_SIZE', async () => {
|
||||
const query = await requestedQuery('?pageSize=100000');
|
||||
expect(query.get('pageSize')).toBe(String(MAX_PAGE_SIZE));
|
||||
});
|
||||
|
||||
it('uses the default page size for invalid values', async () => {
|
||||
expect((await requestedQuery('?pageSize=abc')).get('pageSize')).toBe(String(EXPLORE_PAGE_SIZE));
|
||||
expect((await requestedQuery('')).get('pageSize')).toBe(String(EXPLORE_PAGE_SIZE));
|
||||
});
|
||||
|
||||
it('resets an invalid pageNumber to 1', async () => {
|
||||
expect((await requestedQuery('?pageNumber=-3')).get('pageNumber')).toBe('1');
|
||||
expect((await requestedQuery('?pageNumber=abc')).get('pageNumber')).toBe('1');
|
||||
expect((await requestedQuery('?pageNumber=4')).get('pageNumber')).toBe('4');
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,9 @@
|
||||
<meta name="description" content="Inicia sesión o crea una cuenta en Musify." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="relative grid min-h-screen place-items-center overflow-hidden bg-bg px-6 text-fg">
|
||||
<div
|
||||
class="relative grid min-h-screen place-items-center overflow-hidden bg-bg px-6 text-fg antialiased"
|
||||
>
|
||||
<div class="pointer-events-none absolute inset-0" style="background:var(--mf-ambient)"></div>
|
||||
|
||||
<div class="relative flex w-full max-w-4xl flex-col items-center gap-12 text-center">
|
||||
|
||||
Reference in New Issue
Block a user