10 Commits
Author SHA1 Message Date
AdminandClaude Sonnet 5 ec6313dda1 web-player: P14 — barrido final y guardarraíles
Añade scripts/check-tokens.mjs (npm run lint:tokens, incorporado a
lint) como guardrail: falla si src/**/*.svelte reintroduce valores
arbitrarios fuera de la lista blanca de B.4, rounded-* de fábrica,
pares h-N w-N (deberían ser size-N), espaciado fraccionario en
cuartos, o colores hex/rgba() literales.

Se limpia el repo hasta dejar el script en 0 violaciones: ~40 pares
h-N w-N → size-N (sobre todo iconos, vía script puntual), ~26
espaciados fraccionarios en cuartos redondeados al entero más cercano
(Δ≤1px, p. ej. py-2.75→py-3, gap-1.75→gap-2), radios de fábrica
sueltos (rounded-sm/lg, rounded a secas) migrados a los tokens
correspondientes, y dos tracking-[-0.02em] que coincidían
exactamente con el token tracking-display ya existente pero nunca se
habían migrado. Se retiran las recetas stroke-thin/regular/bold
(creadas en P1, cero consumidores) — decisión tomada, sin bloquear:
no se migra strokeWidth={n} (13 valores, en casi todos los
componentes) a CSS porque no se puede verificar sin Docker que
lucide-svelte respete stroke-width por clase en vez del prop, y una
regresión ahí sería silenciosa en toda la app; el script documenta
este gap explícitamente en vez de fingir cobertura completa.

Detector de huérfanos: 0 componentes sin importadores. Se borra
docs/design-system-refactor-plan.md (plan anterior, superseded por
frontend-refactor-plan.md). web-player/CLAUDE.md gana una sección
"Estilos" con la lista blanca B.4 y los componentes base a reutilizar.
Se recalcula la tabla de línea base (A.4) y se anota en el Registro
(D.1): 61→47 componentes, 44→11 arbitrarios (los 11 restantes en
lista blanca), 13→8 radios distintos, 0 violaciones de lint:tokens.

