Author SHA1 Message Date
Admin d892d976a3 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).
2026-09-21 15:05:07 +02:00
Admin c07676f486 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.
2026-09-21 14:51:43 +02:00
Admin e4b0ce3df0 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).
2026-09-21 14:39:20 +02:00
Admin 1a2bafe96c 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).
2026-09-21 00:58:52 +02:00
Admin 85f06edc53 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).
2026-09-21 00:48:23 +02:00
Admin a0ba57b569 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).
2026-09-21 00:30:30 +02:00
Admin 8c6d4d6a6d 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).
2026-09-21 00:21:15 +02:00
Admin 137a3b62bb 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).
2026-09-21 00:14:20 +02:00
Admin 9fd0bf46d6 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).
2026-09-20 23:05:53 +02:00
Admin e187016c70 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.
2026-09-20 23:01:13 +02:00
Admin 89326c0a16 web-player: P4 — botones (Button, IconButton, PlayButton)
Button: variantes subtle (primary/secondary, se mantienen iguales por
decisión del usuario), glass (antes secondaryOnGlow, con font-normal
propio), accent, strong (cta-strong/ink, sustituye los overrides `!`
del hero de Inicio), danger. Usa los tokens --color-btn* de P1 en vez
de bg-[var(--mf-btn-...)]. Radio unificado a rounded-control.

IconButton: añade shape (square/round), size (xs/sm/md/lg), tone
(muted/subtle/plain), pressed (aria-pressed + text-accent), href/reload
(enlaces), surface (fondo permanente + hover a surface-hover en vez del
alias `hover` genérico, que coincide con surface-2 y no daba contraste).

PlayButton nuevo: un solo sitio para los SVG de play/pausa, variantes
strong/glass, as="span" para overlays no interactivos. Sustituye el
botón principal de TransportControls, el overlay de MixTile y el
círculo de "Mejor resultado" en Explorar.

Migra a IconButton: cerrar cola, "Crear playlist" (Sidebar), Me gusta
(TrackInfo, con pressed), cola (PlayerExtras), atrás (TopBar), login
(MobileHeader) y las acciones de fila en tablas (quitar/añadir,
size xs). BackLink usa size-icon-sm y quita un hover duplicado.

Decisión (sin bloquear): se elimina el botón "adelante" decorativo de
TopBar y el icono AlignJustify decorativo de PlayerExtras (código
muerto visual). Los botones de TransportControls (shuffle/prev/next/
repeat) se dejan sin migrar a propósito: hoy no tienen fondo ni
hit-box propios y IconButton siempre aplica una caja de tamaño fijo —
migrarlos habría añadido una píldora de hover visible no verificable
sin Docker en esta sesión.

Verificado con npm run check/build.
2026-09-20 22:48:08 +02:00
Admin 65518e72e3 Remove no-op playlist picture migration; cover picture-add transition in tests
The Sept migration MakePlayListPictureColumnsOptional altered the
PlayLists picture columns to nullable:true when they were already
nullable:true (set back in MakePlayListPicturesNullable, two months
earlier) — a dead migration left over from making the Pictures
navigation itself optional, which produces no actual schema change.
Verified with `dotnet ef migrations has-pending-model-changes` and a
full migration run against a fresh Postgres container that removing
it introduces no drift.

Also add a test for updating a playlist that has no picture yet,
which was the one Pictures-related state transition without coverage.
2026-09-20 22:40:40 +02:00
Admin 521cafc5c4 web-player: P3 — Artwork y Avatar unificados
Crea src/lib/components/ui/Artwork.svelte y Avatar.svelte (B.3) y
migra todos los usos de Cover, PlaylistArt, MixArt y ArtistAvatar
(Sidebar, Queue, TrackInfo, TrackContextMenu, AlbumContextMenu,
TrackTitleCell, AlbumCard, PlaylistCard, MixTile, AlbumHeader,
PlaylistHeader, Home, Explorar, mezcla, perfil de usuario, TopBar,
MobileHeader). Borra los 4 componentes viejos.

Artwork: tamaño decide dimensión y radio automáticamente (tokens
cover-*/radius de P1); cascada src → mosaico (≥4 trackIds) → primera
carátula → fallback, con caída en cascada si cada paso falla (antes
repartida en 3 componentes con lógica ligeramente distinta). Añade un
tamaño 'fill' (aspect-square w-full) no descrito en la spec, necesario
para las tarjetas de grid fluidas hasta que P9 introduzca MediaCard.

TrackTitleCell cambia su prop `coverSize` (clases Tailwind libres) por
`size: 'sm'|'lg'` delegando en Artwork. SearchResultRow pierde su prop
`kind` y el radio por tipo (ahora lo decide Artwork/Avatar).

Decisión de producto (P3, sin bloquear en pregunta): se quita el
nombre bajo el avatar de perfil en /u/[id] por ser redundante con el
título de CollectionHeader.

Small deltas de radio/tamaño documentados en README del plan (auto-
radio por tamaño reemplaza clases ad-hoc; ver tabla B.1.1/B.1.2).

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).
2026-09-20 22:39:05 +02:00
Admin 3e56725566 web-player: P2 — tema: fuente única de verdad y sin parpadeo en claro
Elimina la duplicación de los ~18 tokens dinámicos por hue entre
theme.css y buildThemeTokens(): theme.css ya solo define neutrales
fijos, superficies, layout y z-index; los valores por hue/modo salen
únicamente de tokens.ts, inyectados por SSR como <style> inline en
+layout.svelte (helper tokensToCss) y reaplicados en cada cambio de
hue. app.html fija data-theme antes del primer paint leyendo
localStorage, eliminando el flash oscuro en modo claro guardado.

Los dos fondos con style= inline pasan a @utility app-backdrop/
app-vignette; el padding-bottom del main pasa a @utility app-main
activada por --mf-dock-h vía [data-has-track].

Nota: <style>{expr}</style> no interpola en Svelte (se compila como
CSS con scope al componente) — el bloque SSR se inyecta con
{@html `<style>${css}</style>`} ya que el contenido es 100% generado
(sin input de usuario).

Verificado en el navegador: --mf-accent/--mf-bg resuelven correctamente
en SSR, data-theme='light' se aplica antes del primer render sin flash.
2026-09-20 22:26:37 +02:00
Admin fbca8156a7 Clean up Application layer commands/queries readability and correctness
Add missing AsNoTracking() on read-only queries, drop redundant
Update() calls on already-tracked entities, and fix inconsistent
formatting (stray spaces in generics, ternaries, and lambdas) across
the Albums/Tracks/PlayLists/Users command and query handlers.
2026-09-20 22:23:57 +02:00
Admin 4b3d0b5fbd web-player: P1 — tokens base del sistema de diseño
Añade de forma aditiva (sin migrar componentes) los tokens de la
sección B.1 del plan de refactor: radios (tag/thumb), tamaños de
carátula e icono, anchos de contenedor, tracking/leading tipográfico,
colores de botón (--color-btn*), recetas @utility de tipografía
(text-display-*, text-eyebrow, text-body, text-count), trazo de icono
(stroke-thin/regular/bold), superficies/grids (glass-bar, grid-cards*,
theme-transition) y el stagger de .animate-enter vía --i. Añade
variables de layout (--mf-player-side-w, --mf-menu-w, --mf-submenu-w,
--mf-range-*) y z-index semántico en theme.css. Limpia utilidades de
color sin uso (--color-accent-dim/glow/hair/line/muted, --color-on-accent)
y borra --mf-sidebar-bg (sin importadores) de theme.css y tokens.ts.
Scrollbar pasa a tokens con variante de modo claro.