Verificado con npm run check (0 errores), npm run lint:tokens (0
violaciones) y npm run build. Smoke test de /login sin errores de
consola achacables al cambio (Docker no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 15:05:07 +02:00
AdminandClaude Sonnet 5 d14e22cbf7 web-player: P13 — páginas grandes (Explorar, Inicio) y limpieza de tracks.ts
Se aborda junto con esta parte la tarea 5 diferida desde P5: se
elimina el envoltorio TrackTarget de tracks.ts y sus 8 accesores
(targetId/targetTitle/targetArtist/targetOwnerUserId/targetExplicit/
targetListensCount/isTargetCurrent/queueItemForTarget/
targetForQueueItem). Explorar, Inicio, Mezcla, menus.svelte.ts y
mixes.ts pasan a usar los objetos de pista directamente, con
toQueueItem/trackFromQueueItem (nuevo, en player.svelte.ts) como
únicos conversores. TypeScript reveló en el proceso que el campo
isExplicit no existe en el schema real de /tracks: el badge
"explicit" de Explorar ya era un no-op antes del refactor (siempre
undefined ?? false); se retira el binding en vez de fingir soporte
que nunca existió.

Explorar: GENRE_INFO/genreTiles se extraen a src/lib/genres.ts; la
lógica de búsqueda (showGroup/capped/matchPlaylists/searchCounts/
searchChips/findTopResult) se extrae a src/lib/search.ts como
funciones puras parametrizadas por sfilter/query en vez de cerrar
sobre el estado del componente. "Mejor resultado" unifica el botón/
enlace duplicado con <svelte:element>. Decisión tomada sin bloquear:
no se colapsan los 4 bloques de grupo (Canciones/Álbumes/Usuarios/
Playlists) en un único {#each groups} — cada grupo tiene snippets,
menú contextual y props de ListRow genuinamente distintos, y una
configuración genérica habría añadido más indirección de la que
elimina.

Inicio: usa Page en vez de page-x a mano; las 3 estadísticas pasan a
{#each}; los fondos de hero/spotlight (style= con variables CSS) se
convierten en las utilities hero-surface/spotlight-surface, y
gap-11.5 se sustituye por el token --spacing-section (2.875rem) vía
gap-section.

Verificado con npm run check (0 errores) y npm run build. /explore
sin sesión redirige a /login porque +layout.server.ts exige
allowAnonymousListening desde el backend (inalcanzable sin Docker en
esta sesión) — comportamiento preexistente, no una regresión. Sin QA
visual autenticada; smoke test de /login sin errores de consola.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:51:43 +02:00
AdminandClaude Sonnet 5 39e6c2d552 web-player: P12 — chrome de la app (layout, navegación, reproductor)
PlayerExtras se fusiona en PlayerBar, que ahora renderiza volumen y
botón de cola inline y usa el nuevo Slider (input range real con
grosor de thumb controlado por variables CSS) para barra de progreso
y volumen, sustituyendo a SeekBar y VolumeControl (se confirmó por
grep que el modo `compact` de SeekBar era código muerto).

Sidebar y TabsBar migran a appNavLinks(user, counts?) (creado en P5),
eliminando la duplicación de los enlaces de navegación entre ambos.
Decisión tomada sin bloquear: Sidebar gana la marca-gradiente Logo
que no tenía antes, para consistencia visual con MobileHeader y la
pantalla de login.

z-index de PlayerDock y MobileHeader corregido a z-(--z-sticky) (son
barras fijas, no menús) y el de SegmentedControl a z-(--z-raised),
tal como indica la nota B.1.5 del plan. glass-bar y theme-transition
se aplican en PlayerBar (ambas variantes), TabsBar, Queue e Inicio
(hero/spotlight), sustituyendo estilos duplicados de blur/transición.

Verificado con npm run check (0 errores) y npm run build; smoke test
de /login sin errores de consola. Sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:39:20 +02:00
AdminandClaude Sonnet 5 90f9a76628 web-player: P11 — formularios (CoverForm, SegmentedControl, Chip)
CoverForm.svelte es el esqueleto compartido (form + enhance + portada
+ mensaje de error + botones) de AlbumForm y PlaylistForm, que quedan
solo con sus campos propios vía snippets `fields` (junto a la
portada) y `extra` (a ancho completo, no está en la spec literal pero
necesario: la descripción del álbum y el selector de visibilidad de
playlist no caben en la columna junto al arte).

ImageDropzone gana `size="hero"` (size-cover-hero + rounded-art-lg) y
un callback `onselect`; la página de subir canción usa ahora este
componente en vez de reimplementar el selector de portada a mano
(envuelto en {#key} para poder resetear tras publicar). Su icono de
posición vacía pasa de 64px a icon-xl (36px), como recomienda el plan.

SegmentedControl.svelte (genérico, N opciones) sustituye el selector
Privada/Pública de PlaylistForm. Chip.svelte sustituye los chips de
filtro de Explorar (selected/count) y los chips de género sugeridos en
"Sin resultados" (href, estilo outline).

@utility field-control en layout.css unifica la cadena de clases
repetida de Input y Textarea.

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:58:52 +02:00
AdminandClaude Sonnet 5 2cf3e9f02d web-player: P10 — cabeceras, diálogos y tipografía
PageHeader fusiona CollectionHeader (title/eyebrow/description/meta/
cover?/actions?/align?): sin cover se comporta como el antiguo
PageHeader simple (acciones en línea), con cover como el antiguo
CollectionHeader (acciones debajo). Fija su propio margen inferior
(mb-7/8); se quitan los mt-* que ponía cada página después. Migra los
9 sitios que usaban uno u otro (álbumes, biblioteca, listas, subir,
Explorar ×2, me gusta, mezcla, perfil). CollectionHeader se borra.

AlbumHeader y PlaylistHeader se inlinean en sus páginas y se borran.
Decisión (recomendada, sin bloquear): PlaylistHeader gana iconos en
Editar/Eliminar/Reproducir, igual que ya tenía AlbumHeader.

ConfirmDialog nuevo sustituye los dos diálogos de borrado (álbum y
playlist), que tenían tipografías distintas. Modal pasa a usar
.glass-panel (en vez de duplicar el style= inline) y z-(--z-modal); su
eyebrow y título usan las recetas text-eyebrow/text-display-3.

SectionHeading gana `count?` (línea "N resultados", sustituye 4 copias
en Explorar) y `href?`/`linkLabel?` ("Ver todas", sustituye la de
Inicio). Aplica recetas de tipografía (text-display-2/3,
text-eyebrow, text-body) y tokens de ancho (max-w-prose-sm, max-w-hero)
en EmptyState, +error, "Sin resultados", hero y stats de Inicio, Queue
y Sidebar. El estado vacío "Todavía no has escuchado nada" (repetido
2 veces en Inicio) pasa a un snippet local.

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:48:23 +02:00
AdminandClaude Sonnet 5 5dec00faf1 web-player: P9 — tarjetas y grids (MediaCard)
MediaCard pasa a construir su propio arte (src/trackIds vía Artwork
size="fill", o un snippet art a medida) y su botón de reproducir
(onPlay, con scrim y PlayButton glass en hover) en vez de depender de
que cada wrapper le pase un snippet. Sustituye a AlbumCard,
PlaylistCard y MixTile, que se borran junto con MediaGrid.svelte (sus
grids pasan a los @utility grid-cards/grid-cards-lg/grid-tiles/
grid-wide de P1).

format.ts gana albumMeta() y playlistMeta() (antes cálculos ad-hoc
dentro de cada tarjeta). El tile de género en Explorar pasa de un
style= con el gradiente inline a la receta @utility genre-tile
+ --tile-hue. De paso, corrige la pluralización "seguidor/es" del
perfil de usuario que quedó suelta.

Delta documentado: el hover-zoom del arte y el scrim+PlayButton (antes
exclusivos de MixTile) pasan a ser comportamiento de MediaCard
condicionado a la prop onPlay — se aplicarían a cualquier tarjeta
futura que la use, por consistencia.

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:30:30 +02:00
AdminandClaude Sonnet 5 a7d5869c23 web-player: P8 — tablas de pistas (TrackList)
Crea TrackList.svelte, que sustituye TrackTable+TrackRow+
TrackIndexCell+TrackMeta y las 6 tablas de pista (biblioteca, me
gusta, mezcla, álbum, playlist, selector de biblioteca en álbum):
columnas declarativas (added/uploaded/plays) con anchos fijos como
constantes del módulo, índice con EQ para la pista activa (o overlay
sobre el arte cuando index={false}, para el selector de biblioteca),
y rowAction que genera el form+IconButton (removeTrack/addTrack/
deleteTrack) o un onclick directo (quitar de Me gusta).

AlbumTrackTable, PlaylistTrackTable y LibraryPicker se inlinean en sus
páginas y se borran, junto con los 4 componentes que sustituye
TrackList.

Decisión (recomendada por el propio plan, sin bloquear): se elimina la
columna "Álbum", que en las 3 tablas que la tenían siempre mostraba
"—" (placeholder sin datos reales).

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:21:15 +02:00
AdminandClaude Sonnet 5 5a528e197e web-player: P7 — filas (ListRow, MediaIdentity, EqBars)
Crea MediaIdentity.svelte (renombra y generaliza TrackTitleCell,
puramente presentacional: arte + título + subtítulo, sin manejo de
click) y ListRow.svelte encima (href|onclick, active, size xs/sm/lg,
variant row|card, leading/trailing/overlay). Usa MediaIdentity también
en player/TrackInfo... (pendiente: TrackInfo se deja para P12, que ya
tiene tarea explícita de tocar ese archivo).

Migra a ListRow: SearchResultRow (Explorar, 4 grupos — se borra el
componente), Inicio (Continuar escuchando, Populares, spotlight, Tus
playlists), Queue (A continuación), Sidebar (playlists). Las 6 tablas
de pista (AlbumTrackTable, PlaylistTrackTable, library, liked, mixes,
LibraryPicker) migran a MediaIdentity con su propio use:pressable
local, ya que TrackList (P8) todavía no existe.

EqBars gana prop `overlay` (fondo scrim/45, color on-art) que
sustituye a NowPlaying.svelte (borrado). Corrige de paso 6 llamadas a
plural() que P5 había dejado sin migrar (liked, library, mixes,
playlists, PlaylistHeader, Inicio).

Deltas documentados en el registro del plan: fila activa de Sidebar
ahora usa el tinte de acento (antes gris neutro); tarjeta "Tus
playlists" de Inicio pierde la línea de autor redundante y su radio
pasa a rounded-art; filas de Queue pierden el opacity-80 del arte.

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 00:14:20 +02:00
AdminandClaude Sonnet 5 a0c9c5fca9 web-player: P6 — menú contextual único (ContextMenu) + AccountMenu
Crea src/lib/components/ui/ContextMenu.svelte, que sustituye
TrackContextMenu y AlbumContextMenu (109 y 119 líneas, 90% idénticas):
misma función de posicionamiento (contextMenuPosition, exportada del
módulo, usando MENU_WIDTH/SUBMENU_WIDTH movidas a config.ts), mismo
backdrop, mismo Escape, mismo submenú "Añadir a playlist" genérico vía
{items, playlistAction, playlists}. Usa MenuItem para las acciones
(antes reimplementaban sus clases a mano) y Artwork size="xs" para las
miniaturas de playlist.

menus.svelte.ts (createTrackMenu) se adapta a la nueva
contextMenuPosition; se migran Inicio, Explorar (menú de pista y de
álbum) y Mezcla.

AccountMenu: panelClass (clases Tailwind libres) pasa a prop
width: 'sm'|'md'; añade cierre con Escape (mejora de accesibilidad,
antes no cerraba); TopBar y MobileHeader dejan de envolverlo en un
div.relative duplicado (su propio root ya lo es). z-index del panel
pasa a z-(--z-menu), al mismo nivel que ContextMenu (antes z-20 en
TopBar / sin z explícito en MobileHeader).

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 23:05:53 +02:00
AdminandClaude Sonnet 5 d858c85a72 web-player: P5 (parcial) — helpers TypeScript
Crea src/lib/actions/pressable.ts y clickOutside.ts (Svelte actions;
role/tabindex se mantienen estáticos en el markup por accesibilidad
SSR, la acción solo añade click + Enter/Espacio) y src/lib/toggle.svelte.ts
(createToggle), usado por playlists.svelte.ts y queuePanel.svelte.ts.

format.ts: añade plural(n, uno, muchos); migra las 15 pluralizaciones
manuales del repo, unificando a minúscula (Δ visible en Playlists,
Inicio, liked, Explorar).

player.svelte.ts: añade toQueueItem, isQueueCurrent, playAllOrToggle,
playShuffled; renombra addToQueue → playNextItem (el nombre anterior
confundía: inserta a continuación, no al final). Migra los 5 sitios
que repetían el patrón isCurrentQueue/playAll (álbum, playlist, liked,
mezcla, Inicio). Simplifica liked.svelte.ts y recentlyPlayed.ts con
object spread en vez de reconstruir cada campo a mano.

navigation.svelte.ts: añade searchHref (migra el buildHref duplicado
de TopBar y Explorar) y appNavLinks (creado; su adopción en Sidebar/
TabsBar queda para P12, como indica esa parte).

menus.svelte.ts: createTrackMenu() sustituye el estado manual de menú
contextual (contextMenu + contextMenuStateFor + addToQueue) en Inicio,
Explorar y Mezcla.

Pendiente a propósito: la tarea 5 (eliminar el envoltorio TrackTarget
de tracks.ts y sus 8 accesores) se difiere a P13 — es el cambio de
mayor riesgo del plan, toca tipos de forma transversal en Inicio/
Explorar/Mezcla/menús, y no se pudo verificar visualmente sin Docker
en esta sesión.

Verificado con npm run check/build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 23:01:13 +02:00
92 changed files with 2389 additions and 3067 deletions
+18 -4
View File
@@ -11,11 +11,12 @@ No hay `svelte.config.js` (config inline en `vite.config.ts`) ni `tailwind.confi
## Comandos
```sh
npm run dev # servidor de desarrollo (puerto 5173)
npm run dev # servidor de desarrollo (puerto 5173)
npm run build
npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio
npm run format # prettier --write .
npm run lint # prettier --check . && eslint .
npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio
npm run format # prettier --write .
npm run lint # prettier --check . && eslint . && npm run lint:tokens
npm run lint:tokens # scripts/check-tokens.mjs — guardrail del sistema de diseño (ver abajo)
```
## Reglas
@@ -28,6 +29,19 @@ npm run lint # prettier --check . && eslint .
- Enlaces a endpoints `+server.ts` (login/logout) llevan `data-sveltekit-reload`.
- Reutiliza los tokens/recetas del sistema de diseño; extrae a `$lib/components/ui/` lo que se repita.
## Estilos
El sistema de diseño vive en `src/routes/layout.css` (tokens `@theme` + recetas `@utility`) y `src/lib/theme/` (`theme.css` para neutros estáticos, `tokens.ts` para los tokens dinámicos por hue de usuario). `scripts/check-tokens.mjs` (`npm run lint:tokens`, parte de `lint`) falla si `src/**/*.svelte` reintroduce lo que el refactor de `docs/frontend-refactor-plan.md` (P0–P14) eliminó — léelo antes de añadir un valor arbitrario o un radio/tamaño "de fábrica".
- **Radios:** siempre `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (o `rounded-full` para píldoras/círculos). Nunca `rounded-sm/md/lg/xl/2xl/3xl` ni `rounded` a secas.
- **Tamaños cuadrados:** `size-*` en vez de `h-N w-N` repetido (icon: `size-icon-xs/sm/md/lg/xl`; portadas: `size-cover-xs…hero`).
- **Tipografía:** recetas `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`; tracking con `tracking-display`/`tracking-eyebrow`, no `tracking-[…]` suelto.
- **Espaciado:** escala de Tailwind en pasos de `.5` (evita cuartos como `.25`/`.75`; solo aceptables si el token semántico lo pide, p. ej. `mb-4.5` en `SectionHeading`).
- **Colores:** siempre `--mf-*`/`text-*`/`bg-*`/`border-*` de `theme.css`/`tokens.ts`. Nunca hex ni `rgba()` literales en un `.svelte` (esos literales solo existen como fuente de verdad dentro de `theme.css`/`tokens.ts`).
- **`style="…"` y arbitrarios `-[…]`:** solo la lista blanca de B.4 en `docs/frontend-refactor-plan.md` (stagger `--i`, hue de tile, posición de menú, anchos/alturas dinámicos vía `var(--mf-*)`, `text-[clamp(…)]`, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, paddings en `vh` para centrado vertical). Todo lo demás, a un token.
- Componentes base a reutilizar antes de crear uno nuevo: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
- Gaps conocidos y aceptados (documentados en `scripts/check-tokens.mjs`): `strokeWidth={n}` en iconos no está unificado (13 valores distintos repartidos por casi todos los componentes; migrarlo a las recetas `stroke-thin/regular/bold` de forma segura requeriría poder verificar visualmente que `lucide-svelte` respeta `stroke-width` por CSS, así que se dejó fuera de esta pasada).
## Auth
`hooks.server.ts` valida la cookie de sesión cifrada (`mf_session`), refresca el `access_token` y puebla `locals.user` / `locals.accessToken`. Para proteger una ruta, comprueba `locals.user` en su `load` y redirige a `/login?returnTo=…`. Para el backend, envía `locals.accessToken` como Bearer hacia `API_BASE_URL`.
@@ -1,375 +0,0 @@
# Plan — Unificación del sistema de diseño y limpieza del front (web-player)
> Este documento es un **plan de trabajo para otro agente/IA**, no un informe de lo ya
> hecho. Recoge conocimiento concreto (bugs reales, patrones, cifras) obtenido durante
> una sesión larga de ajustes de UI, para que quien continúe no tenga que re-descubrirlo.
> No es necesario ejecutar todo de una vez: está pensado para trabajarse página por
> página o componente por componente, en PRs pequeños y verificables.
> **Estado (sesión de seguimiento posterior):** §1–§3 y §5 de este documento (hex
> literales, tamaños de fuente arbitrarios, menú "de cristal" duplicado, componentes
> huérfanos listados) ya están resueltos. El problema había reaparecido con otra forma:
> valores de opacidad sueltos sobre `white`/`black` (`bg-white/[6%]`, `border-white/10`,
> etc.) fuera de los tokens `--mf-surface*`/`--mf-border*`, sombras/gradientes de portada
> duplicados sin token, un bloque de "cabecera de colección" reimplementado 5 veces, un
> menú de cuenta duplicado en `TopBar`/`MobileHeader`, y textos de estado vacío con
> redacción inconsistente ("Aún no..." vs "Todavía no..."). Todo eso se corrigió: se
> añadieron tokens `--shadow-cover-lg/md/sm/xs` y `--mf-liked-grad` a
> `layout.css`/`theme.css`, se migraron los literales `white`/`black` a
> `bg-surface*`/`border-line*`/`hover:bg-hover`/`text-on-art` según si el color está
> sobre el "chrome" de la app o sobre arte/gradiente saturado, se extrajeron
> `lib/components/ui/CollectionHeader.svelte` (portada + título + meta + acciones, usado
> en `AlbumHeader`, `PlaylistHeader`, `liked/+page.svelte`, `mixes/[id]/+page.svelte`,
> `albums/external/.../+page.svelte` y `u/[id]/+page.svelte`) y
> `lib/components/ui/AccountMenu.svelte` (estado de apertura + panel del menú de cuenta,
> usado en `TopBar`/`MobileHeader`), y se corrigió `--color-hover` en `layout.css`, que
> estaba hardcodeado en vez de apuntar a `--mf-surface-2` (rompía el modo claro para
> cualquier `hover:bg-hover`).
>
> **Repaso posterior a la verificación visual** (ya con sesión autenticada): se detectó
> que el fix de §2 anterior para la tarjeta de género de `explore/+page.svelte` estaba
> mal — usaba `var(--mf-ink)` como extremo oscuro del degradado, pero `--mf-ink` es el
> token de "texto sobre botón de acento" y se invierte a blanco en modo claro, rompiendo
> la tarjeta (fondo blanco en vez de degradado oscuro). Se creó un token nuevo,
> `--mf-tile-shade` (deliberadamente **sin** variante de modo claro, igual que
> `--mf-cover-grad`/`--mf-liked-grad`, porque es "arte" decorativo, no "chrome"), y se usa
> ahí en su lugar. También se cambió la línea de meta de `CollectionHeader` de
> `text-muted` a `text-fg-2` (mejor contraste en modo claro; `text-muted` rondaba ~3:1,
> por debajo de WCAG AA para texto normal) y se hizo `actions` opcional para poder migrar
> páginas sin acciones condicionales (como `u/[id]/+page.svelte`) sin renderizar un
> `<div>` vacío.
>
> Pendiente de quien retome esto: `lib/components/ui/Rail.svelte` no tiene importadores
> (confirmado con el mismo método de §5, no porque §5 lo listara — ese hallazgo es nuevo)
> — no se tocó porque no forma parte de este barrido, pero conviene confirmarlo a mano y
> borrarlo si procede. También queda pendiente repetir la verificación visual (§6) sobre
> los cambios de este repaso: tarjetas de género de Explorar, cabeceras de colección
> (contraste de la línea de meta) y `u/[id]/+page.svelte`.
## 0. Objetivo
Tres problemas, en orden de aparición histórica pero **no** de prioridad recomendada:
1. **Colores fijos en vez de variables**: muchas páginas y componentes escriben colores
literales (`text-[#8a8a8e]`, `bg-[#c6c4cb]`, tamaños `text-[13px]` sueltos, etc.) en
vez de usar los tokens del sistema (`text-fg-2`, `bg-surface`, la escala tipográfica
establecida). Esto es lo que impide que cosas como el modo claro/oscuro (ver §2)
funcionen de forma consistente, y hace que cada página tenga ligeras variaciones de
gris que deberían ser el mismo tono.
2. **Falta de componentización**: hay patrones (tarjeta de track, fila de "canción
reciente", menú desplegable tipo cristal, etc.) reimplementados a mano en varias
páginas en vez de reusar (o crear) un componente compartido. Algunos componentes ya
existentes ni siquiera se usan donde deberían.
3. **Código muerto**: imports sin usar, componentes sin importadores, clases residuales.
**Referencia canónica**: la página de Inicio (`src/routes/+page.svelte`) es el patrón a
seguir para el modo oscuro — sus tonos, jerarquía tipográfica y look general están bien
ajustados (fue la página más iterada). Cuando una página no tenga claro qué tono/tamaño
usar para algo, mirar cómo lo resuelve Inicio para lo mismo, **no inventar uno nuevo**.
Ojo: Inicio también usa hex sueltos en vez de tokens en varios sitios (ver §3) — es la
referencia de **valores/jerarquía visual**, no de "cómo escribir la clase". Cuando se
migre Inicio a tokens, los valores resultantes deben ser los mismos que ya tiene hoy.
---
## 1. Limitación conocida: modo claro ("Modo blanco") incompleto
Se añadió un toggle de tema (`$lib/theme/mode.svelte.ts`, menú de cuenta en
`TopBar.svelte` / `MobileHeader.svelte`) que pone `data-theme="light"` en `<html>` y
persiste la preferencia en `localStorage` (`musify.theme`).
**Lo que funciona:** los tokens semánticos definidos como CSS puro en
`$lib/theme/theme.css` (`--mf-text*`, `--mf-surface*`, `--mf-border*`, `--mf-track`,
`--mf-scrim`, `--mf-ink`) tienen su contrapartida en el bloque
`:root[data-theme='light'] { ... }` al final de ese archivo, y los tokens dinámicos por
hue (`--mf-bg`, `--mf-elevated`, `--mf-sidebar-bg`, `--mf-panel-bg`, `--mf-bar-bg`,
`--mf-hairline`) se regeneran correctamente en modo claro dentro de
`buildThemeTokens(hue, mode)` (`$lib/theme/tokens.ts`).
**Gotcha importante para quien toque esto:** `+layout.svelte` reaplica esos tokens
dinámicos como **estilos inline** sobre `document.documentElement` en un `$effect` que
corre en cada cambio de hue (cada canción). Un estilo inline siempre gana sobre
cualquier regla de una hoja de estilos, así que **una variable que se anima por hue no
se puede sobrescribir con `:root[data-theme='light'] { --mf-x: ... }`** — hay que
añadirla dentro de `buildThemeTokens()` con una rama `light ? ... : ...`, no en
`theme.css`. Si en el futuro se anima algo más por hue, aplica la misma regla.
**Lo que NO funciona:** todo componente que usa un color hardcodeado (ver §3) no se
entera del cambio de tema, porque no está leyendo ningún token. En la práctica esto
significa que hoy el modo claro cambia correctamente el lienzo de fondo, la sidebar, los
paneles tipo cristal (menús, modal) y cualquier texto/superficie que ya use
`text-fg`/`bg-surface`/etc., pero deja con contraste pobre o directamente ilegible
cualquier texto en hex literal (la mayoría de títulos, subtítulos y metadatos de la
app hoy).
**No hay forma barata de arreglar esto sin la migración de §3.** El modo claro real
depende, técnicamente, en un 100%, de terminar esa migración. No merece la pena
"parchear" el modo claro sin más — es la misma tarea.
---
## 2. Inventario de bugs/patrones ya encontrados (usar como guía de búsqueda)
Estos son bugs concretos que se repitieron varias veces en distintos componentes esta
sesión. Antes de tocar cada página, buscar estos mismos patrones ahí:
### 2.1 Tamaño de fuente que falta → hereda 16px del navegador
Varios componentes tenían un texto sin ninguna clase `text-*`, lo que hace que use el
tamaño por defecto del navegador (16px) en vez de la escala real de la app (que va de
`10.5px` a `14px` en casi todo). Ya se corrigió en `MediaCard.svelte`,
`TrackTitleCell.svelte` y `MenuItem.svelte`. **Sigue sin corregir** en
`TrackContextMenu.svelte` (usa `text-base`/`text-sm` sueltos igual que tenía
`MenuItem` antes de arreglarlo — mismo fix: bajar a la escala `13–13.5px` y los iconos
de `h-6 w-6`/`h-5 w-5` a `h-[18px] w-[18px]` o similar).
Cómo detectarlo: cualquier `<div>`/`<span>` que pinte texto de usuario (título, nombre,
label) sin una clase `text-[…]`/`text-sm`/`text-xs` explícita es sospechoso.
### 2.2 Conflicto de clases Tailwind que se resuelve por orden interno, no por el HTML
Tailwind no respeta el orden en que escribes las clases en el `class="…"`; resuelve
conflictos de la misma propiedad (`background-color`, `font-weight`, etc.) por el orden
en que esas utilidades existen en su propia hoja de estilos generada. Esto causó bugs
reales y confusos:
- Un botón con `bg-white/[6%] ... {isCurrent ? 'bg-accent-tint' : ''}` — con `isCurrent`
true, el fondo **seguía viéndose** `bg-white/[6%]` porque esa clase "ganaba" en el
cascade aunque apareciera antes en el HTML.
- `Button.svelte` fuerza `font-semibold` en su clase base; para poner un botón en peso
normal (`variant="secondary"` con texto normal) hay que usar `class="!font-normal"`
(con el prefijo `!` de Tailwind, que sí fuerza especificidad), un `class="font-normal"`
a secas no habría ganado.
**Regla a aplicar en la auditoría**: cualquier sitio donde dos clases de la misma
propiedad CSS convivan condicional + incondicionalmente en el mismo elemento (patrón
`class="base-x ... {cond ? 'other-x' : ''}"`) hay que reescribirlo para que sea
mutuamente excluyente (`class="{cond ? 'a' : 'b'}"`), nunca acumulativo. Y cualquier
override de una clase base de un componente compartido (`Button`, etc.) debe usar `!`
si toca la misma propiedad que ya fija la clase base.
### 2.3 Iconos con brillo verde/acento en `:hover` de forma inconsistente
Varias tarjetas tenían `group-hover:text-accent-soft` en el título para dar un hover
"bonito", pero era inconsistente con el resto de la app (la mayoría de textos no
cambian de color al hover, solo cuando están realmente "activos"/reproduciéndose). Se
quitó de `MediaCard.svelte` y de la fila de "Tus playlists" en Inicio. **Quedan
pendientes** los mismos `group-hover/card:text-accent-soft` /
`group-hover/row:text-accent-soft` en `src/routes/(app)/explore/+page.svelte` (líneas
con tarjetas de álbum y filas de resultado — grep por `group-hover.*text-accent-soft`
para localizarlos exactos, las líneas se mueven con cada edición).
---
## 3. Migración de colores/tamaños a tokens
### 3.1 Datos de partida (medidos en esta sesión, `src/` de web-player)
```
27 archivos .svelte con al menos un text-[#…]/bg-[#…]/border-[#…]
106 apariciones totales de color hex literal en clases Tailwind
```
Los 10 valores hex más repetidos como `text-[#…]` (comando usado:
`grep -roE "(text|bg|border)-\[#[0-9a-fA-F]{3,8}\]" --include="*.svelte" . | sort | uniq -c | sort -rn`):
| Ocurrencias | Valor | Coincide EXACTO con token |
| ----------- | --------- | ------------------------------------- |
| 13 | `#8a8a8e` | `--mf-text-2` → usar `text-fg-2` |
| 9 | `#747478` | ninguno exacto (cerca de text-3/4) |
| 7 | `#d5d4d8` | ninguno exacto (cerca de `--mf-text`) |
| 7 | `#7f7f83` | ninguno exacto (cerca de text-3) |
| 6 | `#d9d8dc` | `--mf-text` → usar `text-fg` |
| 6 | `#d4d3d7` | ninguno exacto |
| 4 | `#d0cfd4` | ninguno exacto |
| 4 | `#818185` | ninguno exacto |
| 3 | `#d8d7db` | ninguno exacto |
| 2 | `#7d7d81` | `--mf-text-3` → usar `text-fg-3` |
Interpretación: hay un grupo de valores que son **copias exactas** de un token ya
existente (probablemente porque alguien copió el valor en vez de la clase) — esos son
reemplazo mecánico, bajo riesgo. Y hay un grupo mucho más grande de valores "parecidos
pero no iguales" a los 4 tonos de texto (`--mf-text`, `-2`, `-3`, `-4`) — esto sugiere
que cada componente inventó su propio gris en vez de reusar la escala, y hay que decidir
para cada uno **a qué tier semántico pertenece** (¿es un título? ¿un metadato? ¿un
label?) y mapearlo al token de esa categoría, no crear un tier nuevo por cada valor.
Solo se encontraron 3 `bg-[#…]` (en `MixTile.svelte` y `TransportControls.svelte`,
este último repite el mismo `#c6c4cb`/`#d6d5da` que tenía el botón "Reanudar" de Inicio
antes de moverlo a `Button variant="accent"` con override — es el mismo "botón claro"
reinventado dos veces; candidato a token propio, ver §4.4) y 0 `border-[#…]`.
### 3.2 Metodología sugerida (por archivo)
1. `grep -n "text-\[#\|bg-\[#\|border-\[#" archivo.svelte` para listar todos los casos.
2. Para cada uno, decidir el tier semántico (título/cuerpo/metadato/label/borde) y
sustituir por la utilidad de Tailwind ya mapeada en
`src/routes/layout.css` (`@theme` → `--color-fg`, `--color-fg-2`, `--color-fg-3`,
`--color-muted`, `--color-surface`, `--color-surface-2`, `--color-line`,
`--color-accent*`, etc.) o, si el valor es realmente un tamaño de fuente en vez de
color, a la escala ya usada en el resto de la app (10.5 / 11 / 11.5 / 12 / 12.5 / 13 /
13.5 / 14px — no inventar un tamaño intermedio nuevo salvo que Inicio ya lo use para
ese mismo tipo de texto).
3. Ejecutar `npm run check` tras cada archivo — Tailwind con clases arbitrarias no da
error de compilación aunque el token no exista, así que la única validación real es
visual: abrir la página en el navegador (Browser pane) y comparar contra Inicio.
4. Si dos o más componentes usan **el mismo valor hex no-token** para el mismo propósito
(p. ej. `#c6c4cb` como "botón claro"), considerar promoverlo a un token nuevo en
`theme.css` en vez de simplemente sustituirlo por el más parecido — ver §4.4.
5. `npm run lint` (prettier + eslint) antes de dar por cerrado cada archivo, según las
reglas del `CLAUDE.md` del proyecto.
### 3.3 Prioridad sugerida de páginas/componentes a migrar
Por impacto (páginas que ve todo usuario, no solo estados de error/edge):
1. `Sidebar.svelte`, `TopBar.svelte`, `MobileHeader.svelte` (chrome global, visible
siempre) — MobileHeader/TopBar ya tienen el menú de cuenta arreglado, falta el resto.
2. `src/routes/(app)/playlists/[id]/components/PlaylistHeader.svelte` y
`src/routes/(app)/albums/[id]/components/AlbumHeader.svelte` (comparten patrón,
arreglar juntos para no divergir otra vez).
3. `src/routes/(app)/liked/+page.svelte`, `src/routes/(app)/explore/+page.svelte`,
`src/routes/(app)/upload/+page.svelte`, `src/routes/(app)/mixes/[id]/+page.svelte`.
4. Componentes del reproductor (`lib/components/player/*.svelte`) — muchos hex propios,
pero cambian menos visualmente entre páginas, así que menor prioridad de contraste
pero igual de importantes para el modo claro (la barra del reproductor es visible en
toda la app).
5. `src/routes/+page.svelte` (Inicio) al final — es la referencia, así que migrarla
última minimiza el riesgo de que un "acabado" a medio migrar se convierta en la
referencia visual equivocada mientras se trabaja el resto.
---
## 4. Componentización pendiente
### 4.1 Patrón de fila de track reimplementado en vez de reusar `TrackTitleCell`
`src/routes/+page.svelte` reimplementa a mano, con su propio marcado, **tres** variantes
de "fila de canción" distintas entre sí (Continuar escuchando, Populares esta semana,
filas de la Playlist destacada) en vez de usar los componentes ya existentes
`TrackTitleCell.svelte` / `TrackRow.svelte` / `TrackMeta.svelte` /
`TrackIndexCell.svelte` que sí se usan correctamente en
`PlaylistTrackTable.svelte`/`library/+page.svelte`. Evaluar si esas tres filas de Inicio
se pueden expresar con los componentes de `TrackTable` (aunque sea sin la tabla
completa, solo reusando `TrackTitleCell` para title+cover+artist) para no mantener 4
implementaciones de "fila de canción" en paralelo.
### 4.2 `MediaCard`/`PlaylistCard`/`AlbumCard` vs. tarjetas hechas a mano
`src/routes/+page.svelte` (sección "Tus playlists") y `src/routes/(app)/explore/+page.svelte`
tienen su propia tarjeta de álbum/playlist en vez de usar `MediaCard`/`PlaylistCard`/
`AlbumCard` (`lib/components/ui/`). Antes de tocar el estilo de esas tarjetas a mano,
comprobar si pueden sustituirse directamente por el componente compartido — sale gratis
el arreglo de tipografía/color si el componente compartido ya está migrado a tokens.
### 4.3 Menú desplegable "de cristal" duplicado
`TopBar.svelte` y `MobileHeader.svelte` duplican: estado `menuOpen`, el
`onDocumentClick` para cerrar al hacer click fuera, y el marcado del panel
(`animate-pop ... backdrop-blur-[22px]` + `background-color:var(--mf-panel-bg);
background-image:var(--mf-modal-glow)`). `TrackContextMenu.svelte` tiene un menú
flotante distinto (`bg-elevated` + `border-line`, sin el efecto cristal, y con el bug de
tamaño de fuente de §2.1). Candidatos:
- Extraer un primitivo `Dropdown.svelte` o `FloatingMenu.svelte` que resuelva
posicionamiento + cierre al clicar fuera + Escape, y que ambos menús de cuenta lo usen.
- Definir la receta "panel de cristal" (el `style="background-color:var(--mf-panel-bg);
background-image:var(--mf-modal-glow)"` + `backdrop-blur-[22px]`) en una utilidad o
clase reusable en vez de repetir el `style=` inline en cada sitio, y aplicarla también
a `TrackContextMenu` para que los tres menús floating de la app se vean iguales.
### 4.4 Candidato a token nuevo: "botón claro" (`#c6c4cb`/`#d6d5da`/`#d8d7dc`)
Aparece en al menos: el antiguo botón "Reanudar" de Inicio (ya migrado a
`Button variant="accent"` con `class="!bg-white ..."`, ver commit reciente) y
`TransportControls.svelte` (botón de play principal del reproductor, sigue con hex
propio). Si el diseño quiere mantener un botón "casi blanco" como acento fuerte en
ciertos sitios (distinto del verde de acento normal), vale la pena darle un token
propio (p. ej. `--mf-cta-strong-bg`/`--mf-cta-strong-fg`) en vez de repetir el hex cada
vez, así el modo claro también puede darle un tratamiento coherente en vez de heredar el
mismo blanco fijo en ambos temas (que en modo claro quedaría invisible sobre el fondo).
---
## 5. Código muerto — puntos de partida
Ya se han borrado en esta rama `MobileNav.svelte` y `MixBentoTile.svelte` por no
usarse. Grep rápido (basado en "ningún otro `.svelte`/`.ts` bajo `src/` menciona el
nombre del componente fuera de su propio archivo") encontró estos candidatos
adicionales — **verificar a mano antes de borrar** (el grep no será 100% preciso: no
detecta uso vía imports dinámicos, alias, o si el componente se monta desde fuera de
`src/`):
- `src/lib/components/ui/CoverBadge.svelte`
- `src/lib/components/ui/PlayPauseOverlay.svelte`
Método para repetir esta búsqueda tras cada ronda de refactor (los nombres de
componente que queden sin importadores son limpieza segura):
```bash
for f in $(find src/lib/components -name "*.svelte"); do
name=$(basename "$f" .svelte)
count=$(grep -rl "$name" --include="*.svelte" --include="*.ts" src | grep -v "/$name.svelte$" | wc -l)
[ "$count" -eq 0 ] && echo "$f"
done
```
Además de componentes huérfanos, revisar por archivo (esto no se ha auditado
sistemáticamente esta sesión, son solo los tipos de código muerto más comunes en este
código base):
- Imports de iconos (`@lucide/svelte/icons/*`) que dejaron de usarse tras quitar un
icono de un botón (pasó varias veces esta sesión con `Play`/`Pause`/`NowPlaying`;
`eslint` los marca como `no-unused-vars` — correr `npx eslint <archivo>` tras cada
cambio de marcado, no solo `svelte-check`, porque `svelte-check` no siempre lo pilla).
- Props de componentes que ya no lee nadie (ej. si se elimina el único sitio que pasaba
`description` a `PlaylistCard`, y el componente ya no la usa, quitar el prop además
del sitio que la usaba).
- Clases Tailwind puestas "por si acaso" que no tienen efecto porque una clase
posterior en el cascade las anula (ver §2.2) — no es código muerto en el sentido
clásico, pero es ruido que confunde a quien lea el archivo después.
---
## 6. Cómo verificar cada cambio
Del `CLAUDE.md` del proyecto (`web-player/CLAUDE.md`), aplica igual aquí:
```sh
npm run check # svelte-check — debe salir 0 errores
npm run lint # prettier --check . && eslint . — debe salir limpio
```
Y para lo visual, que `svelte-check`/`eslint` no cubren:
1. Levantar `musify-web` (y `musify-api` si la página necesita datos reales) con el
Browser pane / `.claude/launch.json`.
2. Abrir la página tocada y la página de Inicio **una al lado de otra** (o alternando)
para comparar tono de gris, tamaño de fuente y separaciones — no solo "que no se vea
roto", sino "que se vea igual que Inicio para el mismo tipo de elemento".
3. Si la página tiene estado activo/actual (ej. una fila de canción reproduciéndose),
probarlo — varios de los bugs de esta sesión (§2.2) solo eran visibles con el
`isCurrent`/hover activo, no en el estado por defecto.
4. Alternar el toggle de tema (menú de cuenta → "Modo blanco"/"Modo oscuro") en la
página tocada para confirmar que el texto sigue siendo legible en ambos modos.
---
## 7. Orden de trabajo sugerido
1. Terminar §2 (los 3 bugs conocidos que quedan sueltos: `TrackContextMenu.svelte`
tamaño de fuente, hover verde en `explore/+page.svelte`).
2. Migrar a tokens los componentes de chrome global (§3.3, punto 1) — máximo impacto
visual con menor superficie de código.
3. Extraer el "panel de cristal" y/o `Dropdown.svelte` (§4.3) **antes** de tocar
`TrackContextMenu` a mano, para no maquetarlo dos veces.
4. Migrar el resto de páginas por la prioridad de §3.3.
5. Revisar componentización (§4.1, §4.2) — mejor después de que los componentes base
(`TrackTitleCell`, `MediaCard`, etc.) ya estén en tokens, así "adoptarlos" en Inicio
no introduce una regresión visual.
6. Pasada final de código muerto (§5) con el listado de componentes/props ya
consolidado tras los pasos anteriores (habrá más candidatos a huérfanos según se
vayan unificando duplicados).
7. Migrar Inicio a tokens en último lugar (§3.3, punto 5), verificando que los valores
resultantes son pixel-iguales a los que tiene hoy (es la referencia, no debe cambiar
de aspecto, solo de cómo está escrito el código).
+28 -10
View File
@@ -777,16 +777,16 @@ Tareas:
| P2 | ✅ | (pendiente) | `<style>{expr}</style>` no interpola en Svelte (se trata como CSS del componente); solución: `{@html \`<style>${css}</style>\`}` |
| P3 | ✅ | (pendiente) | Se quitó el nombre bajo el avatar de perfil (decisión tomada: redundante con el título de CollectionHeader). Se añadió `size="fill"` a Artwork (no está en la spec) para las tarjetas de grid fluidas (AlbumCard/PlaylistCard/MixTile), ya que P9 (MediaCard) aún no existe. SearchResultRow perdió su prop `kind`/radio-por-tipo (ahora el radio lo decide Artwork/Avatar). Sin QA visual autenticada (Docker no disponible) |
| P4 | ✅ | (pendiente) | Decisión tomada sin bloquear: se elimina el botón "adelante" decorativo (TopBar) y el icono `AlignJustify` decorativo (PlayerExtras) por ser código muerto visual. `TransportControls` (shuffle/prev/next/repeat) se deja sin migrar a IconButton a propósito: son iconos sin chrome (sin fondo/hit-box), migrarlos habría añadido una píldora de hover no presente hoy — riesgo visual no verificable sin Docker |
| P5 | ⬜ | | |
| P6 | ⬜ | | |
| P7 | ⬜ | | |
| P8 | ⬜ | | |
| P9 | ⬜ | | |
| P10 | ⬜ | | |
| P11 | ⬜ | | |
| P12 | ⬜ | | |
| P13 | ⬜ | | |
| P14 | ⬜ | | |
| P5 | 🟡 | (pendiente) | Hecho: pressable/clickOutside, toggle.svelte.ts, plural() (+minúsculas, Δ visible), helpers de player.svelte.ts (toQueueItem/isQueueCurrent/playAllOrToggle/playShuffled, addToQueue→playNextItem), navigation.svelte.ts (searchHref/appNavLinks — appNavLinks creado pero su migración a Sidebar/TabsBar se deja para P12 tal como indica esa parte), menus.svelte.ts (createTrackMenu, migrado en Inicio/Explorar/Mezcla). **Pendiente a propósito**: la tarea 5 (eliminar el envoltorio `TrackTarget` de tracks.ts y sus 8 accesores) se difiere — es el cambio de mayor riesgo del plan (toca tipos en Inicio/Explorar/Mezcla/menús de forma transversal) y no se pudo verificar visualmente sin Docker; abordarlo junto con P13 cuando se reescriban esas páginas |
| P6 | ✅ | (pendiente) | z-index de AccountMenu unificado a z-(--z-menu) (antes z-20, ahora al mismo nivel que ContextMenu — Δ menor, mejora consistencia); sin QA visual autenticada (Docker no disponible) |
| P7 | ✅ | (pendiente) | MediaIdentity queda "puro" (sin onClick) según spec; las 6 tablas de pista (P8) envuelven su propio `use:pressable` hasta que P8 las sustituya por TrackList. Deltas documentados: fila activa de Sidebar ahora usa bg-accent-tint/text-accent-soft (antes gris neutro, ahora consistente con el resto de filas activas); tarjeta "Tus playlists" de Inicio pierde la línea "por {tu nombre}" (redundante, siempre el propio usuario) y su radio pasa de rounded-panel a rounded-art (consistencia con la tarjeta "Continuar"); filas de Queue "A continuación" pierden el opacity-80 del arte (ListRow no expone esa personalización). Sin QA visual autenticada (Docker no disponible) |
| P8 | ✅ | (pendiente) | Decisión tomada (recomendada por el propio plan, sin bloquear): se elimina la columna "Álbum" que siempre mostraba "—". TrackList unifica TrackTable+TrackRow+TrackIndexCell+TrackMeta; AlbumTrackTable/PlaylistTrackTable/LibraryPicker se inlinean en sus páginas y se borran. Sin QA visual autenticada (Docker no disponible) |
| P9 | ✅ | (pendiente) | MediaCard gana hover-zoom del arte y scrim+PlayButton condicionados a `onPlay` (antes exclusivos de MixTile); ahora se aplicarían a cualquier card futura con onPlay, consistencia intencional. Sin QA visual autenticada (Docker no disponible) |
| P10 | ✅ | (pendiente) | Decisión tomada (recomendada, sin bloquear): iconos en los botones de cabecera de playlist (antes solo álbum tenía). PlaylistHeader pierde su prop `id` sin usar tras inlinearlo. Explorar: la cabecera de "Resultados para…" ahora usa el `mb-7/8` de PageHeader en vez de un `mt-5` ajustado a mano antes de los chips de filtro — gap ligeramente mayor entre el resumen y los chips (Δ menor, documentado). Sin QA visual autenticada (Docker no disponible) |
| P11 | ✅ | (pendiente) | CoverForm añade snippet `extra` (no está en la spec literal, solo `fields`) para el contenido a ancho completo bajo la fila portada+campos (descripción de álbum, selector de visibilidad de playlist) que no cabía en una única columna. Upload usa ImageDropzone con `onselect` (prop nueva) y se envuelve en `{#key}` para poder resetear tras publicar — Δ: el contenedor de portada pasa de 144/176px (móvil/desktop) a 144px fijo (size-cover-hero, sin variante desktop mayor), y el icono de posición vacía de 64px a 36px (icon-xl), tal como recomienda el plan. Sin QA visual autenticada (Docker no disponible) |
| P12 | ✅ | (pendiente) | PlayerExtras se fusiona en PlayerBar (volumen/cola inline); Slider sustituye a SeekBar+VolumeControl (confirmado por grep que el modo `compact` de SeekBar era código muerto). Sidebar y TabsBar migran a `appNavLinks()` (P5). Decisión tomada (sin bloquear): Sidebar gana la marca-gradiente `Logo` que no tenía antes, para consistencia con MobileHeader/login. z-index de PlayerDock/MobileHeader corregido a `z-(--z-sticky)` (son barras fijas, no menús) y SegmentedControl a `z-(--z-raised)`, tal como indica B.1.5. `glass-bar`/`theme-transition` aplicados en PlayerBar (ambas variantes), TabsBar, Queue e Inicio (hero/spotlight), sustituyendo estilos duplicados. Sin QA visual autenticada (Docker no disponible); smoke test de `/login` sin errores de consola |
| P13 | ✅ | (pendiente) | Se aborda junto con esta parte la tarea 5 diferida en P5: se elimina el envoltorio `TrackTarget` de `tracks.ts` y sus 8 accesores (`targetId`/`targetTitle`/`targetArtist`/`targetOwnerUserId`/`targetExplicit`/`targetListensCount`/`isTargetCurrent`/`queueItemForTarget`/`targetForQueueItem`); Explorar, Inicio, Mezcla, `menus.svelte.ts` y `mixes.ts` pasan a usar los objetos de pista (`ApiTrackLike`/`LocalTrack`) directamente, con `toQueueItem`/`trackFromQueueItem` (nuevo, en `player.svelte.ts`) como únicos conversores. TypeScript reveló que el campo `isExplicit` no existe en el schema real de `/tracks` — el badge "explicit" de Explorar ya era un no-op antes del refactor (siempre `undefined ?? false`); se retira el binding en vez de fingir soporte. Explorar: `GENRE_INFO`/`genreTiles` → `src/lib/genres.ts`; lógica de búsqueda (`showGroup`/`capped`/`matchPlaylists`/`searchCounts`/`searchChips`/`findTopResult`) → `src/lib/search.ts` (funciones puras, parametrizadas por `sfilter`/`query` en vez de cerrar sobre el estado del componente); "Mejor resultado" unifica el botón/enlace duplicado con `<svelte:element this={...}>`. Decisión tomada sin bloquear: **no** se colapsan los 4 bloques de grupo (Canciones/Álbumes/Usuarios/Playlists) en un único `{#each groups}` — cada grupo tiene snippets, contextmenu y props de `ListRow` genuinamente distintos (menú de pista vs. de álbum, overlay solo en canciones, trailing con formato distinto), y forzar una configuración genérica habría añadido más indirección de la que elimina. Inicio: usa `Page` (antes `page-x pt-3 sm:pt-4` a mano); stats ×3 → `{#each}`; fondos de hero/spotlight (`style=` con `var(--mf-hero-bg)`/`var(--mf-spotlight-bg)`) → utilities `hero-surface`/`spotlight-surface` (nuevas, en `layout.css`) y token `--spacing-section: 2.875rem` → `gap-section` sustituye `gap-11.5`. Sin QA visual autenticada (Docker no disponible); `/explore` sin sesión redirige a `/login` porque `+layout.server.ts` exige `allowAnonymousListening` desde el backend (inalcanzable sin Docker) — comportamiento preexistente, no una regresión; smoke test de `/login` sin errores de consola |
| P14 | ✅ | (pendiente) | `scripts/check-tokens.mjs` (`npm run lint:tokens`, añadido a `lint`) escanea `src/**/*.svelte` y falla en: arbitrarios `-[…]` fuera de la lista blanca de B.4, `rounded-(sm/md/lg/xl/2xl/3xl)`/`rounded` a secas, pares `h-N w-N` (deberían ser `size-N`), espaciado fraccionario en cuartos (`.25`/`.75`), hex/`rgba()` literales. Se limpió el repo hasta dejarlo en 0 violaciones: ~40 pares `h-N w-N` → `size-N` (icons sobre todo, vía script puntual), ~26 espaciados en cuartos redondeados al entero más cercano (Δ≤1px, p. ej. `py-2.75`→`py-3`, `gap-1.75`→`gap-2`), 3 `rounded-sm/lg` + 1 `rounded` a secas → tokens (`rounded-tag`/`rounded-thumb`/`rounded-control`), 2 `tracking-[-0.02em]` → `tracking-display` (coincidían exactamente con el token ya existente y no se habían migrado), 1 `tracking-[0.14em]` en Explorar → receta `text-eyebrow`, `rounded-3xl` del hero de Inicio → `rounded-panel-lg` (consistente con la tarjeta de playlist destacada). Se retiraron las recetas `stroke-thin/regular/bold` (creadas en P1, 0 consumidores) — **decisión tomada, sin bloquear**: no se migra `strokeWidth={n}` (13 valores, en casi todos los componentes) a esas recetas por CSS porque no se puede verificar sin Docker que `lucide-svelte` respete `stroke-width` por clase en vez del prop, y una regresión ahí sería silenciosa en toda la app; el script documenta este gap explícitamente y no lo exige. Se abordó también junto con la tarea 5 diferida de P5 (ver P13). Detector de huérfanos: 0 componentes sin importadores (los 47 de `src/lib/components/` están todos en uso). `docs/design-system-refactor-plan.md` (plan anterior, superseded) borrado. `web-player/CLAUDE.md` gana sección "Estilos" con la lista blanca B.4, componentes base a reutilizar y el gap de `strokeWidth` documentado. Línea base recalculada (ver tabla debajo) |
Decisiones de producto pendientes (preguntar al usuario antes de la parte indicada):
- P0/P4: ¿`Button` primary debe verse distinto de secondary?
@@ -797,6 +797,24 @@ Decisiones de producto pendientes (preguntar al usuario antes de la parte indica
- P10: ¿iconos en los botones de cabecera de álbum **y** playlist?
- P12: ¿logo con cuadrado de gradiente también en la Sidebar?
### D.1 Línea base recalculada (tras P14, cierre del refactor)
Recalculada con los mismos criterios que A.4 (grep sobre `src/`), en la misma sesión de
cierre de P14.
| Métrica | A.4 (antes) | Tras P14 |
| -------------------------------------------------- | ----------- | ------------------------------------------ |
| Componentes `.svelte` en `src/lib/components/` | 61 | 47 |
| Líneas `.svelte`/`.css`/`.ts` cliente | ~8.700 | ~8.310 (−~390; el objetivo de −1.500/−2.000 ya se había alcanzado en P3–P11, antes de que P13 añadiera `genres.ts`/`search.ts` como código nuevo bien separado) |
| Clases arbitrarias `x-[…]` | 44 | 11 (las 11 restantes, en la lista blanca de B.4) |
| Espaciados fraccionarios en cuartos (`.25`/`.75`) | ~190 apariciones | 0 (redondeados al entero más cercano, Δ≤1px) |
| Radios distintos usados | 13 | 8 (7 tokens B.1.1 + `rounded-full`) |
| `strokeWidth` distintos | 13 | 12 (fuera de alcance, ver Registro P14 y CLAUDE.md) |
| Atributos `style="…"` | 24 | 16 (en 12 archivos; todos dinámicos por instancia — stagger, posición de menú, `var(--mf-*)`, animación — ninguno con color/medida literal) |
| Pluralizaciones a mano `=== 1 ?` | 15 | 2 (1 es la propia `plural()` en `format.ts`; 1 es concordancia de participio en `library` que `plural()` no puede cubrir) |
| Errores de `eslint` | 2 | 0 |
| `npm run lint:tokens` (nuevo, P14) | — | 0 violaciones |
---
## E. Inventario completo (veredicto por archivo)
+2 -1
View File
@@ -12,7 +12,8 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
"lint:tokens": "node scripts/check-tokens.mjs",
"lint": "prettier --check . && eslint . && npm run lint:tokens"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env node
// Guardrail contra la reintroducción de lo que P0-P14 limpió
// (ver docs/frontend-refactor-plan.md, secciones B.4 y P14). Escanea
// src/**/*.svelte en busca de valores arbitrarios y utilidades de Tailwind
// que deberían ser tokens del sistema de diseño (src/routes/layout.css).
//
// Gaps conocidos, no cubiertos aquí a propósito:
// - `strokeWidth={n}` en iconos: hay ~13 valores distintos repartidos por
// casi todos los componentes. Migrarlo a las recetas `stroke-*` requeriría
// verificar que lucide-svelte respeta `stroke-width` por CSS en vez del
// atributo del componente, y no hay forma de comprobarlo visualmente en
// esta sesión (sin Docker). Se deja fuera para no arriesgar una regresión
// silenciosa en todos los iconos de la app.
// - `text-base` sin receta: solo queda un caso real (Queue.svelte, título
// "En cola", 16px) sin equivalente en la escala text-display-*. Crear una
// receta nueva para un único consumidor sería una abstracción prematura;
// se acepta como excepción documentada en vez de forzarlo a un tamaño que
// no le corresponde.
import { readFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
const files = execSync('git ls-files "src/**/*.svelte"', { encoding: 'utf8' })
.split('\n')
.filter(Boolean);
// B.4 — arbitrarios permitidos tras el refactor.
const ARBITRARY_WHITELIST = [
/^text-\[clamp\([^)]+\)\]$/, // tipografía fluida (login, Logo)
/^leading-\[[\d.]+\]$/, // leading ajustado del display gigante de login
/^grid-cols-\[auto_1fr\]$/, // upload: portada + campos
/^max-h-\[85dvh\]$/, // Modal
/^my-\[\d+d?vh\]$/, // +error.svelte: centrado vertical de página de error
/^py-\[\d+d?vh\]$/, // Explorar: centrado vertical de "sin resultados"
/^transition-\[filter\]$/, // Explorar: genre-tile hover
/^bg-\[image:var\(--mf-[\w-]+\)\]$/ // gradientes dinámicos por tema (Artwork, Logo, liked)
];
// Radios semánticos de B.1.1 — cualquier otro rounded-* de Tailwind (o el
// `rounded` a secas de fábrica) es una regresión. `rounded-full` y los
// tokens (`rounded-tag/thumb/control/art/art-lg/panel/panel-lg`) están bien:
// el lookahead exige que no venga más texto de clase pegado con `-`.
const RAW_ROUNDED = /\brounded(-(none|xs|sm|md|lg|xl|2xl|3xl))?(?![\w-])/g;
const PAIRED_HW = /\b(?:([a-z0-9-]+:))?h-([\d.]+)\s+\1w-\2\b/g;
const QUARTER_STEP_SPACING = /\b[a-z-]+-\d+\.(25|75)\b/g;
const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g;
const RGBA_LITERAL = /\brgba?\(/g;
let violations = [];
for (const file of files) {
const content = readFileSync(file, 'utf8');
const lines = content.split('\n');
lines.forEach((line, i) => {
const lineNo = i + 1;
for (const match of line.matchAll(/[a-zA-Z][a-zA-Z0-9-]*-\[[^\]]+\]/g)) {
if (!ARBITRARY_WHITELIST.some((re) => re.test(match[0]))) {
violations.push(`${file}:${lineNo} — arbitrario fuera de la lista blanca: \`${match[0]}\``);
}
}
for (const match of line.matchAll(RAW_ROUNDED)) {
violations.push(`${file}:${lineNo} — radio de Tailwind sin tokenizar: \`${match[0]}\``);
}
for (const match of line.matchAll(PAIRED_HW)) {
violations.push(`${file}:${lineNo} — usa \`size-${match[2]}\` en vez de \`${match[0]}\``);
}
for (const match of line.matchAll(QUARTER_STEP_SPACING)) {
violations.push(
`${file}:${lineNo} — espaciado fraccionario en cuartos: \`${match[0]}\` (redondear al entero más cercano)`
);
}
for (const match of line.matchAll(HEX_COLOR)) {
violations.push(
`${file}:${lineNo} — color hex literal: \`${match[0]}\` (usa un token --mf-*)`
);
}
for (const match of line.matchAll(RGBA_LITERAL)) {
violations.push(
`${file}:${lineNo} — color rgba() literal: \`${match[0]}\` (usa un token --mf-*)`
);
}
});
}
if (violations.length > 0) {
console.error(`\n${violations.length} violación(es) del sistema de diseño:\n`);
for (const v of violations) console.error(` ${v}`);
console.error('\nVer docs/frontend-refactor-plan.md, sección B.4.\n');
process.exit(1);
}
console.log('check-tokens: sin violaciones.');
@@ -0,0 +1,17 @@
import type { Action } from 'svelte/action';
export const clickOutside: Action<HTMLElement, () => void> = (node, fn) => {
function onClick(event: MouseEvent) {
if (!node.contains(event.target as Node)) fn();
}
document.addEventListener('click', onClick, true);
return {
update(next) {
fn = next;
},
destroy() {
document.removeEventListener('click', onClick, true);
}
};
};
+25
View File
@@ -0,0 +1,25 @@
import type { Action } from 'svelte/action';
export const pressable: Action<HTMLElement, () => void> = (node, fn) => {
function onClick() {
fn();
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
fn();
}
node.addEventListener('click', onClick);
node.addEventListener('keydown', onKeydown);
return {
update(next) {
fn = next;
},
destroy() {
node.removeEventListener('click', onClick);
node.removeEventListener('keydown', onKeydown);
}
};
};
@@ -1,118 +0,0 @@
<script lang="ts" module>
const MENU_WIDTH = 240;
const SUBMENU_WIDTH = 256;
export type AlbumMenuState = { x: number; y: number; openLeft: boolean; albumId: string };
export function albumContextMenuStateFor(event: MouseEvent, albumId: string): AlbumMenuState {
event.preventDefault();
return {
albumId,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ListPlus from '@lucide/svelte/icons/list-plus';
import ListEnd from '@lucide/svelte/icons/list-end';
import Artwork from '$lib/components/ui/Artwork.svelte';
import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
interface Props {
menu: AlbumMenuState;
playlists: { id: string; name: string }[];
onClose: () => void;
onPlayNext: () => void;
onAddToQueue: () => void;
}
let { menu, playlists, onClose, onPlayNext, onAddToQueue }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div
class="fixed inset-0 z-30"
role="presentation"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
></div>
<GlassMenu class="fixed z-40 w-60" style="left:{menu.x}px; top:{menu.y}px;">
<button
type="button"
onclick={onPlayNext}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg-2 transition hover:bg-hover"
>
<ListPlus class="h-4.5 w-4.5 shrink-0" strokeWidth={2} />
<span>Reproducir a continuación</span>
</button>
<button
type="button"
onclick={onAddToQueue}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg-2 transition hover:bg-hover"
>
<ListEnd class="h-4.5 w-4.5 shrink-0" strokeWidth={2} />
<span>Añadir a la cola</span>
</button>
{#if playlists.length > 0}
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
>
<span class="flex-1 cursor-default">Añadir álbum a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div>
<div
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {menu.openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 max-sm:w-full"
>
<div class="max-h-80 overflow-y-auto">
{#each playlists as playlist (playlist.id)}
<form
method="POST"
action="?/addAlbumToPlaylist"
use:enhance={() =>
({ update }) => {
onClose();
return update({ reset: false, invalidateAll: false });
}}
>
<input type="hidden" name="playlistId" value={playlist.id} />
<input type="hidden" name="albumId" value={menu.albumId} />
<button
type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
>
<Artwork
src="/api/playlists/{playlist.id}/cover?size=small"
size="xs"
class="shrink-0"
/>
<span class="truncate text-fg-2">{playlist.name}</span>
</button>
</form>
{/each}
</div>
</div>
</div>
</div>
{:else}
<p class="px-2 py-2.5 text-sm text-fg-3">Todavía no tienes playlists.</p>
{/if}
</GlassMenu>
@@ -7,6 +7,7 @@
import AccountMenu from './ui/AccountMenu.svelte';
import Avatar from './ui/Avatar.svelte';
import IconButton from './ui/IconButton.svelte';
import Logo from './ui/Logo.svelte';
interface Props {
user: SessionUser | null;
@@ -17,43 +18,33 @@
</script>
<header
class="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-hairline bg-bg/85 page-x backdrop-blur-md sm:hidden"
class="sticky top-0 z-(--z-sticky) flex h-14 items-center justify-between border-b border-hairline bg-bg/85 page-x backdrop-blur-md sm:hidden"
>
<a
href="/"
class="flex items-center gap-2 font-display text-lg font-semibold tracking-[-0.02em] text-fg"
>
<span class="h-5.5 w-5.5 rounded-lg bg-[image:var(--mf-logo-grad)]"></span>
Musify
<a href="/">
<Logo size="sm" />
</a>
<div class="relative">
{#if user}
<AccountMenu
{user}
{accountUrl}
panelClass="absolute top-full right-0 mt-2 w-56 overflow-hidden"
>
{#snippet trigger({ toggle, open })}
<button
type="button"
onclick={toggle}
aria-label="Tu cuenta"
aria-expanded={open}
class="flex items-center rounded-full"
>
<Avatar name={user.name} src={user.picture} size="sm" />
</button>
{/snippet}
{#snippet extraItems({ close })}
<MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} />
<MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} />
{/snippet}
</AccountMenu>
{:else}
<IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md">
<LogIn class="size-icon-lg" strokeWidth={2} />
</IconButton>
{/if}
</div>
{#if user}
<AccountMenu {user} {accountUrl} width="md">
{#snippet trigger({ toggle, open })}
<button
type="button"
onclick={toggle}
aria-label="Tu cuenta"
aria-expanded={open}
class="flex items-center rounded-full"
>
<Avatar name={user.name} src={user.picture} size="sm" />
</button>
{/snippet}
{#snippet extraItems({ close })}
<MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} />
<MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} />
{/snippet}
</AccountMenu>
{:else}
<IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md">
<LogIn class="size-icon-lg" strokeWidth={2} />
</IconButton>
{/if}
</header>
+21 -46
View File
@@ -1,18 +1,12 @@
<script lang="ts">
import { page } from '$app/state';
import { isSectionActive } from '$lib/navigation.svelte';
import { isSectionActive, appNavLinks } from '$lib/navigation.svelte';
import { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte';
import type { SessionUser } from '$lib/types';
import Artwork from './ui/Artwork.svelte';
import ListRow from './ui/ListRow.svelte';
import IconButton from './ui/IconButton.svelte';
import Home from '@lucide/svelte/icons/house';
import Compass from '@lucide/svelte/icons/compass';
import ListMusic from '@lucide/svelte/icons/list-music';
import Heart from '@lucide/svelte/icons/heart';
import Disc from '@lucide/svelte/icons/disc-2';
import Folder from '@lucide/svelte/icons/folder';
import Upload from '@lucide/svelte/icons/upload';
import Logo from './ui/Logo.svelte';
import Plus from '@lucide/svelte/icons/plus';
interface SidebarPlaylist {
@@ -29,19 +23,7 @@
let { user, playlists }: Props = $props();
const navLinks = $derived(
user
? [
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Descubrir', icon: Compass },
{ href: '/playlists', label: 'Playlists', icon: ListMusic, count: playlists.length },
{ href: '/liked', label: 'Me gusta', icon: Heart, count: liked.count },
{ href: '/albums', label: 'Mis álbumes', icon: Disc },
{ href: '/library', label: 'Canciones subidas', icon: Folder },
{ href: '/upload', label: 'Subir música', icon: Upload }
]
: [{ href: '/explore', label: 'Descubrir', icon: Compass }]
);
const navLinks = $derived(appNavLinks(user, { playlists: playlists.length, liked: liked.count }));
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
@@ -49,10 +31,12 @@
</script>
<aside
class="hidden shrink-0 flex-col border-r border-hairline bg-bg p-5 transition-[background] duration-500 ease-out lg:flex"
class="hidden shrink-0 flex-col border-r border-hairline bg-bg p-5 theme-transition lg:flex"
style="width:var(--mf-sidebar-w)"
>
<a href="/" class="px-3 pb-3 font-display text-lg font-semibold tracking-tight text-fg">Musify</a>
<a href="/" class="px-3 pb-3">
<Logo size="sm" />
</a>
<nav class="flex flex-col gap-1">
{#each navLinks as link (link.href)}
@@ -64,7 +48,7 @@
? 'bg-surface-2 text-fg'
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
>
<link.icon class="h-4.5 w-4.5 shrink-0" strokeWidth={1.5} />
<link.icon class="size-4.5 shrink-0" strokeWidth={1.5} />
<span class="flex-1 truncate">{link.label}</span>
{#if link.count}
<span class="text-xs text-muted tabular-nums">{link.count}</span>
@@ -76,7 +60,7 @@
{#if user}
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
<div class="flex items-center justify-between px-3 pb-1.5">
<span class="text-xs font-medium tracking-widest text-muted uppercase">Tus playlists</span>
<span class="text-eyebrow text-muted">Tus playlists</span>
{#if playlists.length > 0}
<IconButton
label="Crear playlist"
@@ -92,33 +76,24 @@
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5">
{#each playlists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
<a
<ListRow
href="/playlists/{playlist.id}"
class="flex items-center gap-2.75 rounded-control px-3 py-1.75 transition-colors duration-150 {active
? 'bg-surface-hover'
: 'hover:bg-hover'}"
>
<Artwork
src="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds}
size="xs"
class="shrink-0 opacity-90"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-sm {active ? 'text-fg' : 'text-fg-2'}">
{playlist.name}
</div>
</div>
</a>
{active}
size="xs"
title={playlist.name}
coverSrc="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds}
class="px-3 py-2"
/>
{:else}
<button
type="button"
onclick={() => createPlaylistModal.show()}
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-muted transition-colors hover:bg-hover hover:text-fg-2"
>
<Plus class="h-3.5 w-3.5" strokeWidth={1.8} />
<Plus class="size-3.5" strokeWidth={1.8} />
Crear tu primera lista
</button>
{/each}
+5 -18
View File
@@ -1,11 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { isSectionActive } from '$lib/navigation.svelte';
import { isSectionActive, appNavLinks } from '$lib/navigation.svelte';
import type { SessionUser } from '$lib/types';
import Home from '@lucide/svelte/icons/house';
import Compass from '@lucide/svelte/icons/compass';
import ListMusic from '@lucide/svelte/icons/list-music';
import Heart from '@lucide/svelte/icons/heart';
interface Props {
user: SessionUser | null;
@@ -14,16 +10,7 @@
let { user, class: klass = '' }: Props = $props();
const links = $derived(
user
? [
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Descubrir', icon: Compass },
{ href: '/playlists', label: 'Playlists', icon: ListMusic },
{ href: '/liked', label: 'Me gusta', icon: Heart }
]
: [{ href: '/explore', label: 'Descubrir', icon: Compass }]
);
const links = $derived(appNavLinks(user).filter((link) => link.primary));
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
@@ -31,7 +18,7 @@
</script>
<nav
class="pointer-events-auto flex items-center justify-around border-t border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-1.75 backdrop-blur-xl transition-[background] duration-500 lg:hidden {klass}"
class="pointer-events-auto flex items-center justify-around glass-bar px-4 py-2 lg:hidden {klass}"
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
>
{#each links as link (link.href)}
@@ -39,11 +26,11 @@
<a
href={link.href}
aria-current={active ? 'page' : undefined}
class="flex flex-col items-center gap-1.25 rounded-xl px-2.5 py-1.5 {active
class="flex flex-col items-center gap-1 rounded-control px-2.5 py-1.5 {active
? 'text-fg'
: 'text-fg-2'}"
>
<link.icon class="h-4.25 w-4.25" strokeWidth={1.6} />
<link.icon class="size-4" strokeWidth={1.6} />
<span class="text-xs font-medium">{link.label}</span>
</a>
{/each}
+7 -14
View File
@@ -3,6 +3,7 @@
import { page } from '$app/state';
import type { SessionUser } from '$lib/types';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import { searchHref } from '$lib/navigation.svelte';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
@@ -24,13 +25,9 @@
let searchInput: HTMLInputElement | undefined = $state();
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function buildHref(term: string) {
return term ? `/explore?q=${encodeURIComponent(term)}` : '/explore';
}
function runSearch(term: string) {
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
goto(buildHref(term), {
goto(searchHref(term), {
replaceState: page.url.pathname === '/explore',
keepFocus: true,
noScroll: true
@@ -72,7 +69,7 @@
<label
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
>
<Search class="h-3.75 w-3.75 shrink-0 text-fg-3" strokeWidth={1.8} />
<Search class="size-4 shrink-0 text-fg-3" strokeWidth={1.8} />
<input
type="text"
bind:this={searchInput}
@@ -86,9 +83,9 @@
type="button"
onclick={clearQuery}
aria-label="Borrar búsqueda"
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
class="grid size-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
>
<X class="h-3.5 w-3.5" />
<X class="size-3.5" />
</button>
{/if}
</label>
@@ -96,13 +93,9 @@
<div class="flex-1"></div>
<div class="relative shrink-0">
<div class="shrink-0">
{#if user}
<AccountMenu
{user}
{accountUrl}
panelClass="absolute top-full right-0 z-20 mt-2 w-52 overflow-hidden"
>
<AccountMenu {user} {accountUrl}>
{#snippet trigger({ toggle, open })}
<button
type="button"
@@ -1,108 +0,0 @@
<script lang="ts" module>
import type { TrackTarget } from '$lib/tracks';
export type ContextMenuState = TrackTarget & { x: number; y: number; openLeft: boolean };
const MENU_WIDTH = 240;
const SUBMENU_WIDTH = 256;
export function contextMenuStateFor(event: MouseEvent, target: TrackTarget): ContextMenuState {
event.preventDefault();
return {
...target,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import ListPlus from '@lucide/svelte/icons/list-plus';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Artwork from '$lib/components/ui/Artwork.svelte';
import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
interface Props {
menu: ContextMenuState;
playlists: { id: string; name: string }[];
onClose: () => void;
onAddToQueue: () => void;
}
let { menu, playlists, onClose, onAddToQueue }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div
class="fixed inset-0 z-30"
role="presentation"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
></div>
<GlassMenu class="fixed z-40 w-60" style="left:{menu.x}px; top:{menu.y}px;">
<button
type="button"
onclick={onAddToQueue}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg-2 transition hover:bg-hover"
>
<ListPlus class="h-4.5 w-4.5 shrink-0" strokeWidth={2} />
<span>Reproducir a continuación</span>
</button>
{#if playlists.length > 0}
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
>
<span class="flex-1 cursor-default">Añadir a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div>
<div
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {menu.openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 max-sm:w-full"
>
<div class="max-h-80 overflow-y-auto">
{#each playlists as playlist (playlist.id)}
<form
method="POST"
action="?/addTrack"
use:enhance={() =>
({ update }) => {
onClose();
return update({ reset: false, invalidateAll: false });
}}
>
<input type="hidden" name="playlistId" value={playlist.id} />
<input type="hidden" name="trackId" value={menu.track.id} />
<button
type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
>
<Artwork
src="/api/playlists/{playlist.id}/cover?size=small"
size="xs"
class="shrink-0"
/>
<span class="truncate text-fg-2">{playlist.name}</span>
</button>
</form>
{/each}
</div>
</div>
</div>
</div>
{/if}
</GlassMenu>
@@ -1,12 +1,21 @@
<script lang="ts">
import TrackInfo from './TrackInfo.svelte';
import TransportControls from './TransportControls.svelte';
import SeekBar from './SeekBar.svelte';
import PlayerExtras from './PlayerExtras.svelte';
import Slider from '$lib/components/ui/Slider.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import { fmtTime } from '$lib/format';
import Volume2 from '@lucide/svelte/icons/volume-2';
import ListMusic from '@lucide/svelte/icons/list-music';
function seek(value: number) {
if (player.current.duration > 0) player.seekFraction(value / player.current.duration);
}
</script>
<div
class="animate-enter pointer-events-auto flex items-center gap-4.5 border-t border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 backdrop-blur-xl transition-[background] duration-500 sm:hidden"
class="animate-enter pointer-events-auto flex items-center gap-4.5 glass-bar px-4 py-3 sm:hidden"
>
<div class="min-w-0 flex-1">
<TrackInfo compact />
@@ -15,14 +24,46 @@
</div>
<div
class="animate-enter pointer-events-auto hidden items-center gap-5 border-t border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 backdrop-blur-xl transition-[background] duration-500 sm:flex"
class="animate-enter pointer-events-auto hidden items-center gap-5 glass-bar px-4 py-3 sm:flex"
>
<div class="w-64 min-w-0 shrink-0">
<div class="w-(--mf-player-side-w) min-w-0 shrink-0">
<TrackInfo />
</div>
<div class="flex min-w-0 flex-1 flex-col items-center gap-2">
<TransportControls />
<SeekBar />
<div class="flex w-full max-w-hero items-center gap-3">
<span class="min-w-8 text-right text-xs text-fg-3 tabular-nums">
{fmtTime(player.progress)}
</span>
<Slider
value={player.progress}
max={player.current.duration}
label="Barra de progreso"
oninput={seek}
variant="seek"
/>
<span class="min-w-8 text-xs text-fg-3 tabular-nums">
{fmtTime(player.current.duration)}
</span>
</div>
</div>
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
<Volume2 class="size-icon-sm shrink-0 text-fg-3" strokeWidth={1.8} />
<Slider
value={player.volume}
max={100}
label="Volumen"
oninput={(value) => player.setVolume(value)}
variant="volume"
/>
<IconButton
label="Cola de reproducción"
tone="plain"
size="xs"
pressed={queuePanel.open}
onclick={() => queuePanel.toggle()}
>
<ListMusic class="size-icon-sm" strokeWidth={1.8} />
</IconButton>
</div>
<PlayerExtras />
</div>
@@ -15,7 +15,7 @@
</script>
<div
class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex flex-col lg:left-[var(--mf-sidebar-w)]"
class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-sticky) flex flex-col lg:left-(--mf-sidebar-w)"
>
{#if player.error}
<div class="pointer-events-auto px-4 pb-2.5">
@@ -1,19 +0,0 @@
<script lang="ts">
import VolumeControl from './VolumeControl.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import ListMusic from '@lucide/svelte/icons/list-music';
</script>
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
<VolumeControl />
<IconButton
label="Cola de reproducción"
tone="plain"
size="xs"
pressed={queuePanel.open}
onclick={() => queuePanel.toggle()}
>
<ListMusic class="size-icon-sm" strokeWidth={1.8} />
</IconButton>
</div>
@@ -1,8 +1,8 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import MediaIdentity from '$lib/components/ui/MediaIdentity.svelte';
import ListRow from '$lib/components/ui/ListRow.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import { fmtTime } from '$lib/format';
import X from '@lucide/svelte/icons/x';
@@ -18,7 +18,7 @@
{#if queuePanel.open}
<aside
transition:fly={{ x: 24, duration: 220, easing: cubicOut }}
class="hidden shrink-0 flex-col border-l border-hairline bg-bg px-4 pt-5 transition-[background] duration-500 sm:flex"
class="hidden shrink-0 flex-col border-l border-hairline bg-bg px-4 pt-5 theme-transition sm:flex"
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
>
<div class="mb-5 flex items-center justify-between">
@@ -29,31 +29,20 @@
</div>
{#if player.current.id}
<p class="mb-2.75 text-xs font-medium tracking-widest text-fg-3 uppercase">Reproduciendo</p>
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
<Artwork
trackIds={[player.current.id]}
size="sm"
alt={player.current.title}
class="shrink-0"
<p class="mb-3 text-eyebrow text-fg-3">Reproduciendo</p>
<div class="mb-6 rounded-control bg-accent-tint p-2.5">
<MediaIdentity
trackId={player.current.id}
title={player.current.title}
artist={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId}
active
/>
<div class="min-w-0">
<div class="truncate text-sm text-accent-soft">
{player.current.title}
</div>
<ArtistLink
name={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId}
class="mt-0.5 text-xs text-fg-3"
/>
</div>
</div>
{/if}
<div class="mb-2.75 flex items-center justify-between gap-3">
<span class="min-w-0 flex-1 truncate text-xs font-medium tracking-widest text-fg-3 uppercase">
A continuación
</span>
<div class="mb-3 flex items-center justify-between gap-3">
<span class="min-w-0 flex-1 truncate text-eyebrow text-fg-3"> A continuación </span>
{#if upcoming.length > 0}
<button
type="button"
@@ -68,30 +57,22 @@
<div class="flex flex-1 flex-col gap-px overflow-y-auto">
{#each upcoming as track, i (track.id)}
{@const index = player.tracks.length - upcoming.length + i}
<div
role="button"
tabindex="0"
<ListRow
onclick={() => player.playQueueIndex(index)}
onkeydown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
player.playQueueIndex(index);
}}
class="flex items-center gap-3 rounded-control p-2.25 text-left hover:bg-hover"
size="sm"
title={track.title}
subtitle={track.artist || '—'}
subtitleHref={track.ownerUserId}
trackId={track.id}
class="p-2"
>
<Artwork trackIds={[track.id]} size="sm" alt={track.title} class="shrink-0 opacity-80" />
<div class="min-w-0 flex-1">
<div class="truncate text-xs text-fg">{track.title}</div>
<ArtistLink
name={track.artist || '—'}
ownerUserId={track.ownerUserId}
class="mt-0.5 text-xs text-fg-3"
/>
</div>
{#if track.duration}
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span>
{/if}
</div>
{#snippet trailing()}
{#if track.duration}
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span
>
{/if}
{/snippet}
</ListRow>
{:else}
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p>
{/each}
@@ -1,48 +0,0 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import { fmtTime } from '$lib/format';
interface Props {
compact?: boolean;
}
let { compact = false }: Props = $props();
function seek(event: MouseEvent) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
player.seekFraction((event.clientX - rect.left) / rect.width);
}
</script>
{#if compact}
<button
type="button"
onclick={seek}
aria-label="Barra de progreso"
class="relative block h-1 w-full cursor-pointer bg-track"
>
<div
class="absolute inset-y-0 left-0 bg-accent/70"
style="width:{player.progressPercent}%"
></div>
</button>
{:else}
<div class="flex w-full max-w-135 items-center gap-2.75">
<span class="min-w-8 text-right text-xs text-fg-3 tabular-nums">
{fmtTime(player.progress)}
</span>
<button
type="button"
onclick={seek}
aria-label="Barra de progreso"
class="group relative h-3.5 flex-1 cursor-pointer"
>
<div class="h-[3.5px] w-full overflow-hidden rounded-full bg-track">
<div class="h-full rounded-full bg-accent" style="width:{player.progressPercent}%"></div>
</div>
</button>
<span class="min-w-8 text-xs text-fg-3 tabular-nums">
{fmtTime(player.current.duration)}
</span>
</div>
{/if}
@@ -28,7 +28,7 @@
}
</script>
<div class="flex min-w-0 items-center gap-2.5 md:gap-3.25">
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
{#if player.current.id}
<Artwork
trackIds={[player.current.id]}
@@ -12,7 +12,7 @@
const sideClass = $derived(
compact
? 'grid h-10 w-10 place-items-center text-fg opacity-70 transition active:scale-90'
? 'grid size-10 place-items-center text-fg opacity-70 transition active:scale-90'
: 'flex text-fg-2 transition-colors hover:text-fg active:scale-90'
);
</script>
@@ -26,7 +26,7 @@
aria-label="Aleatorio"
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
>
<Shuffle class="h-3.75 w-3.75" strokeWidth={1.8} />
<Shuffle class="size-4" strokeWidth={1.8} />
</button>
{/if}
@@ -58,7 +58,7 @@
aria-label="Repetir"
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
>
<Repeat class="h-3.75 w-3.75" strokeWidth={1.8} />
<Repeat class="size-4" strokeWidth={1.8} />
</button>
{/if}
</div>
@@ -1,52 +0,0 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import Volume2 from '@lucide/svelte/icons/volume-2';
function handleVolumeChange(event: Event) {
const target = event.currentTarget as HTMLInputElement;
player.setVolume(Number(target.value));
}
</script>
<div class="flex items-center gap-2.25">
<Volume2 class="h-3.75 w-3.75 shrink-0 text-fg-3" strokeWidth={1.8} />
<input
type="range"
min="0"
max="100"
value={player.volume}
oninput={handleVolumeChange}
aria-label="Volumen"
class="mf-volume"
style="background:linear-gradient(to right, var(--mf-text-2) {player.volume}%, var(--mf-track) {player.volume}%)"
/>
</div>
<style>
.mf-volume {
-webkit-appearance: none;
appearance: none;
width: 84px;
height: 3.5px;
border-radius: 100px;
outline: none;
cursor: pointer;
}
.mf-volume::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--mf-text);
cursor: pointer;
}
.mf-volume::-moz-range-thumb {
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--mf-text);
border: none;
cursor: pointer;
}
</style>
@@ -9,25 +9,21 @@
import MenuItem from './MenuItem.svelte';
import GlassMenu from './GlassMenu.svelte';
import { themeMode } from '$lib/theme/mode.svelte';
import { clickOutside } from '$lib/actions/clickOutside';
interface Props {
user: SessionUser;
accountUrl?: string | null;
panelClass?: string;
width?: 'sm' | 'md';
extraItems?: Snippet<[{ close: () => void }]>;
trigger: Snippet<[{ toggle: () => void; open: boolean }]>;
}
let {
user,
accountUrl,
panelClass = 'absolute top-full right-0 mt-2 w-52 overflow-hidden',
extraItems,
trigger
}: Props = $props();
let { user, accountUrl, width = 'sm', extraItems, trigger }: Props = $props();
const WIDTH: Record<'sm' | 'md', string> = { sm: 'w-52', md: 'w-56' };
let open = $state(false);
let menuRef: HTMLDivElement | undefined = $state();
function toggle() {
open = !open;
@@ -37,16 +33,18 @@
open = false;
}
function onDocumentClick(event: MouseEvent) {
if (menuRef && !menuRef.contains(event.target as Node)) open = false;
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') close();
}
</script>
<div class="relative" bind:this={menuRef}>
<svelte:window onkeydown={onWindowKeydown} />
<div class="relative" use:clickOutside={close}>
{@render trigger({ toggle, open })}
{#if open}
<GlassMenu class={panelClass}>
<GlassMenu class="absolute top-full right-0 z-(--z-menu) mt-2 overflow-hidden {WIDTH[width]}">
<MenuItem icon={User} label="Ver perfil" href="/u/{user.sub}" onclick={close} />
{@render extraItems?.({ close })}
{#if accountUrl}
@@ -69,5 +67,3 @@
</GlassMenu>
{/if}
</div>
<svelte:window onclick={onDocumentClick} />
@@ -1,57 +0,0 @@
<script lang="ts">
import MediaCard from './MediaCard.svelte';
import Artwork from './Artwork.svelte';
interface Props {
id: string;
title: string;
href?: string;
subtitle?: string;
typeLabel?: string;
releaseYear?: number;
trackCount?: number;
trackIds?: (string | number)[];
coverSrc?: string | null;
index?: number;
onContextMenu?: (event: MouseEvent) => void;
}
let {
id,
title,
href,
subtitle,
typeLabel,
releaseYear,
trackCount,
trackIds = [],
coverSrc,
index = 0,
onContextMenu
}: Props = $props();
const meta = $derived(
[
typeLabel,
subtitle,
releaseYear ? String(releaseYear) : undefined,
trackCount === undefined
? undefined
: `${trackCount} ${trackCount === 1 ? 'canción' : 'canciones'}`
]
.filter((part) => !!part)
.join(' · ')
);
</script>
<MediaCard
href={href ?? `/albums/${id}`}
{title}
subtitle={meta}
{index}
oncontextmenu={onContextMenu}
>
{#snippet art(artClass)}
<Artwork src={coverSrc} {trackIds} size="fill" alt={title} class={artClass} />
{/snippet}
</MediaCard>
@@ -1,13 +1,10 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Disc3 from '@lucide/svelte/icons/disc-3';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import CoverForm from './CoverForm.svelte';
import Field from './Field.svelte';
import Input from './Input.svelte';
import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte';
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
interface Props {
@@ -43,81 +40,55 @@
let title = $state(untrack(() => initialTitle));
let description = $state(untrack(() => initialDescription));
let releaseYear = $state(untrack(() => (initialReleaseYear ? String(initialReleaseYear) : '')));
let submitting = $state(false);
const latestYear = new Date().getFullYear() + 1;
</script>
<form
method="POST"
<CoverForm
{action}
enctype="multipart/form-data"
use:enhance={() => {
submitting = true;
return async ({ update, result }) => {
await update({ reset: false });
submitting = false;
if (result.type === 'success') onSuccess?.();
};
}}
class="mt-5 w-full max-w-2xl space-y-5"
{coverFallbackUrl}
{requireCover}
coverIcon={Disc3}
{formMessage}
{submitLabel}
{submittingLabel}
canSubmit={title.trim() !== ''}
{onCancel}
{onSuccess}
>
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
<Field>
<ImageDropzone
name="cover"
fallbackUrl={coverFallbackUrl}
required={requireCover}
icon={Disc3}
gradient
class="h-36 w-36 rounded-2xl"
{#snippet fields()}
<Field label="Título" for="album-title">
<Input
id="album-title"
name="title"
maxlength={200}
required
bind:value={title}
placeholder={titlePlaceholder}
/>
</Field>
<div class="flex flex-1 flex-col gap-4">
<Field label="Título" for="album-title">
<Input
id="album-title"
name="title"
maxlength={200}
required
bind:value={title}
placeholder={titlePlaceholder}
/>
</Field>
<Field label="Año de publicación" for="album-year">
<Input
id="album-year"
name="releaseYear"
type="number"
min={ALBUM_EARLIEST_YEAR}
max={latestYear}
bind:value={releaseYear}
placeholder={String(new Date().getFullYear())}
/>
</Field>
</div>
</div>
<Field label="Descripción" for="album-description">
<Textarea
id="album-description"
name="description"
maxlength={256}
bind:value={description}
placeholder="¿De qué va este álbum?"
/>
</Field>
{#if formMessage}
<Alert tone="danger">{formMessage}</Alert>
{/if}
<div class="flex items-center justify-end gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || title.trim() === ''}>
{submitting ? submittingLabel : submitLabel}
</Button>
</div>
</form>
<Field label="Año de publicación" for="album-year">
<Input
id="album-year"
name="releaseYear"
type="number"
min={ALBUM_EARLIEST_YEAR}
max={latestYear}
bind:value={releaseYear}
placeholder={String(new Date().getFullYear())}
/>
</Field>
{/snippet}
{#snippet extra()}
<Field label="Descripción" for="album-description">
<Textarea
id="album-description"
name="description"
maxlength={256}
bind:value={description}
placeholder="¿De qué va este álbum?"
/>
</Field>
{/snippet}
</CoverForm>
@@ -58,7 +58,7 @@
{:else}
<button {type} disabled={disabled || loading} {onclick} class={classes}>
{#if loading}
<LoaderCircle class="h-4 w-4 animate-spin" />
<LoaderCircle class="size-4 animate-spin" />
{/if}
{@render children()}
</button>
@@ -13,15 +13,15 @@
</script>
<label class="flex items-center gap-3 text-sm text-fg-2 {klass}">
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
<span class="relative inline-grid size-5 shrink-0 place-items-center">
<input
type="checkbox"
{name}
bind:checked
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
class="peer size-5 cursor-pointer appearance-none rounded-tag border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
/>
<Check
class="pointer-events-none absolute h-3.5 w-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
class="pointer-events-none absolute size-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/>
</span>
@@ -0,0 +1,36 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
selected?: boolean;
count?: number;
href?: string;
onclick?: () => void;
children: Snippet;
}
let { selected = false, count, href, onclick, children }: Props = $props();
const classes = $derived(
href
? 'rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg'
: `flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${
selected
? 'bg-cta-strong text-ink'
: 'bg-surface-2 text-fg-2 hover:bg-surface-hover hover:text-fg'
}`
);
</script>
{#snippet content()}
{@render children()}
{#if count !== undefined}
<span class="tabular-nums {selected ? 'opacity-55' : 'text-muted'}">{count}</span>
{/if}
{/snippet}
{#if href}
<a {href} class={classes}>{@render content()}</a>
{:else}
<button type="button" {onclick} class={classes}>{@render content()}</button>
{/if}
@@ -1,45 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
eyebrow: string;
title: string;
description?: string;
meta?: string;
align?: 'center' | 'end';
cover: Snippet;
actions?: Snippet;
}
let { eyebrow, title, description, meta, align = 'end', cover, actions }: Props = $props();
</script>
<div
class="flex flex-col gap-5 sm:flex-row sm:gap-6 {align === 'end'
? 'mt-3 sm:mt-4 sm:items-end'
: 'sm:items-center'}"
>
{@render cover()}
<div class="min-w-0 flex-1">
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">{eyebrow}</p>
<h1
class="{align === 'end'
? 'mt-1.5'
: 'mt-3'} font-display text-3xl font-medium tracking-tight wrap-break-word text-fg sm:text-4xl"
>
{title}
</h1>
{#if description}
<p class="mt-3 max-w-2xl text-sm text-fg-2 sm:text-base">{description}</p>
{/if}
{#if meta}
<p class="mt-3 text-sm text-fg-2">{meta}</p>
{/if}
</div>
</div>
{#if actions}
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
{@render actions()}
</div>
{/if}
@@ -0,0 +1,26 @@
<script lang="ts">
import Modal from './Modal.svelte';
import Button from './Button.svelte';
interface Props {
open: boolean;
onClose: () => void;
title: string;
description: string;
confirmLabel: string;
action: string;
}
let { open, onClose, title, description, confirmLabel, action }: Props = $props();
</script>
<Modal {open} {onClose} maxWidth="max-w-sm">
<h2 class="text-display-3 text-fg">{title}</h2>
<p class="mt-2.5 text-body text-fg-2">{description}</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
<form method="POST" {action}>
<Button type="submit" variant="danger">{confirmLabel}</Button>
</form>
</div>
</Modal>
@@ -0,0 +1,112 @@
<script lang="ts" module>
import { MENU_WIDTH, SUBMENU_WIDTH } from '$lib/config';
export interface MenuPosition {
x: number;
y: number;
openLeft: boolean;
}
export function contextMenuPosition(event: MouseEvent): MenuPosition {
event.preventDefault();
return {
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import type { LucideIcon } from '@lucide/svelte';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import MediaIdentity from './MediaIdentity.svelte';
import GlassMenu from './GlassMenu.svelte';
import MenuItem from './MenuItem.svelte';
interface Props {
x: number;
y: number;
openLeft: boolean;
onClose: () => void;
items: { icon: LucideIcon; label: string; onclick: () => void }[];
playlistAction?: { action: string; fields: Record<string, string>; label: string };
playlists?: { id: string; name: string }[];
}
let { x, y, openLeft, onClose, items, playlistAction, playlists = [] }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div
class="fixed inset-0 z-(--z-backdrop)"
role="presentation"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
></div>
<GlassMenu class="fixed z-(--z-menu) w-(--mf-menu-w)" style="left:{x}px; top:{y}px;">
{#each items as item (item.label)}
<MenuItem icon={item.icon} label={item.label} onclick={item.onclick} />
{/each}
{#if playlistAction}
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
>
<span class="flex-1 cursor-default">{playlistAction.label}</span>
<ChevronRight class="size-icon-sm shrink-0 text-fg-3" />
</div>
<div
class="invisible absolute top-0 z-(--z-submenu) opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div
class="w-(--mf-submenu-w) overflow-hidden rounded-panel border border-line bg-elevated p-2 max-sm:w-full"
>
<div class="max-h-80 overflow-y-auto">
{#if playlists.length === 0}
<p class="px-2 py-2.5 text-sm text-fg-3">Todavía no tienes playlists.</p>
{/if}
{#each playlists as playlist (playlist.id)}
<form
method="POST"
action={playlistAction.action}
use:enhance={() =>
({ update }) => {
onClose();
return update({ reset: false, invalidateAll: false });
}}
>
<input type="hidden" name="playlistId" value={playlist.id} />
{#each Object.entries(playlistAction.fields) as [name, value] (name)}
<input type="hidden" {name} {value} />
{/each}
<button
type="submit"
class="flex w-full items-center rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
>
<MediaIdentity
title={playlist.name}
coverSrc="/api/playlists/{playlist.id}/cover?size=small"
size="xs"
titleClass="text-fg-2"
/>
</button>
</form>
{/each}
</div>
</div>
</div>
</div>
{/if}
</GlassMenu>
@@ -0,0 +1,94 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { enhance } from '$app/forms';
import type { LucideIcon } from '@lucide/svelte';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte';
import Alert from './Alert.svelte';
interface Props {
action: string;
coverFallbackUrl?: string;
coverIcon: LucideIcon;
requireCover?: boolean;
formMessage?: string;
submitLabel: string;
submittingLabel: string;
canSubmit: boolean;
helperText?: string;
onCancel: () => void;
onSuccess?: () => void;
fields: Snippet;
extra?: Snippet;
}
let {
action,
coverFallbackUrl,
coverIcon,
requireCover = false,
formMessage,
submitLabel,
submittingLabel,
canSubmit,
helperText,
onCancel,
onSuccess,
fields,
extra
}: Props = $props();
let submitting = $state(false);
</script>
<form
method="POST"
{action}
enctype="multipart/form-data"
use:enhance={() => {
submitting = true;
return async ({ update, result }) => {
await update({ reset: false });
submitting = false;
if (result.type === 'success' || result.type === 'redirect') onSuccess?.();
};
}}
class="mt-5 w-full max-w-4xl space-y-5"
>
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
<Field>
<ImageDropzone
name="cover"
fallbackUrl={coverFallbackUrl}
required={requireCover}
icon={coverIcon}
gradient
size="hero"
/>
</Field>
<div class="flex flex-1 flex-col gap-4">
{@render fields()}
</div>
</div>
{@render extra?.()}
{#if formMessage}
<Alert tone="danger">{formMessage}</Alert>
{/if}
<div class="flex items-center justify-between gap-4">
{#if helperText}
<p class="text-sm text-muted">{helperText}</p>
{:else}
<span></span>
{/if}
<div class="flex shrink-0 items-center gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || !canSubmit}>
{submitting ? submittingLabel : submitLabel}
</Button>
</div>
</div>
</form>
@@ -15,10 +15,10 @@
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
{#if Icon}
<Icon class="h-9 w-9 text-fg-2" strokeWidth={1.25} />
<Icon class="size-icon-xl text-fg-2" strokeWidth={1.25} />
{/if}
{#if title}
<h3 class="mt-6 font-display text-xl font-medium tracking-tight text-fg">
<h3 class="mt-6 text-display-3 text-fg">
{title}
</h3>
{/if}
+31 -11
View File
@@ -3,21 +3,41 @@
size?: number;
barWidth?: number;
paused?: boolean;
overlay?: boolean;
class?: string;
}
let { size = 13, barWidth = 2, paused = false, class: klass = '' }: Props = $props();
let {
size = 13,
barWidth = 2,
paused = false,
overlay = false,
class: klass = ''
}: Props = $props();
const delays = [0, 0.3, 0.6];
</script>
<div class="flex items-end justify-center gap-0.5 {klass}" style="height:{size}px">
{#each delays as delay (delay)}
<span
class="rounded-full bg-accent"
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
{#if overlay}
<div class="absolute inset-0 flex items-end justify-center gap-1 bg-scrim/45 pb-2.5 {klass}">
{#each delays as delay (delay)}
<span
class="h-3.5 w-1 origin-bottom rounded-full bg-on-art"
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
{:else}
<div class="flex items-end justify-center gap-0.5 {klass}" style="height:{size}px">
{#each delays as delay (delay)}
<span
class="rounded-full bg-accent"
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
{/if}
@@ -7,7 +7,7 @@
</script>
<span
class="grid h-4 w-4 shrink-0 place-items-center rounded-sm bg-fg-3 text-xs leading-4 font-bold text-bg {klass}"
class="grid size-4 shrink-0 place-items-center rounded-tag bg-fg-3 text-xs leading-4 font-bold text-bg {klass}"
title="Contenido explícito"
aria-label="Contenido explícito">E</span
>
@@ -8,6 +8,8 @@
required?: boolean;
icon?: LucideIcon;
gradient?: boolean;
size?: 'default' | 'hero';
onselect?: (file: File) => void;
class?: string;
}
@@ -17,9 +19,16 @@
required = false,
icon: Icon = ImageIcon,
gradient = false,
class: klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control'
size = 'default',
onselect,
class: klass = ''
}: Props = $props();
const SIZE: Record<'default' | 'hero', string> = {
default: 'size-40 sm:size-52 rounded-control',
hero: 'size-cover-hero rounded-art-lg'
};
let preview = $state('');
let fallbackFailed = $state(false);
@@ -27,14 +36,16 @@
function onInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) preview = URL.createObjectURL(file);
if (!file) return;
preview = URL.createObjectURL(file);
onselect?.(file);
}
</script>
<label
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {gradient
? ''
: 'border border-line bg-surface hover:border-accent/50'} {klass}"
: 'border border-line bg-surface hover:border-accent/50'} {SIZE[size]} {klass}"
style={gradient && !hasImage ? 'background:var(--mf-cover-grad)' : undefined}
>
<input
@@ -57,6 +68,6 @@
class="h-full w-full object-cover"
/>
{:else}
<Icon class="h-9 w-9 {gradient ? 'text-on-art/70' : 'text-muted'}" strokeWidth={1.3} />
<Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-muted'}" strokeWidth={1.3} />
{/if}
</label>
@@ -34,6 +34,6 @@
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
{#if loading}
<LoaderCircle class="h-5 w-5 animate-spin text-muted" />
<LoaderCircle class="size-5 animate-spin text-muted" />
{/if}
</div>
@@ -37,7 +37,7 @@
<div class="relative w-full">
{#if Icon}
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
<Icon class="h-5 w-5" />
<Icon class="size-icon-lg" />
</span>
{/if}
<input
@@ -52,8 +52,6 @@
{autocomplete}
{oninput}
bind:value
class="w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {Icon
? 'pr-4 pl-11'
: 'px-4'} {klass}"
class="field-control {Icon ? 'pr-4 pl-11' : 'px-4'} {klass}"
/>
</div>
@@ -0,0 +1,90 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import MediaIdentity from './MediaIdentity.svelte';
import { pressable } from '$lib/actions/pressable';
interface Props {
href?: string;
onclick?: () => void;
oncontextmenu?: (event: MouseEvent) => void;
active?: boolean;
size?: 'xs' | 'sm' | 'lg';
variant?: 'row' | 'card';
title: string;
subtitle?: string | null;
subtitleHref?: string | number | null;
explicit?: boolean;
trackId?: string | number;
trackIds?: (string | number)[];
coverSrc?: string | null;
art?: Snippet;
overlay?: Snippet;
leading?: Snippet;
trailing?: Snippet;
class?: string;
style?: string;
}
let {
href,
onclick,
oncontextmenu,
active = false,
size = 'sm',
variant = 'row',
title,
subtitle,
subtitleHref,
explicit = false,
trackId,
trackIds,
coverSrc,
art,
overlay,
leading,
trailing,
class: klass = '',
style
}: Props = $props();
const classes = $derived(
`flex items-center gap-3 text-left transition-colors ${
variant === 'card' ? 'rounded-art p-2 pr-3.5' : 'rounded-control p-2'
} ${active ? 'bg-accent-tint' : 'hover:bg-hover'} ${klass}`
);
</script>
{#snippet content()}
{@render leading?.()}
<MediaIdentity
{title}
artist={subtitle}
ownerUserId={subtitleHref}
{trackId}
{trackIds}
{coverSrc}
{size}
{explicit}
{active}
{art}
{overlay}
/>
{@render trailing?.()}
{/snippet}
{#if href}
<a {href} {oncontextmenu} class={classes} {style}>
{@render content()}
</a>
{:else}
<div
role="button"
tabindex="0"
use:pressable={onclick ?? (() => {})}
{oncontextmenu}
class={classes}
{style}
>
{@render content()}
</div>
{/if}
@@ -0,0 +1,22 @@
<script lang="ts">
interface Props {
size?: 'sm' | 'lg';
}
let { size = 'sm' }: Props = $props();
const MARK: Record<'sm' | 'lg', string> = {
sm: 'size-5.5 rounded-thumb',
lg: 'size-8 rounded-control'
};
const TEXT: Record<'sm' | 'lg', string> = {
sm: 'font-display text-lg font-semibold tracking-tight text-fg',
lg: 'font-display text-[clamp(1.5rem,3vw,2.5rem)] font-semibold tracking-tight'
};
</script>
<span class="flex items-center gap-3">
<span class="{MARK[size]} bg-[image:var(--mf-logo-grad)]"></span>
<span class={TEXT[size]}>Musify</span>
</span>
@@ -1,30 +1,59 @@
<script lang="ts" module>
export const mediaCardArt =
'aspect-square w-full rounded-art transition duration-300 ease-out';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import Artwork from './Artwork.svelte';
import PlayButton from './PlayButton.svelte';
interface Props {
href: string;
title: string;
subtitle?: string | null;
index?: number;
src?: string | null;
trackIds?: (string | number)[];
onPlay?: (event: MouseEvent) => void;
art?: Snippet;
oncontextmenu?: (event: MouseEvent) => void;
art: Snippet<[string]>;
index?: number;
}
let { href, title, subtitle, index = 0, oncontextmenu, art }: Props = $props();
let {
href,
title,
subtitle,
src,
trackIds = [],
onPlay,
art,
oncontextmenu,
index = 0
}: Props = $props();
</script>
<a
{href}
class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms"
{oncontextmenu}
>
{@render art(mediaCardArt)}
<a {href} class="group animate-enter min-w-0 text-left" style="--i:{index}" {oncontextmenu}>
<div class="relative">
{#if art}
{@render art()}
{:else}
<Artwork
{src}
{trackIds}
size="fill"
alt={title}
class="rounded-art transition duration-300 group-hover:scale-105"
/>
{/if}
{#if onPlay}
<div
class="pointer-events-none absolute inset-0 rounded-art bg-linear-to-b from-transparent from-45% to-scrim/40"
></div>
<PlayButton
variant="glass"
size="sm"
onclick={onPlay}
label="Reproducir {title}"
class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity duration-150 group-hover:opacity-100"
/>
{/if}
</div>
<div class="mt-2.5 truncate text-sm text-fg">{title}</div>
{#if subtitle}
<div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div>
@@ -1,27 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
as?: 'ul' | 'div';
min?: string;
minMobile?: string;
class?: string;
children: Snippet;
}
let {
as = 'div',
min = '165px',
minMobile = '150px',
class: klass = '',
children
}: Props = $props();
</script>
<svelte:element
this={as}
class="grid media-grid gap-3 sm:gap-4 {klass}"
style="--mf-grid-min:{min}; --mf-grid-min-mobile:{minMobile}"
>
{@render children()}
</svelte:element>
@@ -5,22 +5,25 @@
import ArtistLink from './ArtistLink.svelte';
interface Props {
trackId: string | number;
trackId?: string | number;
trackIds?: (string | number)[];
title: string;
artist?: string | null;
ownerUserId?: string | number | null;
coverSrc?: string | null;
size?: 'sm' | 'lg';
size?: 'xs' | 'sm' | 'lg';
explicit?: boolean;
active?: boolean;
titleClass?: string;
onClick: () => void;
class?: string;
art?: Snippet;
overlay?: Snippet;
badge?: Snippet;
}
let {
trackId,
trackIds,
title,
artist,
ownerUserId,
@@ -29,33 +32,27 @@
explicit = false,
active = false,
titleClass = '',
onClick,
class: klass = '',
art,
overlay,
badge
}: Props = $props();
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick();
}
function onclick(event: MouseEvent) {
event.stopPropagation();
onClick();
}
</script>
<div
role="button"
tabindex="0"
{onclick}
onkeydown={onKeydown}
class="flex min-w-0 items-center gap-3.5 text-left"
>
<Artwork trackIds={[trackId]} src={coverSrc} {size} alt={title} class="shrink-0">
{@render overlay?.()}
</Artwork>
<div class="flex min-w-0 flex-1 items-center gap-3.5 {klass}">
{#if art}
{@render art()}
{:else if trackId !== undefined || coverSrc || trackIds?.length}
<Artwork
trackIds={trackIds ?? (trackId !== undefined ? [trackId] : [])}
src={coverSrc}
{size}
alt={title}
class="shrink-0"
>
{@render overlay?.()}
</Artwork>
{/if}
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
{#if explicit}
@@ -37,12 +37,12 @@
{onclick}
class={classes}
>
<Icon class="h-4.5 w-4.5 shrink-0" strokeWidth={1.8} />
<Icon class="size-icon-md shrink-0" />
{label}
</a>
{:else}
<button {type} {onclick} class={classes}>
<Icon class="h-4.5 w-4.5 shrink-0" strokeWidth={1.8} />
<Icon class="size-icon-md shrink-0" />
{label}
</button>
{/if}
@@ -1,53 +0,0 @@
<script lang="ts">
import Artwork from './Artwork.svelte';
import PlayButton from './PlayButton.svelte';
import { player } from '$lib/player/player.svelte';
import { queueItemForTarget } from '$lib/tracks';
import { mixItemTarget, type Mix } from '$lib/mixes';
interface Props {
mix: Mix;
index?: number;
class?: string;
}
let { mix, index = 0, class: klass = '' }: Props = $props();
const trackIds = $derived(mix.items.map((item) => item.trackId));
function play(event: MouseEvent) {
event.preventDefault();
const items = mix.items.map((item) => queueItemForTarget(mixItemTarget(item)));
player.playQueue(items, 0);
}
</script>
<a
href="/mixes/{mix.id}"
class="group/tile animate-enter block cursor-pointer {klass}"
style="--i:{index}"
>
<Artwork
{trackIds}
size="fill"
alt={mix.title}
class="rounded-art transition duration-300 group-hover/tile:scale-105"
>
<div
class="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent from-45% to-scrim/40"
></div>
<PlayButton
variant="glass"
size="sm"
onclick={play}
label="Reproducir {mix.title}"
class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity duration-150 group-hover/tile:opacity-100"
/>
</Artwork>
<div class="mt-3.25 truncate text-sm font-semibold text-fg">
{mix.title}
</div>
{#if mix.subtitle}
<div class="mt-1 truncate text-xs text-fg-3">{mix.subtitle}</div>
{/if}
</a>
@@ -70,14 +70,13 @@
{#if open}
<div
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm"
class="animate-fade fixed inset-0 z-(--z-modal) grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm"
role="presentation"
onclick={onBackdropClick}
>
<div
bind:this={panel}
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 backdrop-blur-xl transition-[background] duration-500 outline-none sm:p-6 {panelClass}"
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
class="glass-panel animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
role="dialog"
aria-modal="true"
aria-label={title}
@@ -87,16 +86,16 @@
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
{#if eyebrow}
<p class="mb-2.5 text-xs font-medium tracking-widest text-muted uppercase">
<p class="mb-2.5 text-eyebrow text-muted">
{eyebrow}
</p>
{/if}
<h2 class="font-display text-xl font-medium tracking-[-0.02em] text-fg">
<h2 class="text-display-3 text-fg">
{title}
</h2>
</div>
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
<X class="h-4 w-4" />
<X class="size-4" />
</IconButton>
</div>
{/if}
@@ -1,20 +0,0 @@
<script lang="ts">
interface Props {
paused?: boolean;
}
let { paused = false }: Props = $props();
const delays = [0, 0.3, 0.6];
</script>
<div class="absolute inset-0 flex items-end justify-center gap-0.75 bg-scrim/45 pb-2.5">
{#each delays as delay (delay)}
<span
class="h-3.5 w-0.75 origin-bottom rounded-full bg-on-art"
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
@@ -3,24 +3,59 @@
interface Props {
title: string;
subtitle?: string;
class?: string;
eyebrow?: string;
description?: string;
meta?: string;
cover?: Snippet;
actions?: Snippet;
align?: 'center' | 'end';
class?: string;
}
let { title, subtitle, class: klass = '', actions }: Props = $props();
let {
title,
eyebrow,
description,
meta,
cover,
actions,
align = 'end',
class: klass = ''
}: Props = $props();
</script>
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
<div class="min-w-0">
<h1 class="font-display text-3xl font-medium tracking-tight text-fg sm:text-4xl">
{title}
</h1>
{#if subtitle}
<p class="mt-2 text-sm text-fg-2">{subtitle}</p>
<div class="mb-7 sm:mb-8 {klass}">
<div
class="flex flex-col gap-5 sm:flex-row sm:gap-6 {cover
? align === 'end'
? 'sm:items-end'
: 'sm:items-center'
: 'sm:items-end sm:justify-between'}"
>
{#if cover}
{@render cover()}
{/if}
<div class="min-w-0 flex-1">
{#if eyebrow}
<p class="text-eyebrow text-fg-2">{eyebrow}</p>
{/if}
<h1 class="text-display-1 wrap-break-word text-fg {eyebrow ? 'mt-1.5' : ''}">
{title}
</h1>
{#if description}
<p class="mt-3 max-w-2xl text-body text-fg-2">{description}</p>
{/if}
{#if meta}
<p class="mt-3 text-sm text-fg-2">{meta}</p>
{/if}
</div>
{#if actions && !cover}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{/if}
</div>
{#if actions}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{#if actions && cover}
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
{@render actions()}
</div>
{/if}
</div>
@@ -1,29 +0,0 @@
<script lang="ts">
import MediaCard from './MediaCard.svelte';
import Artwork from './Artwork.svelte';
interface Props {
id: string;
name: string;
trackCount: number;
trackIds: (string | number)[];
updatedAt: string;
index?: number;
}
let { id, name, trackCount, trackIds, updatedAt, index = 0 }: Props = $props();
const subtitle = $derived(`${trackCount} ${trackCount === 1 ? 'Canción' : 'Canciones'}`);
</script>
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
{#snippet art(artClass)}
<Artwork
src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
{trackIds}
size="fill"
alt={name}
class={artClass}
/>
{/snippet}
</MediaCard>
@@ -1,13 +1,11 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Music from '@lucide/svelte/icons/music';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import CoverForm from './CoverForm.svelte';
import Field from './Field.svelte';
import Input from './Input.svelte';
import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte';
import SegmentedControl from './SegmentedControl.svelte';
const NAME_MAX_LENGTH = 60;
const DESCRIPTION_MAX_LENGTH = 300;
@@ -44,118 +42,65 @@
let name = $state(untrack(() => initialName));
let description = $state(untrack(() => initialDescription));
let submitting = $state(false);
let visibility = $state(untrack(() => initialVisibility));
</script>
<form
method="POST"
<CoverForm
{action}
enctype="multipart/form-data"
use:enhance={() => {
submitting = true;
return async ({ update, result }) => {
await update({ reset: false });
submitting = false;
if (result.type === 'success' || result.type === 'redirect') onSuccess?.();
};
}}
class="mt-5 w-full max-w-4xl space-y-5"
{coverFallbackUrl}
coverIcon={Music}
{formMessage}
{submitLabel}
{submittingLabel}
canSubmit={name.trim() !== ''}
{helperText}
{onCancel}
{onSuccess}
>
<input type="hidden" name="visibility" value={visibility} />
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
<Field>
<ImageDropzone
name="cover"
fallbackUrl={coverFallbackUrl}
icon={Music}
gradient
class="h-36 w-36 rounded-2xl"
{#snippet fields()}
<input type="hidden" name="visibility" value={visibility} />
<Field label="Nombre" for="playlist-name">
{#snippet hint()}
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
{/snippet}
<Input
id="playlist-name"
name="name"
maxlength={NAME_MAX_LENGTH}
required
bind:value={name}
placeholder={namePlaceholder}
/>
</Field>
<div class="flex flex-1 flex-col gap-2">
<Field label="Nombre" for="playlist-name">
{#snippet hint()}
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
{/snippet}
<Input
id="playlist-name"
name="name"
maxlength={NAME_MAX_LENGTH}
required
bind:value={name}
placeholder={namePlaceholder}
/>
</Field>
<Field label="Descripción" for="playlist-description" class="min-h-0 flex-1">
<Textarea
id="playlist-description"
name="description"
maxlength={DESCRIPTION_MAX_LENGTH}
bind:value={description}
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
class="flex-1"
/>
</Field>
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-art bg-surface px-4 py-3.5">
<div class="min-w-0">
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
<div class="mt-0.5 text-xs text-muted">
{visibility === 'private'
? 'Solo tú la ves en tu biblioteca'
: 'Cualquiera con el enlace puede verla'}
<Field label="Descripción" for="playlist-description" class="min-h-0 flex-1">
<Textarea
id="playlist-description"
name="description"
maxlength={DESCRIPTION_MAX_LENGTH}
bind:value={description}
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
class="flex-1"
/>
</Field>
{/snippet}
{#snippet extra()}
<div class="flex items-center justify-between gap-4 rounded-art bg-surface px-4 py-3.5">
<div class="min-w-0">
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
<div class="mt-0.5 text-xs text-muted">
{visibility === 'private'
? 'Solo tú la ves en tu biblioteca'
: 'Cualquiera con el enlace puede verla'}
</div>
</div>
<SegmentedControl
bind:value={visibility}
options={[
{ value: 'private', label: 'Privada' },
{ value: 'public', label: 'Pública' }
]}
/>
</div>
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
<div
class="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-fg transition-transform duration-300 ease-out {visibility ===
'public'
? 'translate-x-full'
: ''}"
></div>
<button
type="button"
onclick={() => (visibility = 'private')}
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-medium transition-colors {visibility ===
'private'
? 'text-ink'
: 'text-muted hover:text-fg-2'}"
>
Privada
</button>
<button
type="button"
onclick={() => (visibility = 'public')}
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-medium transition-colors {visibility ===
'public'
? 'text-ink'
: 'text-muted hover:text-fg-2'}"
>
Pública
</button>
</div>
</div>
{#if formMessage}
<Alert tone="danger">{formMessage}</Alert>
{/if}
<div class="flex items-center justify-between gap-4">
{#if helperText}
<p class="text-sm text-muted">{helperText}</p>
{:else}
<span></span>
{/if}
<div class="flex shrink-0 items-center gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || name.trim() === ''}>
{submitting ? submittingLabel : submitLabel}
</Button>
</div>
</div>
</form>
{/snippet}
</CoverForm>
@@ -1,65 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
interface Props {
title: string;
subtitle?: string | null;
meta?: string;
explicit?: boolean;
href?: string;
onclick?: () => void;
oncontextmenu?: (event: MouseEvent) => void;
art: Snippet<[string]>;
}
let {
title,
subtitle,
meta,
explicit = false,
href,
onclick,
oncontextmenu,
art
}: Props = $props();
const artClass = 'relative shrink-0';
</script>
{#snippet content()}
{@render art(artClass)}
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.75">
{#if explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-sm font-medium text-fg">{title}</span>
</div>
{#if subtitle}
<div class="mt-0.75 truncate text-xs text-fg-3">{subtitle}</div>
{/if}
</div>
{#if meta}
<span class="shrink-0 text-xs text-muted tabular-nums">{meta}</span>
{/if}
{/snippet}
{#if href}
<a
{href}
{oncontextmenu}
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface"
>
{@render content()}
</a>
{:else}
<button
type="button"
{onclick}
{oncontextmenu}
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
>
{@render content()}
</button>
{/if}
@@ -1,29 +1,64 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { plural } from '$lib/format';
interface Props {
title: string;
subtitle?: string;
count?: number;
href?: string;
linkLabel?: string;
class?: string;
actions?: Snippet;
}
let { title, subtitle, class: klass = '', actions }: Props = $props();
let {
title,
subtitle,
count,
href,
linkLabel = 'Ver todas',
class: klass = '',
actions
}: Props = $props();
const hasActions = $derived(!!actions || count !== undefined || !!href);
</script>
{#snippet heading()}
<h2 class="font-display text-lg font-medium tracking-tight text-fg">
<h2 class="text-display-4 text-fg">
{title}
</h2>
{#if subtitle}
<p class="mt-1.25 text-xs text-fg-3">{subtitle}</p>
<p class="mt-1 text-xs text-fg-3">{subtitle}</p>
{/if}
{/snippet}
{#if actions}
{#snippet autoActions()}
{#if count !== undefined}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{plural(count, 'resultado', 'resultados')}
</span>
{/if}
{#if href}
<a
{href}
class="shrink-0 text-xs font-medium tracking-wider text-fg-3 uppercase transition-colors hover:text-fg"
>
{linkLabel}
</a>
{/if}
{/snippet}
{#if hasActions}
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
<div>{@render heading()}</div>
{@render actions()}
{#if actions}
{@render actions()}
{:else}
{@render autoActions()}
{/if}
</div>
{:else}
<div class="mb-4 sm:mb-4.5 {klass}">
@@ -0,0 +1,34 @@
<script lang="ts" generics="T extends string">
interface Option<T> {
value: T;
label: string;
}
interface Props {
options: Option<T>[];
value: T;
}
let { options, value = $bindable() }: Props = $props();
const index = $derived(options.findIndex((option) => option.value === value));
</script>
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
<div
class="absolute inset-y-1 left-1 rounded-full bg-fg transition-transform duration-300 ease-out"
style="width:calc((100% - 0.5rem) / {options.length}); transform:translateX(calc({index} * 100%))"
></div>
{#each options as option (option.value)}
<button
type="button"
onclick={() => (value = option.value)}
class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value ===
option.value
? 'text-ink'
: 'text-muted hover:text-fg-2'}"
>
{option.label}
</button>
{/each}
</div>
@@ -0,0 +1,62 @@
<script lang="ts">
interface Props {
value: number;
max: number;
label: string;
oninput: (value: number) => void;
variant: 'seek' | 'volume';
}
let { value, max, label, oninput, variant }: Props = $props();
const percent = $derived(max > 0 ? (value / max) * 100 : 0);
function handleInput(event: Event) {
oninput(Number((event.currentTarget as HTMLInputElement).value));
}
</script>
<input
type="range"
min="0"
{max}
{value}
oninput={handleInput}
aria-label={label}
class="mf-slider mf-slider-{variant}"
style="background:linear-gradient(to right, var(--mf-text-2) {percent}%, var(--mf-track) {percent}%)"
/>
<style>
.mf-slider {
-webkit-appearance: none;
appearance: none;
height: var(--mf-range-h);
border-radius: 100px;
outline: none;
cursor: pointer;
}
.mf-slider-seek {
width: 100%;
}
.mf-slider-volume {
width: var(--mf-volume-w);
}
.mf-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--mf-range-thumb);
height: var(--mf-range-thumb);
border-radius: 50%;
background: var(--mf-text);
cursor: pointer;
}
.mf-slider::-moz-range-thumb {
width: var(--mf-range-thumb);
height: var(--mf-range-thumb);
border-radius: 50%;
background: var(--mf-text);
border: none;
cursor: pointer;
}
</style>
@@ -24,5 +24,4 @@
{placeholder}
{maxlength}
bind:value
class="min-h-0 w-full resize-none rounded-control bg-surface px-4 py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {klass}"
></textarea>
class="min-h-0 field-control resize-none px-4 {klass}"></textarea>
@@ -1,27 +0,0 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import EqBars from './EqBars.svelte';
interface Props {
index: number;
active: boolean;
playing: boolean;
onToggle: () => void;
}
let { index, active, playing, onToggle }: Props = $props();
</script>
<button
type="button"
onclick={onToggle}
aria-label={active && playing ? 'Pausar' : 'Reproducir'}
class="relative grid h-8 w-8 place-items-center rounded text-xs text-muted tabular-nums"
>
{#if active}
<EqBars paused={!playing} />
{:else}
<span class="group-hover:hidden">{index + 1}</span>
<Play class="hidden h-3.5 w-3.5 text-fg group-hover:block" fill="currentColor" />
{/if}
</button>
@@ -0,0 +1,200 @@
<script lang="ts" module>
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
export interface TrackListTrack {
id: string | number;
title: string;
artist?: string | null;
ownerUserId?: string | number | null;
isExplicit?: boolean;
explicit?: boolean;
duration?: number | string;
listensCount?: number | string;
addedAt?: string | number;
uploadedAt?: string | number;
}
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
added: { label: 'Añadida', width: '124px' },
uploaded: { label: 'Subida', width: '124px' },
plays: { label: 'Escuchas', width: '104px' }
};
const INDEX_W = { desktop: '32px', mobile: '28px' };
const TITLE_W = { desktop: 'minmax(180px,1fr)', mobile: 'minmax(120px,1fr)' };
const DURATION_W = { desktop: '104px', mobile: '52px' };
const ACTION_W = { desktop: '36px', mobile: '32px' };
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import type { LucideIcon } from '@lucide/svelte';
import Hash from '@lucide/svelte/icons/hash';
import Play from '@lucide/svelte/icons/play';
import { player } from '$lib/player/player.svelte';
import { fmtTime, fmtDate } from '$lib/format';
import { pressable } from '$lib/actions/pressable';
import MediaIdentity from './MediaIdentity.svelte';
import EqBars from './EqBars.svelte';
import IconButton from './IconButton.svelte';
interface RowAction {
action?: string;
onclick?: (track: TrackListTrack) => void;
icon: LucideIcon;
label: string;
}
interface Props {
tracks: TrackListTrack[];
columns?: TrackListColumn[];
index?: boolean;
onPlay: (index: number) => void;
rowAction?: RowAction;
oncontextmenu?: (event: MouseEvent, track: TrackListTrack, index: number) => void;
class?: string;
}
let {
tracks,
columns = [],
index = true,
onPlay,
rowAction,
oncontextmenu,
class: klass = ''
}: Props = $props();
const gridColumns = $derived(
[
index ? INDEX_W.desktop : '',
TITLE_W.desktop,
...columns.map((c) => COLUMN_PRESET[c].width),
DURATION_W.desktop,
rowAction ? ACTION_W.desktop : ''
]
.filter(Boolean)
.join(' ')
);
const gridColumnsMobile = $derived(
[
index ? INDEX_W.mobile : '',
TITLE_W.mobile,
DURATION_W.mobile,
rowAction ? ACTION_W.mobile : ''
]
.filter(Boolean)
.join(' ')
);
</script>
<div class="overflow-x-auto {klass}">
<div
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-3 sm:gap-8 sm:pr-6 sm:pl-3"
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
>
{#if index}
<Hash size={16} strokeWidth={1.5} />
{/if}
<span>Título</span>
{#each columns as column (column)}
<span class="hidden text-center sm:block">{COLUMN_PRESET[column].label}</span>
{/each}
<span class="hidden text-center sm:block">Duración</span>
{#if rowAction}
<span></span>
{/if}
</div>
<div class="mt-1 flex flex-col gap-0.5">
{#each tracks as track, i (track.id)}
{@const active = player.current.id === track.id}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active
? 'bg-accent-tint'
: 'hover:bg-hover'}"
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, track, i) : undefined}
>
{#if index}
<button
type="button"
onclick={() => onPlay(i)}
aria-label={active && player.playing ? 'Pausar' : 'Reproducir'}
class="relative grid size-8 place-items-center rounded-tag text-xs text-muted tabular-nums"
>
{#if active}
<EqBars paused={!player.playing} />
{:else}
<span class="group-hover:hidden">{i + 1}</span>
<Play class="hidden size-icon-sm text-fg group-hover:block" fill="currentColor" />
{/if}
</button>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
use:pressable={() => onPlay(i)}
onclick={(event) => event.stopPropagation()}
class="flex min-w-0 flex-1 text-left"
>
<MediaIdentity
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit ?? track.explicit ?? false}
{active}
>
{#snippet overlay()}
{#if !index && active}
<EqBars overlay paused={!player.playing} />
{/if}
{/snippet}
</MediaIdentity>
</div>
{#each columns as column (column)}
<span class="hidden truncate text-center text-xs text-muted tabular-nums sm:block">
{#if column === 'plays'}
{Number(track.listensCount ?? 0)}
{:else if column === 'added' && track.addedAt}
{fmtDate(track.addedAt)}
{:else if column === 'uploaded' && track.uploadedAt}
{fmtDate(track.uploadedAt)}
{/if}
</span>
{/each}
<span class="truncate text-center text-xs text-muted tabular-nums">
{fmtTime(Number(track.duration))}
</span>
{#if rowAction}
{#if rowAction.action}
<form
method="POST"
action={rowAction.action}
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label={rowAction.label} size="xs" revealOnHover>
<rowAction.icon class="size-icon-sm" />
</IconButton>
</form>
{:else}
<IconButton
label={rowAction.label}
size="xs"
revealOnHover
onclick={() => rowAction.onclick?.(track)}
>
<rowAction.icon class="size-icon-sm" />
</IconButton>
{/if}
{/if}
</div>
{/each}
</div>
</div>
@@ -1,14 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children: Snippet;
}
let { class: klass = '', children }: Props = $props();
</script>
<span class="truncate text-center text-xs text-muted tabular-nums {klass}">
{@render children()}
</span>
@@ -1,27 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Snippet } from 'svelte';
import { trackTableKey, type TrackTableContext } from './TrackTable.svelte';
interface Props {
active?: boolean;
class?: string;
oncontextmenu?: (event: MouseEvent) => void;
children: Snippet;
}
let { active = false, class: klass = '', oncontextmenu, children }: Props = $props();
const table = getContext<TrackTableContext>(trackTableKey);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active
? 'bg-accent-tint'
: 'hover:bg-hover'} {klass}"
style="--mf-track-cols:{table.columns}; --mf-track-cols-mobile:{table.columnsMobile}"
{oncontextmenu}
>
{@render children()}
</div>
@@ -1,79 +0,0 @@
<script lang="ts" module>
export const trackTableKey = Symbol('track-table');
export interface TrackTableContext {
readonly columns: string;
readonly columnsMobile: string;
}
export interface TrackMetaColumn {
label: string;
width: string;
}
</script>
<script lang="ts">
import { Hash } from '@lucide/svelte';
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
index?: boolean;
action?: boolean;
meta?: TrackMetaColumn[];
class?: string;
children: Snippet;
}
let { index = false, action = false, meta = [], class: klass = '', children }: Props = $props();
const columns = $derived(
[
index ? '32px' : '',
'minmax(180px,1fr)',
...meta.map((column) => column.width),
'104px',
action ? '36px' : ''
]
.filter((width) => width !== '')
.join(' ')
);
const columnsMobile = $derived(
[index ? '28px' : '', 'minmax(120px,1fr)', '52px', action ? '32px' : '']
.filter((width) => width !== '')
.join(' ')
);
setContext<TrackTableContext>(trackTableKey, {
get columns() {
return columns;
},
get columnsMobile() {
return columnsMobile;
}
});
</script>
<div class="overflow-x-auto {klass}">
<div
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-3 sm:gap-8 sm:pr-6 sm:pl-3"
style="--mf-track-cols:{columns}; --mf-track-cols-mobile:{columnsMobile}"
>
{#if index}
<Hash size={16} strokeWidth={1.5} />
{/if}
<span class="transition-colors">Título</span>
{#each meta as column (column.label)}
<span class="hidden text-center transition-colors sm:block">{column.label}</span>
{/each}
<span class="hidden text-center transition-colors sm:block">Duración</span>
{#if action}
<span></span>
{/if}
</div>
<div class="mt-1 flex flex-col gap-0.5">
{@render children()}
</div>
</div>
+2
View File
@@ -17,3 +17,5 @@ export const HOME_POPULAR_MAX = 8;
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
export const LIKED_TRACKS_PAGE_SIZE = 500;
export const EXPLORE_USERS_LIMIT = 12;
export const MENU_WIDTH = 240;
export const SUBMENU_WIDTH = 256;
+18 -1
View File
@@ -18,7 +18,24 @@ export function fmtDate(value: string | number | Date): string {
return dateFormatter.format(new Date(value));
}
export function plural(n: number, one: string, many: string): string {
return `${n} ${n === 1 ? one : many}`;
}
export function fmtPlays(count: number | string | undefined): string {
const value = Math.max(0, Math.floor(Number(count ?? 0)));
return `${value} ${value === 1 ? 'Reproducción' : 'Reproducciones'}`;
return plural(value, 'reproducción', 'reproducciones');
}
export function albumMeta(releaseYear?: number, trackCount?: number): string {
return [
releaseYear ? String(releaseYear) : undefined,
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
]
.filter((part): part is string => !!part)
.join(' · ');
}
export function playlistMeta(trackCount: number): string {
return plural(trackCount, 'canción', 'canciones');
}
+24
View File
@@ -0,0 +1,24 @@
// No hay sistema de tags/géneros todavía: esta lista es un placeholder de
// cliente y cada tile enlaza a una búsqueda normal por ese término.
export const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' },
'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' },
indie: { label: 'Indie', tagline: 'Voces fuera del radar' },
salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' },
'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' },
electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' },
jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' },
baladas: { label: 'Baladas', tagline: 'Para sentir con calma' },
trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' },
cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' },
'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' },
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' }
};
export const genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({
query,
hue: Math.round((i * 360) / all.length),
...info
}));
+25
View File
@@ -0,0 +1,25 @@
import { contextMenuPosition, type MenuPosition } from '$lib/components/ui/ContextMenu.svelte';
import { player, toQueueItem, type ApiTrackLike } from './player/player.svelte';
export type TrackMenuState = { track: ApiTrackLike } & MenuPosition;
export function createTrackMenu() {
let state = $state<TrackMenuState | null>(null);
return {
get state() {
return state;
},
open(event: MouseEvent, track: ApiTrackLike) {
state = { track, ...contextMenuPosition(event) };
},
close() {
state = null;
},
playNext() {
if (!state) return;
player.playNextItem(toQueueItem(state.track));
state = null;
}
};
}
+6 -8
View File
@@ -1,5 +1,5 @@
import type { components } from '$lib/api/schema';
import type { TrackTarget } from '$lib/tracks';
import type { ApiTrackLike } from '$lib/player/player.svelte';
export type Mix = components['schemas']['MixApplicationResponse'];
export type MixItem = components['schemas']['MixItemApplicationResponse'];
@@ -8,13 +8,11 @@ export function mixItemKey(item: MixItem): string {
return `mf:${item.trackId}`;
}
export function mixItemTarget(item: MixItem): TrackTarget {
export function mixItemTrack(item: MixItem): ApiTrackLike {
return {
track: {
id: item.trackId,
title: item.title,
artist: item.artist,
listensCount: item.listensCount
}
id: item.trackId,
title: item.title,
artist: item.artist,
listensCount: item.listensCount
};
}
+45
View File
@@ -1,4 +1,13 @@
import { afterNavigate } from '$app/navigation';
import type { LucideIcon } from '@lucide/svelte';
import Home from '@lucide/svelte/icons/house';
import Compass from '@lucide/svelte/icons/compass';
import ListMusic from '@lucide/svelte/icons/list-music';
import Heart from '@lucide/svelte/icons/heart';
import Disc from '@lucide/svelte/icons/disc-2';
import Folder from '@lucide/svelte/icons/folder';
import Upload from '@lucide/svelte/icons/upload';
import type { SessionUser } from './types';
const HISTORY_INDEX = 'sveltekit:history';
@@ -54,3 +63,39 @@ export function isSectionActive(href: string, pathname: string, section?: string
if (section !== undefined) return section === href;
return href === '/' ? pathname === '/' : pathname.startsWith(href);
}
export function searchHref(query: string): string {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
}
export interface AppNavLink {
href: string;
label: string;
icon: LucideIcon;
count?: number;
primary: boolean;
}
export interface AppNavCounts {
playlists?: number;
liked?: number;
}
export function appNavLinks(user: SessionUser | null, counts: AppNavCounts = {}): AppNavLink[] {
if (!user) return [{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true }];
return [
{ href: '/', label: 'Inicio', icon: Home, primary: true },
{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true },
{
href: '/playlists',
label: 'Playlists',
icon: ListMusic,
count: counts.playlists,
primary: true
},
{ href: '/liked', label: 'Me gusta', icon: Heart, count: counts.liked, primary: true },
{ href: '/albums', label: 'Mis álbumes', icon: Disc, primary: false },
{ href: '/library', label: 'Canciones subidas', icon: Folder, primary: false },
{ href: '/upload', label: 'Subir música', icon: Upload, primary: false }
];
}
+5 -18
View File
@@ -34,13 +34,10 @@ class LikedStore {
const items = toQueueItems(tracks);
const next: Record<string, LikedTrack> = {};
items.forEach((item, i) => {
next[String(item.id)] = {
id: String(item.id),
title: item.title,
artist: item.artist,
explicit: item.explicit,
ownerUserId: item.ownerUserId,
listensCount: item.listensCount,
const id = String(item.id);
next[id] = {
...item,
id,
duration: tracks[i].duration,
likedAt: new Date(tracks[i].createdAt).getTime()
};
@@ -58,17 +55,7 @@ class LikedStore {
const wasLiked = id in before;
const next = { ...before };
if (wasLiked) delete next[id];
else {
next[id] = {
id,
title: track.title,
artist: track.artist,
explicit: track.explicit,
ownerUserId: track.ownerUserId,
duration: track.duration,
likedAt: Date.now()
};
}
else next[id] = { ...track, id, likedAt: Date.now() };
this.entries = next;
try {
+32 -1
View File
@@ -1,6 +1,7 @@
import { browser } from '$app/environment';
import { DEFAULT_ACCENT } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette';
import { shuffle } from '$lib/collections';
export interface PlayerTrack {
id: string | number;
@@ -47,6 +48,36 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
}));
}
export function toQueueItem(track: ApiTrackLike): QueueItem {
return toQueueItems([track])[0];
}
export function trackFromQueueItem(item: QueueItem): ApiTrackLike {
return {
id: item.id,
title: item.title,
artist: item.artist,
isExplicit: item.explicit,
ownerUserId: item.ownerUserId,
listensCount: item.listensCount
};
}
export function isQueueCurrent(items: { id: string | number }[]): boolean {
return items.some((item) => item.id === player.current.id);
}
export function playAllOrToggle(items: QueueItem[]) {
if (items.length === 0) return;
if (isQueueCurrent(items)) player.toggle();
else player.playQueue(items, 0);
}
export function playShuffled(items: QueueItem[]) {
if (items.length === 0) return;
player.playQueue(shuffle(items), 0);
}
async function readErrorMessage(res: Response): Promise<string> {
try {
const body = (await res.json()) as { message?: string };
@@ -291,7 +322,7 @@ class PlayerState {
else this.playQueue(list, index);
}
addToQueue(item: QueueItem) {
playNextItem(item: QueueItem) {
this.playNext([item]);
}
+2 -15
View File
@@ -1,16 +1,3 @@
let open = $state(false);
import { createToggle } from '../toggle.svelte';
export const queuePanel = {
get open() {
return open;
},
toggle() {
open = !open;
},
close() {
open = false;
},
show() {
open = true;
}
};
export const queuePanel = createToggle();
+2 -12
View File
@@ -1,13 +1,3 @@
let open = $state(false);
import { createToggle } from './toggle.svelte';
export const createPlaylistModal = {
get open() {
return open;
},
show() {
open = true;
},
close() {
open = false;
}
};
export const createPlaylistModal = createToggle();
+2 -16
View File
@@ -12,28 +12,14 @@ export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): Que
const id = String(t.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({
id,
title: t.title,
artist: t.artist,
explicit: t.explicit,
ownerUserId: t.ownerUserId,
listensCount: t.listensCount
});
merged.push({ ...t, id });
}
for (const item of toQueueItems(history)) {
const id = String(item.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({
id,
title: item.title,
artist: item.artist,
explicit: item.explicit,
ownerUserId: item.ownerUserId,
listensCount: item.listensCount
});
merged.push({ ...item, id });
}
return merged.slice(0, limit);
+74
View File
@@ -0,0 +1,74 @@
export const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const;
export type SearchFilter = (typeof SEARCH_FILTERS)[number];
export const SEARCH_GROUP_PREVIEW = 4;
export function showGroup(filter: SearchFilter, label: Exclude<SearchFilter, 'Todo'>) {
return filter === 'Todo' || filter === label;
}
export function capped<T>(filter: SearchFilter, list: T[]) {
return filter === 'Todo' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
}
export function matchPlaylists<T extends { name: string }>(playlists: T[], query: string): T[] {
return query ? playlists.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) : [];
}
export function searchCounts(counts: {
tracks: number;
albums: number;
playlists: number;
users: number;
}) {
return {
Canciones: counts.tracks,
Álbumes: counts.albums,
Playlists: counts.playlists,
Usuarios: counts.users
};
}
export function searchChips(counts: ReturnType<typeof searchCounts>) {
const total = counts.Canciones + counts.Álbumes + counts.Playlists + counts.Usuarios;
return SEARCH_FILTERS.map((label) => ({
label,
count: label === 'Todo' ? total : counts[label]
}));
}
export type TopResult<Track, Album, Playlist, User> =
| { kind: 'track'; track: Track }
| { kind: 'album'; album: Album }
| { kind: 'playlist'; playlist: Playlist }
| { kind: 'user'; user: User };
export function findTopResult<
Track extends { title: string },
Album extends { title: string },
Playlist extends { name: string },
User extends { name: string }
>(params: {
query: string;
tracks: Track[];
albums: Album[];
playlists: Playlist[];
users: User[];
}): TopResult<Track, Album, Playlist, User> | null {
const q = params.query.trim().toLowerCase();
const startsWithQuery = (value: string) => value.toLowerCase().startsWith(q);
const userHit = params.users.find((u) => startsWithQuery(u.name));
if (userHit) return { kind: 'user', user: userHit };
const trackHit = params.tracks.find((track) => startsWithQuery(track.title));
if (trackHit) return { kind: 'track', track: trackHit };
const albumHit = params.albums.find((album) => startsWithQuery(album.title));
if (albumHit) return { kind: 'album', album: albumHit };
const playlistHit = params.playlists.find((p) => startsWithQuery(p.name));
if (playlistHit) return { kind: 'playlist', playlist: playlistHit };
if (params.users.length) return { kind: 'user', user: params.users[0] };
if (params.tracks.length) return { kind: 'track', track: params.tracks[0] };
if (params.albums.length) return { kind: 'album', album: params.albums[0] };
if (params.playlists.length) return { kind: 'playlist', playlist: params.playlists[0] };
return null;
}
+18
View File
@@ -0,0 +1,18 @@
export function createToggle(initial = false) {
let open = $state(initial);
return {
get open() {
return open;
},
show() {
open = true;
},
close() {
open = false;
},
toggle() {
open = !open;
}
};
}
-48
View File
@@ -1,48 +0,0 @@
import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
export type TrackTarget = { track: ApiTrackLike };
export function targetId(target: TrackTarget): string | number {
return target.track.id;
}
export function targetTitle(target: TrackTarget): string {
return target.track.title;
}
export function targetArtist(target: TrackTarget): string | undefined {
return target.track.artist ?? undefined;
}
export function targetOwnerUserId(target: TrackTarget): string | number | undefined {
return target.track.ownerUserId ?? undefined;
}
export function targetExplicit(target: TrackTarget): boolean {
return target.track.isExplicit ?? false;
}
export function targetListensCount(target: TrackTarget): number | string | undefined {
return target.track.listensCount;
}
export function isTargetCurrent(target: TrackTarget): boolean {
return player.current.id === target.track.id;
}
export function queueItemForTarget(target: TrackTarget): QueueItem {
return toQueueItems([target.track])[0];
}
export function targetForQueueItem(item: QueueItem): TrackTarget {
return {
track: {
id: item.id,
title: item.title,
artist: item.artist,
isExplicit: item.explicit,
ownerUserId: item.ownerUserId,
listensCount: item.listensCount
}
};
}
+13 -11
View File
@@ -3,12 +3,12 @@
import Disc from '@lucide/svelte/icons/disc-3';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
import MediaCard from '$lib/components/ui/MediaCard.svelte';
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { albumMeta } from '$lib/format';
let { data, form } = $props();
@@ -23,11 +23,11 @@
</svelte:head>
<Page>
<PageHeader title="Álbumes" subtitle="Agrupa la música que subes en discos.">
<PageHeader title="Álbumes" description="Agrupa la música que subes en discos.">
{#snippet actions()}
{#if items.length > 0}
<Button onclick={() => (creating = true)}>
<Plus class="h-4 w-4" strokeWidth={2.5} />
<Plus class="size-4" strokeWidth={2.5} />
Nuevo álbum
</Button>
{/if}
@@ -35,19 +35,21 @@
</PageHeader>
{#if items.length > 0}
<MediaGrid class="mt-7 sm:mt-8">
<div class="grid-cards">
{#each items as album, i (album.id)}
<AlbumCard
id={album.id}
<MediaCard
href="/albums/{album.id}"
title={album.title}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackCount={Number(album.trackCount)}
subtitle={albumMeta(
album.releaseYear === null ? undefined : Number(album.releaseYear),
Number(album.trackCount)
)}
trackIds={album.coverTrackIds}
coverSrc="/api/albums/{album.id}/cover?size=large"
src="/api/albums/{album.id}/cover?size=large"
index={i}
/>
{/each}
</MediaGrid>
</div>
{:else}
<EmptyState
icon={Disc}
@@ -1,13 +1,23 @@
<script lang="ts">
import { player, toQueueItems } from '$lib/player/player.svelte';
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import X from '@lucide/svelte/icons/x';
import Plus from '@lucide/svelte/icons/plus';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
import Button from '$lib/components/ui/Button.svelte';
import AlbumHeader from './components/AlbumHeader.svelte';
import AlbumTrackTable from './components/AlbumTrackTable.svelte';
import LibraryPicker from './components/LibraryPicker.svelte';
import BackLink from '$lib/components/ui/BackLink.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte';
import { albumMeta } from '$lib/format';
let { data, form } = $props();
@@ -19,12 +29,18 @@
let editing = $state(false);
let confirmingDelete = $state(false);
const isCurrentQueue = $derived(tracks.some((track) => track.id === player.current.id));
const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playAll() {
if (tracks.length === 0) return;
if (isCurrentQueue) player.toggle();
else player.playQueue(toQueueItems(tracks), 0);
playAllOrToggle(toQueueItems(tracks));
}
function playTrackFrom(index: number) {
player.playOrToggle(toQueueItems(tracks), index);
}
function playLibraryFrom(index: number) {
player.playOrToggle(toQueueItems(library), index);
}
</script>
@@ -34,29 +50,76 @@
</svelte:head>
<Page>
<AlbumHeader
title={album.title}
description={album.description ?? undefined}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackIds={tracks.map((track) => track.id)}
coverUrl="/api/albums/{album.id}/cover?size=large"
playing={isCurrentQueue && player.playing}
{isOwner}
onPlayAll={playAll}
onEdit={() => (editing = true)}
onDelete={() => (confirmingDelete = true)}
<BackLink
href={isOwner ? '/albums' : '/'}
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
/>
<PageHeader
eyebrow="Álbum"
title={album.title}
description={album.description ?? undefined}
meta={albumMeta(
album.releaseYear === null ? undefined : Number(album.releaseYear),
tracks.length
)}
>
{#snippet cover()}
<Artwork
src="/api/albums/{album.id}/cover?size=large"
trackIds={tracks.map((track) => track.id)}
size="hero"
alt={album.title}
class="shrink-0"
/>
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="size-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="size-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
{#if isOwner}
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>
<SquarePencil class="size-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
<Trash class="size-4" strokeWidth={1.5} />
Eliminar
</Button>
{/if}
{/snippet}
</PageHeader>
{#if form?.message}
<Alert tone="danger" class="mt-6">{form.message}</Alert>
<Alert tone="danger" class="mb-6">{form.message}</Alert>
{/if}
{#if tracks.length > 0}
<AlbumTrackTable {tracks} {isOwner} />
<TrackList
{tracks}
onPlay={playTrackFrom}
rowAction={isOwner
? { action: '?/removeTrack', icon: X, label: 'Quitar del álbum' }
: undefined}
/>
{/if}
{#if isOwner && library.length > 0}
<LibraryPicker {library} />
<div class="mt-10 sm:mt-12">
<SectionHeading title="Tus canciones subidas" />
<TrackList
tracks={library}
index={false}
onPlay={playLibraryFrom}
rowAction={{ action: '?/addTrack', icon: Plus, label: 'Añadir al álbum' }}
/>
</div>
{/if}
</Page>
@@ -75,17 +138,11 @@
/>
</Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<h2 class="text-lg font-semibold tracking-tight text-fg">
¿Eliminar «{album.title}»?
</h2>
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca.
</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
<form method="POST" action="?/delete">
<Button type="submit" variant="danger">Eliminar</Button>
</form>
</div>
</Modal>
<ConfirmDialog
open={confirmingDelete}
onClose={() => (confirmingDelete = false)}
title="¿Eliminar «{album.title}»?"
description="Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca."
confirmLabel="Eliminar"
action="?/delete"
/>
@@ -1,77 +0,0 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import Artwork from '$lib/components/ui/Artwork.svelte';
import BackLink from '$lib/components/ui/BackLink.svelte';
import Button from '$lib/components/ui/Button.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
interface Props {
title: string;
description?: string;
releaseYear?: number;
trackIds: (string | number)[];
coverUrl?: string;
playing: boolean;
isOwner: boolean;
onPlayAll: () => void;
onEdit: () => void;
onDelete: () => void;
}
let {
title,
description,
releaseYear,
trackIds,
coverUrl,
playing,
isOwner,
onPlayAll,
onEdit,
onDelete
}: Props = $props();
const meta = $derived(
[
releaseYear ? String(releaseYear) : undefined,
`${trackIds.length} ${trackIds.length === 1 ? 'canción' : 'canciones'}`
]
.filter((part) => part !== undefined)
.join(' · ')
);
</script>
<BackLink
href={isOwner ? '/albums' : '/'}
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
/>
<CollectionHeader eyebrow="Álbum" {title} {description} {meta}>
{#snippet cover()}
<Artwork src={coverUrl} {trackIds} size="hero" alt={title} class="shrink-0" />
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{#if playing}
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
{#if isOwner}
<Button size="sm" variant="secondary" onclick={onEdit}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={onDelete}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</Button>
{/if}
{/snippet}
</CollectionHeader>
@@ -1,64 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import X from '@lucide/svelte/icons/x';
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
import { fmtTime } from '$lib/format';
import IconButton from '$lib/components/ui/IconButton.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
type AlbumTrack = ApiTrackLike & { duration: number | string; listensCount: number | string };
interface Props {
tracks: AlbumTrack[];
isOwner: boolean;
}
let { tracks, isOwner }: Props = $props();
function playFrom(index: number) {
player.playOrToggle(toQueueItems(tracks), index);
}
</script>
<TrackTable
index
action={isOwner}
meta={[{ label: 'Escuchas', width: '100px' }]}
class="mt-6 sm:mt-8"
>
{#each tracks as track, i (track.id)}
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
{#if isOwner}
<form
method="POST"
action="?/removeTrack"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label="Quitar del álbum" size="xs" revealOnHover>
<X class="h-3.5 w-3.5" />
</IconButton>
</form>
{/if}
</TrackRow>
{/each}
</TrackTable>
@@ -1,65 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Plus from '@lucide/svelte/icons/plus';
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
import { fmtTime } from '$lib/format';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
type LibraryTrack = ApiTrackLike & { duration: number | string; listensCount: number | string };
interface Props {
library: LibraryTrack[];
}
let { library }: Props = $props();
function playFrom(index: number) {
player.playOrToggle(toQueueItems(library), index);
}
</script>
<div class="mt-10 sm:mt-12">
<SectionHeading title="Tus canciones subidas" />
<TrackTable action meta={[{ label: 'Escuchas', width: '100px' }]}>
{#each library as track, i (track.id)}
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
>
{#snippet overlay()}
{#if player.current.id === track.id}
<NowPlaying paused={!player.playing} />
{/if}
{/snippet}
</TrackTitleCell>
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<form
method="POST"
action="?/addTrack"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label="Añadir al álbum" size="xs" revealOnHover>
<Plus class="h-3.5 w-3.5" />
</IconButton>
</form>
</TrackRow>
{/each}
</TrackTable>
</div>
+181 -279
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import Music from '@lucide/svelte/icons/music';
import { player } from '$lib/player/player.svelte';
import { player, toQueueItem } from '$lib/player/player.svelte';
import { fetchAlbumQueueItems } from '$lib/albums';
import { fmtTime, fmtPlays } from '$lib/format';
import { fmtTime, fmtPlays, plural } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
@@ -11,29 +11,29 @@
import Artwork from '$lib/components/ui/Artwork.svelte';
import Avatar from '$lib/components/ui/Avatar.svelte';
import PlayButton from '$lib/components/ui/PlayButton.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte';
import EqBars from '$lib/components/ui/EqBars.svelte';
import ListRow from '$lib/components/ui/ListRow.svelte';
import Chip from '$lib/components/ui/Chip.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { EXPLORE_PAGE_SIZE } from '$lib/config';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import AlbumContextMenu, {
albumContextMenuStateFor,
type AlbumMenuState
} from '$lib/components/AlbumContextMenu.svelte';
import { searchHref } from '$lib/navigation.svelte';
import { createTrackMenu } from '$lib/menus.svelte';
import ContextMenu, { contextMenuPosition } from '$lib/components/ui/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus';
import ListEnd from '@lucide/svelte/icons/list-end';
import { appendUnique } from '$lib/collections';
import { genreTiles } from '$lib/genres';
import {
isTargetCurrent,
queueItemForTarget,
targetArtist,
targetExplicit,
targetId,
targetListensCount,
targetTitle,
type TrackTarget
} from '$lib/tracks';
type SearchFilter,
showGroup,
capped,
matchPlaylists,
searchCounts,
searchChips,
findTopResult
} from '$lib/search';
type AlbumMenuState = { x: number; y: number; openLeft: boolean; albumId: string };
const TOP_RESULT_KIND_LABEL: Record<TopResult['kind'], string> = {
track: 'Canción',
@@ -42,38 +42,9 @@
user: 'Usuario'
};
// No hay sistema de tags/géneros todavía: esta lista es un placeholder de
// cliente y cada tile enlaza a una búsqueda normal por ese término.
const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' },
'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' },
indie: { label: 'Indie', tagline: 'Voces fuera del radar' },
salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' },
'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' },
electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' },
jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' },
baladas: { label: 'Baladas', tagline: 'Para sentir con calma' },
trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' },
cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' },
'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' },
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' }
};
const genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({
query,
hue: Math.round((i * 360) / all.length),
...info
}));
let { data, form } = $props();
const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const;
type SearchFilter = (typeof SEARCH_FILTERS)[number];
const SEARCH_GROUP_PREVIEW = 4;
let contextMenu = $state<ContextMenuState | null>(null);
const trackMenu = createTrackMenu();
let albumMenu = $state<AlbumMenuState | null>(null);
let sfilter = $state<SearchFilter>('Todo');
@@ -85,7 +56,7 @@
let loadingMore = $state(false);
$effect(() => {
contextMenu = null;
trackMenu.close();
albumMenu = null;
sfilter = 'Todo';
localItems = data.tracks.items.map((item) => item.track);
@@ -95,12 +66,12 @@
const songRows = $derived(
localItems.map((track) => ({
target: { track } satisfies TrackTarget,
track,
seconds: Number(track.duration)
}))
);
const items = $derived<TrackTarget[]>(songRows.map((row) => row.target));
const items = $derived(songRows.map((row) => row.track));
const albums = $derived(data.albums);
const users = $derived(data.users);
@@ -108,75 +79,52 @@
const hasAlbums = $derived(albums.length > 0);
const hasUsers = $derived(users.length > 0);
const playlistMatches = $derived(
data.query
? data.playlists.filter((p) => p.name.toLowerCase().includes(data.query.toLowerCase()))
: []
);
const playlistMatches = $derived(matchPlaylists(data.playlists, data.query));
const hasPlaylists = $derived(playlistMatches.length > 0);
const nothingFound = $derived(
!!data.query && items.length === 0 && !hasAlbums && !hasUsers && !hasPlaylists
);
const searchCounts = $derived({
Canciones: songRows.length,
Álbumes: albums.length,
Playlists: playlistMatches.length,
Usuarios: users.length
});
const counts = $derived(
searchCounts({
tracks: songRows.length,
albums: albums.length,
playlists: playlistMatches.length,
users: users.length
})
);
const chips = $derived(searchChips(counts));
const totalHits = $derived(
searchCounts.Canciones + searchCounts.Álbumes + searchCounts.Playlists + searchCounts.Usuarios
);
const searchChips = $derived(
SEARCH_FILTERS.map((label) => ({
label,
count: label === 'Todo' ? totalHits : searchCounts[label]
}))
counts.Canciones + counts.Álbumes + counts.Playlists + counts.Usuarios
);
type TopResult =
| { kind: 'track'; target: TrackTarget }
| { kind: 'album'; album: (typeof albums)[number] }
| { kind: 'playlist'; playlist: (typeof playlistMatches)[number] }
| { kind: 'user'; user: (typeof users)[number] };
type TopResult = NonNullable<
ReturnType<
typeof findTopResult<
LocalTrack,
(typeof albums)[number],
(typeof playlistMatches)[number],
(typeof users)[number]
>
>
>;
const topResult = $derived.by((): TopResult | null => {
if (sfilter !== 'Todo' || !data.query) return null;
const q = data.query.trim().toLowerCase();
const startsWithQuery = (value: string) => value.toLowerCase().startsWith(q);
const userHit = users.find((u) => startsWithQuery(u.name));
if (userHit) return { kind: 'user', user: userHit };
const trackHit = items.find((target) => startsWithQuery(targetTitle(target)));
if (trackHit) return { kind: 'track', target: trackHit };
const albumHit = albums.find((album) => startsWithQuery(album.title));
if (albumHit) return { kind: 'album', album: albumHit };
const playlistHit = playlistMatches.find((p) => startsWithQuery(p.name));
if (playlistHit) return { kind: 'playlist', playlist: playlistHit };
if (users.length) return { kind: 'user', user: users[0] };
if (items.length) return { kind: 'track', target: items[0] };
if (albums.length) return { kind: 'album', album: albums[0] };
if (playlistMatches.length) return { kind: 'playlist', playlist: playlistMatches[0] };
return null;
});
function showGroup(label: Exclude<SearchFilter, 'Todo'>) {
return sfilter === 'Todo' || sfilter === label;
}
function capped<T>(list: T[]) {
return sfilter === 'Todo' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
}
function buildHref(query: string) {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
}
const topResult = $derived<TopResult | null>(
sfilter === 'Todo' && data.query
? findTopResult({
query: data.query,
tracks: items,
albums,
playlists: playlistMatches,
users
})
: null
);
function playTopResult() {
if (topResult?.kind !== 'track') return;
player.playOrToggle([queueItemForTarget(topResult.target)], 0);
player.playOrToggle([toQueueItem(topResult.track)], 0);
}
const topResultHref = $derived.by(() => {
@@ -187,15 +135,15 @@
});
function togglePlay(index: number) {
player.playOrToggle(items.map(queueItemForTarget), index);
player.playOrToggle(items.map(toQueueItem), index);
}
function openContextMenu(event: MouseEvent, target: TrackTarget) {
contextMenu = contextMenuStateFor(event, target);
function openContextMenu(event: MouseEvent, track: LocalTrack) {
trackMenu.open(event, track);
}
function openAlbumMenu(event: MouseEvent, albumId: string) {
albumMenu = albumContextMenuStateFor(event, albumId);
albumMenu = { ...contextMenuPosition(event), albumId };
}
async function albumPlayNext() {
@@ -212,12 +160,6 @@
player.appendToQueue(await fetchAlbumQueueItems(albumId));
}
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(queueItemForTarget(contextMenu));
contextMenu = null;
}
async function loadMore() {
if (loadingMore) return;
loadingMore = true;
@@ -257,39 +199,28 @@
{#if !data.query}
<PageHeader
title="Descubrir"
subtitle="Encuentra tu próxima canción favorita explorando por género."
description="Encuentra tu próxima canción favorita explorando por género."
/>
{:else if !nothingFound}
<div>
<div class="mb-2.5 text-sm font-semibold tracking-[0.16em] text-fg-3 uppercase">
Resultados de búsqueda
</div>
<h1
class="font-display text-2xl font-semibold tracking-[-0.03em] text-pretty text-fg sm:text-3xl"
>
Resultados para «{data.query}»
</h1>
<p class="mt-1.75 text-sm text-fg-2">
{totalHits}
{totalHits === 1 ? 'Coincidencia' : 'Coincidencias'} en canciones, álbumes, playlists y usuarios
</p>
</div>
<PageHeader
eyebrow="Resultados de búsqueda"
title="Resultados para «{data.query}»"
description="{plural(
totalHits,
'coincidencia',
'coincidencias'
)} en canciones, álbumes, playlists y usuarios"
/>
<div class="mt-5 flex flex-wrap gap-1.75">
{#each searchChips as chip (chip.label)}
<button
type="button"
<div class="mb-7 flex flex-wrap gap-2 sm:mb-8">
{#each chips as chip (chip.label)}
<Chip
selected={sfilter === chip.label}
count={chip.count}
onclick={() => (sfilter = chip.label)}
class="flex items-center gap-1.75 rounded-full px-3.5 py-1.75 text-xs font-medium transition {sfilter ===
chip.label
? 'bg-cta-strong text-ink'
: 'bg-surface-2 text-fg-2 hover:bg-surface-hover hover:text-fg'}"
>
<span>{chip.label}</span>
<span class="text-xs tabular-nums {sfilter === chip.label ? 'opacity-55' : 'text-muted'}"
>{chip.count}</span
>
</button>
{chip.label}
</Chip>
{/each}
</div>
@@ -297,9 +228,9 @@
{#snippet topResultBody()}
{#if topResult.kind === 'track'}
<Artwork
trackIds={[targetId(topResult.target)]}
trackIds={[topResult.track.id]}
size="xl"
alt={targetTitle(topResult.target)}
alt={topResult.track.title}
class="shrink-0"
/>
{:else if topResult.kind === 'album'}
@@ -318,23 +249,23 @@
{/if}
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3">
<div class="text-xs leading-none font-semibold tracking-[0.14em] text-fg-3 uppercase">
<div class="text-eyebrow leading-none font-semibold text-fg-3">
Mejor resultado · {TOP_RESULT_KIND_LABEL[topResult.kind]}
</div>
<div
class="truncate font-display text-lg leading-none font-medium tracking-[-0.02em] text-fg"
class="truncate font-display text-lg leading-none font-medium tracking-display text-fg"
>
{topResult.kind === 'track'
? targetTitle(topResult.target)
? topResult.track.title
: topResult.kind === 'album'
? topResult.album.title
: topResult.kind === 'playlist'
? topResult.playlist.name
: topResult.user.name}
</div>
{#if topResult.kind === 'track' && targetArtist(topResult.target)}
{#if topResult.kind === 'track' && topResult.track.artist}
<div class="truncate text-sm leading-none text-fg-3">
{targetArtist(topResult.target)}
{topResult.track.artist}
</div>
{/if}
</div>
@@ -342,22 +273,16 @@
<PlayButton as="span" size="lg" label="Reproducir" class="group-hover/top:brightness-110" />
{/snippet}
{#if topResult.kind === 'track'}
<button
type="button"
onclick={playTopResult}
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()}
</button>
{:else}
<a
href={topResultHref}
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()}
</a>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svelte:element
this={topResult.kind === 'track' ? 'button' : 'a'}
type={topResult.kind === 'track' ? 'button' : undefined}
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>
{/if}
{/if}
@@ -366,22 +291,17 @@
{/if}
{#if nothingFound}
<div class="mx-auto max-w-115 py-[8vh] text-center">
<h1 class="font-display text-xl font-semibold tracking-tight text-fg">
<div class="mx-auto max-w-prose-sm py-[8vh] text-center">
<h1 class="text-display-3 text-fg">
Sin resultados para «{data.query}»
</h1>
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
<p class="mt-2.5 text-body text-fg-2">
Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o
por playlist.
</p>
<div class="mt-5.5 flex flex-wrap justify-center gap-2">
{#each genreTiles.slice(0, 4) as genre (genre.query)}
<a
href={buildHref(genre.query)}
class="rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg"
>
{genre.label}
</a>
<Chip href={searchHref(genre.query)}>{genre.label}</Chip>
{/each}
</div>
</div>
@@ -389,13 +309,12 @@
{#if !data.query}
<!-- GÉNEROS -->
<div class="mt-7 grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
<div class="grid-wide">
{#each genreTiles as genre, i (genre.query)}
<a
href={buildHref(genre.query)}
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-110"
style="animation-delay:{Math.min(i, 10) *
40}ms; background:linear-gradient(140deg, oklch(var(--mf-tile-l) var(--mf-tile-c) {genre.hue}), oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) {genre.hue}))"
href={searchHref(genre.query)}
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:{genre.hue}"
>
<div class="text-on-art">
{genre.label}
@@ -408,42 +327,34 @@
{#if data.query && !nothingFound}
<div class="mt-9 flex flex-col gap-9">
{#if showGroup('Canciones')}
{#if showGroup(sfilter, 'Canciones')}
<div>
{#if songRows.length > 0}
<SectionHeading title="Canciones">
{#snippet actions()}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{searchCounts.Canciones}
{searchCounts.Canciones === 1 ? 'Resultado' : 'Resultados'}
</span>
{/snippet}
</SectionHeading>
<SectionHeading title="Canciones" count={counts.Canciones} />
<ul class="flex flex-col gap-1">
{#each capped(songRows) as { target: item, seconds }, i (targetId(item))}
{#each capped(sfilter, songRows) as { track, seconds }, i (track.id)}
<li>
<SearchResultRow
title={targetTitle(item)}
subtitle={targetArtist(item)}
meta="{fmtTime(seconds)} · {fmtPlays(targetListensCount(item))}"
explicit={targetExplicit(item)}
<ListRow
title={track.title}
subtitle={track.artist}
subtitleHref={track.ownerUserId}
active={player.current.id === track.id}
size="lg"
trackId={track.id}
onclick={() => togglePlay(i)}
oncontextmenu={(e) => openContextMenu(e, item)}
oncontextmenu={(e) => openContextMenu(e, track)}
>
{#snippet art(artClass)}
<Artwork
trackIds={[targetId(item)]}
size="lg"
alt={targetTitle(item)}
class="{artClass} ring-1 ring-line ring-inset"
>
{#if isTargetCurrent(item)}
<NowPlaying paused={!player.playing} />
{/if}
</Artwork>
{#snippet overlay()}
{#if player.current.id === track.id}
<EqBars overlay paused={!player.playing} />
{/if}
{/snippet}
</SearchResultRow>
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
{fmtTime(seconds)} · {fmtPlays(track.listensCount)}
</span>
{/snippet}
</ListRow>
</li>
{/each}
</ul>
@@ -460,91 +371,64 @@
</div>
{/if}
{#if hasAlbums && showGroup('Álbumes')}
{#if hasAlbums && showGroup(sfilter, 'Álbumes')}
<div>
<SectionHeading title="Álbumes">
{#snippet actions()}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{searchCounts.Álbumes}
{searchCounts.Álbumes === 1 ? 'Resultado' : 'Resultados'}
</span>
{/snippet}
</SectionHeading>
<SectionHeading title="Álbumes" count={counts.Álbumes} />
<ul class="flex flex-col gap-1">
{#each capped(albums) as album (album.id)}
{#each capped(sfilter, albums) as album (album.id)}
<li>
<SearchResultRow
<ListRow
title={album.title}
meta="{Number(album.trackCount)} canciones"
href="/albums/{album.id}"
size="lg"
trackIds={album.coverTrackIds}
oncontextmenu={(e) => openAlbumMenu(e, album.id)}
>
{#snippet art(artClass)}
<Artwork trackIds={album.coverTrackIds} size="lg" class={artClass} />
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
{Number(album.trackCount)} canciones
</span>
{/snippet}
</SearchResultRow>
</ListRow>
</li>
{/each}
</ul>
</div>
{/if}
{#if hasUsers && showGroup('Usuarios')}
{#if hasUsers && showGroup(sfilter, 'Usuarios')}
<div>
<SectionHeading title="Usuarios">
{#snippet actions()}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{searchCounts.Usuarios}
{searchCounts.Usuarios === 1 ? 'Resultado' : 'Resultados'}
</span>
{/snippet}
</SectionHeading>
<SectionHeading title="Usuarios" count={counts.Usuarios} />
<ul class="flex flex-col gap-1">
{#each capped(users) as u (u.id)}
{#each capped(sfilter, users) as u (u.id)}
<li>
<SearchResultRow title={u.name} href="/u/{u.id}">
{#snippet art(artClass)}
<Avatar name={u.name} src={u.profilePictureUrl} size="md" class={artClass} />
<ListRow title={u.name} href="/u/{u.id}" size="lg">
{#snippet art()}
<Avatar name={u.name} src={u.profilePictureUrl} size="md" />
{/snippet}
</SearchResultRow>
</ListRow>
</li>
{/each}
</ul>
</div>
{/if}
{#if hasPlaylists && showGroup('Playlists')}
{#if hasPlaylists && showGroup(sfilter, 'Playlists')}
<div>
<SectionHeading title="Playlists">
{#snippet actions()}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{searchCounts.Playlists}
{searchCounts.Playlists === 1 ? 'Resultado' : 'Resultados'}
</span>
{/snippet}
</SectionHeading>
<SectionHeading title="Playlists" count={counts.Playlists} />
<ul class="flex flex-col gap-1">
{#each capped(playlistMatches) as playlist (playlist.id)}
{#each capped(sfilter, playlistMatches) as playlist (playlist.id)}
<li>
<SearchResultRow
<ListRow
title={playlist.name}
subtitle={playlist.description}
href="/playlists/{playlist.id}"
>
{#snippet art(artClass)}
<Artwork
src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds}
size="lg"
class={artClass}
/>
{/snippet}
</SearchResultRow>
size="lg"
coverSrc="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds}
/>
</li>
{/each}
</ul>
@@ -554,21 +438,39 @@
{/if}
</Page>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
{#if trackMenu.state}
<ContextMenu
x={trackMenu.state.x}
y={trackMenu.state.y}
openLeft={trackMenu.state.openLeft}
onClose={() => trackMenu.close()}
items={[
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => trackMenu.playNext() }
]}
playlistAction={{
action: '?/addTrack',
fields: { trackId: String(trackMenu.state.track.id) },
label: 'Añadir a una playlist'
}}
playlists={data.playlists}
onClose={() => (contextMenu = null)}
onAddToQueue={addToQueue}
/>
{/if}
{#if albumMenu}
<AlbumContextMenu
menu={albumMenu}
playlists={data.playlists}
<ContextMenu
x={albumMenu.x}
y={albumMenu.y}
openLeft={albumMenu.openLeft}
onClose={() => (albumMenu = null)}
onPlayNext={albumPlayNext}
onAddToQueue={albumAddToQueue}
items={[
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: albumPlayNext },
{ icon: ListEnd, label: 'Añadir a la cola', onclick: albumAddToQueue }
]}
playlistAction={{
action: '?/addAlbumToPlaylist',
fields: { albumId: albumMenu.albumId },
label: 'Añadir álbum a una playlist'
}}
playlists={data.playlists}
/>
{/if}
@@ -1,25 +1,20 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Upload from '@lucide/svelte/icons/upload';
import X from '@lucide/svelte/icons/x';
import Music from '@lucide/svelte/icons/music';
import { player, toQueueItems } from '$lib/player/player.svelte';
import { fmtTime, fmtDate } from '$lib/format';
import { plural } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte';
let { data } = $props();
const items = $derived(data.tracks.items);
const total = $derived(Number(data.tracks.totalItemCount));
const listTracks = $derived(items.map((track) => ({ ...track, uploadedAt: track.createdAt })));
function togglePlay(index: number) {
player.playOrToggle(toQueueItems(items), index);
@@ -34,12 +29,12 @@
<Page>
<PageHeader
title="Tus canciones subidas"
subtitle="{total} {total === 1 ? 'canción' : 'canciones'} subida{total === 1 ? '' : 's'}."
description="{plural(total, 'canción', 'canciones')} subida{total === 1 ? '' : 's'}."
>
{#snippet actions()}
{#if items.length > 0}
<Button href="/upload">
<Upload class="h-4 w-4" />
<Upload class="size-4" />
Subir canción
</Button>
{/if}
@@ -47,50 +42,12 @@
</PageHeader>
{#if items.length > 0}
<TrackTable
index
action
meta={[
{ label: 'Álbum', width: '76px' },
{ label: 'Subida', width: '124px' },
{ label: 'Escuchas', width: '106px' }
]}
class="mt-7 sm:mt-8"
>
{#each items as track, i (track.id)}
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackIndexCell
index={i}
{active}
playing={player.playing}
onToggle={() => togglePlay(i)}
/>
<TrackTitleCell
trackId={track.id}
title={track.title}
{active}
onClick={() => togglePlay(i)}
/>
<TrackMeta class="hidden sm:block">—</TrackMeta>
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<form
method="POST"
action="?/deleteTrack"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label="Borrar canción" size="xs" revealOnHover>
<X class="h-3.5 w-3.5" />
</IconButton>
</form>
</TrackRow>
{/each}
</TrackTable>
<TrackList
tracks={listTracks}
columns={['uploaded', 'plays']}
onPlay={togglePlay}
rowAction={{ action: '?/deleteTrack', icon: X, label: 'Borrar canción' }}
/>
{:else}
<EmptyState
icon={Music}
@@ -99,7 +56,7 @@
>
{#snippet actions()}
<Button href="/upload">
<Upload class="h-4 w-4" />
<Upload class="size-4" />
Sube la primera
</Button>
{/snippet}
+23 -70
View File
@@ -1,39 +1,25 @@
<script lang="ts">
import Heart from '@lucide/svelte/icons/heart';
import X from '@lucide/svelte/icons/x';
import { player, type QueueItem } from '$lib/player/player.svelte';
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import { liked } from '$lib/player/liked.svelte';
import { fmtDate, fmtTime } from '$lib/format';
import { plural } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Button from '$lib/components/ui/Button.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte';
const tracks = $derived(liked.list);
const queueItems = $derived<QueueItem[]>(
tracks.map((t) => ({
id: t.id,
title: t.title,
artist: t.artist,
explicit: t.explicit
}))
);
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.likedAt })));
const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playFrom(index: number) {
player.playOrToggle(queueItems, index);
player.playOrToggle(tracks, index);
}
function playAll() {
if (queueItems.length === 0) return;
if (isCurrentQueue) player.toggle();
else player.playQueue(queueItems, 0);
playAllOrToggle(tracks);
}
</script>
@@ -43,17 +29,17 @@
</svelte:head>
<Page>
<CollectionHeader
<PageHeader
eyebrow="Colección"
title="Me gusta"
meta="{tracks.length} {tracks.length === 1 ? 'Canción' : 'Canciones'}"
meta={plural(tracks.length, 'canción', 'canciones')}
align="center"
>
{#snippet cover()}
<div
class="grid h-36 w-36 shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:h-41 sm:w-41"
class="grid size-cover-hero shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:size-cover-hero-sm"
>
<Heart class="h-10 w-10 text-on-art/90" fill="currentColor" strokeWidth={0} />
<Heart class="size-icon-xl text-on-art/90" fill="currentColor" strokeWidth={0} />
</div>
{/snippet}
{#snippet actions()}
@@ -61,52 +47,19 @@
{isCurrentQueue && player.playing ? 'Pausar' : 'Reproducir'}
</Button>
{/snippet}
</CollectionHeader>
</PageHeader>
{#if tracks.length > 0}
<TrackTable
index
action
meta={[
{ label: 'Álbum', width: '76px' },
{ label: 'Añadida', width: '124px' },
{ label: 'Escuchas', width: '106px' }
]}
class="mt-6 sm:mt-8"
>
{#each tracks as track, i (track.id)}
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackIndexCell
index={i}
{active}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.explicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta class="hidden sm:block">—</TrackMeta>
<TrackMeta class="hidden sm:block">{fmtDate(track.likedAt)}</TrackMeta>
<TrackMeta class="hidden sm:block">{Number(track.listensCount ?? 0)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<IconButton
label="Quitar de Me gusta"
size="xs"
revealOnHover
onclick={() => liked.toggle(track)}
>
<X class="h-3.5 w-3.5" />
</IconButton>
</TrackRow>
{/each}
</TrackTable>
<TrackList
tracks={listTracks}
columns={['added', 'plays']}
onPlay={playFrom}
rowAction={{
onclick: (track) => liked.toggle({ ...track, artist: track.artist ?? undefined }),
icon: X,
label: 'Quitar de Me gusta'
}}
/>
{:else}
<EmptyState
icon={Heart}
@@ -2,62 +2,52 @@
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import Shuffle from '@lucide/svelte/icons/shuffle';
import { player } from '$lib/player/player.svelte';
import { fmtTime } from '$lib/format';
import { shuffle } from '$lib/collections';
import {
player,
isQueueCurrent,
playAllOrToggle,
playShuffled,
toQueueItem
} from '$lib/player/player.svelte';
import { fmtTime, plural } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte';
import BackLink from '$lib/components/ui/BackLink.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import { mixItemKey, mixItemTarget } from '$lib/mixes';
import {
isTargetCurrent,
queueItemForTarget,
targetArtist,
targetId,
targetListensCount,
targetTitle
} from '$lib/tracks';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte';
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus';
import { createTrackMenu } from '$lib/menus.svelte';
import { mixItemTrack } from '$lib/mixes';
let { data, form } = $props();
const mix = $derived(data.mix);
const items = $derived(mix.items);
const targets = $derived(items.map(mixItemTarget));
const queue = $derived(targets.map(queueItemForTarget));
const tracks = $derived(items.map(mixItemTrack));
const queue = $derived(tracks.map(toQueueItem));
const listTracks = $derived(
items.map((item) => ({
id: item.trackId,
title: item.title,
artist: item.artist,
duration: item.durationSeconds,
listensCount: item.listensCount
}))
);
const totalSeconds = $derived(
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
);
const isCurrentQueue = $derived(targets.some(isTargetCurrent));
const isCurrentQueue = $derived(isQueueCurrent(queue));
let contextMenu = $state<ContextMenuState | null>(null);
const trackMenu = createTrackMenu();
function playFrom(index: number) {
player.playOrToggle(queue, index);
}
function playAll() {
if (queue.length === 0) return;
if (isCurrentQueue) player.toggle();
else player.playQueue(queue, 0);
}
function playShuffled() {
if (queue.length === 0) return;
player.playQueue(shuffle(queue), 0);
}
</script>
<svelte:head>
@@ -68,11 +58,11 @@
<Page>
<BackLink href="/" label="Volver al inicio" />
<CollectionHeader
<PageHeader
eyebrow="Mezcla"
title={mix.title}
description={mix.subtitle ?? undefined}
meta="{items.length} {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}"
meta="{plural(items.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
>
{#snippet cover()}
<Artwork
@@ -83,57 +73,53 @@
/>
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
<Button size="sm" onclick={() => playAllOrToggle(queue)} disabled={queue.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" strokeWidth={1.5} />
<Pause class="size-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" strokeWidth={1.5} />
<Play class="size-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
<Button variant="secondary" size="sm" onclick={playShuffled} disabled={queue.length === 0}>
<Shuffle class="h-4 w-4" />
<Button
variant="secondary"
size="sm"
onclick={() => playShuffled(queue)}
disabled={queue.length === 0}
>
<Shuffle class="size-4" />
Aleatorio
</Button>
{/snippet}
</CollectionHeader>
</PageHeader>
{#if form?.message}
<Alert tone="danger" class="mt-4">{form.message}</Alert>
<Alert tone="danger" class="mb-6">{form.message}</Alert>
{/if}
<TrackTable index meta={[{ label: 'Escuchas', width: '100px' }]} class="mt-6 sm:mt-8">
{#each items as item, i (mixItemKey(item))}
{@const active = isTargetCurrent(targets[i])}
<TrackRow {active} oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
<TrackTitleCell
trackId={targetId(targets[i])}
title={targetTitle(targets[i])}
artist={targetArtist(targets[i])}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta class="hidden sm:block">{Number(targetListensCount(targets[i]) ?? 0)}</TrackMeta>
<TrackMeta>{fmtTime(Number(item.durationSeconds))}</TrackMeta>
</TrackRow>
{/each}
</TrackTable>
<TrackList
tracks={listTracks}
columns={['plays']}
onPlay={playFrom}
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])}
/>
</Page>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
playlists={data.playlists}
onClose={() => (contextMenu = null)}
onAddToQueue={() => {
const menu = contextMenu;
if (menu) {
const index = targets.findIndex((target) => targetId(target) === targetId(menu));
if (index >= 0) player.addToQueue(queue[index]);
}
contextMenu = null;
{#if trackMenu.state}
<ContextMenu
x={trackMenu.state.x}
y={trackMenu.state.y}
openLeft={trackMenu.state.openLeft}
onClose={() => trackMenu.close()}
items={[
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => trackMenu.playNext() }
]}
playlistAction={{
action: '?/addTrack',
fields: { trackId: String(trackMenu.state.track.id) },
label: 'Añadir a una playlist'
}}
playlists={data.playlists}
/>
{/if}
@@ -2,19 +2,18 @@
import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import MediaCard from '$lib/components/ui/MediaCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte';
import { fmtDurationLong } from '$lib/format';
import { fmtDurationLong, plural, playlistMeta } from '$lib/format';
let { data } = $props();
const items = $derived(data.playlists.items);
const totals = $derived(data.totals);
const summary = $derived(
`${totals.playlistCount} ${totals.playlistCount === 1 ? 'Playlist' : 'Playlists'} · ${totals.trackCount} ${totals.trackCount === 1 ? 'Canción' : 'Canciones'} · ${fmtDurationLong(totals.durationSeconds)}`
`${plural(totals.playlistCount, 'playlist', 'playlists')} · ${plural(totals.trackCount, 'canción', 'canciones')} · ${fmtDurationLong(totals.durationSeconds)}`
);
</script>
@@ -26,7 +25,7 @@
<Page>
<PageHeader
title="Mis listas"
subtitle={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
description={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
>
{#snippet actions()}
{#if items.length > 0}
@@ -36,18 +35,20 @@
</PageHeader>
{#if items.length > 0}
<MediaGrid min="210px" minMobile="180px" class="mt-7 sm:mt-8">
<div class="grid-cards-lg">
{#each items as playlist, i (playlist.id)}
<PlaylistCard
id={playlist.id}
name={playlist.name}
trackCount={playlist.trackCount}
<MediaCard
href="/playlists/{playlist.id}"
title={playlist.name}
subtitle={playlistMeta(Number(playlist.trackCount))}
trackIds={playlist.coverTrackIds}
updatedAt={playlist.updatedAt}
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt
)}"
index={i}
/>
{/each}
</MediaGrid>
</div>
{:else}
<EmptyState
icon={ListMusic}
@@ -1,28 +1,39 @@
<script lang="ts">
import ListMusic from '@lucide/svelte/icons/list-music';
import { player, toQueueItems } from '$lib/player/player.svelte';
import X from '@lucide/svelte/icons/x';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import PlaylistHeader from './components/PlaylistHeader.svelte';
import PlaylistTrackTable from './components/PlaylistTrackTable.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte';
import { playlistMeta } from '$lib/format';
let { data, form } = $props();
const playlist = $derived(data.playlist);
const tracks = $derived(data.tracks);
const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.createdAt })));
let editing = $state(false);
let confirmingDelete = $state(false);
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playAll() {
if (tracks.length === 0) return;
if (isCurrentQueue) player.toggle();
else player.playQueue(toQueueItems(tracks), 0);
playAllOrToggle(toQueueItems(tracks));
}
function playFrom(index: number) {
player.playOrToggle(toQueueItems(tracks), index);
}
</script>
@@ -32,27 +43,59 @@
</svelte:head>
<Page>
<PlaylistHeader
id={playlist.id}
name={playlist.name}
description={playlist.description}
trackIds={tracks.map((t) => t.id)}
updatedAt={playlist.updatedAt}
visibility={playlist.visibility}
playing={isCurrentQueue && player.playing}
onPlayAll={playAll}
onEdit={() => (editing = true)}
onDelete={() => (confirmingDelete = true)}
/>
<PageHeader
eyebrow="Lista"
title={playlist.name}
description={playlist.description ?? undefined}
meta="{playlistMeta(tracks.length)} · {playlist.visibility === 'Public'
? 'Pública'
: 'Privada'}"
align="center"
>
{#snippet cover()}
<Artwork
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={tracks.map((t) => t.id)}
size="hero"
alt={playlist.name}
class="shrink-0"
/>
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="size-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="size-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>
<SquarePencil class="size-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
<Trash class="size-4" strokeWidth={1.5} />
Eliminar
</Button>
{/snippet}
</PageHeader>
{#if tracks.length > 0}
<PlaylistTrackTable {tracks} />
<TrackList
tracks={listTracks}
columns={['added', 'plays']}
onPlay={playFrom}
rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }}
/>
{:else}
<EmptyState
icon={ListMusic}
title="Esta playlist todavía está vacía"
description="Añade canciones desde tu biblioteca o explora música nueva para empezar."
class="mt-6 sm:mt-8"
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
@@ -78,18 +121,11 @@
/>
</Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
¿Eliminar «{playlist.name}»?
</h2>
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
dispositivos donde la tengas guardada.
</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
<form method="POST" action="?/delete">
<Button type="submit" variant="danger">Eliminar</Button>
</form>
</div>
</Modal>
<ConfirmDialog
open={confirmingDelete}
onClose={() => (confirmingDelete = false)}
title="¿Eliminar «{playlist.name}»?"
description="Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los dispositivos donde la tengas guardada."
confirmLabel="Eliminar"
action="?/delete"
/>
@@ -1,62 +0,0 @@
<script lang="ts">
import Artwork from '$lib/components/ui/Artwork.svelte';
import Button from '$lib/components/ui/Button.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
interface Props {
id: string;
name: string;
description?: string | null;
trackIds: (string | number)[];
updatedAt: string;
visibility: 'Public' | 'Private';
playing: boolean;
onPlayAll: () => void;
onEdit: () => void;
onDelete: () => void;
}
let {
id,
name,
description,
trackIds,
updatedAt,
visibility,
playing,
onPlayAll,
onEdit,
onDelete
}: Props = $props();
const meta = $derived(
`${trackIds.length} ${trackIds.length === 1 ? 'Canción' : 'Canciones'} · ${
visibility === 'Public' ? 'Pública' : 'Privada'
}`
);
</script>
<CollectionHeader
eyebrow="Lista"
title={name}
description={description ?? undefined}
{meta}
align="center"
>
{#snippet cover()}
<Artwork
src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
{trackIds}
size="hero"
alt={name}
class="shrink-0"
/>
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{playing ? 'Pausar' : 'Reproducir'}
</Button>
<Button size="sm" variant="secondary" onclick={onEdit}>Editar</Button>
<Button size="sm" variant="secondary" onclick={onDelete}>Eliminar</Button>
{/snippet}
</CollectionHeader>
@@ -1,71 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import X from '@lucide/svelte/icons/x';
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
import { fmtTime, fmtDate } from '$lib/format';
import IconButton from '$lib/components/ui/IconButton.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
type PlaylistTrack = ApiTrackLike & {
createdAt: string;
duration: number | string;
listensCount: number | string;
};
interface Props {
tracks: PlaylistTrack[];
}
let { tracks }: Props = $props();
function playFrom(index: number) {
player.playOrToggle(toQueueItems(tracks), index);
}
</script>
<TrackTable
index
action
meta={[
{ label: 'Álbum', width: '76px' },
{ label: 'Añadida', width: '124px' },
{ label: 'Escuchas', width: '106px' }
]}
class="mt-6 sm:mt-8"
>
{#each tracks as track, i (track.id)}
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackIndexCell index={i} playing={player.playing} onToggle={() => playFrom(i)} {active} />
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta class="hidden sm:block">—</TrackMeta>
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<form
method="POST"
action="?/removeTrack"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label="Quitar de la playlist" size="xs" revealOnHover>
<X class="h-3.5 w-3.5" />
</IconButton>
</form>
</TrackRow>
{/each}
</TrackTable>
+20 -16
View File
@@ -3,13 +3,13 @@
import { enhance } from '$app/forms';
import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/Page.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import MediaCard from '$lib/components/ui/MediaCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Avatar from '$lib/components/ui/Avatar.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { plural, playlistMeta } from '$lib/format';
let { data, form } = $props();
@@ -55,36 +55,40 @@
{/snippet}
<Page>
<CollectionHeader
<PageHeader
eyebrow="Perfil"
title={profile.name}
meta="{profile.followersCount} {profile.followersCount === 1
? 'seguidor'
: 'seguidores'} · {profile.followingCount} siguiendo"
meta="{plural(
Number(profile.followersCount),
'seguidor',
'seguidores'
)} · {profile.followingCount} siguiendo"
align="center"
actions={canFollow ? followAction : undefined}
>
{#snippet cover()}
<Avatar name={profile.name} src={profile.profilePictureUrl} size="hero" />
{/snippet}
</CollectionHeader>
</PageHeader>
<div class="mt-9">
<div>
<SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" />
{#if playlists.length > 0}
<MediaGrid min="210px" minMobile="180px" class="mt-6">
<div class="mt-6 grid-cards-lg">
{#each playlists as playlist, i (playlist.id)}
<PlaylistCard
id={playlist.id}
name={playlist.name}
trackCount={playlist.trackCount}
<MediaCard
href="/playlists/{playlist.id}"
title={playlist.name}
subtitle={playlistMeta(Number(playlist.trackCount))}
trackIds={playlist.coverTrackIds}
updatedAt={playlist.updatedAt}
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt
)}"
index={i}
/>
{/each}
</MediaGrid>
</div>
{:else}
<EmptyState
icon={ListMusic}
+23 -35
View File
@@ -7,6 +7,7 @@
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Surface from '$lib/components/ui/Surface.svelte';
import ImageDropzone from '$lib/components/ui/ImageDropzone.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import Field from '$lib/components/ui/Field.svelte';
import Input from '$lib/components/ui/Input.svelte';
@@ -21,7 +22,8 @@
let title = $state('');
let audioName = $state('');
let audioSize = $state(0);
let coverPreview = $state('');
let hasCover = $state(false);
let coverResetToken = $state(0);
let dragging = $state(false);
let status = $state<Status>('idle');
let acceptedTerms = $state(false);
@@ -38,7 +40,7 @@
const busy = $derived(status === 'uploading');
const canPublish = $derived(
title.trim() !== '' && audioName !== '' && coverPreview !== '' && acceptedTerms && !busy
title.trim() !== '' && audioName !== '' && hasCover && acceptedTerms && !busy
);
const stepStates = $derived.by<StepState[]>(() => {
@@ -79,16 +81,12 @@
pickAudio(file);
}
function onCoverInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) coverPreview = URL.createObjectURL(file);
}
function reset() {
title = '';
audioName = '';
audioSize = 0;
coverPreview = '';
hasCover = false;
coverResetToken += 1;
status = 'idle';
acceptedTerms = false;
errorMsg = '';
@@ -103,23 +101,22 @@
<Page>
<PageHeader
title="Subir música"
subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
description="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
/>
<ol class="mt-7 grid gap-3 sm:mt-8 sm:grid-cols-3 sm:gap-4">
<ol class="grid gap-3 sm:grid-cols-3 sm:gap-4">
{#each steps as step, i (step.key)}
<li>
<Surface padding="sm" class={stepClass(stepStates[i])}>
<div class="flex items-center gap-3">
<span
class="grid h-7 w-7 shrink-0 place-items-center rounded-full text-sm {stepStates[
i
] === 'idle'
class="grid size-7 shrink-0 place-items-center rounded-full text-sm {stepStates[i] ===
'idle'
? 'bg-surface-2 text-fg-2'
: 'bg-accent-btn text-accent-soft'}"
>
{#if stepStates[i] === 'done'}
<Check class="h-3.5 w-3.5" strokeWidth={2.5} />
<Check class="size-3.5" strokeWidth={2.5} />
{:else}
{i + 1}
{/if}
@@ -134,10 +131,10 @@
{#if status === 'done'}
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
<span class="mx-auto grid h-14 w-14 place-items-center rounded-full bg-accent-soft text-ink">
<Check class="h-7 w-7" strokeWidth={2.5} />
<span class="mx-auto grid size-14 place-items-center rounded-full bg-accent-soft text-ink">
<Check class="size-7" strokeWidth={2.5} />
</span>
<h2 class="mt-6 font-display text-2xl font-semibold tracking-[-0.02em] text-fg">¡Subida!</h2>
<h2 class="mt-6 font-display text-2xl font-semibold tracking-display text-fg">¡Subida!</h2>
<p class="mt-3 text-sm text-fg-2">
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
unos momentos.
@@ -194,9 +191,9 @@
aria-label="Seleccionar archivo de audio"
/>
{#if audioName === ''}
<Headphones class="h-9 w-9 text-muted sm:h-10 sm:w-10" strokeWidth={1.4} />
<Headphones class="size-9 text-muted sm:size-10" strokeWidth={1.4} />
{:else}
<Music class="h-9 w-9 text-accent-soft sm:h-10 sm:w-10" strokeWidth={1.4} />
<Music class="size-9 text-accent-soft sm:size-10" strokeWidth={1.4} />
{/if}
{#if audioName === ''}
<p class="mt-4 text-sm font-medium text-fg">
@@ -213,24 +210,15 @@
<div class="grid items-start gap-6 sm:grid-cols-[auto_1fr] sm:gap-8">
<Field label="Portada">
<label
class="relative grid h-36 w-36 cursor-pointer place-items-center overflow-hidden rounded-control transition sm:h-44 sm:w-44"
style={coverPreview === '' ? 'background:var(--mf-cover-grad)' : undefined}
>
<input
type="file"
{#key coverResetToken}
<ImageDropzone
name="cover"
accept="image/*"
onchange={onCoverInput}
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada"
icon={ImageIcon}
gradient
size="hero"
onselect={() => (hasCover = true)}
/>
{#if coverPreview !== ''}
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
{:else}
<ImageIcon class="h-16 w-16 text-on-art/60" strokeWidth={1} />
{/if}
</label>
{/key}
</Field>
<Field label="Título" for="title">
+3 -3
View File
@@ -3,11 +3,11 @@
import Button from '$lib/components/ui/Button.svelte';
</script>
<div class="animate-enter mx-auto my-[14vh] max-w-105 text-center">
<h1 class="mb-2.5 font-display text-xl font-semibold tracking-[-0.02em] text-fg">
<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-sm leading-[1.65] text-fg-2">
<p class="mb-6.5 text-body text-fg-2">
{page.status === 404
? 'Comprueba el enlace o vuelve al inicio.'
: 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'}
+261 -323
View File
@@ -3,41 +3,38 @@
import History from '@lucide/svelte/icons/history';
import Shuffle from '@lucide/svelte/icons/shuffle';
import ListMusic from '@lucide/svelte/icons/list-music';
import { player } from '$lib/player/player.svelte';
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
import { shuffle as shuffleList } from '$lib/collections';
import { fmtTime, fmtDurationLong, fmtPlays } from '$lib/format';
import {
isTargetCurrent,
queueItemForTarget,
targetArtist,
targetExplicit,
targetForQueueItem,
targetId,
targetListensCount,
targetOwnerUserId,
targetTitle,
type TrackTarget
} from '$lib/tracks';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
player,
isQueueCurrent,
playAllOrToggle,
playShuffled,
toQueueItem,
trackFromQueueItem,
type ApiTrackLike
} from '$lib/player/player.svelte';
import { pressable } from '$lib/actions/pressable';
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
import { mixItemTrack } from '$lib/mixes';
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import MixTile from '$lib/components/ui/MixTile.svelte';
import MediaCard from '$lib/components/ui/MediaCard.svelte';
import EqBars from '$lib/components/ui/EqBars.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import ListRow from '$lib/components/ui/ListRow.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus';
import { createTrackMenu } from '$lib/menus.svelte';
import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config';
let { data } = $props();
let contextMenu = $state<ContextMenuState | null>(null);
const trackMenu = createTrackMenu();
type SpotlightRow = { target: TrackTarget; seconds: number };
type SpotlightTrack = Awaited<typeof data.spotlightTracks>['items'][number];
type SpotlightRow = { track: SpotlightTrack; seconds: number };
let spotlightRows = $state<SpotlightRow[]>([]);
let spotlightTotal = $state(0);
@@ -48,7 +45,7 @@
data.spotlightTracks.then(({ items, totalCount }) => {
if (cancelled) return;
spotlightRows = items.map((track) => ({
target: { track },
track,
seconds: Number(track.duration)
}));
spotlightTotal = totalCount;
@@ -61,7 +58,7 @@
const mixes = $derived(data.mixes);
const playlists = $derived(data.playlists);
const spotlight = $derived(data.spotlightPlaylist);
const spotlightItems = $derived(spotlightRows.map((row) => row.target));
const spotlightItems = $derived(spotlightRows.map((row) => row.track));
const spotlightDurationSeconds = $derived(
spotlightRows.reduce((sum, row) => sum + row.seconds, 0)
);
@@ -73,7 +70,7 @@
spotlightTrackCount === 0
? spotlightVisibilityLabel
: [
`${spotlightTrackCount} ${spotlightTrackCount === 1 ? 'Canción' : 'Canciones'}`,
plural(spotlightTrackCount, 'canción', 'canciones'),
fmtDurationLong(spotlightDurationSeconds),
spotlightVisibilityLabel
].join(' · ')
@@ -82,70 +79,74 @@
const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24));
const popularTargets = $derived<TrackTarget[]>(
recentPool.map(targetForQueueItem).slice(0, HOME_POPULAR_MAX)
const popularTracks = $derived<ApiTrackLike[]>(
recentPool.map(trackFromQueueItem).slice(0, HOME_POPULAR_MAX)
);
const spotlightQueue = $derived(spotlightItems.map(queueItemForTarget));
const spotlightIsCurrent = $derived(
spotlightQueue.length > 0 && spotlightQueue.some((item) => item.id === player.current.id)
);
const spotlightQueue = $derived(spotlightItems.map(toQueueItem));
const spotlightIsCurrent = $derived(isQueueCurrent(spotlightQueue));
const heroPlaying = $derived(spotlightIsCurrent && player.playing);
function playContinue(index: number) {
player.playOrToggle(continueItems, index);
}
function playPopular(index: number) {
player.playOrToggle(popularTargets.map(queueItemForTarget), index);
player.playOrToggle(popularTracks.map(toQueueItem), index);
}
function playSpotlightTrack(index: number) {
player.playOrToggle(spotlightQueue, index);
}
function playSpotlight() {
if (spotlightQueue.length === 0) return;
if (spotlightIsCurrent) player.toggle();
else player.playQueue(spotlightQueue, 0);
playAllOrToggle(spotlightQueue);
}
function shuffleSpotlight() {
if (spotlightQueue.length === 0) return;
player.playQueue(shuffleList(spotlightQueue), 0);
playShuffled(spotlightQueue);
}
function onRowKeydown(event: KeyboardEvent, action: () => void) {
if (event.key !== 'Enter' && event.key !== ' ') return;
function openContextMenu(event: MouseEvent, track: ApiTrackLike) {
trackMenu.open(event, track);
}
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
event.preventDefault();
action();
}
function openContextMenu(event: MouseEvent, target: TrackTarget) {
contextMenu = contextMenuStateFor(event, target);
}
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(queueItemForTarget(contextMenu));
contextMenu = null;
player.playQueue(
mix.items.map((item) => toQueueItem(mixItemTrack(item))),
0
);
}
</script>
{#snippet noListeningHistory()}
<EmptyState
icon={History}
title="Todavía no has escuchado nada"
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</EmptyState>
{/snippet}
<svelte:head>
<title>Musify</title>
<meta name="description" content="Tu música, sin límites." />
</svelte:head>
<section class="page-x pt-3 sm:pt-4">
<Page>
<div
class="relative overflow-hidden rounded-3xl bg-[image:var(--mf-hero-bg)] px-6 py-8 transition-[background] duration-500 sm:px-9 sm:py-8.5"
class="relative overflow-hidden rounded-panel-lg hero-surface px-6 py-8 theme-transition sm:px-9 sm:py-8.5"
>
<div
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
>
<div class="animate-enter max-w-135 min-w-65">
<div class="animate-enter max-w-hero min-w-65">
<h1
class="font-display text-3xl leading-tight font-medium tracking-tight text-pretty text-fg sm:text-4xl"
>
{data.greeting}
</h1>
<p class="mt-3.5 max-w-108 text-sm leading-[1.65] text-fg-2">
<p class="mt-3.5 max-w-prose-sm text-body text-fg-2">
Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.
</p>
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
@@ -160,300 +161,237 @@
</div>
{#await data.listeningStats then stats}
{@const statsList = [
{ value: stats.tracksThisWeek, label: 'Canciones nuevas escuchadas esta semana' },
{
value: fmtDurationLong(Number(stats.secondsThisWeek)),
label: 'Tiempo de escucha esta semana'
},
{ value: stats.streakDays, label: 'Días consecutivos escuchando' }
]}
<div class="flex gap-6.5">
<div>
<div class="font-display text-2xl font-medium tracking-tight text-fg">
{stats.tracksThisWeek}
{#each statsList as stat (stat.label)}
<div>
<div class="text-display-2 text-fg">{stat.value}</div>
<div class="mt-1 text-xs text-fg-3">{stat.label}</div>
</div>
<div class="mt-1 text-xs text-fg-3">Canciones nuevas escuchadas esta semana</div>
</div>
<div>
<div class="font-display text-2xl font-medium tracking-tight text-fg">
{fmtDurationLong(Number(stats.secondsThisWeek))}
</div>
<div class="mt-1 text-xs text-fg-3">Tiempo de escucha esta semana</div>
</div>
<div>
<div class="font-display text-2xl font-medium tracking-tight text-fg">
{stats.streakDays}
</div>
<div class="mt-1 text-xs text-fg-3">Días consecutivos escuchando</div>
</div>
{/each}
</div>
{/await}
</div>
</div>
</section>
<div class="flex flex-col gap-11.5 page-x pt-10 pb-8 sm:pb-10">
<!-- CONTINUAR ESCUCHANDO -->
<section>
<SectionHeading title="Continuar escuchando" subtitle="Retomalo donde lo dejaste" />
{#if continueItems.length > 0}
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
{#each continueItems as item, i (item.id)}
{@const isCurrent = player.current.id === item.id}
<div
role="button"
tabindex="0"
class="animate-enter rounded-art p-2.25 pr-3.5 transition-colors {isCurrent
? 'bg-accent-tint'
: 'hover:bg-hover'}"
style="animation-delay:{Math.min(i, 10) * 40}ms"
onclick={() => playContinue(i)}
onkeydown={(e) => onRowKeydown(e, () => playContinue(i))}
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
>
<TrackTitleCell
trackId={item.id}
title={item.title}
artist={item.artist}
ownerUserId={item.ownerUserId}
size="lg"
active={isCurrent}
onClick={() => playContinue(i)}
/>
</div>
{/each}
</div>
{:else}
<EmptyState
icon={History}
title="Todavía no has escuchado nada"
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</EmptyState>
{/if}
</section>
<!-- PLAYLIST DESTACADA -->
{#if spotlight}
<div class="mt-10 flex flex-col gap-section">
<!-- CONTINUAR ESCUCHANDO -->
<section>
<div
class="grid grid-cols-1 overflow-hidden rounded-panel-lg transition-[background] duration-500 lg:grid-cols-2"
style="background-image:var(--mf-spotlight-bg), linear-gradient(to right, transparent 50%, color-mix(in oklch, var(--mf-scrim) 22%, transparent) 61%, transparent 92.5%)"
>
<div class="flex flex-col justify-center p-8 sm:p-9">
<div
role="button"
tabindex="0"
class="flex w-fit items-center gap-5 self-start sm:gap-6"
onclick={() => goto(`/playlists/${spotlight.id}`)}
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${spotlight.id}`))}
>
<Artwork
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
spotlight.updatedAt
)}"
trackIds={spotlight.coverTrackIds}
size="2xl"
alt={spotlight.name}
class="shrink-0"
<SectionHeading title="Continuar escuchando" subtitle="Retomalo donde lo dejaste" />
{#if continueItems.length > 0}
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
{#each continueItems as item, i (item.id)}
{@const isCurrent = player.current.id === item.id}
<ListRow
onclick={() => playContinue(i)}
oncontextmenu={(e) => openContextMenu(e, trackFromQueueItem(item))}
active={isCurrent}
size="lg"
variant="card"
title={item.title}
subtitle={item.artist}
subtitleHref={item.ownerUserId}
trackId={item.id}
class="animate-enter"
style="--i:{i}"
/>
<div class="flex min-w-0 flex-col gap-3">
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">
Playlist destacada
</p>
<h3 class="truncate font-display text-2xl font-medium text-fg">
{spotlight.name}
</h3>
<p class="text-sm text-fg-2">{spotlightMeta}</p>
</div>
</div>
<p class="mt-5 max-w-110 text-sm text-fg-2">
{spotlight.description || 'Tu colección, siempre a mano.'}
</p>
<div class="mt-5 flex gap-2.5">
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
<Button variant="glass" onclick={shuffleSpotlight}>Aleatorio</Button>
</div>
{/each}
</div>
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6">
{#each spotlightRows as { target, seconds }, i (targetId(target))}
{:else}
{@render noListeningHistory()}
{/if}
</section>
<!-- PLAYLIST DESTACADA -->
{#if spotlight}
<section>
<div
class="grid grid-cols-1 overflow-hidden rounded-panel-lg spotlight-surface theme-transition lg:grid-cols-2"
>
<div class="flex flex-col justify-center p-8 sm:p-9">
<div
role="button"
tabindex="0"
onclick={() => playSpotlightTrack(i)}
onkeydown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
playSpotlightTrack(i);
}}
class="flex items-center gap-4 rounded-control p-2 text-left transition-colors {isTargetCurrent(
target
)
? 'bg-accent-tint'
: 'hover:bg-hover'}"
use:pressable={() => goto(`/playlists/${spotlight.id}`)}
class="flex w-fit items-center gap-5 self-start sm:gap-6"
>
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums">{i + 1}</span>
<div class="min-w-0 flex-1">
<div
class="truncate text-sm {isTargetCurrent(target)
? 'text-accent-soft'
: 'text-fg'}"
>
{targetTitle(target)}
</div>
<ArtistLink
name={targetArtist(target)}
ownerUserId={targetOwnerUserId(target)}
class="mt-0.5 text-xs text-fg-3"
/>
<Artwork
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
spotlight.updatedAt
)}"
trackIds={spotlight.coverTrackIds}
size="2xl"
alt={spotlight.name}
class="shrink-0"
/>
<div class="flex min-w-0 flex-col gap-3">
<p class="text-eyebrow text-fg-2">Playlist destacada</p>
<h3 class="truncate text-display-2 text-fg">
{spotlight.name}
</h3>
<p class="text-sm text-fg-2">{spotlightMeta}</p>
</div>
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
</div>
{:else}
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
<p class="mt-5 max-w-prose-sm text-sm text-fg-2">
{spotlight.description || 'Tu colección, siempre a mano.'}
</p>
<div class="mt-5 flex gap-2.5">
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
<Button variant="glass" onclick={shuffleSpotlight}>Aleatorio</Button>
</div>
</div>
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6">
{#each spotlightRows as { track, seconds }, i (track.id)}
<ListRow
onclick={() => playSpotlightTrack(i)}
active={player.current.id === track.id}
size="sm"
title={track.title}
subtitle={track.artist}
subtitleHref={track.ownerUserId}
>
{#snippet leading()}
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums"
>{i + 1}</span
>
{/snippet}
{#snippet trailing()}
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
{/snippet}
</ListRow>
{:else}
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
{/each}
</div>
</div>
</section>
{/if}
<!-- MIXES -->
<section>
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
{#if mixes.length > 0}
<div class="grid-tiles">
{#each mixes as mix, i (mix.id)}
<MediaCard
href="/mixes/{mix.id}"
title={mix.title}
subtitle={mix.subtitle}
trackIds={mix.items.map((item) => item.trackId)}
onPlay={(event) => playMix(mix, event)}
index={i}
/>
{/each}
</div>
</div>
{:else}
<EmptyState
icon={Shuffle}
title="Todavía no tienes mezclas"
description="Se generan automáticamente a partir de lo que escuchas."
/>
{/if}
</section>
{/if}
<!-- MIXES -->
<section>
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
{#if mixes.length > 0}
<div class="grid grid-cols-[repeat(auto-fill,minmax(164px,1fr))] gap-5">
{#each mixes as mix, i (mix.id)}
<MixTile {mix} index={i} />
{/each}
</div>
{:else}
<EmptyState
icon={Shuffle}
title="Todavía no tienes mezclas"
description="Se generan automáticamente a partir de lo que escuchas."
<!-- POPULARES ESTA SEMANA -->
<section>
<SectionHeading title="Populares esta semana" subtitle="Lo más escuchado de tu biblioteca" />
{#if popularTracks.length > 0}
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
{#each popularTracks as track, i (track.id)}
{@const isCurrent = player.current.id === track.id}
<ListRow
onclick={() => playPopular(i)}
oncontextmenu={(e) => openContextMenu(e, track)}
active={isCurrent}
size="sm"
title={track.title}
subtitle={track.artist}
subtitleHref={track.ownerUserId}
explicit={track.isExplicit}
trackId={track.id}
>
{#snippet leading()}
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
{#if isCurrent}
<EqBars size={13} paused={!player.playing} />
{:else}
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
{/if}
</span>
{/snippet}
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
{fmtPlays(track.listensCount)}
</span>
{/snippet}
</ListRow>
{/each}
</div>
{:else}
{@render noListeningHistory()}
{/if}
</section>
<!-- TUS PLAYLISTS -->
<section>
<SectionHeading
title="Tus playlists"
subtitle="Creadas y guardadas por ti"
href={data.playlistsHasMore ? '/playlists' : undefined}
/>
{/if}
</section>
<!-- POPULARES ESTA SEMANA -->
<section>
<SectionHeading title="Populares esta semana" subtitle="Lo más escuchado de tu biblioteca" />
{#if popularTargets.length > 0}
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
{#each popularTargets as target, i (targetId(target))}
{@const isCurrent = isTargetCurrent(target)}
<div
role="button"
tabindex="0"
class="flex items-center gap-3.5 rounded-control p-2 transition-colors {isCurrent
? 'bg-accent-tint'
: 'hover:bg-hover'}"
onclick={() => playPopular(i)}
onkeydown={(e) => onRowKeydown(e, () => playPopular(i))}
oncontextmenu={(e) => openContextMenu(e, target)}
>
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
{#if isCurrent}
<EqBars size={13} paused={!player.playing} />
{:else}
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
{/if}
</span>
<div class="min-w-0 flex-1">
<TrackTitleCell
trackId={targetId(target)}
title={targetTitle(target)}
artist={targetArtist(target)}
ownerUserId={targetOwnerUserId(target)}
explicit={targetExplicit(target)}
active={isCurrent}
onClick={() => playPopular(i)}
/>
</div>
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
{fmtPlays(targetListensCount(target))}
</span>
</div>
{/each}
</div>
{:else}
<EmptyState
icon={History}
title="Todavía no has escuchado nada"
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</EmptyState>
{/if}
</section>
<!-- TUS PLAYLISTS -->
<section>
<SectionHeading title="Tus playlists" subtitle="Creadas y guardadas por ti">
{#snippet actions()}
{#if data.playlistsHasMore}
<a
href="/playlists"
class="shrink-0 text-xs font-medium tracking-wider text-fg-3 uppercase transition-colors hover:text-fg"
>
Ver todas
</a>
{/if}
{/snippet}
</SectionHeading>
{#if playlists.length > 0}
<div class="grid grid-cols-[repeat(auto-fill,minmax(258px,1fr))] gap-3.5">
{#each playlists as playlist, i (playlist.id)}
<div
role="button"
tabindex="0"
class="group/card animate-enter flex items-center gap-3.5 rounded-panel p-3 transition-colors hover:bg-hover"
style="--i:{i}"
onclick={() => goto(`/playlists/${playlist.id}`)}
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${playlist.id}`))}
>
<Artwork
src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
{#if playlists.length > 0}
<div class="grid-wide">
{#each playlists as playlist, i (playlist.id)}
<ListRow
onclick={() => goto(`/playlists/${playlist.id}`)}
size="lg"
variant="card"
title={playlist.name}
subtitle={playlist.description}
coverSrc="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds}
size="lg"
alt={playlist.name}
class="shrink-0"
style="--i:{i}"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-sm tracking-tight text-fg">
{playlist.name}
</div>
{#if playlist.description}
<div class="mt-1 truncate text-xs text-fg-3">{playlist.description}</div>
{/if}
<ArtistLink
name={data.user?.name}
ownerUserId={data.user?.sub}
class="mt-1 text-xs text-fg-3"
/>
</div>
</div>
{/each}
</div>
{:else}
<EmptyState
icon={ListMusic}
title="Todavía no tienes listas"
description="Crea tu primera lista y añádele canciones de tu biblioteca."
>
{#snippet actions()}
<Button href="/playlists">Crear lista</Button>
{/snippet}
</EmptyState>
{/if}
</section>
</div>
{/each}
</div>
{:else}
<EmptyState
icon={ListMusic}
title="Todavía no tienes listas"
description="Crea tu primera lista y añádele canciones de tu biblioteca."
>
{#snippet actions()}
<Button href="/playlists">Crear lista</Button>
{/snippet}
</EmptyState>
{/if}
</section>
</div>
</Page>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
{#if trackMenu.state}
<ContextMenu
x={trackMenu.state.x}
y={trackMenu.state.y}
openLeft={trackMenu.state.openLeft}
onClose={() => trackMenu.close()}
items={[
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => trackMenu.playNext() }
]}
playlistAction={{
action: '?/addTrack',
fields: { trackId: String(trackMenu.state.track.id) },
label: 'Añadir a una playlist'
}}
{playlists}
onClose={() => (contextMenu = null)}
onAddToQueue={addToQueue}
/>
{/if}
+24 -10
View File
@@ -57,6 +57,7 @@
--spacing-icon-md: 1.125rem;
--spacing-icon-lg: 1.25rem;
--spacing-icon-xl: 2.25rem;
--spacing-section: 2.875rem;
--container-prose-sm: 26rem;
--container-hero: 34rem;
@@ -96,16 +97,6 @@
scrollbar-gutter: stable;
}
@utility stroke-thin {
stroke-width: 1.3;
}
@utility stroke-regular {
stroke-width: 1.8;
}
@utility stroke-bold {
stroke-width: 2.5;
}
@utility text-display-1 {
@apply font-display text-3xl font-medium tracking-tight sm:text-4xl;
}
@@ -196,6 +187,29 @@
--mf-dock-h: var(--mf-player-h);
}
}
@utility genre-tile {
background: linear-gradient(
140deg,
oklch(var(--mf-tile-l) var(--mf-tile-c) var(--tile-hue)),
oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) var(--tile-hue))
);
}
@utility hero-surface {
background-image: var(--mf-hero-bg);
}
@utility spotlight-surface {
background-image:
var(--mf-spotlight-bg),
linear-gradient(
to right,
transparent 50%,
color-mix(in oklch, var(--mf-scrim) 22%, transparent) 61%,
transparent 92.5%
);
}
@utility field-control {
@apply w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none;
}
@layer base {
svg.lucide {
+3 -7
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
import Logo from '$lib/components/ui/Logo.svelte';
let { data } = $props();
@@ -15,12 +16,7 @@
<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">
<div class="flex items-center gap-3">
<span class="h-8 w-8 rounded-xl bg-[image:var(--mf-logo-grad)]"></span>
<span class="font-display text-[clamp(1.5rem,3vw,2.5rem)] font-semibold tracking-tight">
Musify
</span>
</div>
<Logo size="lg" />
<div class="animate-enter flex flex-col items-center gap-5">
<h1
@@ -32,7 +28,7 @@
<p class="text-lg text-fg-2">Inicia sesión para continuar.</p>
</div>
<div class="flex w-full max-w-80 flex-col gap-3">
<div class="flex w-full max-w-cta flex-col gap-3">
<Button href={`/auth/login?${returnParam}`} reload size="lg" class="w-full">
Iniciar sesión
</Button>