Verificado con npm run build (Tailwind compila los @utility con
variantes responsive dentro de @apply).
2026-09-20 22:21:04 +02:00
Admin ec5b84f748 web-player: P0 — línea base y bugs sueltos
Corrige eslint (imports muertos en liked), tamaño de título ausente en
MediaCard, clases no excluyentes en las filas del spotlight de Inicio,
tipografía del modal de borrado de álbumes, text-base inútil en botones
de icono (Sidebar, Queue) y borra Badge.svelte (sin usos).
2026-09-20 22:14:48 +02:00
129 changed files with 3080 additions and 4909 deletions
@@ -77,8 +77,6 @@ namespace Musify.Application.Albums
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
} }
database.Albums.Update(album);
if (pictureIntent != null && finalPictureKey != null) if (pictureIntent != null && finalPictureKey != null)
{ {
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync( var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
@@ -106,7 +106,7 @@ namespace Musify.Application.PlayLists
}, validation.Value); }, validation.Value);
} }
async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync( private async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
Guid playListId, Guid playListId,
UploadIntent pictureIntent, UploadIntent pictureIntent,
string finalPictureKey, string finalPictureKey,
@@ -85,8 +85,6 @@ namespace Musify.Application.PlayLists
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
} }
database.PlayLists.Update(playListEntity);
if (pictureIntent != null && finalPictureKey != null) if (pictureIntent != null && finalPictureKey != null)
{ {
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync( var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
@@ -117,7 +115,7 @@ namespace Musify.Application.PlayLists
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds); return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
} }
async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync( private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
Guid playListId, Guid playListId,
UploadIntent pictureIntent, UploadIntent pictureIntent,
string finalPictureKey, string finalPictureKey,
@@ -14,6 +14,7 @@ namespace Musify.Application.Tracks
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
{ {
var track = await database.ListeningHistories var track = await database.ListeningHistories
.AsNoTracking()
.Include(t => t.Track.Owner) .Include(t => t.Track.Owner)
.Include(t => t.Track.Tags) .Include(t => t.Track.Tags)
.Where(t => t.UserId == query.UserId) .Where(t => t.UserId == query.UserId)
@@ -16,6 +16,7 @@ namespace Musify.Application.Tracks
public async ValueTask<IEnumerable<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) public async ValueTask<IEnumerable<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
{ {
var recentTrackIds = await database.ListeningHistories var recentTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == query.UserId) .Where(l => l.UserId == query.UserId)
.GroupBy(l => l.TrackId) .GroupBy(l => l.TrackId)
.OrderByDescending(g => g.Max(l => l.ListenedAt)) .OrderByDescending(g => g.Max(l => l.ListenedAt))
@@ -24,6 +25,7 @@ namespace Musify.Application.Tracks
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var tracksById = await database.Tracks var tracksById = await database.Tracks
.AsNoTracking()
.Include(t => t.Owner) .Include(t => t.Owner)
.Include(t => t.Tags) .Include(t => t.Tags)
.Where(t => recentTrackIds.Contains(t.Id)) .Where(t => recentTrackIds.Contains(t.Id))
@@ -16,6 +16,7 @@ namespace Musify.Application.Users
var weekAgo = DateTime.UtcNow.AddDays(-7); var weekAgo = DateTime.UtcNow.AddDays(-7);
var thisWeekHistory = database.ListeningHistories var thisWeekHistory = database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo); .Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo);
var tracksThisWeek = await thisWeekHistory var tracksThisWeek = await thisWeekHistory
@@ -24,10 +25,11 @@ namespace Musify.Application.Users
.CountAsync(cancellationToken); .CountAsync(cancellationToken);
var secondsThisWeek = await thisWeekHistory var secondsThisWeek = await thisWeekHistory
.Join(database.Tracks, l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds) .Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds)
.SumAsync(cancellationToken); .SumAsync(cancellationToken);
var listenedDates = await database.ListeningHistories var listenedDates = await database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == request.UserId) .Where(l => l.UserId == request.UserId)
.Select(l => l.ListenedAt.Date) .Select(l => l.ListenedAt.Date)
.Distinct() .Distinct()
@@ -16,22 +16,22 @@ namespace Musify.Application.Users
{ {
public async ValueTask<ErrorOr<PaginatedResponse<UserApplicationResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PaginatedResponse<UserApplicationResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
{ {
var user = database.Users var usersQuery = database.Users
.AsNoTracking() .AsNoTracking()
.AsQueryable(); .AsQueryable();
if (!string.IsNullOrEmpty(request.UsernameSearch)) if (!string.IsNullOrEmpty(request.UsernameSearch))
{ {
var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant(); var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant();
user = user.Where(u => u.NormalizedName.Contains(normalizedUsername)); usersQuery = usersQuery.Where(u => u.NormalizedName.Contains(normalizedUsername));
} }
var totalCount = await user.CountAsync(cancellationToken); var totalCount = await usersQuery.CountAsync(cancellationToken);
var pagedUser = await user var pagedUsers = await usersQuery
.Select(u => UserApplicationResponse.FromEntity(u)) .Select(u => UserApplicationResponse.FromEntity(u))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserApplicationResponse>.FromPagedList(pagedUser); return PaginatedResponse<UserApplicationResponse>.FromPagedList(pagedUsers);
} }
} }
} }
@@ -1,106 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class MakePlayListPictureColumnsOptional : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "SmallPictureName",
table: "PlayLists",
type: "character varying(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "OriginalPictureName",
table: "PlayLists",
type: "character varying(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "MediumPictureName",
table: "PlayLists",
type: "character varying(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "LargePictureName",
table: "PlayLists",
type: "character varying(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64,
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "SmallPictureName",
table: "PlayLists",
type: "character varying(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "OriginalPictureName",
table: "PlayLists",
type: "character varying(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "MediumPictureName",
table: "PlayLists",
type: "character varying(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "LargePictureName",
table: "PlayLists",
type: "character varying(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64,
oldNullable: true);
}
}
}
@@ -18,7 +18,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "10.0.8") .HasAnnotation("ProductVersion", "10.0.12")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" });
@@ -68,7 +68,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("Delivered"); b.HasIndex("Delivered");
b.ToTable("InboxState"); b.ToTable("InboxState", (string)null);
}); });
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
@@ -159,7 +159,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
.IsUnique(); .IsUnique();
b.ToTable("OutboxMessage"); b.ToTable("OutboxMessage", (string)null);
}); });
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
@@ -189,7 +189,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("Created"); b.HasIndex("Created");
b.ToTable("OutboxState"); b.ToTable("OutboxState", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.Album", b => modelBuilder.Entity("Musify.Domain.Entities.Album", b =>
@@ -231,7 +231,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId");
b.ToTable("Albums"); b.ToTable("Albums", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b =>
@@ -259,7 +259,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("AlbumId", "TrackId") b.HasIndex("AlbumId", "TrackId")
.IsUnique(); .IsUnique();
b.ToTable("AlbumHasTrack"); b.ToTable("AlbumHasTrack", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b =>
@@ -283,7 +283,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("ListeningHistory"); b.ToTable("ListeningHistory", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.Mix", b => modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
@@ -317,7 +317,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId", "Position"); b.HasIndex("UserId", "Position");
b.ToTable("Mixes"); b.ToTable("Mixes", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => modelBuilder.Entity("Musify.Domain.Entities.MixItem", b =>
@@ -341,7 +341,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("MixId", "Position"); b.HasIndex("MixId", "Position");
b.ToTable("MixItems"); b.ToTable("MixItems", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
@@ -385,7 +385,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("PlayLists"); b.ToTable("PlayLists", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
@@ -412,7 +412,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("TrackId"); b.HasIndex("TrackId");
b.ToTable("PlayListHasTrack"); b.ToTable("PlayListHasTrack", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.Track", b => modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
@@ -450,7 +450,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId");
b.ToTable("Tracks"); b.ToTable("Tracks", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
@@ -475,7 +475,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId", "TrackId") b.HasIndex("UserId", "TrackId")
.IsUnique(); .IsUnique();
b.ToTable("TrackLikes"); b.ToTable("TrackLikes", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b =>
@@ -495,7 +495,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("TrackId", "Tag") b.HasIndex("TrackId", "Tag")
.IsUnique(); .IsUnique();
b.ToTable("TrackTags"); b.ToTable("TrackTags", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.Upload", b => modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
@@ -524,7 +524,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Upload"); b.ToTable("Upload", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
@@ -575,7 +575,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("UploadIntents"); b.ToTable("UploadIntents", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.User", b => modelBuilder.Entity("Musify.Domain.Entities.User", b =>
@@ -613,7 +613,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("User"); b.ToTable("User", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b =>
@@ -638,7 +638,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("FollowerId", "FollowedId") b.HasIndex("FollowerId", "FollowedId")
.IsUnique(); .IsUnique();
b.ToTable("UserFollows"); b.ToTable("UserFollows", (string)null);
}); });
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
@@ -659,7 +659,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("UserHasTrack"); b.ToTable("UserHasTrack", (string)null);
}); });
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b =>
@@ -686,7 +686,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId"); b.HasKey("CorrelationId");
b.ToTable("AlbumProcessingState"); b.ToTable("AlbumProcessingState", (string)null);
}); });
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b =>
@@ -713,7 +713,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId"); b.HasKey("CorrelationId");
b.ToTable("PlayListProcessingState"); b.ToTable("PlayListProcessingState", (string)null);
}); });
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b =>
@@ -749,7 +749,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId"); b.HasKey("CorrelationId");
b.ToTable("TrackProcessingState"); b.ToTable("TrackProcessingState", (string)null);
}); });
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
@@ -803,7 +803,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("AlbumId"); b1.HasKey("AlbumId");
b1.ToTable("Albums"); b1.ToTable("Albums", (string)null);
b1.WithOwner() b1.WithOwner()
.HasForeignKey("AlbumId"); .HasForeignKey("AlbumId");
@@ -896,28 +896,32 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasColumnType("uuid"); .HasColumnType("uuid");
b1.Property<string>("LargeName") b1.Property<string>("LargeName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("LargePictureName"); .HasColumnName("LargePictureName");
b1.Property<string>("MediumName") b1.Property<string>("MediumName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName"); .HasColumnName("MediumPictureName");
b1.Property<string>("OriginalName") b1.Property<string>("OriginalName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("OriginalPictureName"); .HasColumnName("OriginalPictureName");
b1.Property<string>("SmallName") b1.Property<string>("SmallName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("SmallPictureName"); .HasColumnName("SmallPictureName");
b1.HasKey("PlayListId"); b1.HasKey("PlayListId");
b1.ToTable("PlayLists"); b1.ToTable("PlayLists", (string)null);
b1.WithOwner() b1.WithOwner()
.HasForeignKey("PlayListId"); .HasForeignKey("PlayListId");
@@ -988,7 +992,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("TrackId"); b1.HasKey("TrackId");
b1.ToTable("Tracks"); b1.ToTable("Tracks", (string)null);
b1.WithOwner() b1.WithOwner()
.HasForeignKey("TrackId"); .HasForeignKey("TrackId");
@@ -1025,7 +1029,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("TrackId"); b1.HasKey("TrackId");
b1.ToTable("Tracks"); b1.ToTable("Tracks", (string)null);
b1.WithOwner() b1.WithOwner()
.HasForeignKey("TrackId"); .HasForeignKey("TrackId");
@@ -107,5 +107,30 @@ namespace Musify.Application.Tests.PlayLists
Assert.Equal("Original Name", result.Value.Name); Assert.Equal("Original Name", result.Value.Name);
Assert.Equal("Original description", result.Value.Description); Assert.Equal("Original description", result.Value.Description);
} }
[Fact]
public async Task Handle_PlayListWithoutPictures_ValidPictureIntent_SetsPictureAndPublishesSourceEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
playList.Pictures = null;
var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "new-cover.webp");
await SeedAsync(owner, playList, intent);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, intent.Id);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("new-cover.webp", result.Value.SmallImageKeyName);
var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.UpdatePlayListPictureSourceEvent>(), Arg.Any<CancellationToken>());
}
} }
} }
+15 -1
View File
@@ -15,7 +15,8 @@ npm run dev # servidor de desarrollo (puerto 5173)
npm run build npm run build
npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio
npm run format # prettier --write . npm run format # prettier --write .
npm run lint # prettier --check . && eslint . 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 ## Reglas
@@ -28,6 +29,19 @@ npm run lint # prettier --check . && eslint .
- Enlaces a endpoints `+server.ts` (login/logout) llevan `data-sveltekit-reload`. - 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. - 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 ## 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`. `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).
+33 -15
View File
@@ -772,21 +772,21 @@ Tareas:
| Parte | Estado | Commit | Notas / decisiones tomadas | | Parte | Estado | Commit | Notas / decisiones tomadas |
| ----- | ------ | ------ | -------------------------- | | ----- | ------ | ------ | -------------------------- |
| P0 | ⬜ | | | | P0 | ✅ | (pendiente) | Button primary/secondary se mantienen iguales (decisión del usuario) |
| P1 | ⬜ | | | | P1 | ✅ | (pendiente) | Verificado con `build` (Tailwind compila `@apply sm:` dentro de `@utility`); sin QA visual autenticada (Docker no disponible en esta sesión) |
| P2 | ⬜ | | | | P2 | ✅ | (pendiente) | `<style>{expr}</style>` no interpola en Svelte (se trata como CSS del componente); solución: `{@html \`<style>${css}</style>\`}` |
| P3 | ⬜ | | | | 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 | ⬜ | | | | 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 | ⬜ | | | | 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 | ⬜ | | | | 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 | ⬜ | | | | 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 | ⬜ | | | | 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 | ⬜ | | | | 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 | ⬜ | | | | 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 | ⬜ | | | | 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 | ⬜ | | | | 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 | ⬜ | | | | 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 | ⬜ | | | | 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): Decisiones de producto pendientes (preguntar al usuario antes de la parte indicada):
- P0/P4: ¿`Button` primary debe verse distinto de secondary? - 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? - P10: ¿iconos en los botones de cabecera de álbum **y** playlist?
- P12: ¿logo con cuadrado de gradiente también en la Sidebar? - 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) ## 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", "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", "gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "prettier --check . && eslint ." "lint:tokens": "node scripts/check-tokens.mjs",
"lint": "prettier --check . && eslint . && npm run lint:tokens"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@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.');
+7
View File
@@ -3,6 +3,13 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
try {
if (localStorage.getItem('musify.theme') === 'light') {
document.documentElement.dataset.theme = 'light';
}
} catch {}
</script>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
@@ -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,119 +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 PlaylistArt from '$lib/components/ui/PlaylistArt.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"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={[]}
size="small"
class="h-9 w-9 shrink-0 rounded-control"
/>
<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>
@@ -5,6 +5,9 @@
import LogIn from '@lucide/svelte/icons/log-in'; import LogIn from '@lucide/svelte/icons/log-in';
import MenuItem from './ui/MenuItem.svelte'; import MenuItem from './ui/MenuItem.svelte';
import AccountMenu from './ui/AccountMenu.svelte'; 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 { interface Props {
user: SessionUser | null; user: SessionUser | null;
@@ -15,23 +18,14 @@
</script> </script>
<header <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 <a href="/">
href="/" <Logo size="sm" />
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> </a>
<div class="relative">
{#if user} {#if user}
<AccountMenu <AccountMenu {user} {accountUrl} width="md">
{user}
{accountUrl}
panelClass="absolute top-full right-0 mt-2 w-56 overflow-hidden"
>
{#snippet trigger({ toggle, open })} {#snippet trigger({ toggle, open })}
<button <button
type="button" type="button"
@@ -40,15 +34,7 @@
aria-expanded={open} aria-expanded={open}
class="flex items-center rounded-full" class="flex items-center rounded-full"
> >
{#if user.picture} <Avatar name={user.name} src={user.picture} size="sm" />
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<span
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
</button> </button>
{/snippet} {/snippet}
{#snippet extraItems({ close })} {#snippet extraItems({ close })}
@@ -57,14 +43,8 @@
{/snippet} {/snippet}
</AccountMenu> </AccountMenu>
{:else} {:else}
<a <IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md">
href="/auth/login" <LogIn class="size-icon-lg" strokeWidth={2} />
data-sveltekit-reload </IconButton>
aria-label="Iniciar sesión"
class="flex items-center rounded-full p-1.5 text-fg-3 transition hover:bg-hover hover:text-fg"
>
<LogIn class="h-6 w-6" strokeWidth={2} />
</a>
{/if} {/if}
</div>
</header> </header>
+26 -49
View File
@@ -1,17 +1,12 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; 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 { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte'; import { createPlaylistModal } from '$lib/playlists.svelte';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import PlaylistArt from './ui/PlaylistArt.svelte'; import ListRow from './ui/ListRow.svelte';
import Home from '@lucide/svelte/icons/house'; import IconButton from './ui/IconButton.svelte';
import Compass from '@lucide/svelte/icons/compass'; import Logo from './ui/Logo.svelte';
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 Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
interface SidebarPlaylist { interface SidebarPlaylist {
@@ -28,19 +23,7 @@
let { user, playlists }: Props = $props(); let { user, playlists }: Props = $props();
const navLinks = $derived( const navLinks = $derived(appNavLinks(user, { playlists: playlists.length, liked: liked.count }));
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 }]
);
function isActive(href: string) { function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section); return isSectionActive(href, page.url.pathname, page.data.section);
@@ -48,10 +31,12 @@
</script> </script>
<aside <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)" 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"> <nav class="flex flex-col gap-1">
{#each navLinks as link (link.href)} {#each navLinks as link (link.href)}
@@ -63,7 +48,7 @@
? 'bg-surface-2 text-fg' ? 'bg-surface-2 text-fg'
: 'text-fg-2 hover:bg-hover hover: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> <span class="flex-1 truncate">{link.label}</span>
{#if link.count} {#if link.count}
<span class="text-xs text-muted tabular-nums">{link.count}</span> <span class="text-xs text-muted tabular-nums">{link.count}</span>
@@ -75,48 +60,40 @@
{#if user} {#if user}
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div> <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"> <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} {#if playlists.length > 0}
<button <IconButton
type="button" label="Crear playlist"
tone="plain"
size="xs"
onclick={() => createPlaylistModal.show()} onclick={() => createPlaylistModal.show()}
aria-label="Crear playlist"
class="text-base text-muted transition-colors hover:text-fg"
> >
<Plus class="h-3.5 w-3.5" strokeWidth={1.25} /> <Plus class="size-icon-sm" strokeWidth={1.25} />
</button> </IconButton>
{/if} {/if}
</div> </div>
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5"> <div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5">
{#each playlists as playlist (playlist.id)} {#each playlists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`} {@const active = page.url.pathname === `/playlists/${playlist.id}`}
<a <ListRow
href="/playlists/{playlist.id}" href="/playlists/{playlist.id}"
class="flex items-center gap-2.75 rounded-control px-3 py-1.75 transition-colors duration-150 {active {active}
? 'bg-surface-hover' size="xs"
: 'hover:bg-hover'}" title={playlist.name}
> coverSrc="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
<PlaylistArt playlist.updatedAt
playlistId={playlist.id} )}"
trackIds={playlist.coverTrackIds} trackIds={playlist.coverTrackIds}
version={playlist.updatedAt} class="px-3 py-2"
size="small"
class="h-8.5 w-8.5 shrink-0 overflow-hidden rounded-lg 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>
{:else} {:else}
<button <button
type="button" type="button"
onclick={() => createPlaylistModal.show()} 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" 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 Crear tu primera lista
</button> </button>
{/each} {/each}
+5 -18
View File
@@ -1,11 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; 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 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 { interface Props {
user: SessionUser | null; user: SessionUser | null;
@@ -14,16 +10,7 @@
let { user, class: klass = '' }: Props = $props(); let { user, class: klass = '' }: Props = $props();
const links = $derived( const links = $derived(appNavLinks(user).filter((link) => link.primary));
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 }]
);
function isActive(href: string) { function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section); return isSectionActive(href, page.url.pathname, page.data.section);
@@ -31,7 +18,7 @@
</script> </script>
<nav <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))" style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
> >
{#each links as link (link.href)} {#each links as link (link.href)}
@@ -39,11 +26,11 @@
<a <a
href={link.href} href={link.href}
aria-current={active ? 'page' : undefined} 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'
: 'text-fg-2'}" : '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> <span class="text-xs font-medium">{link.label}</span>
</a> </a>
{/each} {/each}
+28 -46
View File
@@ -3,11 +3,14 @@
import { page } from '$app/state'; import { page } from '$app/state';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config'; import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import { searchHref } from '$lib/navigation.svelte';
import Search from '@lucide/svelte/icons/search'; import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import AccountMenu from './ui/AccountMenu.svelte'; import AccountMenu from './ui/AccountMenu.svelte';
import Avatar from './ui/Avatar.svelte';
import IconButton from './ui/IconButton.svelte';
import Button from './ui/Button.svelte';
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
@@ -22,13 +25,9 @@
let searchInput: HTMLInputElement | undefined = $state(); let searchInput: HTMLInputElement | undefined = $state();
let searchTimeout: ReturnType<typeof setTimeout> | undefined; let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function buildHref(term: string) {
return term ? `/explore?q=${encodeURIComponent(term)}` : '/explore';
}
function runSearch(term: string) { function runSearch(term: string) {
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return; if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
goto(buildHref(term), { goto(searchHref(term), {
replaceState: page.url.pathname === '/explore', replaceState: page.url.pathname === '/explore',
keepFocus: true, keepFocus: true,
noScroll: true noScroll: true
@@ -55,28 +54,22 @@
</script> </script>
<header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex"> <header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
<div class="flex shrink-0 gap-1.5"> <IconButton
<button label="Atrás"
type="button" shape="round"
tone="subtle"
surface
size="xs"
onclick={() => history.back()} onclick={() => history.back()}
aria-label="Atrás"
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-surface-2 text-fg-2 transition-colors hover:bg-surface-hover hover:text-fg"
> >
<ChevronLeft class="h-3.75 w-3.75" strokeWidth={1.9} /> <ChevronLeft class="size-icon-sm" strokeWidth={1.9} />
</button> </IconButton>
<span
aria-hidden="true"
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-surface-2 text-muted"
>
<ChevronRight class="h-3.75 w-3.75" strokeWidth={1.9} />
</span>
</div>
<form onsubmit={onSubmit} class="max-w-120 min-w-0 flex-1"> <form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1">
<label <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" 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 <input
type="text" type="text"
bind:this={searchInput} bind:this={searchInput}
@@ -90,9 +83,9 @@
type="button" type="button"
onclick={clearQuery} onclick={clearQuery}
aria-label="Borrar búsqueda" 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> </button>
{/if} {/if}
</label> </label>
@@ -100,41 +93,30 @@
<div class="flex-1"></div> <div class="flex-1"></div>
<div class="relative shrink-0"> <div class="shrink-0">
{#if user} {#if user}
<AccountMenu <AccountMenu {user} {accountUrl}>
{user}
{accountUrl}
panelClass="absolute top-full right-0 z-20 mt-2 w-52 overflow-hidden"
>
{#snippet trigger({ toggle, open })} {#snippet trigger({ toggle, open })}
<button <button
type="button" type="button"
onclick={toggle} onclick={toggle}
aria-label="Tu cuenta" aria-label="Tu cuenta"
aria-expanded={open} aria-expanded={open}
class="block h-8.25 w-8.25 shrink-0 overflow-hidden rounded-full border border-line-strong transition active:scale-95" class="block shrink-0 rounded-full transition active:scale-95"
> >
{#if user.picture} <Avatar
<img src={user.picture} alt="" class="h-full w-full object-cover opacity-90" /> name={user.name}
{:else} src={user.picture}
<span size="sm"
class="grid h-full w-full place-items-center bg-surface-2 text-xs font-semibold text-fg uppercase" class="border border-line-strong opacity-90"
> />
{user.name.charAt(0)}
</span>
{/if}
</button> </button>
{/snippet} {/snippet}
</AccountMenu> </AccountMenu>
{:else} {:else}
<a <Button href="/auth/login" reload variant="accent" size="sm" class="shrink-0"
href="/auth/login" >Iniciar sesión</Button
data-sveltekit-reload
class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-ink transition hover:brightness-110"
> >
Iniciar sesión
</a>
{/if} {/if}
</div> </div>
</header> </header>
@@ -1,109 +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 PlaylistArt from '$lib/components/ui/PlaylistArt.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"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={[]}
size="small"
class="h-9 w-9 shrink-0 rounded-control"
/>
<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"> <script lang="ts">
import TrackInfo from './TrackInfo.svelte'; import TrackInfo from './TrackInfo.svelte';
import TransportControls from './TransportControls.svelte'; import TransportControls from './TransportControls.svelte';
import SeekBar from './SeekBar.svelte'; import Slider from '$lib/components/ui/Slider.svelte';
import PlayerExtras from './PlayerExtras.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> </script>
<div <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"> <div class="min-w-0 flex-1">
<TrackInfo compact /> <TrackInfo compact />
@@ -15,14 +24,46 @@
</div> </div>
<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 /> <TrackInfo />
</div> </div>
<div class="flex min-w-0 flex-1 flex-col items-center gap-2"> <div class="flex min-w-0 flex-1 flex-col items-center gap-2">
<TransportControls /> <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> </div>
<PlayerExtras />
</div> </div>
@@ -15,7 +15,7 @@
</script> </script>
<div <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} {#if player.error}
<div class="pointer-events-auto px-4 pb-2.5"> <div class="pointer-events-auto px-4 pb-2.5">
@@ -1,22 +0,0 @@
<script lang="ts">
import VolumeControl from './VolumeControl.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import AlignJustify from '@lucide/svelte/icons/align-justify';
import ListMusic from '@lucide/svelte/icons/list-music';
</script>
<div class="hidden w-64 shrink-0 items-center justify-end gap-3.5 sm:flex">
<span aria-hidden="true" class="text-fg-3">
<AlignJustify class="h-3.75 w-3.75" strokeWidth={1.8} />
</span>
<VolumeControl />
<button
type="button"
onclick={() => queuePanel.toggle()}
aria-pressed={queuePanel.open}
aria-label="Cola de reproducción"
class="transition-colors hover:text-fg {queuePanel.open ? 'text-fg' : 'text-fg-2'}"
>
<ListMusic class="h-4 w-4" strokeWidth={1.8} />
</button>
</div>
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte'; import { queuePanel } from '$lib/player/queuePanel.svelte';
import Cover from '$lib/components/ui/Cover.svelte'; import MediaIdentity from '$lib/components/ui/MediaIdentity.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte'; import ListRow from '$lib/components/ui/ListRow.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import { fmtTime } from '$lib/format'; import { fmtTime } from '$lib/format';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
@@ -17,49 +18,31 @@
{#if queuePanel.open} {#if queuePanel.open}
<aside <aside
transition:fly={{ x: 24, duration: 220, easing: cubicOut }} 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)" style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
> >
<div class="mb-5 flex items-center justify-between"> <div class="mb-5 flex items-center justify-between">
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2> <h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
<button <IconButton label="Cerrar cola" tone="plain" size="xs" onclick={() => queuePanel.close()}>
type="button" <X class="size-icon-md" />
onclick={() => queuePanel.close()} </IconButton>
aria-label="Cerrar cola"
class="text-base text-fg-3 transition-colors hover:text-fg"
>
<X class="h-4 w-4" />
</button>
</div> </div>
{#if player.current.id} {#if player.current.id}
<p class="mb-2.75 text-xs font-medium tracking-widest text-fg-3 uppercase">Reproduciendo</p> <p class="mb-3 text-eyebrow text-fg-3">Reproduciendo</p>
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5"> <div class="mb-6 rounded-control bg-accent-tint p-2.5">
<Cover <MediaIdentity
trackId={player.current.id} trackId={player.current.id}
size="small" title={player.current.title}
alt={player.current.title} artist={player.current.artist || '—'}
class="h-10 w-10 shrink-0 rounded-lg"
/>
<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} ownerUserId={player.current.ownerUserId}
class="mt-0.5 text-xs text-fg-3" active
/> />
</div> </div>
</div>
{/if} {/if}
<div class="mb-2.75 flex items-center justify-between gap-3"> <div class="mb-3 flex items-center justify-between gap-3">
<span <span class="min-w-0 flex-1 truncate text-eyebrow text-fg-3"> A continuación </span>
class="min-w-0 flex-1 truncate text-xs font-medium tracking-widest text-fg-3 uppercase"
>
A continuación
</span>
{#if upcoming.length > 0} {#if upcoming.length > 0}
<button <button
type="button" type="button"
@@ -74,35 +57,22 @@
<div class="flex flex-1 flex-col gap-px overflow-y-auto"> <div class="flex flex-1 flex-col gap-px overflow-y-auto">
{#each upcoming as track, i (track.id)} {#each upcoming as track, i (track.id)}
{@const index = player.tracks.length - upcoming.length + i} {@const index = player.tracks.length - upcoming.length + i}
<div <ListRow
role="button"
tabindex="0"
onclick={() => player.playQueueIndex(index)} onclick={() => player.playQueueIndex(index)}
onkeydown={(e) => { size="sm"
if (e.key !== 'Enter' && e.key !== ' ') return; title={track.title}
e.preventDefault(); subtitle={track.artist || '—'}
player.playQueueIndex(index); subtitleHref={track.ownerUserId}
}}
class="flex items-center gap-3 rounded-control p-2.25 text-left hover:bg-hover"
>
<Cover
trackId={track.id} trackId={track.id}
size="small" class="p-2"
alt={track.title} >
class="h-10 w-10 shrink-0 rounded-lg opacity-80" {#snippet trailing()}
/>
<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} {#if track.duration}
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span> <span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span
>
{/if} {/if}
</div> {/snippet}
</ListRow>
{:else} {:else}
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p> <p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p>
{/each} {/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}
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { liked } from '$lib/player/liked.svelte'; import { liked } from '$lib/player/liked.svelte';
import Cover from '$lib/components/ui/Cover.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte'; import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte'; import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import Heart from '@lucide/svelte/icons/heart'; import Heart from '@lucide/svelte/icons/heart';
interface Props { interface Props {
@@ -27,16 +28,16 @@
} }
</script> </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} {#if player.current.id}
<Cover <Artwork
trackId={player.current.id} trackIds={[player.current.id]}
size="small" size="sm"
alt={player.current.title} alt={player.current.title}
class="h-11 w-11 shrink-0 rounded-xl md:h-12 md:w-12" class="shrink-0 md:size-cover-md"
/> />
{:else} {:else}
<div class="h-11 w-11 shrink-0 rounded-xl bg-surface md:h-12 md:w-12"></div> <div class="size-cover-sm shrink-0 rounded-art bg-surface md:size-cover-md"></div>
{/if} {/if}
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5"> <div class="flex min-w-0 items-center gap-1.5">
@@ -52,14 +53,14 @@
/> />
</div> </div>
{#if !compact} {#if !compact}
<button <IconButton
type="button" label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
tone="plain"
size="xs"
pressed={isLiked}
onclick={toggleLike} onclick={toggleLike}
aria-label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
aria-pressed={isLiked}
class="shrink-0 transition hover:opacity-80 {isLiked ? 'text-accent' : 'text-fg-3'}"
> >
<Heart class="h-4 w-4" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} /> <Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} />
</button> </IconButton>
{/if} {/if}
</div> </div>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import PlayButton from '$lib/components/ui/PlayButton.svelte';
import Shuffle from '@lucide/svelte/icons/shuffle'; import Shuffle from '@lucide/svelte/icons/shuffle';
import Repeat from '@lucide/svelte/icons/repeat'; import Repeat from '@lucide/svelte/icons/repeat';
@@ -11,7 +12,7 @@
const sideClass = $derived( const sideClass = $derived(
compact 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' : 'flex text-fg-2 transition-colors hover:text-fg active:scale-90'
); );
</script> </script>
@@ -25,7 +26,7 @@
aria-label="Aleatorio" aria-label="Aleatorio"
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}" 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> </button>
{/if} {/if}
@@ -36,23 +37,11 @@
</svg> </svg>
</button> </button>
<button <PlayButton
type="button" playing={player.playing}
onclick={() => player.toggle()} onclick={() => player.toggle()}
aria-label={player.playing ? 'Pausar' : 'Reproducir'} label={player.playing ? 'Pausar' : 'Reproducir'}
class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-cta-strong text-ink transition duration-150 hover:brightness-95" />
>
{#if player.playing}
<svg width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
<rect x="3" y="1" width="3" height="13" rx="1" />
<rect x="9" y="1" width="3" height="13" rx="1" />
</svg>
{:else}
<svg width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
<path d="M3 1.5v12l10-6z" />
</svg>
{/if}
</button>
<button type="button" onclick={() => player.next()} aria-label="Siguiente" class={sideClass}> <button type="button" onclick={() => player.next()} aria-label="Siguiente" class={sideClass}>
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor"> <svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
@@ -69,7 +58,7 @@
aria-label="Repetir" aria-label="Repetir"
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}" 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> </button>
{/if} {/if}
</div> </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 MenuItem from './MenuItem.svelte';
import GlassMenu from './GlassMenu.svelte'; import GlassMenu from './GlassMenu.svelte';
import { themeMode } from '$lib/theme/mode.svelte'; import { themeMode } from '$lib/theme/mode.svelte';
import { clickOutside } from '$lib/actions/clickOutside';
interface Props { interface Props {
user: SessionUser; user: SessionUser;
accountUrl?: string | null; accountUrl?: string | null;
panelClass?: string; width?: 'sm' | 'md';
extraItems?: Snippet<[{ close: () => void }]>; extraItems?: Snippet<[{ close: () => void }]>;
trigger: Snippet<[{ toggle: () => void; open: boolean }]>; trigger: Snippet<[{ toggle: () => void; open: boolean }]>;
} }
let { let { user, accountUrl, width = 'sm', extraItems, trigger }: Props = $props();
user,
accountUrl, const WIDTH: Record<'sm' | 'md', string> = { sm: 'w-52', md: 'w-56' };
panelClass = 'absolute top-full right-0 mt-2 w-52 overflow-hidden',
extraItems,
trigger
}: Props = $props();
let open = $state(false); let open = $state(false);
let menuRef: HTMLDivElement | undefined = $state();
function toggle() { function toggle() {
open = !open; open = !open;
@@ -37,16 +33,18 @@
open = false; open = false;
} }
function onDocumentClick(event: MouseEvent) { function onWindowKeydown(event: KeyboardEvent) {
if (menuRef && !menuRef.contains(event.target as Node)) open = false; if (event.key === 'Escape') close();
} }
</script> </script>
<div class="relative" bind:this={menuRef}> <svelte:window onkeydown={onWindowKeydown} />
<div class="relative" use:clickOutside={close}>
{@render trigger({ toggle, open })} {@render trigger({ toggle, open })}
{#if 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} /> <MenuItem icon={User} label="Ver perfil" href="/u/{user.sub}" onclick={close} />
{@render extraItems?.({ close })} {@render extraItems?.({ close })}
{#if accountUrl} {#if accountUrl}
@@ -69,5 +67,3 @@
</GlassMenu> </GlassMenu>
{/if} {/if}
</div> </div>
<svelte:window onclick={onDocumentClick} />
@@ -1,62 +0,0 @@
<script lang="ts">
import MediaCard from './MediaCard.svelte';
import PlaylistArt from './PlaylistArt.svelte';
import Cover from './Cover.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)}
{#if coverSrc}
<Cover trackId={id} src={coverSrc} size="large" alt={title} class={artClass} />
{:else}
<PlaylistArt {trackIds} class={artClass} />
{/if}
{/snippet}
</MediaCard>
@@ -1,13 +1,10 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Disc3 from '@lucide/svelte/icons/disc-3'; import Disc3 from '@lucide/svelte/icons/disc-3';
import ImageDropzone from './ImageDropzone.svelte'; import CoverForm from './CoverForm.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte'; import Field from './Field.svelte';
import Input from './Input.svelte'; import Input from './Input.svelte';
import Textarea from './Textarea.svelte'; import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte';
import { ALBUM_EARLIEST_YEAR } from '$lib/config'; import { ALBUM_EARLIEST_YEAR } from '$lib/config';
interface Props { interface Props {
@@ -43,38 +40,23 @@
let title = $state(untrack(() => initialTitle)); let title = $state(untrack(() => initialTitle));
let description = $state(untrack(() => initialDescription)); let description = $state(untrack(() => initialDescription));
let releaseYear = $state(untrack(() => (initialReleaseYear ? String(initialReleaseYear) : ''))); let releaseYear = $state(untrack(() => (initialReleaseYear ? String(initialReleaseYear) : '')));
let submitting = $state(false);
const latestYear = new Date().getFullYear() + 1; const latestYear = new Date().getFullYear() + 1;
</script> </script>
<form <CoverForm
method="POST"
{action} {action}
enctype="multipart/form-data" {coverFallbackUrl}
use:enhance={() => { {requireCover}
submitting = true; coverIcon={Disc3}
return async ({ update, result }) => { {formMessage}
await update({ reset: false }); {submitLabel}
submitting = false; {submittingLabel}
if (result.type === 'success') onSuccess?.(); canSubmit={title.trim() !== ''}
}; {onCancel}
}} {onSuccess}
class="mt-5 w-full max-w-2xl space-y-5"
> >
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8"> {#snippet fields()}
<Field>
<ImageDropzone
name="cover"
fallbackUrl={coverFallbackUrl}
required={requireCover}
icon={Disc3}
gradient
class="h-36 w-36 rounded-2xl"
/>
</Field>
<div class="flex flex-1 flex-col gap-4">
<Field label="Título" for="album-title"> <Field label="Título" for="album-title">
<Input <Input
id="album-title" id="album-title"
@@ -97,9 +79,8 @@
placeholder={String(new Date().getFullYear())} placeholder={String(new Date().getFullYear())}
/> />
</Field> </Field>
</div> {/snippet}
</div> {#snippet extra()}
<Field label="Descripción" for="album-description"> <Field label="Descripción" for="album-description">
<Textarea <Textarea
id="album-description" id="album-description"
@@ -109,15 +90,5 @@
placeholder="¿De qué va este álbum?" placeholder="¿De qué va este álbum?"
/> />
</Field> </Field>
{/snippet}
{#if formMessage} </CoverForm>
<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>
@@ -1,36 +0,0 @@
<script lang="ts">
interface Props {
name: string;
imageUrl?: string | null;
size?: number;
}
let { name, imageUrl, size = 108 }: Props = $props();
const initials = $derived(
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
);
</script>
<div class="flex flex-col items-center gap-2.5 text-center" style="width:{size}px">
<div
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface ring-1 ring-line transition duration-300 ease-out ring-inset"
style="width:{size}px;height:{size}px"
>
{#if imageUrl}
<img src={imageUrl} alt="" class="absolute inset-0 h-full w-full object-cover" />
{:else}
<div
class="pointer-events-none absolute inset-0"
style="background:repeating-linear-gradient(135deg, color-mix(in oklch, var(--mf-text) 12%, transparent) 0 2px, transparent 2px 10px)"
></div>
<span class="relative text-lg font-semibold text-fg">{initials}</span>
{/if}
</div>
<div class="w-full truncate text-sm text-fg">{name}</div>
</div>
@@ -0,0 +1,133 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'hero' | 'fill';
interface Props {
src?: string | null;
trackIds?: (string | number)[];
size: Size;
shape?: 'auto' | 'round';
imageSize?: 'small' | 'medium' | 'large';
fallback?: 'music' | 'gradient' | 'none';
alt?: string;
class?: string;
children?: Snippet;
}
let {
src,
trackIds = [],
size,
shape = 'auto',
imageSize,
fallback = 'music',
alt = '',
class: klass = '',
children
}: Props = $props();
const DIMENSION: Record<Size, string> = {
xs: 'size-cover-xs',
sm: 'size-cover-sm',
md: 'size-cover-md',
lg: 'size-cover-lg',
xl: 'size-cover-xl',
'2xl': 'size-cover-2xl',
hero: 'size-cover-hero sm:size-cover-hero-sm',
fill: 'aspect-square w-full'
};
const RADIUS: Record<Size, string> = {
xs: 'rounded-thumb',
sm: 'rounded-art',
md: 'rounded-art',
lg: 'rounded-art',
xl: 'rounded-art',
'2xl': 'rounded-art-lg',
hero: 'rounded-art-lg',
fill: ''
};
const AUTO_IMAGE_SIZE: Record<Size, 'small' | 'medium' | 'large'> = {
xs: 'small',
sm: 'small',
md: 'medium',
lg: 'medium',
xl: 'medium',
'2xl': 'large',
hero: 'large',
fill: 'large'
};
const resolvedImageSize = $derived(imageSize ?? AUTO_IMAGE_SIZE[size]);
const radius = $derived(shape === 'round' ? 'rounded-full' : RADIUS[size]);
let srcFailed = $state(false);
$effect(() => {
src;
srcFailed = false;
});
let singleFailed = $state(false);
$effect(() => {
trackIds[0];
singleFailed = false;
});
const showSrc = $derived(!!src && !srcFailed);
const showMosaic = $derived(!showSrc && trackIds.length >= 4);
const singleId = $derived(!showSrc && !showMosaic && trackIds.length > 0 ? trackIds[0] : null);
const showSingle = $derived(singleId !== null && !singleFailed);
</script>
<div class="relative overflow-hidden {DIMENSION[size]} {radius} {klass}">
{#if showSrc}
<img
{src}
{alt}
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={() => (srcFailed = true)}
class="h-full w-full object-cover"
/>
{:else if showMosaic}
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
{#each trackIds.slice(0, 4) as id (id)}
<img
src="/api/tracks/{id}/cover?size=small"
alt=""
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={(e) => ((e.currentTarget as HTMLImageElement).style.visibility = 'hidden')}
class="h-full w-full object-cover"
/>
{/each}
</div>
{:else if showSingle}
<img
src="/api/tracks/{singleId}/cover?size={resolvedImageSize}"
{alt}
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={() => (singleFailed = true)}
class="h-full w-full object-cover"
/>
{:else if fallback === 'music'}
<div class="grid h-full w-full place-items-center bg-surface">
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-3">
<path
d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z"
/>
</svg>
</div>
{:else if fallback === 'gradient'}
<div class="h-full w-full bg-[image:var(--mf-cover-grad)]"></div>
{:else}
<div class="h-full w-full bg-surface"></div>
{/if}
{@render children?.()}
</div>
@@ -0,0 +1,60 @@
<script lang="ts">
type Size = 'sm' | 'md' | 'lg' | 'hero';
interface Props {
name: string;
src?: string | null;
size: Size;
class?: string;
}
let { name, src, size, class: klass = '' }: Props = $props();
const DIMENSION: Record<Size, string> = {
sm: 'size-cover-xs',
md: 'size-cover-lg',
lg: 'size-cover-xl',
hero: 'size-cover-hero'
};
const TEXT: Record<Size, string> = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-xl',
hero: 'text-3xl'
};
const initials = $derived(
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
);
let failed = $state(false);
$effect(() => {
src;
failed = false;
});
</script>
<div
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface-2 {DIMENSION[
size
]} {klass}"
>
{#if src && !failed}
<img
{src}
alt=""
loading="lazy"
decoding="async"
onerror={() => (failed = true)}
class="absolute inset-0 h-full w-full object-cover"
/>
{:else}
<span class="font-semibold text-fg uppercase {TEXT[size]}">{initials}</span>
{/if}
</div>
@@ -24,10 +24,10 @@
<a <a
href={target.url} href={target.url}
{onclick} {onclick}
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft" class="inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
> >
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft"> <span class="grid size-8 place-items-center">
<ArrowLeft class="h-4 w-4" /> <ArrowLeft class="size-icon-sm" />
</span> </span>
{target.label} {target.label}
</a> </a>
@@ -1,23 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
tone?: 'neutral' | 'accent' | 'danger';
class?: string;
children: Snippet;
}
let { tone = 'neutral', class: klass = '', children }: Props = $props();
const tones = {
neutral: 'bg-surface-2 text-fg',
accent: 'bg-accent/20 text-accent',
danger: 'bg-danger/10 text-danger-fg'
};
</script>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-sm leading-none font-semibold {tones[tone]} {klass}"
>
{@render children()}
</span>
+10 -11
View File
@@ -3,7 +3,7 @@
import LoaderCircle from '@lucide/svelte/icons/loader-circle'; import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props { interface Props {
variant?: 'primary' | 'secondary' | 'secondaryOnGlow' | 'accent' | 'danger'; variant?: 'primary' | 'secondary' | 'glass' | 'accent' | 'strong' | 'danger';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit'; type?: 'button' | 'submit';
href?: string; href?: string;
@@ -34,21 +34,20 @@
lg: 'px-6 py-3.5 text-sm' lg: 'px-6 py-3.5 text-sm'
}; };
const subtleButton = const subtle = 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg';
'bg-[var(--mf-btn-bg)] text-fg-2 hover:bg-[var(--mf-btn-bg-hover)] hover:text-fg'; const glass = 'font-normal bg-btn-glass text-fg-2 hover:bg-btn-glass-hover hover:text-fg';
const subtleButtonOnGlow =
'bg-[var(--mf-btn-secondary-bg)] text-fg-2 hover:bg-[var(--mf-btn-secondary-bg-hover)] hover:text-fg';
const variants = { const variants = {
primary: subtleButton, primary: subtle,
secondary: subtleButton, secondary: subtle,
secondaryOnGlow: subtleButtonOnGlow, glass,
accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover', accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover',
danger: 'bg-danger text-on-accent hover:brightness-110' strong: 'bg-cta-strong text-ink hover:brightness-95',
danger: 'bg-danger text-on-art hover:brightness-110'
}; };
const classes = $derived( const classes = $derived(
`inline-flex items-center justify-center gap-2 rounded-xl transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}` `inline-flex items-center justify-center gap-2 rounded-control transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
); );
</script> </script>
@@ -59,7 +58,7 @@
{:else} {:else}
<button {type} disabled={disabled || loading} {onclick} class={classes}> <button {type} disabled={disabled || loading} {onclick} class={classes}>
{#if loading} {#if loading}
<LoaderCircle class="h-4 w-4 animate-spin" /> <LoaderCircle class="size-4 animate-spin" />
{/if} {/if}
{@render children()} {@render children()}
</button> </button>
@@ -13,15 +13,15 @@
</script> </script>
<label class="flex items-center gap-3 text-sm text-fg-2 {klass}"> <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 <input
type="checkbox" type="checkbox"
{name} {name}
bind:checked 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 <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} strokeWidth={3}
/> />
</span> </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>
@@ -1,47 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
trackId: string | number;
size?: 'small' | 'medium' | 'large';
src?: string | null;
alt?: string;
class?: string;
children?: Snippet;
oncontextmenu?: (event: MouseEvent) => void;
}
let {
trackId,
size = 'medium',
src,
alt = '',
class: klass = '',
children,
oncontextmenu
}: Props = $props();
let failed = $state(false);
const imageSrc = $derived(src ?? `/api/tracks/${trackId}/cover?size=${size}`);
$effect(() => {
imageSrc;
failed = false;
});
</script>
<div class="relative overflow-hidden bg-surface {klass}" role="presentation" {oncontextmenu}>
{#if !failed}
<img
src={imageSrc}
{alt}
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={() => (failed = true)}
class="h-full w-full object-cover"
/>
{/if}
{@render children?.()}
</div>
@@ -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}"> <div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
{#if Icon} {#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}
{#if title} {#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} {title}
</h3> </h3>
{/if} {/if}
+21 -1
View File
@@ -3,14 +3,33 @@
size?: number; size?: number;
barWidth?: number; barWidth?: number;
paused?: boolean; paused?: boolean;
overlay?: boolean;
class?: string; 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]; const delays = [0, 0.3, 0.6];
</script> </script>
{#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"> <div class="flex items-end justify-center gap-0.5 {klass}" style="height:{size}px">
{#each delays as delay (delay)} {#each delays as delay (delay)}
<span <span
@@ -21,3 +40,4 @@
></span> ></span>
{/each} {/each}
</div> </div>
{/if}
@@ -7,7 +7,7 @@
</script> </script>
<span <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" title="Contenido explícito"
aria-label="Contenido explícito">E</span aria-label="Contenido explícito">E</span
> >
@@ -4,8 +4,14 @@
interface Props { interface Props {
label: string; label: string;
type?: 'button' | 'submit'; type?: 'button' | 'submit';
size?: 'sm' | 'md'; size?: 'xs' | 'sm' | 'md' | 'lg';
shape?: 'square' | 'round';
tone?: 'muted' | 'subtle' | 'plain';
pressed?: boolean;
surface?: boolean;
revealOnHover?: boolean; revealOnHover?: boolean;
href?: string;
reload?: boolean;
onclick?: (event: MouseEvent) => void; onclick?: (event: MouseEvent) => void;
class?: string; class?: string;
children: Snippet; children: Snippet;
@@ -14,26 +20,55 @@
let { let {
label, label,
type = 'button', type = 'button',
size = 'md', size = 'sm',
shape = 'square',
tone = 'muted',
pressed,
surface = false,
revealOnHover = false, revealOnHover = false,
href,
reload = false,
onclick, onclick,
class: klass = '', class: klass = '',
children children
}: Props = $props(); }: Props = $props();
const sizes = { sm: 'h-7 w-7', md: 'h-8 w-8' }; const SIZE: Record<'xs' | 'sm' | 'md' | 'lg', string> = {
xs: 'size-7',
sm: 'size-8',
md: 'size-9',
lg: 'size-10'
};
const TEXT: Record<'muted' | 'subtle' | 'plain', string> = {
muted: 'text-muted hover:text-fg',
subtle: 'text-fg-2 hover:text-fg',
plain: 'text-fg-2 hover:text-fg'
};
const background = $derived(
surface ? 'bg-surface-2 hover:bg-surface-hover' : tone === 'plain' ? '' : 'hover:bg-hover'
);
const classes = $derived(
`grid place-items-center transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : TEXT[tone]} ${background} ${
revealOnHover ? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100' : ''
} ${klass}`
);
</script> </script>
<button {#if href}
{type} <a
{onclick} {href}
data-sveltekit-reload={reload ? '' : undefined}
aria-label={label} aria-label={label}
title={label} title={label}
class="grid place-items-center rounded-control text-muted transition hover:bg-hover hover:text-fg focus-visible:bg-hover focus-visible:text-fg focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {sizes[ class={classes}
size
]} {revealOnHover
? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100'
: ''} {klass}"
> >
{@render children()} {@render children()}
</a>
{:else}
<button {type} {onclick} aria-label={label} title={label} aria-pressed={pressed} class={classes}>
{@render children()}
</button> </button>
{/if}
@@ -8,6 +8,8 @@
required?: boolean; required?: boolean;
icon?: LucideIcon; icon?: LucideIcon;
gradient?: boolean; gradient?: boolean;
size?: 'default' | 'hero';
onselect?: (file: File) => void;
class?: string; class?: string;
} }
@@ -17,9 +19,16 @@
required = false, required = false,
icon: Icon = ImageIcon, icon: Icon = ImageIcon,
gradient = false, gradient = false,
class: klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control' size = 'default',
onselect,
class: klass = ''
}: Props = $props(); }: 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 preview = $state('');
let fallbackFailed = $state(false); let fallbackFailed = $state(false);
@@ -27,14 +36,16 @@
function onInput(event: Event) { function onInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0]; const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) preview = URL.createObjectURL(file); if (!file) return;
preview = URL.createObjectURL(file);
onselect?.(file);
} }
</script> </script>
<label <label
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {gradient 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} style={gradient && !hasImage ? 'background:var(--mf-cover-grad)' : undefined}
> >
<input <input
@@ -57,6 +68,6 @@
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else} {: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} {/if}
</label> </label>
@@ -34,6 +34,6 @@
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8"> <div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
{#if loading} {#if loading}
<LoaderCircle class="h-5 w-5 animate-spin text-muted" /> <LoaderCircle class="size-5 animate-spin text-muted" />
{/if} {/if}
</div> </div>
@@ -37,7 +37,7 @@
<div class="relative w-full"> <div class="relative w-full">
{#if Icon} {#if Icon}
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted"> <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> </span>
{/if} {/if}
<input <input
@@ -52,8 +52,6 @@
{autocomplete} {autocomplete}
{oninput} {oninput}
bind:value 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 class="field-control {Icon ? 'pr-4 pl-11' : 'px-4'} {klass}"
? 'pr-4 pl-11'
: 'px-4'} {klass}"
/> />
</div> </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,31 +1,60 @@
<script lang="ts" module>
export const mediaCardArt =
'aspect-square w-full rounded-art transition duration-300 ease-out';
</script>
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import Artwork from './Artwork.svelte';
import PlayButton from './PlayButton.svelte';
interface Props { interface Props {
href: string; href: string;
title: string; title: string;
subtitle?: string | null; subtitle?: string | null;
index?: number; src?: string | null;
trackIds?: (string | number)[];
onPlay?: (event: MouseEvent) => void;
art?: Snippet;
oncontextmenu?: (event: MouseEvent) => void; 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> </script>
<a <a {href} class="group animate-enter min-w-0 text-left" style="--i:{index}" {oncontextmenu}>
{href} <div class="relative">
class="group animate-enter min-w-0 text-left" {#if art}
style="animation-delay:{index * 45}ms" {@render art()}
{oncontextmenu} {:else}
> <Artwork
{@render art(mediaCardArt)} {src}
<div class="mt-2.5 truncate text-fg">{title}</div> {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} {#if subtitle}
<div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div> <div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div>
{/if} {/if}
@@ -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>
@@ -1,67 +1,58 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import Cover from './Cover.svelte'; import Artwork from './Artwork.svelte';
import ExplicitBadge from './ExplicitBadge.svelte'; import ExplicitBadge from './ExplicitBadge.svelte';
import ArtistLink from './ArtistLink.svelte'; import ArtistLink from './ArtistLink.svelte';
interface Props { interface Props {
trackId: string | number; trackId?: string | number;
trackIds?: (string | number)[];
title: string; title: string;
artist?: string | null; artist?: string | null;
ownerUserId?: string | number | null; ownerUserId?: string | number | null;
coverSrc?: string | null; coverSrc?: string | null;
coverSize?: string; size?: 'xs' | 'sm' | 'lg';
explicit?: boolean; explicit?: boolean;
active?: boolean; active?: boolean;
titleClass?: string; titleClass?: string;
onClick: () => void; class?: string;
art?: Snippet;
overlay?: Snippet; overlay?: Snippet;
badge?: Snippet; badge?: Snippet;
} }
let { let {
trackId, trackId,
trackIds,
title, title,
artist, artist,
ownerUserId, ownerUserId,
coverSrc, coverSrc,
coverSize = 'h-10.5 w-10.5', size = 'sm',
explicit = false, explicit = false,
active = false, active = false,
titleClass = '', titleClass = '',
onClick, class: klass = '',
art,
overlay, overlay,
badge badge
}: Props = $props(); }: Props = $props();
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick();
}
function onclick(event: MouseEvent) {
event.stopPropagation();
onClick();
}
</script> </script>
<div <div class="flex min-w-0 flex-1 items-center gap-3.5 {klass}">
role="button" {#if art}
tabindex="0" {@render art()}
{onclick} {:else if trackId !== undefined || coverSrc || trackIds?.length}
onkeydown={onKeydown} <Artwork
class="flex min-w-0 items-center gap-3.5 text-left" trackIds={trackIds ?? (trackId !== undefined ? [trackId] : [])}
>
<Cover
{trackId}
src={coverSrc} src={coverSrc}
size="small" {size}
alt={title} alt={title}
class="{coverSize} shrink-0 rounded-control" class="shrink-0"
> >
{@render overlay?.()} {@render overlay?.()}
</Cover> </Artwork>
{/if}
<div class="min-w-0"> <div class="min-w-0">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
{#if explicit} {#if explicit}
@@ -37,12 +37,12 @@
{onclick} {onclick}
class={classes} class={classes}
> >
<Icon class="h-4.5 w-4.5 shrink-0" strokeWidth={1.8} /> <Icon class="size-icon-md shrink-0" />
{label} {label}
</a> </a>
{:else} {:else}
<button {type} {onclick} class={classes}> <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} {label}
</button> </button>
{/if} {/if}
@@ -1,25 +0,0 @@
<script lang="ts">
import Cover from './Cover.svelte';
import { mixItemKey, type MixItem } from '$lib/mixes';
interface Props {
items: MixItem[];
class?: string;
}
let { items, class: klass = '' }: Props = $props();
const tiles = $derived(items.slice(0, 4));
</script>
{#if tiles.length >= 4}
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
{#each tiles as item (mixItemKey(item))}
<Cover trackId={item.trackId} size="small" class="h-full w-full" />
{/each}
</div>
{:else if tiles.length > 0}
<Cover trackId={tiles[0].trackId} size="medium" class={klass} />
{:else}
<div class="bg-surface {klass}"></div>
{/if}
@@ -1,51 +0,0 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import MixArt from './MixArt.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();
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="animation-delay:{Math.min(index, 10) * 45}ms"
>
<div class="relative aspect-square overflow-hidden rounded-art">
<MixArt
items={mix.items}
class="h-full w-full scale-100 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-black/42"
></div>
<button
type="button"
onclick={play}
aria-label="Reproducir {mix.title}"
class="absolute right-2.5 bottom-2.5 grid h-9.5 w-9.5 place-items-center rounded-full border border-on-art/12 bg-ink/76 text-fg opacity-0 backdrop-blur transition-opacity duration-150 group-hover/tile:opacity-100"
>
<Play class="h-3.5 w-3.5" fill="currentColor" />
</button>
</div>
<div class="mt-3.25 truncate text-sm font-semibold tracking-[-0.01em] 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} {#if open}
<div <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" role="presentation"
onclick={onBackdropClick} onclick={onBackdropClick}
> >
<div <div
bind:this={panel} 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}" class="glass-panel animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
@@ -87,16 +86,16 @@
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
{#if eyebrow} {#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} {eyebrow}
</p> </p>
{/if} {/if}
<h2 class="font-display text-xl font-medium tracking-[-0.02em] text-fg"> <h2 class="text-display-3 text-fg">
{title} {title}
</h2> </h2>
</div> </div>
<IconButton label="Cerrar" onclick={onClose} class="shrink-0"> <IconButton label="Cerrar" onclick={onClose} class="shrink-0">
<X class="h-4 w-4" /> <X class="size-4" />
</IconButton> </IconButton>
</div> </div>
{/if} {/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 { interface Props {
title: string; title: string;
subtitle?: string; eyebrow?: string;
class?: string; description?: string;
meta?: string;
cover?: Snippet;
actions?: 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> </script>
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}"> <div class="mb-7 sm:mb-8 {klass}">
<div class="min-w-0"> <div
<h1 class="font-display text-3xl font-medium tracking-tight text-fg sm:text-4xl"> 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} {title}
</h1> </h1>
{#if subtitle} {#if description}
<p class="mt-2 text-sm text-fg-2">{subtitle}</p> <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} {/if}
</div> </div>
{#if actions} {#if actions && !cover}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div> <div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{/if} {/if}
</div> </div>
{#if actions && cover}
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
{@render actions()}
</div>
{/if}
</div>
@@ -0,0 +1,59 @@
<script lang="ts">
interface Props {
playing?: boolean;
size?: 'sm' | 'md' | 'lg';
variant?: 'strong' | 'glass';
label: string;
onclick?: (event: MouseEvent) => void;
as?: 'button' | 'span';
class?: string;
}
let {
playing = false,
size = 'md',
variant = 'strong',
label,
onclick,
as = 'button',
class: klass = ''
}: Props = $props();
const SIZE: Record<'sm' | 'md' | 'lg', string> = {
sm: 'size-9.5',
md: 'size-10',
lg: 'size-11.5'
};
const VARIANT: Record<'strong' | 'glass', string> = {
strong: 'bg-cta-strong text-ink hover:brightness-95',
glass: 'border border-on-art/12 bg-ink/76 text-fg backdrop-blur'
};
const classes = $derived(
`grid shrink-0 place-items-center rounded-full transition duration-150 ${SIZE[size]} ${VARIANT[variant]} ${klass}`
);
</script>
{#snippet icon()}
{#if playing}
<svg width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
<rect x="3" y="1" width="3" height="13" rx="1" />
<rect x="9" y="1" width="3" height="13" rx="1" />
</svg>
{:else}
<svg width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
<path d="M3 1.5v12l10-6z" />
</svg>
{/if}
{/snippet}
{#if as === 'span'}
<span aria-hidden="true" class={classes}>
{@render icon()}
</span>
{:else}
<button type="button" {onclick} aria-label={label} class={classes}>
{@render icon()}
</button>
{/if}
@@ -1,61 +0,0 @@
<script lang="ts">
import Cover from './Cover.svelte';
interface Props {
playlistId?: string;
coverUrl?: string;
trackIds: (string | number)[];
size?: 'small' | 'medium' | 'large';
version?: string;
class?: string;
}
let {
playlistId,
coverUrl,
trackIds,
size = 'medium',
version,
class: klass = ''
}: Props = $props();
let coverFailed = $state(false);
const mosaic = $derived(trackIds.length >= 4);
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
const src = $derived(
coverUrl
? coverUrl
: playlistId
? `/api/playlists/${playlistId}/cover?size=${size}${version ? `&v=${encodeURIComponent(version)}` : ''}`
: undefined
);
</script>
{#if src && !coverFailed}
<div class="relative overflow-hidden bg-surface {klass}">
<img
{src}
alt=""
loading="lazy"
onerror={() => (coverFailed = true)}
class="h-full w-full object-cover"
/>
</div>
{:else if mosaic}
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
{#each trackIds.slice(0, 4) as id (id)}
<Cover trackId={id} size="small" class="h-full w-full" />
{/each}
</div>
{:else if single !== null}
<Cover trackId={single} size="medium" class={klass} />
{:else}
<div class="flex items-center justify-center bg-surface {klass}">
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-3">
<path
d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z"
/>
</svg>
</div>
{/if}
@@ -1,23 +0,0 @@
<script lang="ts">
import MediaCard from './MediaCard.svelte';
import PlaylistArt from './PlaylistArt.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)}
<PlaylistArt playlistId={id} {trackIds} version={updatedAt} class={artClass} />
{/snippet}
</MediaCard>
@@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Music from '@lucide/svelte/icons/music'; import Music from '@lucide/svelte/icons/music';
import ImageDropzone from './ImageDropzone.svelte'; import CoverForm from './CoverForm.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte'; import Field from './Field.svelte';
import Input from './Input.svelte'; import Input from './Input.svelte';
import Textarea from './Textarea.svelte'; import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte'; import SegmentedControl from './SegmentedControl.svelte';
const NAME_MAX_LENGTH = 60; const NAME_MAX_LENGTH = 60;
const DESCRIPTION_MAX_LENGTH = 300; const DESCRIPTION_MAX_LENGTH = 300;
@@ -44,37 +42,23 @@
let name = $state(untrack(() => initialName)); let name = $state(untrack(() => initialName));
let description = $state(untrack(() => initialDescription)); let description = $state(untrack(() => initialDescription));
let submitting = $state(false);
let visibility = $state(untrack(() => initialVisibility)); let visibility = $state(untrack(() => initialVisibility));
</script> </script>
<form <CoverForm
method="POST"
{action} {action}
enctype="multipart/form-data" {coverFallbackUrl}
use:enhance={() => { coverIcon={Music}
submitting = true; {formMessage}
return async ({ update, result }) => { {submitLabel}
await update({ reset: false }); {submittingLabel}
submitting = false; canSubmit={name.trim() !== ''}
if (result.type === 'success' || result.type === 'redirect') onSuccess?.(); {helperText}
}; {onCancel}
}} {onSuccess}
class="mt-5 w-full max-w-4xl space-y-5"
> >
{#snippet fields()}
<input type="hidden" name="visibility" value={visibility} /> <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"
/>
</Field>
<div class="flex flex-1 flex-col gap-2">
<Field label="Nombre" for="playlist-name"> <Field label="Nombre" for="playlist-name">
{#snippet hint()} {#snippet hint()}
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span> <span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
@@ -99,9 +83,8 @@
class="flex-1" class="flex-1"
/> />
</Field> </Field>
</div> {/snippet}
</div> {#snippet extra()}
<div class="flex items-center justify-between gap-4 rounded-art bg-surface px-4 py-3.5"> <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="min-w-0">
<div class="text-sm font-medium text-fg-2">Visibilidad</div> <div class="text-sm font-medium text-fg-2">Visibilidad</div>
@@ -111,51 +94,13 @@
: 'Cualquiera con el enlace puede verla'} : 'Cualquiera con el enlace puede verla'}
</div> </div>
</div> </div>
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1"> <SegmentedControl
<div bind:value={visibility}
class="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-fg transition-transform duration-300 ease-out {visibility === options={[
'public' { value: 'private', label: 'Privada' },
? 'translate-x-full' { value: 'public', label: 'Pública' }
: ''}" ]}
></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>
</div> {/snippet}
</CoverForm>
{#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>
@@ -1,78 +0,0 @@
<script lang="ts" module>
export type SearchResultKind = 'track' | 'album' | 'playlist' | 'user';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
const RADIUS: Record<SearchResultKind, string> = {
track: 'rounded-control',
album: 'rounded-md',
playlist: 'rounded-art',
user: 'rounded-full'
};
interface Props {
kind: SearchResultKind;
title: string;
subtitle?: string | null;
meta?: string;
explicit?: boolean;
href?: string;
onclick?: () => void;
oncontextmenu?: (event: MouseEvent) => void;
art: Snippet<[string]>;
}
let {
kind,
title,
subtitle,
meta,
explicit = false,
href,
onclick,
oncontextmenu,
art
}: Props = $props();
const artClass = $derived(`relative h-13 w-13 shrink-0 overflow-hidden ${RADIUS[kind]}`);
</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"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { plural } from '$lib/format';
interface Props { interface Props {
title: string; title: string;
subtitle?: string; subtitle?: string;
count?: number;
href?: string;
linkLabel?: string;
class?: string; class?: string;
actions?: Snippet; 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> </script>
{#snippet heading()} {#snippet heading()}
<h2 class="font-display text-lg font-medium tracking-tight text-fg"> <h2 class="text-display-4 text-fg">
{title} {title}
</h2> </h2>
{#if subtitle} {#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} {/if}
{/snippet} {/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 class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
<div>{@render heading()}</div> <div>{@render heading()}</div>
{#if actions}
{@render actions()} {@render actions()}
{:else}
{@render autoActions()}
{/if}
</div> </div>
{:else} {:else}
<div class="mb-4 sm:mb-4.5 {klass}"> <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} {placeholder}
{maxlength} {maxlength}
bind:value 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}" class="min-h-0 field-control resize-none px-4 {klass}"></textarea>
></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 PLAYLIST_PICKER_PAGE_SIZE = 50;
export const LIKED_TRACKS_PAGE_SIZE = 500; export const LIKED_TRACKS_PAGE_SIZE = 500;
export const EXPLORE_USERS_LIMIT = 12; 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)); 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 { export function fmtPlays(count: number | string | undefined): string {
const value = Math.max(0, Math.floor(Number(count ?? 0))); 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;
}
};
}
+2 -4
View File
@@ -1,5 +1,5 @@
import type { components } from '$lib/api/schema'; 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 Mix = components['schemas']['MixApplicationResponse'];
export type MixItem = components['schemas']['MixItemApplicationResponse']; export type MixItem = components['schemas']['MixItemApplicationResponse'];
@@ -8,13 +8,11 @@ export function mixItemKey(item: MixItem): string {
return `mf:${item.trackId}`; return `mf:${item.trackId}`;
} }
export function mixItemTarget(item: MixItem): TrackTarget { export function mixItemTrack(item: MixItem): ApiTrackLike {
return { return {
track: {
id: item.trackId, id: item.trackId,
title: item.title, title: item.title,
artist: item.artist, artist: item.artist,
listensCount: item.listensCount listensCount: item.listensCount
}
}; };
} }
+45
View File
@@ -1,4 +1,13 @@
import { afterNavigate } from '$app/navigation'; 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'; const HISTORY_INDEX = 'sveltekit:history';
@@ -54,3 +63,39 @@ export function isSectionActive(href: string, pathname: string, section?: string
if (section !== undefined) return section === href; if (section !== undefined) return section === href;
return href === '/' ? pathname === '/' : pathname.startsWith(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 items = toQueueItems(tracks);
const next: Record<string, LikedTrack> = {}; const next: Record<string, LikedTrack> = {};
items.forEach((item, i) => { items.forEach((item, i) => {
next[String(item.id)] = { const id = String(item.id);
id: String(item.id), next[id] = {
title: item.title, ...item,
artist: item.artist, id,
explicit: item.explicit,
ownerUserId: item.ownerUserId,
listensCount: item.listensCount,
duration: tracks[i].duration, duration: tracks[i].duration,
likedAt: new Date(tracks[i].createdAt).getTime() likedAt: new Date(tracks[i].createdAt).getTime()
}; };
@@ -58,17 +55,7 @@ class LikedStore {
const wasLiked = id in before; const wasLiked = id in before;
const next = { ...before }; const next = { ...before };
if (wasLiked) delete next[id]; if (wasLiked) delete next[id];
else { else next[id] = { ...track, id, likedAt: Date.now() };
next[id] = {
id,
title: track.title,
artist: track.artist,
explicit: track.explicit,
ownerUserId: track.ownerUserId,
duration: track.duration,
likedAt: Date.now()
};
}
this.entries = next; this.entries = next;
try { try {
+32 -1
View File
@@ -1,6 +1,7 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { DEFAULT_ACCENT } from '$lib/theme/color'; import { DEFAULT_ACCENT } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette'; import { extractAccent, type Accent } from '$lib/theme/palette';
import { shuffle } from '$lib/collections';
export interface PlayerTrack { export interface PlayerTrack {
id: string | number; 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> { async function readErrorMessage(res: Response): Promise<string> {
try { try {
const body = (await res.json()) as { message?: string }; const body = (await res.json()) as { message?: string };
@@ -291,7 +322,7 @@ class PlayerState {
else this.playQueue(list, index); else this.playQueue(list, index);
} }
addToQueue(item: QueueItem) { playNextItem(item: QueueItem) {
this.playNext([item]); this.playNext([item]);
} }
+2 -15
View File
@@ -1,16 +1,3 @@
let open = $state(false); import { createToggle } from '../toggle.svelte';
export const queuePanel = { export const queuePanel = createToggle();
get open() {
return open;
},
toggle() {
open = !open;
},
close() {
open = false;
},
show() {
open = true;
}
};
+2 -12
View File
@@ -1,13 +1,3 @@
let open = $state(false); import { createToggle } from './toggle.svelte';
export const createPlaylistModal = { export const createPlaylistModal = createToggle();
get open() {
return open;
},
show() {
open = true;
},
close() {
open = false;
}
};
+2 -16
View File
@@ -12,28 +12,14 @@ export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): Que
const id = String(t.id); const id = String(t.id);
if (seen.has(id)) continue; if (seen.has(id)) continue;
seen.add(id); seen.add(id);
merged.push({ merged.push({ ...t, id });
id,
title: t.title,
artist: t.artist,
explicit: t.explicit,
ownerUserId: t.ownerUserId,
listensCount: t.listensCount
});
} }
for (const item of toQueueItems(history)) { for (const item of toQueueItems(history)) {
const id = String(item.id); const id = String(item.id);
if (seen.has(id)) continue; if (seen.has(id)) continue;
seen.add(id); seen.add(id);
merged.push({ merged.push({ ...item, id });
id,
title: item.title,
artist: item.artist,
explicit: item.explicit,
ownerUserId: item.ownerUserId,
listensCount: item.listensCount
});
} }
return merged.slice(0, limit); 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;
}
+27 -68
View File
@@ -1,56 +1,17 @@
:root { :root {
/* Dynamic tokens: overwritten at runtime from the playing track's hue. /* Hue-based tokens (--mf-accent*, --mf-bg/elevated/panel-bg/bar-bg/hairline,
These are the fallback values for the default accent (hue 145) so the --mf-ambient, --mf-hero-bg/spotlight-bg/modal-glow, --mf-logo-grad,
page never flashes unstyled before hydration. See theme/tokens.ts. */ --mf-cover-grad) are NOT defined here: they come only from
--mf-bg: oklch(0.068 0.008 145); buildThemeTokens() in theme/tokens.ts, inlined as SSR <style> by
--mf-elevated: oklch(0.11 0.012 145); +layout.svelte and reapplied on every hue change. A duplicate default
--mf-sidebar-bg: linear-gradient(180deg, oklch(0.095 0.012 145), oklch(0.068 0.007 145)); here could never be overridden by a light-mode selector (see A.2). */
--mf-panel-bg: oklch(0.09 0.01 145 / 0.28);
--mf-bar-bg: oklch(0.105 0.012 145 / 0.9);
--mf-hairline: oklch(0.62 0.03 145 / 0.08);
--mf-ambient:
radial-gradient(52% 38% at 14% 0%, oklch(0.2611 0.0913 145 / 0.26), transparent 54%),
radial-gradient(46% 34% at 78% 0%, oklch(0.2347 0.085 145 / 0.2), transparent 56%);
--mf-hero-bg: linear-gradient(
105deg,
oklch(0.2295 0.0838 145 / 0.34) 0%,
oklch(0.1558 0.0663 145 / 0.11) 37%,
oklch(0.1084 0.055 145 / 0.03) 65%,
rgba(6, 6, 9, 0) 92%
);
--mf-spotlight-bg: linear-gradient(
100deg,
oklch(0.2189 0.0813 145 / 0.28) 0%,
oklch(0.1453 0.0638 145 / 0.1) 40%,
oklch(0.1084 0.055 145 / 0.03) 69%,
rgba(6, 6, 9, 0) 96%
);
--mf-modal-glow: linear-gradient(
135deg,
oklch(0.2295 0.0838 145 / 0.34) 0%,
oklch(0.1558 0.0663 145 / 0.11) 28%,
oklch(0.1084 0.055 145 / 0.03) 50%,
rgba(6, 6, 9, 0) 70%
);
--mf-logo-grad: linear-gradient(140deg, oklch(0.78 0.15 145), oklch(0.62 0.13 185) 92%);
--mf-cover-grad: linear-gradient(150deg, oklch(0.5 0.13 145), oklch(0.16 0.035 145));
--mf-liked-grad: linear-gradient(150deg, oklch(0.4 0.09 330), oklch(0.18 0.04 10)); --mf-liked-grad: linear-gradient(150deg, oklch(0.4 0.09 330), oklch(0.18 0.04 10));
--mf-accent-dim: color-mix(in oklch, var(--mf-accent) 22%, transparent);
--mf-tile-l: 0.34; --mf-tile-l: 0.34;
--mf-tile-c: 0.1; --mf-tile-c: 0.1;
--mf-tile-shade-l: 0.14; --mf-tile-shade-l: 0.14;
--mf-tile-shade-c: 0.035; --mf-tile-shade-c: 0.035;
--mf-accent: oklch(0.74 0.145 145);
--mf-accent-title: oklch(0.87 0.07 145);
--mf-accent-muted: oklch(0.79 0.035 145);
--mf-accent-soft-bg: oklch(0.74 0.145 145 / 0.08);
--mf-accent-line: oklch(0.74 0.145 145 / 0.2);
--mf-accent-hair: oklch(0.74 0.145 145 / 0.09);
--mf-accent-btn-bg: oklch(0.74 0.145 145 / 0.15);
--mf-accent-btn-bg-hover: oklch(0.74 0.145 145 / 0.24);
--mf-accent-glow: color-mix(in oklch, var(--mf-accent) 45%, transparent);
--mf-accent-dim: color-mix(in oklch, var(--mf-accent) 22%, transparent);
/* Fixed neutrals: deliberately hue-less, so only the accent carries color. */ /* Fixed neutrals: deliberately hue-less, so only the accent carries color. */
--mf-text: #d9d8dc; --mf-text: #d9d8dc;
--mf-text-2: #8a8a8e; --mf-text-2: #8a8a8e;
@@ -82,6 +43,23 @@
--mf-nav-h: 66px; --mf-nav-h: 66px;
--mf-player-h: 78px; --mf-player-h: 78px;
--mf-safe-b: env(safe-area-inset-bottom, 0px); --mf-safe-b: env(safe-area-inset-bottom, 0px);
--mf-player-side-w: 16rem;
--mf-menu-w: 15rem;
--mf-submenu-w: 16rem;
--mf-range-h: 3.5px;
--mf-range-thumb: 11px;
--mf-volume-w: 84px;
--mf-scrollbar: rgba(255, 255, 255, 0.18);
--mf-scrollbar-hover: rgba(255, 255, 255, 0.32);
--z-raised: 10;
--z-sticky: 20;
--z-backdrop: 30;
--z-menu: 40;
--z-submenu: 50;
--z-modal: 60;
--z-grain: 80;
} }
/* PlayerBar switches from its compact row to the full layout at `sm` (640px). */ /* PlayerBar switches from its compact row to the full layout at `sm` (640px). */
@@ -108,13 +86,6 @@
palette. Components that reference these tokens re-theme correctly; palette. Components that reference these tokens re-theme correctly;
components hardcoding literal dark-mode hex colors do not. */ components hardcoding literal dark-mode hex colors do not. */
:root[data-theme='light'] { :root[data-theme='light'] {
--mf-bg: oklch(0.97 0.006 90);
--mf-elevated: oklch(0.99 0.003 90);
--mf-sidebar-bg: oklch(0.97 0.006 90); /*linear-gradient(180deg, oklch(0.985 0.004 90), oklch(0.965 0.006 90));*/
--mf-panel-bg: oklch(0.97 0.006 90 / 0.7);
--mf-bar-bg: oklch(0.97 0.006 90 / 0.9);
--mf-hairline: rgba(0, 0, 0, 0.08);
--mf-text: #17171a; --mf-text: #17171a;
--mf-text-2: #4a4a4e; --mf-text-2: #4a4a4e;
--mf-text-3: #6b6b6f; --mf-text-3: #6b6b6f;
@@ -128,6 +99,9 @@
--mf-btn-secondary-bg: rgba(0, 0, 0, 0.09); --mf-btn-secondary-bg: rgba(0, 0, 0, 0.09);
--mf-btn-secondary-bg-hover: rgba(0, 0, 0, 0.14); --mf-btn-secondary-bg-hover: rgba(0, 0, 0, 0.14);
--mf-scrollbar: rgba(0, 0, 0, 0.18);
--mf-scrollbar-hover: rgba(0, 0, 0, 0.32);
--mf-border: rgba(0, 0, 0, 0.08); --mf-border: rgba(0, 0, 0, 0.08);
--mf-border-strong: rgba(0, 0, 0, 0.14); --mf-border-strong: rgba(0, 0, 0, 0.14);
--mf-track: rgba(0, 0, 0, 0.12); --mf-track: rgba(0, 0, 0, 0.12);
@@ -141,19 +115,4 @@
--mf-tile-shade-l: 0.42; --mf-tile-shade-l: 0.42;
--mf-tile-shade-c: 0.05; --mf-tile-shade-c: 0.05;
--mf-liked-grad: linear-gradient(150deg, oklch(0.62 0.12 330), oklch(0.46 0.08 10)); --mf-liked-grad: linear-gradient(150deg, oklch(0.62 0.12 330), oklch(0.46 0.08 10));
--mf-ambient: none;
--mf-hero-bg: linear-gradient(oklch(0.789 0.0926 145 / 0.16), oklch(0.789 0.0926 145 / 0.16));
--mf-spotlight-bg: linear-gradient(
oklch(0.789 0.0926 145 / 0.16),
oklch(0.789 0.0926 145 / 0.16)
);
--mf-modal-glow: linear-gradient(
135deg,
oklch(0.7531 0.1172 145 / 0.34) 0%,
oklch(0.789 0.0926 145 / 0.2) 30%,
oklch(0.8303 0.0641 145 / 0.1) 55%,
oklch(0.8717 0.0357 145 / 0.04) 80%,
oklch(0.88 0.03 145 / 0.02) 100%
);
} }
+6 -3
View File
@@ -67,9 +67,6 @@ export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'):
'--mf-accent-btn-bg-hover': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.24)`, '--mf-accent-btn-bg-hover': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.24)`,
'--mf-bg': light ? `oklch(0.97 0.006 ${H})` : `oklch(0.068 0.008 ${H})`, '--mf-bg': light ? `oklch(0.97 0.006 ${H})` : `oklch(0.068 0.008 ${H})`,
'--mf-elevated': light ? `oklch(0.99 0.003 ${H})` : `oklch(0.11 0.012 ${H})`, '--mf-elevated': light ? `oklch(0.99 0.003 ${H})` : `oklch(0.11 0.012 ${H})`,
'--mf-sidebar-bg': light
? `linear-gradient(180deg, oklch(0.985 0.004 ${H}), oklch(0.965 0.006 ${H}))`
: `linear-gradient(180deg, oklch(0.095 0.012 ${H}), oklch(0.068 0.007 ${H}))`,
'--mf-panel-bg': light ? `oklch(0.97 0.006 ${H} / 0.7)` : `oklch(0.09 0.01 ${H} / 0.28)`, '--mf-panel-bg': light ? `oklch(0.97 0.006 ${H} / 0.7)` : `oklch(0.09 0.01 ${H} / 0.28)`,
'--mf-bar-bg': light ? `oklch(0.97 0.006 ${H} / 0.9)` : `oklch(0.105 0.012 ${H} / 0.9)`, '--mf-bar-bg': light ? `oklch(0.97 0.006 ${H} / 0.9)` : `oklch(0.105 0.012 ${H} / 0.9)`,
'--mf-hairline': light ? `oklch(0.3 0.02 ${H} / 0.1)` : `oklch(0.62 0.03 ${H} / 0.08)`, '--mf-hairline': light ? `oklch(0.3 0.02 ${H} / 0.1)` : `oklch(0.62 0.03 ${H} / 0.08)`,
@@ -100,3 +97,9 @@ export function applyThemeTokens(
) { ) {
for (const [key, value] of Object.entries(tokens)) target.style.setProperty(key, value); for (const [key, value] of Object.entries(tokens)) target.style.setProperty(key, value);
} }
export function tokensToCss(tokens: ThemeTokens): string {
return Object.entries(tokens)
.map(([key, value]) => `${key}:${value}`)
.join(';');
}
+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 Disc from '@lucide/svelte/icons/disc-3';
import Page from '$lib/components/ui/Page.svelte'; import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte'; import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte'; import MediaCard from '$lib/components/ui/MediaCard.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
import AlbumForm from '$lib/components/ui/AlbumForm.svelte'; import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
import Modal from '$lib/components/ui/Modal.svelte'; import Modal from '$lib/components/ui/Modal.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import { albumMeta } from '$lib/format';
let { data, form } = $props(); let { data, form } = $props();
@@ -23,11 +23,11 @@
</svelte:head> </svelte:head>
<Page> <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()} {#snippet actions()}
{#if items.length > 0} {#if items.length > 0}
<Button onclick={() => (creating = true)}> <Button onclick={() => (creating = true)}>
<Plus class="h-4 w-4" strokeWidth={2.5} /> <Plus class="size-4" strokeWidth={2.5} />
Nuevo álbum Nuevo álbum
</Button> </Button>
{/if} {/if}
@@ -35,19 +35,21 @@
</PageHeader> </PageHeader>
{#if items.length > 0} {#if items.length > 0}
<MediaGrid class="mt-7 sm:mt-8"> <div class="grid-cards">
{#each items as album, i (album.id)} {#each items as album, i (album.id)}
<AlbumCard <MediaCard
id={album.id} href="/albums/{album.id}"
title={album.title} title={album.title}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)} subtitle={albumMeta(
trackCount={Number(album.trackCount)} album.releaseYear === null ? undefined : Number(album.releaseYear),
Number(album.trackCount)
)}
trackIds={album.coverTrackIds} trackIds={album.coverTrackIds}
coverSrc="/api/albums/{album.id}/cover?size=large" src="/api/albums/{album.id}/cover?size=large"
index={i} index={i}
/> />
{/each} {/each}
</MediaGrid> </div>
{:else} {:else}
<EmptyState <EmptyState
icon={Disc} icon={Disc}
@@ -1,13 +1,23 @@
<script lang="ts"> <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 Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Modal from '$lib/components/ui/Modal.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 Alert from '$lib/components/ui/Alert.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import AlbumForm from '$lib/components/ui/AlbumForm.svelte'; import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import AlbumHeader from './components/AlbumHeader.svelte'; import BackLink from '$lib/components/ui/BackLink.svelte';
import AlbumTrackTable from './components/AlbumTrackTable.svelte'; import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import LibraryPicker from './components/LibraryPicker.svelte'; import TrackList from '$lib/components/ui/TrackList.svelte';
import { albumMeta } from '$lib/format';
let { data, form } = $props(); let { data, form } = $props();
@@ -19,12 +29,18 @@
let editing = $state(false); let editing = $state(false);
let confirmingDelete = $state(false); let confirmingDelete = $state(false);
const isCurrentQueue = $derived(tracks.some((track) => track.id === player.current.id)); const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playAll() { function playAll() {
if (tracks.length === 0) return; playAllOrToggle(toQueueItems(tracks));
if (isCurrentQueue) player.toggle(); }
else player.playQueue(toQueueItems(tracks), 0);
function playTrackFrom(index: number) {
player.playOrToggle(toQueueItems(tracks), index);
}
function playLibraryFrom(index: number) {
player.playOrToggle(toQueueItems(library), index);
} }
</script> </script>
@@ -34,29 +50,76 @@
</svelte:head> </svelte:head>
<Page> <Page>
<AlbumHeader <BackLink
title={album.title} href={isOwner ? '/albums' : '/'}
description={album.description ?? undefined} label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
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)}
/> />
<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} {#if form?.message}
<Alert tone="danger" class="mt-6">{form.message}</Alert> <Alert tone="danger" class="mb-6">{form.message}</Alert>
{/if} {/if}
{#if tracks.length > 0} {#if tracks.length > 0}
<AlbumTrackTable {tracks} {isOwner} /> <TrackList
{tracks}
onPlay={playTrackFrom}
rowAction={isOwner
? { action: '?/removeTrack', icon: X, label: 'Quitar del álbum' }
: undefined}
/>
{/if} {/if}
{#if isOwner && library.length > 0} {#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} {/if}
</Page> </Page>
@@ -75,17 +138,11 @@
/> />
</Modal> </Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm"> <ConfirmDialog
<h2 class="text-lg font-semibold tracking-tight text-fg"> open={confirmingDelete}
¿Eliminar «{album.title}»? onClose={() => (confirmingDelete = false)}
</h2> title="¿Eliminar «{album.title}»?"
<p class="mt-2 text-base text-fg-3"> description="Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca."
Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca. confirmLabel="Eliminar"
</p> action="?/delete"
<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>
@@ -1,82 +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 PlaylistArt from '$lib/components/ui/PlaylistArt.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()}
<PlaylistArt
{trackIds}
{coverUrl}
size="large"
class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41"
/>
{/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>

Some files were not shown because too many files have changed in this diff Show More