Add likes, follows and playlist visibility; redesign web-player UI
Backend: - New Likes feature (toggle/get liked tracks) and endpoints - User follow/unfollow, listening stats, public user profile - Playlist visibility (public/private) support - Various auth and DTO/response adjustments to support the above Frontend (web-player): - New design system pass: theme tokens/mode (light/dark), glass menu, tabs bar, player dock/extras/queue components, mix tiles, artist link - New routes: Liked, public user profile (/u/[id]), likes API proxy - Search refactor: shared SearchBox component used by TopBar and MobileHeader (search now reachable on mobile), live results as you type instead of requiring Enter, dropped the "Buscando…" quick-result preview - Removed now-unused components (MobileNav, CoverBadge, MixBentoTile, PlayPauseOverlay, SuggestionsTable, userDirectory) - Design handoff reference docs added under web-player/design_handoff_musify_player and web-player/docs/design-system-refactor-plan.md
@@ -0,0 +1,325 @@
|
||||
# Handoff: Musify — Web Music Player
|
||||
|
||||
## Overview
|
||||
|
||||
Musify es un reproductor de música web: un player permanente, navegación lateral, y vistas de Home / Descubrir / Playlists / Me gusta / Reciente, más estados de carga, error, vacío y sin resultados. Su rasgo de identidad es que **toda la paleta de acento y los fondos derivan de la canción en reproducción**: al cambiar de pista, el tinte del hero, el glow ambiental, el sidebar, la barra del player, el logo, la barra de progreso y los acentos viran en bloque con una transición de 0.6s.
|
||||
|
||||
## About the Design Files
|
||||
|
||||
Los archivos de este paquete son **referencias de diseño hechas en HTML** — prototipos que muestran aspecto y comportamiento previstos, **no código de producción para copiar tal cual**. La tarea es **recrear estos diseños en el entorno del codebase destino** (React, Vue, SwiftUI, nativo…) siguiendo sus patrones y librerías ya establecidos. Si todavía no hay entorno, elige el framework más apropiado para el proyecto e implementa allí.
|
||||
|
||||
El prototipo está escrito como un componente único con estado local y estilos inline. En producción conviene separarlo en componentes (Sidebar, TopBar, Player, Queue, TrackRow, MediaCard, sections de Home) y mover los tokens a la solución de estilos del proyecto.
|
||||
|
||||
## Fidelity
|
||||
|
||||
**Alta fidelidad (hifi).** Colores, tipografía, espaciado, radios, estados y microinteracciones son definitivos. Se espera una recreación fiel usando las librerías del codebase. Las carátulas son fotografías genéricas de marcador de posición; el contenido musical (artistas, álbumes, canciones, playlists, duraciones, reproducciones) es **mock data** y debe sustituirse por datos reales.
|
||||
|
||||
**Regla de producto:** la UI nunca menciona la procedencia de la música. Sin etiquetas de fuente, proveedor ni "powered by". Para el usuario esto es solo Musify.
|
||||
|
||||
---
|
||||
|
||||
## Layout global
|
||||
|
||||
Altura fija de viewport, sin scroll de página; solo el área de contenido y la cola scrollean.
|
||||
|
||||
```
|
||||
┌──────────┬──────────────────────────────┬──────────┐
|
||||
│ Sidebar │ TopBar │ Queue │
|
||||
│ 254px ├──────────────────────────────┤ 304px │
|
||||
│ │ Main (scroll) │ (258px │
|
||||
│ │ │ <1180) │
|
||||
└──────────┴──────────────────────────────┴──────────┘
|
||||
Player flotante (absolute, left/right 0, bottom 0, padding 0 16px 16px)
|
||||
```
|
||||
|
||||
- Raíz: `position:relative; height:100vh; overflow:hidden; background: pageBg; transition: background .6s ease`.
|
||||
- Dos capas decorativas absolutas y no interactivas sobre el fondo: el **ambient glow** y un scrim inferior `radial-gradient(130% 85% at 50% 118%, rgba(0,0,0,.8), transparent 62%)`.
|
||||
- Fila principal `display:flex; height:100%`.
|
||||
- El main tiene `padding: 6px 26px` y `padding-bottom` dinámico: `126px` en desktop, `206px` cuando hay barra de pestañas móvil.
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| Ancho | Comportamiento |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `>= 1180px` | Sidebar visible (254px), cola 304px, player completo |
|
||||
| `< 1180px` (tablet) | Sidebar oculto → barra de pestañas bajo el player; cola 258px; padding inferior 206px |
|
||||
| `< 760px` (móvil) | Cola oculta; se ocultan shuffle/repeat/volumen/letra/cola del player (queda carátula + título + transporte + progreso); el bloque de info del player pasa a `width:auto` |
|
||||
|
||||
---
|
||||
|
||||
## Sistema de color dinámico
|
||||
|
||||
Cada pista lleva un `hue` (0–360) asignado. Todo el color de la app se deriva de ese hue en oklch. Los valores están repartidos por la rueda para que el cambio entre pistas consecutivas sea evidente (paso mínimo ~50°).
|
||||
|
||||
```js
|
||||
// hue = hue de la pista en reproducción
|
||||
accent = oklch(0.74 0.145 H) // acción, barra de progreso, corazón activo, EQ
|
||||
accentTitle = oklch(0.87 0.07 H) // títulos sobre superficie tintada
|
||||
accentMuted = oklch(0.79 0.035 H) // título de la pista activa en tarjetas
|
||||
accentSoft = oklch(0.74 0.145 H / .08) // fondo de fila/tarjeta activa
|
||||
accentLine = oklch(0.74 0.145 H / .2) // borde de superficie acentuada
|
||||
accentHair = oklch(0.74 0.145 H / .09) // borde de tarjeta activa
|
||||
accentBtnBg = oklch(0.74 0.145 H / .15)
|
||||
accentBtnBgHover = oklch(0.74 0.145 H / .24)
|
||||
|
||||
// superficies
|
||||
pageBg = oklch(0.068 0.008 H)
|
||||
sidebarBg = linear-gradient(180deg, oklch(0.095 0.012 H), oklch(0.068 0.007 H))
|
||||
panelBg = oklch(0.09 0.010 H / .28) // panel de cola
|
||||
barBg = oklch(0.105 0.012 H / .9) // player y tabs (con backdrop-filter: blur(22px))
|
||||
hairline = oklch(0.62 0.030 H / .08) // todos los separadores
|
||||
|
||||
// gradientes: tc(a, l) = oklch(0.34 - l/380 , 0.11 - l/1600 , H / a)
|
||||
ambient = radial-gradient(52% 38% at 14% 0%, tc(.26,30), transparent 54%),
|
||||
radial-gradient(46% 34% at 78% 0%, tc(.20,40), transparent 56%)
|
||||
heroBg = linear-gradient(105deg, tc(.34,42) 0%, tc(.11,70) 28%, tc(.03,88) 50%, rgba(6,6,9,0) 70%)
|
||||
spotlightBg = linear-gradient(100deg, tc(.28,46) 0%, tc(.10,74) 30%, tc(.03,88) 52%, rgba(6,6,9,0) 72%)
|
||||
logoGrad = linear-gradient(140deg, oklch(0.78 0.15 H), oklch(0.62 0.13 H+40) 92%)
|
||||
logoGlow = 0 0 20px oklch(0.74 0.145 H / .3)
|
||||
```
|
||||
|
||||
**Importante:** hero y spotlight terminan en `alpha 0` antes del borde derecho (70–72%) y **no llevan borde**, para fundirse con el fondo sin corte visible.
|
||||
|
||||
Los elementos que cambian de color reciben `transition: background .6s ease` (y `box-shadow .6s ease` en el logo): raíz, sidebar, panel de cola, barra del player, tabs.
|
||||
|
||||
### Grises fijos (no dependen del hue)
|
||||
|
||||
| Rol | Hex |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| Texto principal (títulos de sección, h1/h2) | `#d3d2d6` – `#d9d8dc` |
|
||||
| Texto de tarjeta / fila | `#cbcacf` / `#cdccd1` |
|
||||
| Texto base del documento | `#c9c8cc` |
|
||||
| Secundario | `#8a8a8e`, `#8f8f93`, `#919195` |
|
||||
| Terciario | `#7f7f83`, `#7d7d81`, `#848488` |
|
||||
| Cuaternario / contadores | `#6c6c70`, `#696a6d`, `#646468`, `#616165`, `#5c5c60` |
|
||||
| Iconos inactivos player | `#747478` |
|
||||
| Botón primario (fondo) | `#c6c4cb`, hover `#d8d7dc`; texto `#0a0a0d` |
|
||||
| Superficies neutras | `rgba(255,255,255,.022)` base, `.045` hover; bordes `rgba(255,255,255,.04)` / `.075` |
|
||||
|
||||
Los grises son **neutros a propósito** (sin tinte), para que solo el acento lleve color.
|
||||
|
||||
---
|
||||
|
||||
## Tipografía
|
||||
|
||||
- Display / títulos: **Sora** 400/500/600/700.
|
||||
- UI / texto: **Plus Jakarta Sans** 400/500/600/700.
|
||||
- `-webkit-font-smoothing: antialiased`.
|
||||
|
||||
| Uso | Fuente | Tamaño | Peso | Tracking |
|
||||
| ------------------------------------------ | ------- | ---------------- | ---- | ------------------------------------ |
|
||||
| H1 hero | Sora | 36px / 1.1 | 600 | -0.035em |
|
||||
| H1 vista (Descubrir, Playlists, Tracklist) | Sora | 28–33px | 600 | -0.03 / -0.035em |
|
||||
| H2 sección | Sora | 17.5px | 600 | -0.025em |
|
||||
| Título spotlight | Sora | 23px | 600 | -0.03em |
|
||||
| Wordmark | Sora | 17.5px | 600 | -0.02em |
|
||||
| Subtítulo de sección | Jakarta | 12px | 400 | — |
|
||||
| Eyebrow / cabecera de columna | Jakarta | 10.5px uppercase | 600 | 0.12–0.17em |
|
||||
| Título de tarjeta | Jakarta | 13–13.5px | 600 | -0.01em |
|
||||
| Fila de canción (título) | Jakarta | 12.5–13px | 500 | — |
|
||||
| Meta / artista | Jakarta | 11–11.5px | 400 | — |
|
||||
| Números (duración, índice, reproducciones) | Jakarta | 11–12px | 400 | `font-variant-numeric: tabular-nums` |
|
||||
|
||||
Todo texto de una línea en tarjetas y filas usa `white-space:nowrap; overflow:hidden; text-overflow:ellipsis` con `min-width:0` en el contenedor flex. Los titulares llevan `text-wrap: pretty`.
|
||||
|
||||
---
|
||||
|
||||
## Escalas
|
||||
|
||||
**Radios:** 8px (miniaturas pequeñas, chips) · 9–11px (carátulas 36–48px, botones) · 12–13px (filas activas, carátulas de tarjeta) · 14–16px (tarjetas, portadas grandes) · 18px (player, tabs, portada de cabecera) · 22–24px (hero, spotlight) · 50% (avatares, botón de play, artistas) · 999px (chips de sugerencia).
|
||||
|
||||
**Sombras:** carátula de tarjeta `0 14px 32px rgba(0,0,0,.45)` · artista `0 12px 28px rgba(0,0,0,.4)` · portada de cabecera `0 24px 56px rgba(0,0,0,.55)` · player `0 20px 56px rgba(0,0,0,.6)` · botón primario hero `0 10px 28px rgba(0,0,0,.45)`.
|
||||
|
||||
**Gaps de grid:** tarjetas 20px · artistas 18px · playlists 14–22px · filas compactas 6–14px.
|
||||
|
||||
**Anchos mínimos de columna (auto-fill):** mixes/álbumes/descubrir 164px · artistas 132px · playlists (tarjeta ancha) 258px · playlists (índice) 196px · continuar escuchando 268px · populares 330px · géneros 228px.
|
||||
|
||||
---
|
||||
|
||||
## Pantallas
|
||||
|
||||
### 1. Sidebar (`>= 1180px`)
|
||||
|
||||
`width:254px; padding:22px 12px 0 20px; border-right:1px solid hairline; background: sidebarBg`.
|
||||
|
||||
- **Wordmark**: cuadrado 24px con `logoGrad` + `logoGlow`, gap 11px, texto "Musify". Padding inferior 26px.
|
||||
- **Nav** (columna, gap 2px). Ítem: `padding:9px 12px; border-radius:10px; font-size:13.5px; weight:500`, icono SVG 17px stroke 1.6, etiqueta flexible, contador a la derecha (11px, `#5c5c60`, tabular).
|
||||
- Activo: color `#d5d4d8`, fondo `rgba(255,255,255,.06)`.
|
||||
- Inactivo: color `#8b8b8f`, fondo transparente. Hover: fondo `rgba(255,255,255,.04)`, color `#d5d4d8`. Transición `.16s`.
|
||||
- Ítems: Inicio · Descubrir · Playlists (7) · Me gusta (312) · Reciente.
|
||||
- **Separador**: `margin:24px 12px 14px; height:1px; background:rgba(255,255,255,.05)`.
|
||||
- **Cabecera "TUS PLAYLISTS"** (10.5px uppercase, 0.13em, `#616165`) con un "+" a la derecha que navega al índice de playlists.
|
||||
- **Lista de playlists** (scroll, gap 1px). Fila: miniatura 34px radio 8px (imagen `opacity:.88`), nombre 12.5px, meta 11px `#6c6c70`. Si esa playlist está sonando, un ecualizador de 3 barras de 2px a la derecha.
|
||||
|
||||
### 2. TopBar
|
||||
|
||||
`padding:16px 26px 12px; gap:14px`.
|
||||
|
||||
- Atrás / adelante: círculos de 30px, fondo `rgba(255,255,255,.035)`, borde `rgba(255,255,255,.05)`, icono 15px. Atrás activo (`#8b8b8f`, hover `#dbdadf`), adelante deshabilitado (`#555559`).
|
||||
- Buscador: `flex:1; max-width:480px; height:38px; radius:11px`, fondo `rgba(255,255,255,.035)`, borde `rgba(255,255,255,.055)`, lupa 15px `#737377`, input 13px transparente. Placeholder: "Canciones, artistas, álbumes o playlists".
|
||||
- Spacer.
|
||||
- Botón "Cola": alto 33px, radio 10px, 12.5px/500, icono 15px. Activo: color `#d6d5da`, fondo `rgba(255,255,255,.06)`. Inactivo: `#8b8b8f`, fondo `rgba(255,255,255,.028)`.
|
||||
- Avatar 33px circular con borde `rgba(255,255,255,.1)`.
|
||||
|
||||
### 3. Home
|
||||
|
||||
Ritmo editorial deliberado — no filas iguales encadenadas:
|
||||
|
||||
**a) Hero** — `border-radius:24px; margin-bottom:42px; background: heroBg`, sin borde, `padding:38px 36px 34px; max-width:620px`.
|
||||
|
||||
- Saludo H1 dinámico por hora y día (ver _Copy dinámico_).
|
||||
- Blurb 13.5px `#8a8a8e`, `line-height:1.65`, `max-width:440px`.
|
||||
- Botones: primario "Reproducir" (`#c6c4cb` sobre `#0a0a0d`, 12px 21px, radio 12px, icono play 14px) + secundario "Guardar" (borde `rgba(255,255,255,.11)`), y un meta a la derecha en 12px.
|
||||
- Tres métricas: valor Sora 26px/600 y etiqueta 11.5px — "Canciones esta semana", "Tiempo de escucha", "Artistas nuevos".
|
||||
|
||||
**b) Continuar escuchando** — grid `auto-fill minmax(268px,1fr)`, gap 10px. Fila-tarjeta: `padding:9px 14px 9px 9px; radius:13px`, carátula 46px radio 9px, título 13px/600, sub 11.5px, y un icono play 15px en `accent` que aparece en hover. La pista en curso usa `accentSoft` de fondo, `accentHair` de borde y `accentMuted` en el título.
|
||||
|
||||
**c) Playlist destacada (spotlight)** — grid de dos columnas `auto-fit minmax(290px,1fr)`, `radius:22px`, `background: spotlightBg`, sin borde ni divisor central.
|
||||
|
||||
- Izquierda: portada 88px radio 14px, eyebrow "PLAYLIST DESTACADA", nombre Sora 23px, meta 12px, blurb 13px/1.7, botón "Reproducir" (`accentBtnBg` + `accentLine` + `accentTitle`, hover `accentBtnBgHover`) y "Mezclar" secundario.
|
||||
- Derecha: 5 canciones con índice 20px, título 12.5px, artista 11px, duración tabular; fondo `rgba(0,0,0,.26)` que se desvanece a transparente hacia la derecha.
|
||||
|
||||
**d) Mixes** — H2 "Mixes" + sub "Generados a partir de lo que más repites". Grid `minmax(164px,1fr)` gap 20px. Tarjeta cuadrada radio 13px con scrim `linear-gradient(180deg,transparent 45%,rgba(0,0,0,.42))` y botón de play circular 38px abajo a la derecha (`rgba(10,10,13,.76)`, blur 8px, borde `rgba(255,255,255,.12)`) que aparece en hover.
|
||||
|
||||
**e) Artistas que sigues** — grid `minmax(132px,1fr)` gap 18px, retratos circulares, texto centrado, nombre 12.5px/600, oyentes 11px.
|
||||
|
||||
**f) Populares esta semana** — grid de dos columnas `auto-fit minmax(330px,1fr)`, `gap:6px 34px`. Fila: índice/EQ 18px, carátula 38px, título + artista, reproducciones 11px y duración 11.5px.
|
||||
|
||||
**g) Tus playlists** — tarjetas anchas `minmax(258px,1fr)` gap 14px: portada 62px radio 11px, nombre 13.5px/600, nº de canciones 11.5px, artistas principales 11px `#696a6d`, play en hover. Enlace "VER TODAS" en la cabecera.
|
||||
|
||||
### 4. Descubrir
|
||||
|
||||
H1 28px + sub. Mosaico de géneros `minmax(228px,1fr)` gap 14px: tarjetas de 112px, radio 16px, fondo `linear-gradient(140deg, oklch(0.24 0.07 Hgénero), rgba(9,9,12,.95))`, borde `rgba(255,255,255,.05)`, nombre Sora 15.5px, meta 11.5px, y una miniatura de 80px rotada 20° asomando en la esquina inferior derecha (`opacity:.82`). Debajo, "Fuera de tu burbuja" con la misma tarjeta cuadrada de Mixes.
|
||||
|
||||
### 5. Tracklist (Me gusta / Reciente / Playlist / Resultados)
|
||||
|
||||
Cabecera: portada 164px radio 18px + eyebrow (`Colección` / `Historial` / `Playlist` / `Búsqueda`), H1 33px, meta 12.5px, botón "Reproducir" y un botón cuadrado 40px de "aleatorio".
|
||||
|
||||
Tabla: `grid-template-columns: 32px 1fr 1fr 92px; gap:16px`. Cabecera 10.5px uppercase `#616165` con borde inferior `rgba(255,255,255,.05)`. Fila `padding:10px 14px; radius:12px`:
|
||||
|
||||
- Col 1: número o, si es la pista activa, EQ de 3 barras de 2.5px en `accent`.
|
||||
- Col 2: carátula 40px radio 9px + título 13px/500 + artista 11.5px.
|
||||
- Col 3: álbum (o `—` si falta).
|
||||
- Col 4: corazón (14px; visible siempre si está marcado, si no solo en hover) + duración tabular.
|
||||
- Activa: fondo `accentSoft`, título `accentTitle`. Hover: `rgba(255,255,255,.032)`.
|
||||
|
||||
### 6. Índice de playlists
|
||||
|
||||
H1 "Tus playlists" + meta agregada, botón "Nueva playlist" secundario, grid `minmax(196px,1fr)` gap 22px de tarjetas cuadradas con nombre, nº de canciones y artistas principales.
|
||||
|
||||
### 7. Cola (aside derecho)
|
||||
|
||||
`border-left:1px solid hairline; background: panelBg; padding:20px 16px` + padding inferior dinámico.
|
||||
|
||||
- Cabecera "En cola" (Sora 14.5px) + cerrar "×".
|
||||
- "REPRODUCIENDO": tarjeta con fondo `accentSoft`, borde `accentLine`, carátula 40px, título en `accentTitle`.
|
||||
- Cabecera "A CONTINUACIÓN · {playlist}" con `gap:12px`; la etiqueta trunca con elipsis (`flex:1 1 auto; min-width:0; nowrap`) y "Vaciar" es `flex:0 0 auto`.
|
||||
- Lista scrollable: miniatura 36px, título 12px, artista 11px, duración.
|
||||
|
||||
### 8. Player (siempre visible)
|
||||
|
||||
Barra flotante: `radius:18px; padding:11px 16px; background: barBg; backdrop-filter: blur(22px); border:1px solid hairline; box-shadow:0 20px 56px rgba(0,0,0,.6)`; `pointer-events:none` en el contenedor y `auto` en la barra.
|
||||
|
||||
- **Izquierda (256px):** carátula 48px radio 11px, título 13px/600, artista · álbum 11.5px, corazón 16px (activo en `accent`).
|
||||
- **Centro (flex:1):** shuffle · anterior · play/pausa · siguiente · repetir, gap 14px. Play: círculo 40px `#c6c4cb` con icono `#0a0a0d`, hover `#d6d5da` (**sin scale**). Shuffle/repeat activos en `accent`, inactivos `#747478`.
|
||||
Progreso: tiempo transcurrido (10.5px tabular, ancho mín. 32px) · pista `rgba(255,255,255,.09)` de 3.5px con relleno en `accent` · duración. Clic en la pista = seek proporcional.
|
||||
- **Derecha (256px):** letra, volumen (icono + pista de 84px con relleno `#8e8c98`), y botón de cola. Oculto por completo en móvil.
|
||||
|
||||
### 9. Barra de pestañas (`< 1180px`)
|
||||
|
||||
Bajo el player, `margin-top:10px; padding:7px; radius:18px`, mismo `barBg` y blur. Icono 17px + etiqueta 10px, distribuidos con `space-around`. Muestra los 5 ítems de navegación.
|
||||
|
||||
---
|
||||
|
||||
## Estados
|
||||
|
||||
- **Loading (skeleton):** bloque de hero de 260px + 2 secciones de 6 tarjetas. Gradiente `linear-gradient(90deg, rgba(255,255,255,.025), .055, .025)` con `background-size:420px 100%` y `animation: shim 1.25s linear infinite` (`-420px 0` → `420px 0`). Se muestra 900ms al arrancar.
|
||||
- **Error:** cuadro 52px con "!" (`rgba(226,130,130,.08)` / borde `.18` / texto `#e09292`), título 20px, cuerpo 13px, botones "Reintentar" (primario) y "Ver descargas". Copy: "No se pudo cargar tu música" / "La conexión se interrumpió. Lo que tengas descargado sigue disponible sin conexión." Reintentar vuelve al skeleton 800ms.
|
||||
- **Vacío:** icono 70px radio 19px con degradado tenue, título 20px, cuerpo 13px, CTA primario + "Volver al inicio". Dos variantes de copy: playlists sin crear y "me gusta" sin canciones.
|
||||
- **Sin resultados:** título `Sin resultados para "{query}"`, cuerpo de ayuda y 4 chips de sugerencia (radio 999px, borde `rgba(255,255,255,.08)`, 12px).
|
||||
- **Datos incompletos:** si falta el álbum, la columna muestra `—` y el player omite el `· álbum`; si falta el subtítulo de una tarjeta, "Sin información". Toda carátula tiene un degradado de respaldo detrás de la imagen.
|
||||
|
||||
---
|
||||
|
||||
## Interacciones
|
||||
|
||||
| Elemento | Comportamiento |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| Nav / tabs | Cambia de vista y limpia la búsqueda |
|
||||
| Playlist (sidebar, tarjeta, índice, spotlight) | Abre la vista de playlist con su contexto |
|
||||
| Tarjeta / fila de canción | Fija la pista, `pos = 0`, `playing = true` |
|
||||
| Play/pausa | Alterna reproducción (el reloj avanza 1s por segundo) |
|
||||
| Siguiente / anterior | Cíclicos sobre el array de pistas, resetean la posición |
|
||||
| Pista de progreso | Clic → `pos = (x / ancho) * duración` |
|
||||
| Pista de volumen | Clic → `vol` entre 0 y 1 |
|
||||
| Corazón (fila o player) | Alterna en el mapa `liked` |
|
||||
| Shuffle / repeat | Alternan, se tiñen de `accent` |
|
||||
| Botón "Cola" | Abre/cierra el aside |
|
||||
| Buscar | Filtra por título, artista y álbum; vacío → vista normal; sin coincidencias → estado "sin resultados" |
|
||||
|
||||
**Animación — reglas:**
|
||||
|
||||
- **Nada de elevación en hover.** Se eliminó a propósito: el feedback es solo un cambio de fondo y la aparición del botón de play.
|
||||
- Hover de fondo/color: `.15–.18s ease`. Play del player: `.14s` solo de color, sin `scale`.
|
||||
- Cambio de tinte por canción: `.6s ease`.
|
||||
- Entrada de vista: `rise` (`opacity 0 → 1`, `translateY(12px) → 0`) en `.3s ease`. Cola: `.22s`.
|
||||
- EQ de pista activa: `eq` `.9s ease-in-out infinite` alternando `scaleY(.25)` y `scaleY(1)`, con desfases de 0.25/0.5s (0.3/0.6s en el sidebar).
|
||||
|
||||
---
|
||||
|
||||
## Estado necesario
|
||||
|
||||
```
|
||||
view 'home' | 'explore' | 'playlists' | 'playlist' | 'liked' | 'recent'
|
||||
listCtx nombre de la playlist abierta
|
||||
idx índice de la pista en reproducción
|
||||
playing boolean
|
||||
pos segundos transcurridos (intervalo de 1s, vuelve a 0 al llegar a la duración)
|
||||
vol 0..1
|
||||
queueOpen boolean (forzado a false por debajo de 760px)
|
||||
query texto de búsqueda
|
||||
liked { [trackId]: true }
|
||||
shuffle / repeat boolean
|
||||
hover clave del elemento con hover (o null)
|
||||
w ancho de ventana (listener de resize)
|
||||
demoState 'normal' | 'loading' | 'error' | 'empty' (solo para demostrar estados)
|
||||
```
|
||||
|
||||
### Datos esperados
|
||||
|
||||
`Track { id, title, artist, album?, duration, cover?, hue, plays? }` ·
|
||||
`Album { title, artist, year, cover? }` ·
|
||||
`Artist { name, listeners, photo? }` ·
|
||||
`Playlist { name, trackCount, topArtists, cover?, isPlaying? }`
|
||||
|
||||
Todos los campos opcionales deben tener degradado o texto de respaldo. El `hue` debería calcularse en backend desde la carátula (color dominante, con saturación mínima garantizada) y venir en el payload; en el prototipo está precalculado por pista.
|
||||
|
||||
---
|
||||
|
||||
## Copy dinámico
|
||||
|
||||
Saludo del hero según hora y día de la semana:
|
||||
|
||||
- `< 5h` → "¿Sesión de madrugada, {nombre}?"
|
||||
- `< 13h` → "Buenos días, {nombre}"
|
||||
- `< 20h` → "Buenas tardes, {nombre}" (miércoles → "Feliz miércoles"; viernes → "Feliz viernes")
|
||||
- `>= 20h` → "Buenas noches, {nombre}"
|
||||
- Sábado/domingo → "Que tengas buen finde, {nombre}" (o "¿Madrugada de finde, {nombre}?" antes de las 5h)
|
||||
|
||||
---
|
||||
|
||||
## Assets
|
||||
|
||||
- **Carátulas y retratos:** marcadores de posición fotográficos servidos por `picsum.photos` con una semilla por elemento, pintados como `background-image` (no `<img>`) para evitar peticiones fallidas mientras se resuelven los datos. Sustituir por las imágenes reales del catálogo.
|
||||
- **Iconos:** SVG inline, `stroke-width` 1.6–1.8, `stroke-linecap:round`. Reemplazables por el icon set del codebase.
|
||||
- **Fuentes:** Sora y Plus Jakarta Sans (Google Fonts).
|
||||
- Sin logotipos de terceros. El wordmark de Musify es tipográfico + el cuadrado con degradado.
|
||||
|
||||
## Files
|
||||
|
||||
- `Musify.dc.html` — prototipo completo (plantilla + lógica + datos de ejemplo). Contiene todas las vistas y estados; el selector `demoState` permite ver loading / error / empty.
|
||||
- `support.js` — runtime del prototipo. **No portar**: solo hace funcionar el HTML de referencia.
|
||||
- `screenshots/` — capturas de referencia (viewport ~924px, es decir en modo tablet: sidebar plegado a barra de pestañas inferior):
|
||||
- `01-home-hero.png` … `04-home-populares-playlists.png` — recorrido vertical de Home
|
||||
- `05-descubrir.png`, `06-playlists.png`, `07-tracklist-me-gusta.png`, `08-sin-resultados.png`
|
||||
Todas con "Yankee" sonando, de ahí el tinte ámbar: con otra pista el mismo layout se ve en otro color.
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,335 @@
|
||||
# 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.
|
||||
|
||||
## 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).
|
||||
@@ -36,6 +36,12 @@ export default ts.config(
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'static/', 'src/lib/api/schema.d.ts']
|
||||
ignores: [
|
||||
'build/',
|
||||
'.svelte-kit/',
|
||||
'static/',
|
||||
'src/lib/api/schema.d.ts',
|
||||
'design_handoff_musify_player/'
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource-variable/manrope": "^5.3.0",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
|
||||
"@fontsource-variable/sora": "^5.3.0",
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"jose": "^6.2.3",
|
||||
@@ -193,6 +195,24 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/plus-jakarta-sans": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/plus-jakarta-sans/-/plus-jakarta-sans-5.3.0.tgz",
|
||||
"integrity": "sha512-/l/4r0yyWK9JzAlmA0LiYgGgmJe/Gswt4jTJEzr5QhJfwMbvJZDmYWyW0M4X7yCK69BajMVlV/Lx8g7WDc1+sw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/sora": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/sora/-/sora-5.3.0.tgz",
|
||||
"integrity": "sha512-h8kHta4Z8SkfUGeI82TUy1n3rbrKpBRGwuk4t4qCwto5oEHxlbOFD2yGP+Df5fbmTXwKGn+o667vYp0E4K8Whw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource-variable/manrope": "^5.3.0",
|
||||
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
|
||||
"@fontsource-variable/sora": "^5.3.0",
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"jose": "^6.2.3",
|
||||
|
||||
@@ -194,6 +194,40 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/likes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get The Current User'S Liked Tracks. */
|
||||
get: operations["GetLikedTracks"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/likes/toggle": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Like Or Unlike A Track, By Id Or By External Source, Provisioning It In The Background If Needed. */
|
||||
post: operations["ToggleTrackLike"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/mixes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -556,6 +590,75 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users/{id}/listening-stats": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get A User'S Listening Stats For The Current Week And Streak. */
|
||||
get: operations["GetListeningStats"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users/{id}/profile": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get A User'S Public Profile, Including Follower Counts. */
|
||||
get: operations["GetUserProfile"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users/{id}/is-following": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Check Whether The Current User Follows Another User. */
|
||||
get: operations["IsFollowingUser"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/users/{id}/follow": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Follow A User. */
|
||||
post: operations["FollowUser"];
|
||||
/** Unfollow A User. */
|
||||
delete: operations["UnfollowUser"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -576,8 +679,7 @@ export interface components {
|
||||
description: null | string;
|
||||
/** Format: int32 */
|
||||
releaseYear: null | number | string;
|
||||
/** Format: int64 */
|
||||
ownerUserId: number | string;
|
||||
ownerUserId: string;
|
||||
/** Format: int32 */
|
||||
trackCount: number | string;
|
||||
smallImageKeyName: null | string;
|
||||
@@ -600,8 +702,7 @@ export interface components {
|
||||
description: null | string;
|
||||
/** Format: int32 */
|
||||
releaseYear: null | number | string;
|
||||
/** Format: int64 */
|
||||
ownerUserId: number | string;
|
||||
ownerUserId: string;
|
||||
/** Format: int32 */
|
||||
trackCount: number | string;
|
||||
smallImageKeyName: null | string;
|
||||
@@ -658,6 +759,7 @@ export interface components {
|
||||
description: null | string;
|
||||
/** Format: uuid */
|
||||
pictureIntentId: null | string;
|
||||
visibility?: components["schemas"]["PlaylistVisibility"];
|
||||
};
|
||||
CreateTrackRequest: {
|
||||
title: string;
|
||||
@@ -684,6 +786,14 @@ export interface components {
|
||||
[key: string]: string[];
|
||||
};
|
||||
};
|
||||
ListeningStatsResponse: {
|
||||
/** Format: int32 */
|
||||
tracksThisWeek: number | string;
|
||||
/** Format: int32 */
|
||||
secondsThisWeek: number | string;
|
||||
/** Format: int32 */
|
||||
streakDays: number | string;
|
||||
};
|
||||
MixApplicationResponse: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -772,6 +882,7 @@ export interface components {
|
||||
smallImageKeyName: null | string;
|
||||
mediumImageKeyName: null | string;
|
||||
largeImageKeyName: null | string;
|
||||
visibility: components["schemas"]["PlaylistVisibility"];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
@@ -790,6 +901,8 @@ export interface components {
|
||||
uploadUrl: string;
|
||||
};
|
||||
/** @enum {unknown} */
|
||||
PlaylistVisibility: "Private" | "Public";
|
||||
/** @enum {unknown} */
|
||||
ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed";
|
||||
RequestAlbumPictureUploadRequest: {
|
||||
fileType: string;
|
||||
@@ -813,6 +926,12 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
expectedAudioSizeBytes?: null | number | string;
|
||||
};
|
||||
ToggleLikeRequest: {
|
||||
/** Format: uuid */
|
||||
trackId: null | string;
|
||||
source: null | components["schemas"]["TrackSource"];
|
||||
externalId: null | string;
|
||||
};
|
||||
TrackApplicationResponse: components["schemas"]["TrackApplicationResponseLocalTrackApplicationResponse"] | components["schemas"]["TrackApplicationResponseExternalTrackApplicationResponse"];
|
||||
TrackApplicationResponseExternalTrackApplicationResponse: {
|
||||
/** @enum {string} */
|
||||
@@ -835,8 +954,7 @@ export interface components {
|
||||
TrackApplicationResponseLocalTrackApplicationResponse: {
|
||||
/** @enum {string} */
|
||||
source?: "Local";
|
||||
/** Format: int64 */
|
||||
ownerUserId: number | string;
|
||||
ownerUserId: string;
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -910,10 +1028,10 @@ export interface components {
|
||||
newDescription: null | string;
|
||||
/** Format: uuid */
|
||||
newPictureIntentId: null | string;
|
||||
newVisibility?: null | components["schemas"]["PlaylistVisibility"];
|
||||
};
|
||||
UserApplicationResponse: {
|
||||
/** Format: int64 */
|
||||
id: number | string;
|
||||
id: string;
|
||||
name: string;
|
||||
firstName: null | string;
|
||||
secondName: null | string;
|
||||
@@ -923,6 +1041,19 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
UserProfileResponse: {
|
||||
id: string;
|
||||
name: string;
|
||||
firstName: null | string;
|
||||
secondName: null | string;
|
||||
profilePictureUrl: null | string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: int32 */
|
||||
followersCount: number | string;
|
||||
/** Format: int32 */
|
||||
followingCount: number | string;
|
||||
};
|
||||
YouTubeAlbumDetail: {
|
||||
album: components["schemas"]["YouTubeAlbumResult"];
|
||||
description: string;
|
||||
@@ -1465,6 +1596,83 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
GetLikedTracks: {
|
||||
parameters: {
|
||||
query?: {
|
||||
pageNumber?: number | string;
|
||||
pageSize?: number | string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
ToggleTrackLike: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ToggleLikeRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": boolean;
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
GetMixes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1633,6 +1841,7 @@ export interface operations {
|
||||
name?: string;
|
||||
pageNumber?: number | string;
|
||||
pageSize?: number | string;
|
||||
onlyPublic?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
@@ -2395,4 +2604,154 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
GetListeningStats: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number | string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ListeningStatsResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
GetUserProfile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number | string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["UserProfileResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
IsFollowingUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number | string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": boolean;
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
FollowUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number | string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
UnfollowUser: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number | string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
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;
|
||||
@@ -55,32 +56,29 @@
|
||||
onClose();
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
style="left:{menu.x}px; top:{menu.y}px;"
|
||||
>
|
||||
<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 text-fg-2 transition hover:bg-hover"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
|
||||
>
|
||||
<ListPlus class="h-5 w-5 shrink-0" strokeWidth={2} />
|
||||
<span class="text-base">Reproducir a continuación</span>
|
||||
<ListPlus class="h-[18px] w-[18px] 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 text-fg-2 transition hover:bg-hover"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
|
||||
>
|
||||
<ListEnd class="h-5 w-5 shrink-0" strokeWidth={2} />
|
||||
<span class="text-base">Añadir a la cola</span>
|
||||
<ListEnd class="h-[18px] w-[18px] 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 text-fg-2 transition group-hover/addmenu:bg-hover"
|
||||
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-[13.5px] font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
|
||||
>
|
||||
<span class="flex-1 cursor-default text-base">Añadir álbum a una playlist</span>
|
||||
<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
|
||||
@@ -108,7 +106,7 @@
|
||||
<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-base text-fg transition hover:bg-hover"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg transition hover:bg-hover"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
@@ -127,4 +125,4 @@
|
||||
{:else}
|
||||
<p class="px-2 py-2.5 text-sm text-fg-3">No tienes playlists todavía.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</GlassMenu>
|
||||
|
||||
@@ -4,13 +4,21 @@
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import LogIn from '@lucide/svelte/icons/log-in';
|
||||
import User from '@lucide/svelte/icons/user';
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import MenuItem from './ui/MenuItem.svelte';
|
||||
import GlassMenu from './ui/GlassMenu.svelte';
|
||||
import SearchBox from './SearchBox.svelte';
|
||||
import { themeMode } from '$lib/theme/mode.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
accountUrl?: string | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
let { user, accountUrl }: Props = $props();
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let menuRef: HTMLDivElement | undefined = $state();
|
||||
@@ -21,61 +29,92 @@
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-line bg-bg/85 page-x backdrop-blur-md md:hidden"
|
||||
class="sticky top-0 z-40 flex flex-col gap-2.5 border-b border-hairline bg-bg/85 page-x pt-3 pb-3 backdrop-blur-md sm:hidden"
|
||||
>
|
||||
<a href="/" class="font-display text-lg font-semibold tracking-tight text-fg">Musify</a>
|
||||
<div class="flex h-9 items-center justify-between">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg"
|
||||
>
|
||||
<span
|
||||
class="h-5.5 w-5.5 rounded-[7px] bg-[image:var(--mf-logo-grad)] shadow-[var(--mf-logo-glow)]"
|
||||
></span>
|
||||
Musify
|
||||
</a>
|
||||
|
||||
<div class="relative" bind:this={menuRef}>
|
||||
{#if user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
aria-expanded={menuOpen}
|
||||
class="flex items-center rounded-full"
|
||||
>
|
||||
{#if user.picture}
|
||||
<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>
|
||||
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="animate-pop absolute top-full right-0 mt-2 w-56 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
<div class="relative" bind:this={menuRef}>
|
||||
{#if user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
aria-expanded={menuOpen}
|
||||
class="flex items-center rounded-full"
|
||||
>
|
||||
<MenuItem
|
||||
icon={Folder}
|
||||
label="Canciones subidas"
|
||||
href="/library"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={Upload}
|
||||
label="Subir música"
|
||||
href="/upload"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
<div class="-mx-1.5 my-1.5 border-t border-line"></div>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Salir" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
{#if user.picture}
|
||||
<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>
|
||||
|
||||
{#if menuOpen}
|
||||
<GlassMenu class="absolute top-full right-0 mt-2 w-56 overflow-hidden">
|
||||
<MenuItem
|
||||
icon={User}
|
||||
label="Ver perfil"
|
||||
href="/u/{user.sub}"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={Folder}
|
||||
label="Canciones subidas"
|
||||
href="/library"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={Upload}
|
||||
label="Subir música"
|
||||
href="/upload"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{#if accountUrl}
|
||||
<MenuItem
|
||||
icon={Settings}
|
||||
label="Ajustes"
|
||||
href={accountUrl}
|
||||
target="_blank"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
<MenuItem
|
||||
icon={themeMode.current === 'dark' ? Sun : Moon}
|
||||
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
|
||||
onclick={() => themeMode.toggle()}
|
||||
/>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
|
||||
</form>
|
||||
</GlassMenu>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/login"
|
||||
data-sveltekit-reload
|
||||
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}
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/login"
|
||||
data-sveltekit-reload
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchBox class="w-full" />
|
||||
</header>
|
||||
|
||||
<svelte:window onclick={onDocumentClick} />
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive } from '$lib/navigation.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
const links = $derived([
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
{ href: '/explore', label: 'Buscar', icon: Search },
|
||||
...(user
|
||||
? [
|
||||
{ href: '/playlists', label: 'Listas', icon: Music },
|
||||
{ href: '/library', label: 'Subidas', icon: Folder }
|
||||
]
|
||||
: [])
|
||||
]);
|
||||
|
||||
function isActive(href: string) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="fixed inset-x-0 bottom-0 z-50 border-t border-line bg-bg/95 backdrop-blur-md md:hidden"
|
||||
style="padding-bottom:var(--mf-safe-b)"
|
||||
>
|
||||
<ul class="grid h-[var(--mf-nav-h)]" style="grid-template-columns:repeat({links.length}, 1fr)">
|
||||
{#each links as link (link.href)}
|
||||
{@const active = isActive(link.href)}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex h-full flex-col items-center justify-center gap-1 transition-colors {active
|
||||
? 'text-fg'
|
||||
: 'text-fg-3'}"
|
||||
>
|
||||
<link.icon class="h-[22px] w-[22px] {active ? 'text-accent-soft' : ''}" />
|
||||
<span class="text-[11px] leading-none font-medium">{link.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { class: klass = '', placeholder = 'Canciones, artistas, álbumes o playlists' }: Props =
|
||||
$props();
|
||||
|
||||
let query = $state('');
|
||||
let searchInput: HTMLInputElement | undefined = $state();
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function navigateSearch() {
|
||||
const term = query.trim();
|
||||
goto(term ? `/explore?q=${encodeURIComponent(term)}` : '/explore', {
|
||||
replaceState: page.url.pathname === '/explore',
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
const term = query.trim();
|
||||
const onExplore = page.url.pathname === '/explore';
|
||||
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
|
||||
if (term.length === 0 && !onExplore) return;
|
||||
searchTimeout = setTimeout(navigateSearch, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function clearQuery() {
|
||||
clearTimeout(searchTimeout);
|
||||
query = '';
|
||||
if (page.url.pathname === '/explore') navigateSearch();
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
clearTimeout(searchTimeout);
|
||||
navigateSearch();
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
clearTimeout(searchTimeout);
|
||||
navigateSearch();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative min-w-0 {klass}">
|
||||
<form onsubmit={onSubmit}>
|
||||
<label
|
||||
class="flex h-9.5 w-full items-center gap-2.5 rounded-[11px] bg-white/[3.5%] px-3.5 transition focus-within:bg-white/[6%]"
|
||||
>
|
||||
<Search class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
|
||||
<input
|
||||
type="text"
|
||||
bind:this={searchInput}
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
onkeydown={onKeydown}
|
||||
{placeholder}
|
||||
class="w-full min-w-0 bg-transparent text-[13px] text-fg placeholder:text-muted focus:outline-none"
|
||||
/>
|
||||
{#if query}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearQuery}
|
||||
aria-label="Borrar búsqueda"
|
||||
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,117 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive } from '$lib/navigation.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import PlaylistArt from './ui/PlaylistArt.svelte';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
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 Music from '@lucide/svelte/icons/music';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
|
||||
interface SidebarPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
coverTrackIds: (string | number)[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
playlists: SidebarPlaylist[];
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
let { user, playlists }: Props = $props();
|
||||
|
||||
let navBox: HTMLElement | undefined = $state();
|
||||
let pillTop = $state(0);
|
||||
let pillHeight = $state(0);
|
||||
let pillVisible = $state(false);
|
||||
let animate = $state(false);
|
||||
|
||||
const mainLinks = $derived([
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
...(user ? [{ href: '/playlists', label: 'Listas', icon: Music }] : [])
|
||||
]);
|
||||
|
||||
const secondaryLinks = $derived(
|
||||
const navLinks = $derived(
|
||||
user
|
||||
? [
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
{ href: '/explore', label: 'Descubrir', icon: Compass },
|
||||
{ href: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||
{ href: '/liked', label: 'Me gusta', icon: Heart, 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) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void page.url.pathname;
|
||||
const box = navBox;
|
||||
if (!box) return;
|
||||
const active = box.querySelector<HTMLElement>('[data-active="true"]');
|
||||
if (!active) {
|
||||
pillVisible = false;
|
||||
return;
|
||||
}
|
||||
const boxRect = box.getBoundingClientRect();
|
||||
const rect = active.getBoundingClientRect();
|
||||
pillTop = rect.top - boxRect.top;
|
||||
pillHeight = rect.height;
|
||||
pillVisible = true;
|
||||
if (!animate) requestAnimationFrame(() => (animate = true));
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet navLink(link: { href: string; label: string; icon: typeof Home })}
|
||||
{@const active = isActive(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
data-active={active}
|
||||
class="relative flex items-center gap-3.5 rounded-control px-4 py-2.5 text-sm transition-colors duration-100 {active
|
||||
? 'text-fg'
|
||||
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
||||
>
|
||||
<link.icon class="h-5 w-5 shrink-0 {active ? 'text-accent-soft' : ''}" />
|
||||
{link.label}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
{#snippet groupLabel(label: string)}
|
||||
<p class="mb-4 px-4 text-sm font-semibold tracking-[0.12em] text-fg-2 uppercase">
|
||||
{label}
|
||||
</p>
|
||||
{/snippet}
|
||||
|
||||
<aside
|
||||
class="sticky top-0 hidden h-screen shrink-0 flex-col border-r border-line bg-bg md:flex"
|
||||
class="hidden shrink-0 flex-col border-r border-hairline bg-[image:var(--mf-sidebar-bg)] pt-5.5 pr-3 pl-5 transition-[background] duration-[600ms] ease-out lg:flex"
|
||||
style="width:var(--mf-sidebar-w)"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-1 flex-col gap-6 overflow-y-auto px-4.5 py-6 md:pt-8"
|
||||
bind:this={navBox}
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-4.5 z-0 overflow-hidden rounded-control border border-line bg-surface {animate
|
||||
? 'transition-[top,height] duration-200 ease-in-out'
|
||||
: ''}"
|
||||
style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}"
|
||||
>
|
||||
<span class="absolute inset-y-0 left-0 w-0.75 rounded-r-full bg-accent-soft"></span>
|
||||
<a href="/" class="flex items-center gap-2.5 px-1.5 pb-6.5">
|
||||
<span class="font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg">Musify</span>
|
||||
</a>
|
||||
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each navLinks as link (link.href)}
|
||||
{@const active = isActive(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex items-center gap-3 rounded-[10px] px-3 py-2.25 text-[13.5px] font-medium transition-colors duration-150 {active
|
||||
? 'bg-surface-hover text-fg'
|
||||
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
||||
>
|
||||
<link.icon class="h-[17px] w-[17px] shrink-0" strokeWidth={1.6} />
|
||||
<span class="flex-1 truncate">{link.label}</span>
|
||||
{#if link.count}
|
||||
<span class="text-[11px] text-muted tabular-nums">{link.count}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if user}
|
||||
<div class="mx-3 mt-6 mb-3.5 h-px bg-white/5"></div>
|
||||
<div class="flex items-center justify-between px-3 pb-1.5">
|
||||
<span class="text-[10.5px] font-semibold tracking-[0.13em] text-muted uppercase"
|
||||
>Tus playlists</span
|
||||
>
|
||||
{#if playlists.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => createPlaylistModal.show()}
|
||||
aria-label="Crear playlist"
|
||||
class="text-base text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{@render groupLabel('Navegación')}
|
||||
<nav class="relative z-10 flex flex-col gap-2">
|
||||
{#each mainLinks as link (link.href)}
|
||||
{@render navLink(link)}
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5">
|
||||
{#each playlists as playlist (playlist.id)}
|
||||
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
||||
<a
|
||||
href="/playlists/{playlist.id}"
|
||||
class="flex items-center gap-2.75 rounded-[10px] px-3 py-1.75 transition-colors duration-150 {active
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-hover'}"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
version={playlist.updatedAt}
|
||||
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-[13.5px] font-medium {active ? 'text-fg' : 'text-fg-2'}">
|
||||
{playlist.name}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => createPlaylistModal.show()}
|
||||
class="flex items-center gap-2 rounded-[10px] px-3 py-2 text-[12px] text-muted transition-colors hover:bg-hover hover:text-fg-2"
|
||||
>
|
||||
<Plus class="h-3.5 w-3.5" strokeWidth={1.8} />
|
||||
Crear tu primera lista
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if secondaryLinks.length > 0}
|
||||
<div>
|
||||
{@render groupLabel('Tu música')}
|
||||
<nav class="relative z-10 flex flex-col gap-1">
|
||||
{#each secondaryLinks as link (link.href)}
|
||||
{@render navLink(link)}
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive } from '$lib/navigation.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Compass from '@lucide/svelte/icons/compass';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { user, class: klass = '' }: Props = $props();
|
||||
|
||||
const links = $derived(
|
||||
user
|
||||
? [
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
{ href: '/explore', label: 'Descubrir', icon: Compass },
|
||||
{ href: '/playlists', label: 'Playlists', icon: ListMusic },
|
||||
{ href: '/liked', label: 'Me gusta', icon: Heart }
|
||||
]
|
||||
: [{ href: '/explore', label: 'Descubrir', icon: Compass }]
|
||||
);
|
||||
|
||||
function isActive(href: string) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="pointer-events-auto flex items-center justify-around rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] p-1.75 backdrop-blur-[22px] transition-[background] duration-[600ms] lg:hidden {klass}"
|
||||
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
|
||||
>
|
||||
{#each links as link (link.href)}
|
||||
{@const active = isActive(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex flex-col items-center gap-1.25 rounded-xl px-2.5 py-1.5 {active
|
||||
? 'text-fg'
|
||||
: 'text-fg-2'}"
|
||||
>
|
||||
<link.icon class="h-[17px] w-[17px]" strokeWidth={1.6} />
|
||||
<span class="text-[10px] font-medium">{link.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -1,220 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { SessionUser, YouTubeSong } from '$lib/types';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import {
|
||||
queueItemForTarget,
|
||||
targetArtist,
|
||||
targetCoverSrc,
|
||||
targetExplicit,
|
||||
targetId,
|
||||
targetTitle,
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import User from '@lucide/svelte/icons/user';
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import MenuItem from './ui/MenuItem.svelte';
|
||||
import Cover from './ui/Cover.svelte';
|
||||
import ExplicitBadge from './ui/ExplicitBadge.svelte';
|
||||
import GlassMenu from './ui/GlassMenu.svelte';
|
||||
import SearchBox from './SearchBox.svelte';
|
||||
import { themeMode } from '$lib/theme/mode.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
accountUrl?: string | null;
|
||||
}
|
||||
|
||||
const QUICK_RESULTS_LIMIT = 5;
|
||||
let { user, accountUrl }: Props = $props();
|
||||
|
||||
interface QuickSearchTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
artist?: string | null;
|
||||
isExplicit?: boolean;
|
||||
}
|
||||
|
||||
interface QuickSearchResponse {
|
||||
items: { track?: QuickSearchTrack | null; youTubeSong?: YouTubeSong | null }[];
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<TrackTarget[]>([]);
|
||||
let resultsOpen = $state(false);
|
||||
let searching = $state(false);
|
||||
let menuOpen = $state(false);
|
||||
let menuRef: HTMLDivElement | undefined = $state();
|
||||
let searchRef: HTMLDivElement | undefined = $state();
|
||||
let searchInput: HTMLInputElement | undefined = $state();
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let searchToken = 0;
|
||||
|
||||
function clearQuery() {
|
||||
query = '';
|
||||
results = [];
|
||||
resultsOpen = false;
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
const term = query.trim();
|
||||
if (term.length < SEARCH_MIN_LENGTH) {
|
||||
results = [];
|
||||
resultsOpen = false;
|
||||
return;
|
||||
}
|
||||
resultsOpen = true;
|
||||
searchTimeout = setTimeout(() => runQuickSearch(term), SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function runQuickSearch(term: string) {
|
||||
const token = ++searchToken;
|
||||
searching = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ name: term, pageSize: String(QUICK_RESULTS_LIMIT) });
|
||||
const res = await fetch(`/api/tracks?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = (await res.json()) as QuickSearchResponse;
|
||||
if (token !== searchToken) return;
|
||||
results = data.items
|
||||
.flatMap((item): TrackTarget[] => {
|
||||
if (item.track) return [{ kind: 'local', track: item.track }];
|
||||
if (item.youTubeSong) return [{ kind: 'youtube', song: item.youTubeSong }];
|
||||
return [];
|
||||
})
|
||||
.slice(0, QUICK_RESULTS_LIMIT);
|
||||
} catch {
|
||||
if (token === searchToken) results = [];
|
||||
} finally {
|
||||
if (token === searchToken) searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function playResult(index: number) {
|
||||
player.playOrToggle(results.map(queueItemForTarget), index);
|
||||
resultsOpen = false;
|
||||
}
|
||||
|
||||
function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
clearTimeout(searchTimeout);
|
||||
resultsOpen = false;
|
||||
const q = query.trim();
|
||||
goto(q ? `/explore?q=${encodeURIComponent(q)}` : '/explore');
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
|
||||
if (searchRef && !searchRef.contains(event.target as Node)) resultsOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-30 hidden grid-cols-[1fr_minmax(0,3fr)_1fr] items-center gap-4 border-b border-line bg-bg/70 px-8 py-4 backdrop-blur-lg md:grid"
|
||||
>
|
||||
<div></div>
|
||||
|
||||
<div class="relative mx-auto w-full max-w-2xl" bind:this={searchRef}>
|
||||
<form onsubmit={onSubmit}>
|
||||
<label
|
||||
class="flex w-full items-center gap-2.5 rounded-full border border-line bg-surface px-4 py-2.5 transition focus-within:border-accent-soft/60"
|
||||
>
|
||||
<Search class="h-4 w-4 shrink-0 text-fg-2" />
|
||||
<input
|
||||
type="text"
|
||||
bind:this={searchInput}
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
onfocus={() => (resultsOpen = results.length > 0)}
|
||||
placeholder="Buscar"
|
||||
class="w-full bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
|
||||
/>
|
||||
{#if query}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearQuery}
|
||||
aria-label="Borrar búsqueda"
|
||||
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{#if resultsOpen && (results.length > 0 || searching)}
|
||||
<div
|
||||
class="animate-pop absolute top-full right-0 left-0 z-20 mt-2 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
>
|
||||
{#if searching && results.length === 0}
|
||||
<p class="px-3 py-2.5 text-sm text-fg-2">Buscando…</p>
|
||||
{:else}
|
||||
{#each results as target, i (targetId(target))}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playResult(i)}
|
||||
class="group/result flex w-full min-w-0 items-center gap-3 rounded-control p-2 text-left transition hover:bg-hover"
|
||||
>
|
||||
<Cover
|
||||
trackId={targetId(target)}
|
||||
src={targetCoverSrc(target)}
|
||||
size="small"
|
||||
alt={targetTitle(target)}
|
||||
class="h-9 w-9 shrink-0 rounded-control shadow-art"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(target)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-sm text-fg">{targetTitle(target)}</span>
|
||||
</div>
|
||||
{#if targetArtist(target)}
|
||||
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => history.back()}
|
||||
aria-label="Atrás"
|
||||
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-white/[3.5%] text-fg-2 transition-colors hover:bg-white/7 hover:text-fg"
|
||||
>
|
||||
<ChevronLeft class="h-[15px] w-[15px]" strokeWidth={1.9} />
|
||||
</button>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-white/[3.5%] text-muted"
|
||||
>
|
||||
<ChevronRight class="h-[15px] w-[15px]" strokeWidth={1.9} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-6">
|
||||
<SearchBox class="max-w-[480px] flex-1" />
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<div class="relative shrink-0" bind:this={menuRef}>
|
||||
{#if user}
|
||||
<div class="relative" bind:this={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
class="block shrink-0 rounded-full transition active:scale-95"
|
||||
>
|
||||
{#if user.picture}
|
||||
<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 font-semibold text-fg uppercase"
|
||||
>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="animate-pop absolute top-full right-0 z-20 mt-2 w-40 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
class="block h-8.25 w-8.25 shrink-0 overflow-hidden rounded-full border border-white/10 transition active:scale-95"
|
||||
>
|
||||
{#if user.picture}
|
||||
<img src={user.picture} alt="" class="h-full w-full object-cover opacity-90" />
|
||||
{:else}
|
||||
<span
|
||||
class="grid h-full w-full place-items-center bg-surface-2 text-xs font-semibold text-fg uppercase"
|
||||
>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Salir" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<GlassMenu class="absolute top-full right-0 z-20 mt-2 w-52 overflow-hidden">
|
||||
<MenuItem
|
||||
icon={User}
|
||||
label="Ver perfil"
|
||||
href="/u/{user.sub}"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{#if accountUrl}
|
||||
<MenuItem
|
||||
icon={Settings}
|
||||
label="Ajustes"
|
||||
href={accountUrl}
|
||||
target="_blank"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
<MenuItem
|
||||
icon={themeMode.current === 'dark' ? Sun : Moon}
|
||||
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
|
||||
onclick={() => themeMode.toggle()}
|
||||
/>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
|
||||
</form>
|
||||
</GlassMenu>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/login"
|
||||
data-sveltekit-reload
|
||||
class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-on-accent transition hover:brightness-110"
|
||||
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>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
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;
|
||||
@@ -48,24 +49,21 @@
|
||||
onClose();
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
style="left:{menu.x}px; top:{menu.y}px;"
|
||||
>
|
||||
<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 text-fg-2 transition hover:bg-hover"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
|
||||
>
|
||||
<ListPlus class="h-5 w-5 shrink-0" strokeWidth={2} />
|
||||
<span class="text-base">Reproducir a continuación</span>
|
||||
<ListPlus class="h-[18px] w-[18px] 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 text-fg-2 transition group-hover/addmenu:bg-hover"
|
||||
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-[13.5px] font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
|
||||
>
|
||||
<span class="flex-1 cursor-default text-base">Añadir a una playlist</span>
|
||||
<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
|
||||
@@ -99,7 +97,7 @@
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg transition hover:bg-hover"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg transition hover:bg-hover"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
@@ -116,4 +114,4 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</GlassMenu>
|
||||
|
||||
@@ -2,44 +2,27 @@
|
||||
import TrackInfo from './TrackInfo.svelte';
|
||||
import TransportControls from './TransportControls.svelte';
|
||||
import SeekBar from './SeekBar.svelte';
|
||||
import VolumeControl from './VolumeControl.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import PlayerExtras from './PlayerExtras.svelte';
|
||||
</script>
|
||||
|
||||
{#if player.error}
|
||||
<div
|
||||
class="fixed inset-x-0 z-40 page-x py-2 md:left-[var(--mf-sidebar-w)] md:z-50"
|
||||
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-player-h))"
|
||||
>
|
||||
<Alert tone="danger">{player.error}</Alert>
|
||||
<div
|
||||
class="animate-enter pointer-events-auto flex items-center gap-4.5 rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 shadow-player backdrop-blur-[22px] transition-[background] duration-[600ms] sm:hidden"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<TrackInfo compact />
|
||||
</div>
|
||||
{/if}
|
||||
<TransportControls compact />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="animate-enter fixed right-0 left-0 z-40 h-[var(--mf-player-h)] border-t border-line bg-bg md:left-[var(--mf-sidebar-w)] md:z-50"
|
||||
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b))"
|
||||
class="animate-enter pointer-events-auto hidden items-center gap-5 rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 shadow-player backdrop-blur-[22px] transition-[background] duration-[600ms] sm:flex"
|
||||
>
|
||||
<div class="absolute inset-x-0 top-0 md:hidden">
|
||||
<SeekBar compact />
|
||||
</div>
|
||||
|
||||
<div class="flex h-full items-center gap-3 page-x md:hidden">
|
||||
<div class="min-w-0 flex-1">
|
||||
<TrackInfo />
|
||||
</div>
|
||||
<TransportControls compact />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden h-full items-center gap-5 px-8 md:grid"
|
||||
style="grid-template-columns:minmax(200px,1fr) minmax(0,3fr) minmax(200px,1fr)"
|
||||
>
|
||||
<div class="w-64 min-w-0 shrink-0">
|
||||
<TrackInfo />
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<TransportControls />
|
||||
<SeekBar />
|
||||
</div>
|
||||
<VolumeControl />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col items-center gap-2">
|
||||
<TransportControls />
|
||||
<SeekBar />
|
||||
</div>
|
||||
<PlayerExtras />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import PlayerBar from './PlayerBar.svelte';
|
||||
import TabsBar from '../TabsBar.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
const hasTrack = $derived(player.currentId !== null);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex flex-col gap-2.5 px-4 pb-4 lg:left-[var(--mf-sidebar-w)]"
|
||||
>
|
||||
{#if player.error}
|
||||
<div class="pointer-events-auto">
|
||||
<Alert tone="danger">{player.error}</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
{#if hasTrack}
|
||||
<PlayerBar />
|
||||
{/if}
|
||||
<TabsBar {user} />
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<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-[15px] w-[15px]" 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>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
|
||||
const upcoming = $derived.by(() => {
|
||||
const idx = player.tracks.findIndex((t) => t.id === player.currentId);
|
||||
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if queuePanel.open}
|
||||
<aside
|
||||
transition:fly={{ x: 24, duration: 220, easing: cubicOut }}
|
||||
class="hidden shrink-0 flex-col border-l border-hairline bg-[image:var(--mf-panel-bg)] px-4 pt-5 transition-[background] duration-[600ms] sm:flex"
|
||||
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h2 class="font-display text-[14.5px] font-semibold tracking-[-0.02em] text-fg">En cola</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => queuePanel.close()}
|
||||
aria-label="Cerrar cola"
|
||||
class="text-base text-fg-3 transition-colors hover:text-fg"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if player.current.id}
|
||||
<p class="mb-2.75 text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase">
|
||||
Reproduciendo
|
||||
</p>
|
||||
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
|
||||
<Cover
|
||||
trackId={player.current.id}
|
||||
src={player.current.coverUrl}
|
||||
size="small"
|
||||
alt={player.current.title}
|
||||
class="h-10 w-10 shrink-0 rounded-[9px]"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[12.5px] font-semibold text-accent-soft">
|
||||
{player.current.title}
|
||||
</div>
|
||||
<ArtistLink
|
||||
name={player.current.artist || '—'}
|
||||
ownerUserId={player.current.ownerUserId}
|
||||
class="mt-0.75 text-[11px] text-fg-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-2.75 flex items-center justify-between gap-3">
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase"
|
||||
>
|
||||
A continuación
|
||||
</span>
|
||||
{#if upcoming.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.clearUpcoming()}
|
||||
class="shrink-0 text-[11px] text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
Vaciar
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto">
|
||||
{#each upcoming as track, i (track.id)}
|
||||
{@const index = player.tracks.length - upcoming.length + i}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => player.playQueueIndex(index)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
player.playQueueIndex(index);
|
||||
}}
|
||||
class="flex items-center gap-3 rounded-[11px] p-2.25 text-left hover:bg-hover"
|
||||
>
|
||||
<Cover
|
||||
trackId={track.id}
|
||||
src={track.coverUrl}
|
||||
size="small"
|
||||
alt={track.title}
|
||||
class="h-9 w-9 shrink-0 rounded-lg opacity-90"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[12.5px] font-medium text-fg">{track.title}</div>
|
||||
<ArtistLink
|
||||
name={track.artist || '—'}
|
||||
ownerUserId={track.ownerUserId}
|
||||
class="mt-0.5 text-[11px] text-fg-3"
|
||||
/>
|
||||
</div>
|
||||
{#if track.duration}
|
||||
<span class="shrink-0 text-[11px] text-muted tabular-nums"
|
||||
>{fmtTime(track.duration)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
@@ -27,26 +27,21 @@
|
||||
></div>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="flex w-full max-w-172 items-center gap-3">
|
||||
<span class="text-right text-xs text-muted tabular-nums">
|
||||
<div class="flex w-full max-w-135 items-center gap-2.75">
|
||||
<span class="min-w-8 text-right text-[10.5px] text-fg-3 tabular-nums">
|
||||
{fmtTime(player.progress)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={seek}
|
||||
aria-label="Barra de progreso"
|
||||
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-track"
|
||||
class="group relative h-3.5 flex-1 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded-full bg-accent/60 group-hover:brightness-110"
|
||||
style="width:{player.progressPercent}%"
|
||||
></div>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-on-art opacity-0 shadow-[0_1px_4px_rgba(0,0,0,0.5)] transition-opacity duration-75 group-hover:opacity-100"
|
||||
style="left:{player.progressPercent}%"
|
||||
></span>
|
||||
<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="text-xs text-muted tabular-nums">
|
||||
<span class="min-w-8 text-[10.5px] text-fg-3 tabular-nums">
|
||||
{fmtTime(player.current.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
|
||||
interface Props {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let { compact = false }: Props = $props();
|
||||
|
||||
const isLiked = $derived(player.current.id !== '' && liked.isLiked(player.current.id));
|
||||
|
||||
function toggleLike() {
|
||||
if (!player.current.id) return;
|
||||
liked.toggle({
|
||||
id: player.current.id,
|
||||
title: player.current.title,
|
||||
artist: player.current.artist,
|
||||
coverUrl: player.current.coverUrl,
|
||||
source: player.current.source,
|
||||
explicit: player.current.explicit,
|
||||
ownerUserId: player.current.ownerUserId
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2.5 md:gap-3.25">
|
||||
{#if player.current.id}
|
||||
<Cover
|
||||
trackId={player.current.id}
|
||||
src={player.current.coverUrl}
|
||||
size="small"
|
||||
alt={player.current.title}
|
||||
class="h-11 w-11 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
|
||||
class="h-11 w-11 shrink-0 rounded-[11px] shadow-[0_8px_20px_rgba(0,0,0,0.5)] md:h-12 md:w-12"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="h-11 w-11 shrink-0 rounded-lg bg-surface shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
|
||||
class="h-11 w-11 shrink-0 rounded-[11px] bg-surface shadow-[0_8px_20px_rgba(0,0,0,0.5)] md:h-12 md:w-12"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if player.current.explicit}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-fg">{player.current.title}</span>
|
||||
<span class="truncate text-[13px] font-semibold tracking-[-0.01em] text-fg"
|
||||
>{player.current.title}</span
|
||||
>
|
||||
</div>
|
||||
<div class="truncate text-sm text-muted">{player.current.artist || '—'}</div>
|
||||
<ArtistLink
|
||||
name={player.current.artist || '—'}
|
||||
ownerUserId={player.current.ownerUserId}
|
||||
class="mt-0.5 text-[11.5px] text-fg-3"
|
||||
/>
|
||||
</div>
|
||||
{#if !compact}
|
||||
<button
|
||||
type="button"
|
||||
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} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||
import Repeat from '@lucide/svelte/icons/repeat';
|
||||
|
||||
interface Props {
|
||||
compact?: boolean;
|
||||
@@ -10,11 +12,23 @@
|
||||
const sideClass = $derived(
|
||||
compact
|
||||
? 'grid h-10 w-10 place-items-center text-fg opacity-70 transition active:scale-90'
|
||||
: 'flex text-fg opacity-70 transition hover:opacity-100 active:scale-90'
|
||||
: 'flex text-fg-2 transition-colors hover:text-fg active:scale-90'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex shrink-0 items-center {compact ? 'gap-0.5' : 'gap-5'}">
|
||||
<div class="flex shrink-0 items-center {compact ? 'gap-0.5' : 'gap-3.5'}">
|
||||
{#if !compact}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.toggleShuffle()}
|
||||
aria-pressed={player.shuffle}
|
||||
aria-label="Aleatorio"
|
||||
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
|
||||
>
|
||||
<Shuffle class="h-[15px] w-[15px]" strokeWidth={1.8} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button type="button" onclick={() => player.previous()} aria-label="Anterior" class={sideClass}>
|
||||
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
|
||||
<rect x="0" y="0" width="2.5" height="14" rx="1" />
|
||||
@@ -26,16 +40,16 @@
|
||||
type="button"
|
||||
onclick={() => player.toggle()}
|
||||
aria-label={player.playing ? 'Pausar' : 'Reproducir'}
|
||||
class="grid h-[36px] w-[36px] shrink-0 place-items-center rounded-full bg-fg text-bg shadow-lg transition duration-100 active:scale-95"
|
||||
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="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<rect x="1.5" y="0" width="3.5" height="12" rx="1" />
|
||||
<rect x="7" y="0" width="3.5" height="12" rx="1" />
|
||||
<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="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<path d="M2.5 0 12 6l-9.5 6z" />
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
|
||||
<path d="M3 1.5v12l10-6z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -46,4 +60,16 @@
|
||||
<rect x="13.5" y="0" width="2.5" height="14" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if !compact}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.toggleRepeat()}
|
||||
aria-pressed={player.repeat}
|
||||
aria-label="Repetir"
|
||||
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
|
||||
>
|
||||
<Repeat class="h-[15px] w-[15px]" strokeWidth={1.8} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<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;
|
||||
const newVolume = Number(target.value);
|
||||
player.setVolume(newVolume);
|
||||
player.setVolume(Number(target.value));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-end gap-3.5">
|
||||
<div class="flex items-center gap-2.25">
|
||||
<Volume2 class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
@@ -17,7 +18,7 @@
|
||||
oninput={handleVolumeChange}
|
||||
aria-label="Volumen"
|
||||
class="mf-volume"
|
||||
style="background:linear-gradient(to right, var(--color-accent-soft) {player.volume}%, var(--color-track) {player.volume}%)"
|
||||
style="background:linear-gradient(to right, var(--mf-text-2) {player.volume}%, var(--mf-track) {player.volume}%)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -25,8 +26,8 @@
|
||||
.mf-volume {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
width: 84px;
|
||||
height: 3.5px;
|
||||
border-radius: 100px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
@@ -34,17 +35,17 @@
|
||||
.mf-volume::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-fg);
|
||||
background: var(--mf-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mf-volume::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-fg);
|
||||
background: var(--mf-text);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import Disc3 from '@lucide/svelte/icons/disc-3';
|
||||
import ImageDropzone from './ImageDropzone.svelte';
|
||||
import Button from './Button.svelte';
|
||||
import Field from './Field.svelte';
|
||||
@@ -59,11 +60,18 @@
|
||||
if (result.type === 'success') onSuccess?.();
|
||||
};
|
||||
}}
|
||||
class="mt-4 w-full max-w-2xl space-y-4"
|
||||
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">
|
||||
<Field label="Portada" optional={!requireCover}>
|
||||
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} required={requireCover} />
|
||||
<Field>
|
||||
<ImageDropzone
|
||||
name="cover"
|
||||
fallbackUrl={coverFallbackUrl}
|
||||
required={requireCover}
|
||||
icon={Disc3}
|
||||
gradient
|
||||
class="h-36 w-36 rounded-[16px]"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-4">
|
||||
@@ -78,7 +86,7 @@
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Año de publicación" for="album-year" optional>
|
||||
<Field label="Año de publicación" for="album-year">
|
||||
<Input
|
||||
id="album-year"
|
||||
name="releaseYear"
|
||||
@@ -92,7 +100,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Descripción" for="album-description" optional>
|
||||
<Field label="Descripción" for="album-description">
|
||||
<Textarea
|
||||
id="album-description"
|
||||
name="description"
|
||||
|
||||
@@ -11,10 +11,14 @@
|
||||
|
||||
const tones = {
|
||||
neutral: 'border-line bg-surface text-fg-2',
|
||||
danger: 'border-danger/40 bg-danger/10 text-danger-fg'
|
||||
danger: 'border-danger/20 bg-danger/10 text-danger-fg'
|
||||
};
|
||||
</script>
|
||||
|
||||
<p class="rounded-control border px-4 py-3 text-base {tones[tone]} {klass}">
|
||||
<p
|
||||
class="rounded-control border px-4 py-3 text-sm shadow-menu backdrop-blur-md {tones[
|
||||
tone
|
||||
]} {klass}"
|
||||
>
|
||||
{@render children()}
|
||||
</p>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string;
|
||||
imageUrl?: string | null;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { name, size = 108 }: Props = $props();
|
||||
let { name, imageUrl, size = 108 }: Props = $props();
|
||||
|
||||
const initials = $derived(
|
||||
name
|
||||
@@ -21,11 +22,15 @@
|
||||
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/artist:shadow-art-lg"
|
||||
style="width:{size}px;height:{size}px"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0"
|
||||
style="background:repeating-linear-gradient(135deg, rgba(255,255,255,0.12) 0 2px, transparent 2px 10px)"
|
||||
></div>
|
||||
<span class="relative text-lg font-semibold text-white/90">{initials}</span>
|
||||
{#if imageUrl}
|
||||
<img src={imageUrl} alt="" class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0"
|
||||
style="background:repeating-linear-gradient(135deg, rgba(255,255,255,0.12) 0 2px, transparent 2px 10px)"
|
||||
></div>
|
||||
<span class="relative text-lg font-semibold text-white/90">{initials}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-full truncate text-sm text-fg">{name}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name?: string | null;
|
||||
ownerUserId?: string | number | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { name, ownerUserId, class: klass = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if name}
|
||||
{#if ownerUserId}
|
||||
<a
|
||||
href="/u/{ownerUserId}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="inline-block max-w-full truncate align-top hover:text-fg-2 hover:underline {klass}"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="block truncate {klass}">{name}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -3,7 +3,7 @@
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'subtle' | 'danger';
|
||||
variant?: 'primary' | 'secondary' | 'accent' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
type?: 'button' | 'submit';
|
||||
href?: string;
|
||||
@@ -29,20 +29,22 @@
|
||||
}: Props = $props();
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-4 py-2 text-base',
|
||||
md: 'px-5 py-2.5 text-base',
|
||||
lg: 'px-8 py-3.5 text-base'
|
||||
sm: 'px-4 py-2.5 text-[12.5px]',
|
||||
md: 'px-5 py-3 text-[13px]',
|
||||
lg: 'px-6 py-3.5 text-sm'
|
||||
};
|
||||
|
||||
const subtleButton = 'bg-white/[6%] text-fg-2 hover:bg-white/[9%] hover:text-fg';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-accent-soft text-on-accent active:scale-[0.97]',
|
||||
secondary: 'bg-bg text-fg ring-1 ring-line hover:bg-surface',
|
||||
subtle: 'bg-white/10 text-fg hover:bg-accent/18',
|
||||
primary: subtleButton,
|
||||
secondary: subtleButton,
|
||||
accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover',
|
||||
danger: 'bg-danger text-on-accent hover:brightness-110'
|
||||
};
|
||||
|
||||
const classes = $derived(
|
||||
`inline-flex items-center justify-center gap-2 rounded-control font-medium 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-xl font-semibold 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>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<label class="flex items-center gap-3 text-base 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">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -21,7 +21,7 @@
|
||||
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"
|
||||
/>
|
||||
<Check
|
||||
class="pointer-events-none absolute h-3.5 w-3.5 text-on-accent opacity-0 transition peer-checked:opacity-100"
|
||||
class="pointer-events-none absolute h-3.5 w-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
label: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { label, class: klass = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="pointer-events-none absolute top-2 left-2 rounded-full bg-black/45 px-2 py-0.5 text-[10px] font-semibold tracking-[0.08em] text-white uppercase backdrop-blur-sm {klass}"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
@@ -13,24 +13,19 @@
|
||||
let { icon: Icon, title, description, class: klass = '', actions }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center rounded-panel border border-dashed border-line 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}
|
||||
<span
|
||||
class="grid h-16 w-16 place-items-center rounded-full text-on-accent"
|
||||
style="background:radial-gradient(circle at 30% 30%, color-mix(in oklch, var(--color-accent), white 15%), var(--color-accent))"
|
||||
>
|
||||
<Icon class="h-7 w-7" />
|
||||
</span>
|
||||
<Icon class="h-9 w-9 text-fg-2" strokeWidth={1.3} />
|
||||
{/if}
|
||||
{#if title}
|
||||
<h3 class="mt-6 font-display text-2xl font-medium tracking-tight text-fg">{title}</h3>
|
||||
<h3 class="mt-6 font-display text-xl font-semibold tracking-[-0.025em] text-fg">
|
||||
{title}
|
||||
</h3>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="mt-2 max-w-sm text-fg-2">{description}</p>
|
||||
<p class="mt-2.5 max-w-sm text-[13px] leading-[1.65] text-fg-2">{description}</p>
|
||||
{/if}
|
||||
{#if actions}
|
||||
<div class="mt-6 flex items-center gap-3">{@render actions()}</div>
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-2.5">{@render actions()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: number;
|
||||
barWidth?: number;
|
||||
paused?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { size = 13, barWidth = 2, paused = false, class: klass = '' }: Props = $props();
|
||||
|
||||
const delays = [0, 0.3, 0.6];
|
||||
</script>
|
||||
|
||||
<div class="flex items-end justify-center gap-[2px] {klass}" style="height:{size}px">
|
||||
{#each delays as delay (delay)}
|
||||
<span
|
||||
class="rounded-full bg-accent"
|
||||
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
||||
? 'paused'
|
||||
: 'running'}"
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -2,25 +2,25 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
label?: string;
|
||||
for?: string;
|
||||
optional?: boolean;
|
||||
class?: string;
|
||||
hint?: Snippet;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { label, for: forId, optional = false, class: klass = '', hint, children }: Props = $props();
|
||||
let { label, for: forId, class: klass = '', hint, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 flex-col {klass}">
|
||||
<label for={forId} class="mb-2 block text-base font-medium text-fg-2">
|
||||
{label}
|
||||
{#if optional}<span class="text-muted">(Opcional)</span>{/if}
|
||||
</label>
|
||||
{#if label}
|
||||
<label for={forId} class="mb-2 block text-sm font-medium text-fg-2">
|
||||
{label}
|
||||
</label>
|
||||
{/if}
|
||||
{@render children()}
|
||||
{#if hint}
|
||||
<div class="mt-2 flex items-center justify-between gap-3 text-sm text-muted">
|
||||
<div class="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
|
||||
{@render hint()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { class: klass = '', style = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="glass-panel animate-pop rounded-panel p-1.5 shadow-menu {klass}" {style}>
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import type { LucideIcon } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
fallbackUrl?: string;
|
||||
required?: boolean;
|
||||
icon?: LucideIcon;
|
||||
gradient?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -12,13 +15,16 @@
|
||||
name,
|
||||
fallbackUrl,
|
||||
required = false,
|
||||
class:
|
||||
klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control border border-line bg-surface hover:border-accent/50'
|
||||
icon: Icon = ImageIcon,
|
||||
gradient = false,
|
||||
class: klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control'
|
||||
}: Props = $props();
|
||||
|
||||
let preview = $state('');
|
||||
let fallbackFailed = $state(false);
|
||||
|
||||
const hasImage = $derived(preview !== '' || (!!fallbackUrl && !fallbackFailed));
|
||||
|
||||
function onInput(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (file) preview = URL.createObjectURL(file);
|
||||
@@ -26,7 +32,10 @@
|
||||
</script>
|
||||
|
||||
<label
|
||||
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {klass}"
|
||||
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {gradient
|
||||
? ''
|
||||
: 'border border-line bg-surface hover:border-accent/50'} {klass}"
|
||||
style={gradient && !hasImage ? 'background:var(--mf-cover-grad)' : undefined}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@@ -48,6 +57,6 @@
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<ImageIcon class="h-12 w-12 text-muted" />
|
||||
<Icon class="h-9 w-9 {gradient ? 'text-white/70' : 'text-muted'}" strokeWidth={1.3} />
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
{autocomplete}
|
||||
{oninput}
|
||||
bind:value
|
||||
class="w-full rounded-control border border-line bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/60 focus:outline-none {Icon
|
||||
class="w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {Icon
|
||||
? 'pr-4 pl-11'
|
||||
: 'px-4'} {klass}"
|
||||
/>
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
{oncontextmenu}
|
||||
>
|
||||
{@render art(mediaCardArt)}
|
||||
<div class="mt-2.5 truncate text-fg transition-colors group-hover:text-accent-soft">{title}</div>
|
||||
<div class="mt-2.5 truncate text-sm font-medium text-fg">{title}</div>
|
||||
{#if subtitle}
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">{subtitle}</div>
|
||||
<div class="mt-0.5 truncate text-[12.5px] text-fg-3">{subtitle}</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
href?: string;
|
||||
target?: string;
|
||||
type?: 'button' | 'submit';
|
||||
reload?: boolean;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
@@ -15,6 +16,7 @@
|
||||
icon: Icon,
|
||||
label,
|
||||
href,
|
||||
target,
|
||||
type = 'button',
|
||||
reload = false,
|
||||
onclick,
|
||||
@@ -22,18 +24,25 @@
|
||||
}: Props = $props();
|
||||
|
||||
const classes = $derived(
|
||||
`flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg-2 transition hover:bg-hover ${klass}`
|
||||
`flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover ${klass}`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} data-sveltekit-reload={reload ? '' : undefined} {onclick} class={classes}>
|
||||
<Icon class="h-6 w-6 shrink-0" strokeWidth={2} />
|
||||
<a
|
||||
{href}
|
||||
{target}
|
||||
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
||||
data-sveltekit-reload={reload ? '' : undefined}
|
||||
{onclick}
|
||||
class={classes}
|
||||
>
|
||||
<Icon class="h-[18px] w-[18px] shrink-0" strokeWidth={1.8} />
|
||||
{label}
|
||||
</a>
|
||||
{:else}
|
||||
<button {type} {onclick} class={classes}>
|
||||
<Icon class="h-6 w-6 shrink-0" strokeWidth={2} />
|
||||
<Icon class="h-[18px] w-[18px] shrink-0" strokeWidth={1.8} />
|
||||
{label}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Cover from './Cover.svelte';
|
||||
import type { Mix } from '$lib/mixes';
|
||||
|
||||
interface Props {
|
||||
mix: Mix;
|
||||
featured?: boolean;
|
||||
banner?: boolean;
|
||||
class?: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { mix, featured = false, banner = false, class: klass = '', index = 0 }: Props = $props();
|
||||
|
||||
const fillCount = $derived(banner ? 40 : 12);
|
||||
|
||||
const tiles = $derived.by(() => {
|
||||
if (mix.items.length === 0) return [];
|
||||
const out: Mix['items'] = [];
|
||||
while (out.length < fillCount) out.push(...mix.items);
|
||||
return out.slice(0, fillCount);
|
||||
});
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/mixes/{mix.id}"
|
||||
class="group/tile animate-enter relative block overflow-hidden rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out [clip-path:inset(0_round_var(--radius-art))] ring-inset hover:shadow-art-lg focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {klass}"
|
||||
style="animation-delay:{Math.min(index, 10) * 45}ms"
|
||||
>
|
||||
{#if tiles.length > 0}
|
||||
<div
|
||||
class="absolute inset-0 flex scale-105 transition duration-500 ease-out group-hover/tile:scale-110"
|
||||
>
|
||||
{#each tiles as item, i (i)}
|
||||
<Cover
|
||||
trackId={item.trackId ?? item.videoId ?? ''}
|
||||
src={item.thumbnailUrl}
|
||||
size="medium"
|
||||
class="aspect-square h-full shrink-0"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-linear-to-t from-scrim/85 via-scrim/65 to-scrim/40 {banner
|
||||
? 'sm:bg-linear-to-r'
|
||||
: ''}"
|
||||
></div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-scrim/25 opacity-0 transition-opacity duration-300"
|
||||
></div>
|
||||
<div
|
||||
class="relative flex h-full flex-col justify-end p-3 sm:p-4 {banner
|
||||
? 'sm:flex-row sm:items-center sm:justify-between sm:gap-4'
|
||||
: ''}"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-on-art {featured ? 'text-xl sm:text-2xl' : 'text-base'}">
|
||||
{mix.title}
|
||||
</div>
|
||||
{#if mix.subtitle}
|
||||
<div class="mt-0.5 truncate text-xs text-on-art/75 sm:text-sm">{mix.subtitle}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,51 @@
|
||||
<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 shadow-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-white/12 bg-ink/76 text-fg opacity-0 backdrop-blur-[8px] 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-[13px] font-semibold tracking-[-0.01em] text-fg">
|
||||
{mix.title}
|
||||
</div>
|
||||
{#if mix.subtitle}
|
||||
<div class="mt-1 truncate text-[11.5px] text-fg-3">{mix.subtitle}</div>
|
||||
{/if}
|
||||
</a>
|
||||
@@ -6,6 +6,7 @@
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
eyebrow?: string;
|
||||
title?: string;
|
||||
maxWidth?: string;
|
||||
panelClass?: string;
|
||||
@@ -15,9 +16,10 @@
|
||||
let {
|
||||
open,
|
||||
onClose,
|
||||
eyebrow,
|
||||
title,
|
||||
maxWidth = 'max-w-2xl',
|
||||
panelClass = 'border border-line bg-elevated',
|
||||
panelClass = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
@@ -74,16 +76,26 @@
|
||||
>
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 shadow-2xl outline-none sm:p-6 {panelClass}"
|
||||
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 shadow-2xl backdrop-blur-[22px] transition-[background] duration-[600ms] outline-none sm:p-6 {panelClass}"
|
||||
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if title}
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold tracking-tight text-fg">{title}</h2>
|
||||
<IconButton label="Cerrar" onclick={onClose}>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
{#if eyebrow}
|
||||
<p class="mb-2.5 text-xs font-semibold tracking-[0.1em] text-muted uppercase">
|
||||
{eyebrow}
|
||||
</p>
|
||||
{/if}
|
||||
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
|
||||
<X class="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -5,19 +5,14 @@
|
||||
|
||||
let { paused = false }: Props = $props();
|
||||
|
||||
const bars = [
|
||||
{ delay: 0, dur: 0.9 },
|
||||
{ delay: 0.25, dur: 0.7 },
|
||||
{ delay: 0.1, dur: 1.05 },
|
||||
{ delay: 0.4, dur: 0.8 }
|
||||
];
|
||||
const delays = [0, 0.3, 0.6];
|
||||
</script>
|
||||
|
||||
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-scrim/45 pb-2">
|
||||
{#each bars as bar, i (i)}
|
||||
<div class="absolute inset-0 flex items-end justify-center gap-[3px] bg-scrim/45 pb-2.5">
|
||||
{#each delays as delay (delay)}
|
||||
<span
|
||||
class="w-[3px] origin-bottom rounded-full bg-on-art"
|
||||
style="height:14px; animation:equalize {bar.dur}s ease-in-out {bar.delay}s infinite; animation-play-state:{paused
|
||||
style="height:14px; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
||||
? 'paused'
|
||||
: 'running'}"
|
||||
></span>
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
let { class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="page-x pt-7 pb-8 sm:pt-11 sm:pb-10 {klass}">
|
||||
<section class="page-x pt-3 pb-8 sm:pt-4 sm:pb-10 {klass}">
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
|
||||
<div class="min-w-0">
|
||||
<h1 class="font-display text-2xl font-semibold tracking-tight text-fg sm:text-4xl">{title}</h1>
|
||||
<h1 class="font-display text-[28px] font-semibold tracking-[-0.03em] text-fg sm:text-[32px]">
|
||||
{title}
|
||||
</h1>
|
||||
{#if subtitle}
|
||||
<p class="mt-2 text-sm text-fg-2 sm:text-base">{subtitle}</p>
|
||||
<p class="mt-2 text-[13px] text-fg-2">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if actions}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
|
||||
interface Props {
|
||||
active: boolean;
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
let { active, playing }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 place-items-center rounded-full bg-accent-soft text-on-accent opacity-0 shadow-lg transition-opacity group-hover:opacity-100"
|
||||
class:!opacity-100={active}
|
||||
>
|
||||
{#if active && playing}
|
||||
<Pause class="h-5 w-5" fill="currentColor" />
|
||||
{:else}
|
||||
<Play class="h-5 w-5" fill="currentColor" />
|
||||
{/if}
|
||||
</span>
|
||||
@@ -5,16 +5,18 @@
|
||||
interface Props {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
trackCount: number;
|
||||
trackIds: (string | number)[];
|
||||
updatedAt: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { id, name, description, trackIds, updatedAt, index = 0 }: Props = $props();
|
||||
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={description} {index}>
|
||||
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
|
||||
{#snippet art(artClass)}
|
||||
<PlaylistArt playlistId={id} {trackIds} version={updatedAt} class={artClass} />
|
||||
{/snippet}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import ImageDropzone from './ImageDropzone.svelte';
|
||||
import Button from './Button.svelte';
|
||||
import Field from './Field.svelte';
|
||||
@@ -8,15 +9,20 @@
|
||||
import Textarea from './Textarea.svelte';
|
||||
import Alert from './Alert.svelte';
|
||||
|
||||
const NAME_MAX_LENGTH = 60;
|
||||
const DESCRIPTION_MAX_LENGTH = 300;
|
||||
|
||||
interface Props {
|
||||
action: string;
|
||||
initialName?: string;
|
||||
initialDescription?: string;
|
||||
initialVisibility?: 'private' | 'public';
|
||||
namePlaceholder?: string;
|
||||
coverFallbackUrl?: string;
|
||||
formMessage?: string;
|
||||
submitLabel: string;
|
||||
submittingLabel: string;
|
||||
helperText?: string;
|
||||
onCancel: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
@@ -25,11 +31,13 @@
|
||||
action,
|
||||
initialName = '',
|
||||
initialDescription = '',
|
||||
namePlaceholder = '',
|
||||
initialVisibility = 'private',
|
||||
namePlaceholder = 'Playlist sin título',
|
||||
coverFallbackUrl,
|
||||
formMessage,
|
||||
submitLabel,
|
||||
submittingLabel,
|
||||
helperText,
|
||||
onCancel,
|
||||
onSuccess
|
||||
}: Props = $props();
|
||||
@@ -37,6 +45,7 @@
|
||||
let name = $state(untrack(() => initialName));
|
||||
let description = $state(untrack(() => initialDescription));
|
||||
let submitting = $state(false);
|
||||
let visibility = $state(untrack(() => initialVisibility));
|
||||
</script>
|
||||
|
||||
<form
|
||||
@@ -48,49 +57,105 @@
|
||||
return async ({ update, result }) => {
|
||||
await update({ reset: false });
|
||||
submitting = false;
|
||||
if (result.type === 'success') onSuccess?.();
|
||||
if (result.type === 'success' || result.type === 'redirect') onSuccess?.();
|
||||
};
|
||||
}}
|
||||
class="mt-4 w-full max-w-4xl space-y-4"
|
||||
class="mt-5 w-full max-w-4xl space-y-5"
|
||||
>
|
||||
<input type="hidden" name="visibility" value={visibility} />
|
||||
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
|
||||
<Field label="Portada" optional>
|
||||
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} />
|
||||
<Field>
|
||||
<ImageDropzone
|
||||
name="cover"
|
||||
fallbackUrl={coverFallbackUrl}
|
||||
icon={Music}
|
||||
gradient
|
||||
class="h-36 w-36 rounded-[16px]"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-4">
|
||||
<Field label="Nombre" for="playlist-name">
|
||||
{#snippet hint()}
|
||||
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
|
||||
{/snippet}
|
||||
<Input
|
||||
id="playlist-name"
|
||||
name="name"
|
||||
maxlength={100}
|
||||
maxlength={NAME_MAX_LENGTH}
|
||||
required
|
||||
bind:value={name}
|
||||
placeholder={namePlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Descripción" for="playlist-description" optional class="min-h-0 flex-1">
|
||||
<Field label="Descripción" for="playlist-description" class="min-h-0 flex-1">
|
||||
<Textarea
|
||||
id="playlist-description"
|
||||
name="description"
|
||||
maxlength={300}
|
||||
maxlength={DESCRIPTION_MAX_LENGTH}
|
||||
bind:value={description}
|
||||
placeholder="¿De qué va esta lista?"
|
||||
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
|
||||
class="flex-1"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-[14px] bg-surface px-4 py-3.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
|
||||
<div class="mt-0.5 text-xs text-muted">
|
||||
{visibility === 'private'
|
||||
? 'Solo tú la ves en tu biblioteca'
|
||||
: 'Cualquiera con el enlace puede verla'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
|
||||
<div
|
||||
class="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-fg transition-transform duration-300 ease-out {visibility ===
|
||||
'public'
|
||||
? 'translate-x-full'
|
||||
: ''}"
|
||||
></div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (visibility = 'private')}
|
||||
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-semibold 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-semibold transition-colors {visibility ===
|
||||
'public'
|
||||
? 'text-ink'
|
||||
: 'text-muted hover:text-fg-2'}"
|
||||
>
|
||||
Pública
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if formMessage}
|
||||
<Alert tone="danger">{formMessage}</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
||||
<Button type="submit" disabled={submitting || name.trim() === ''}>
|
||||
{submitting ? submittingLabel : submitLabel}
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
@@ -3,24 +3,30 @@
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
class?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { title, class: klass = '', actions }: Props = $props();
|
||||
let { title, subtitle, class: klass = '', actions }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet heading()}
|
||||
<h2 class="font-display text-[17.5px] font-semibold tracking-[-0.025em] text-fg">
|
||||
{title}
|
||||
</h2>
|
||||
{#if subtitle}
|
||||
<p class="mt-1.25 text-xs text-fg-3">{subtitle}</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if actions}
|
||||
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-5 {klass}">
|
||||
<h2 class="font-display text-xl font-medium tracking-tight text-fg sm:text-[1.75rem]">
|
||||
{title}
|
||||
</h2>
|
||||
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
|
||||
<div>{@render heading()}</div>
|
||||
{@render actions()}
|
||||
</div>
|
||||
{:else}
|
||||
<h2
|
||||
class="mb-4 font-display text-xl font-medium tracking-tight text-fg sm:mb-5 sm:text-[1.75rem] {klass}"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div class="mb-4 sm:mb-4.5 {klass}">
|
||||
{@render heading()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
const paddings = { sm: 'p-4', md: 'p-5 sm:p-6', lg: 'p-6 sm:p-12' };
|
||||
</script>
|
||||
|
||||
<div class="rounded-panel border border-line bg-surface transition {paddings[padding]} {klass}">
|
||||
<div class="rounded-panel bg-surface transition {paddings[padding]} {klass}">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -24,5 +24,5 @@
|
||||
{placeholder}
|
||||
{maxlength}
|
||||
bind:value
|
||||
class="min-h-0 w-full resize-none rounded-control border border-line bg-surface px-4 py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {klass}"
|
||||
class="min-h-0 w-full resize-none rounded-control bg-surface px-4 py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {klass}"
|
||||
></textarea>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import NowPlaying from './NowPlaying.svelte';
|
||||
import EqBars from './EqBars.svelte';
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
@@ -16,10 +16,10 @@
|
||||
type="button"
|
||||
onclick={onToggle}
|
||||
aria-label={active && playing ? 'Pausar' : 'Reproducir'}
|
||||
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-muted"
|
||||
class="relative grid h-8 w-8 place-items-center rounded text-xs text-muted tabular-nums"
|
||||
>
|
||||
{#if active}
|
||||
<NowPlaying paused={!playing} />
|
||||
<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" />
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
let { class: klass = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class="truncate text-center text-sm text-muted tabular-nums {klass}">
|
||||
<span class="truncate text-center text-[12.5px] text-muted tabular-nums {klass}">
|
||||
{@render children()}
|
||||
</span>
|
||||
|
||||
@@ -4,19 +4,22 @@
|
||||
import { trackTableKey, type TrackTableContext } from './TrackTable.svelte';
|
||||
|
||||
interface Props {
|
||||
active?: boolean;
|
||||
class?: string;
|
||||
oncontextmenu?: (event: MouseEvent) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { class: klass = '', oncontextmenu, children }: Props = $props();
|
||||
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-2.5 pr-4 pl-2 transition hover:bg-surface sm:gap-8 sm:pr-6 sm:pl-3 {klass}"
|
||||
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-white/[3.2%]'} {klass}"
|
||||
style="--mf-track-cols:{table.columns}; --mf-track-cols-mobile:{table.columnsMobile}"
|
||||
{oncontextmenu}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
const columns = $derived(
|
||||
[
|
||||
index ? '32px' : '',
|
||||
'1fr',
|
||||
'minmax(180px,1fr)',
|
||||
...meta.map((column) => column.width),
|
||||
'64px',
|
||||
action ? '36px' : ''
|
||||
@@ -39,7 +39,7 @@
|
||||
);
|
||||
|
||||
const columnsMobile = $derived(
|
||||
[index ? '28px' : '', '1fr', '52px', action ? '32px' : '']
|
||||
[index ? '28px' : '', 'minmax(120px,1fr)', '52px', action ? '32px' : '']
|
||||
.filter((width) => width !== '')
|
||||
.join(' ')
|
||||
);
|
||||
@@ -54,9 +54,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={klass}>
|
||||
<div class="overflow-x-auto {klass}">
|
||||
<div
|
||||
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-2 pl-2 text-sm font-medium tracking-wide text-muted uppercase sm:gap-8 sm:pr-6 sm:pl-3"
|
||||
class="grid track-grid items-center gap-3 border-b border-white/5 pr-4 pb-3 pl-2 text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase sm:gap-8 sm:pr-6 sm:pl-3"
|
||||
style="--mf-track-cols:{columns}; --mf-track-cols-mobile:{columnsMobile}"
|
||||
>
|
||||
{#if index}
|
||||
@@ -71,7 +71,7 @@
|
||||
<span></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-col">
|
||||
<div class="mt-1 flex flex-col gap-0.5">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import Cover from './Cover.svelte';
|
||||
import ExplicitBadge from './ExplicitBadge.svelte';
|
||||
import ArtistLink from './ArtistLink.svelte';
|
||||
|
||||
interface Props {
|
||||
trackId: string | number;
|
||||
title: string;
|
||||
artist?: string | null;
|
||||
ownerUserId?: string | number | null;
|
||||
coverSrc?: string | null;
|
||||
coverSize?: string;
|
||||
explicit?: boolean;
|
||||
active?: boolean;
|
||||
titleClass?: string;
|
||||
onClick: () => void;
|
||||
overlay?: Snippet;
|
||||
@@ -20,17 +23,31 @@
|
||||
trackId,
|
||||
title,
|
||||
artist,
|
||||
ownerUserId,
|
||||
coverSrc,
|
||||
coverSize = 'h-[42px] w-[42px]',
|
||||
explicit = false,
|
||||
active = false,
|
||||
titleClass = '',
|
||||
onClick,
|
||||
overlay,
|
||||
badge
|
||||
}: Props = $props();
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
</script>
|
||||
|
||||
<button type="button" onclick={onClick} class="flex min-w-0 items-center gap-3.5 text-left">
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={onClick}
|
||||
onkeydown={onKeydown}
|
||||
class="flex min-w-0 items-center gap-3.5 text-left"
|
||||
>
|
||||
<Cover
|
||||
{trackId}
|
||||
src={coverSrc}
|
||||
@@ -45,11 +62,13 @@
|
||||
{#if explicit}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="min-w-0 truncate text-fg {titleClass}">{title}</span>
|
||||
<span
|
||||
class="min-w-0 truncate text-sm font-medium {active
|
||||
? 'text-accent-soft'
|
||||
: 'text-fg'} {titleClass}">{title}</span
|
||||
>
|
||||
{@render badge?.()}
|
||||
</div>
|
||||
{#if artist}
|
||||
<div class="truncate text-left text-sm text-fg-3">{artist}</div>
|
||||
{/if}
|
||||
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-[12.5px] text-fg-3" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,12 +9,13 @@ export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
||||
export const EXPLORE_ALBUMS_LIMIT = 18;
|
||||
export const HOME_LATEST_PAGE_SIZE = 10;
|
||||
export const HOME_ALBUMS_LIMIT = 12;
|
||||
export const HOME_MIXES_BENTO = 5;
|
||||
export const HOME_MIXES_LIMIT = 6;
|
||||
export const HOME_SHELF_LIMIT = 6;
|
||||
export const HOME_CONTINUE_LIMIT = 6;
|
||||
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
||||
export const YOUTUBE_FILLER_LIMIT = 27;
|
||||
export const HOME_TOP_MUSIC_FILLER_LIMIT = 10;
|
||||
export const HOME_POPULAR_FILLER_LIMIT = 10;
|
||||
export const HOME_POPULAR_MAX = 5;
|
||||
export const HOME_ALBUM_FILLER_LIMIT = 8;
|
||||
export const HOME_POPULAR_MAX = 8;
|
||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||
export const LIKED_TRACKS_PAGE_SIZE = 500;
|
||||
export const EXPLORE_USERS_LIMIT = 12;
|
||||
|
||||
@@ -5,6 +5,13 @@ export function fmtTime(seconds: number): string {
|
||||
return `${minutes}:${rest < 10 ? '0' : ''}${rest}`;
|
||||
}
|
||||
|
||||
export function fmtDurationLong(seconds: number): string {
|
||||
const total = Math.max(0, Math.floor(seconds || 0));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
return hours > 0 ? `${hours} h ${minutes} min` : `${minutes} min`;
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
||||
|
||||
export function fmtDate(value: string | number | Date): string {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { toQueueItems, type ApiTrackLike, type TrackSourceKind } from './player.svelte';
|
||||
|
||||
export interface LikedTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
artist?: string;
|
||||
coverUrl?: string;
|
||||
source?: TrackSourceKind;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
likedAt: number;
|
||||
}
|
||||
|
||||
type LikeToggleInput = {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist?: string;
|
||||
coverUrl?: string;
|
||||
source?: TrackSourceKind;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
};
|
||||
|
||||
function toggleRequestBody(track: LikeToggleInput) {
|
||||
return track.source === 'youtube'
|
||||
? { source: 'YouTube' as const, externalId: String(track.id) }
|
||||
: { trackId: String(track.id) };
|
||||
}
|
||||
|
||||
class LikedStore {
|
||||
entries = $state<Record<string, LikedTrack>>({});
|
||||
private hydrated = false;
|
||||
|
||||
count = $derived(Object.keys(this.entries).length);
|
||||
list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt));
|
||||
|
||||
hydrate(tracks: (ApiTrackLike & { createdAt: string })[]) {
|
||||
if (this.hydrated) return;
|
||||
this.hydrated = true;
|
||||
|
||||
const items = toQueueItems(tracks);
|
||||
const next: Record<string, LikedTrack> = {};
|
||||
items.forEach((item, i) => {
|
||||
next[String(item.id)] = {
|
||||
id: String(item.id),
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
coverUrl: item.coverUrl,
|
||||
source: item.source,
|
||||
explicit: item.explicit,
|
||||
ownerUserId: item.ownerUserId,
|
||||
likedAt: new Date(tracks[i].createdAt).getTime()
|
||||
};
|
||||
});
|
||||
this.entries = next;
|
||||
}
|
||||
|
||||
isLiked(id: string | number): boolean {
|
||||
return String(id) in this.entries;
|
||||
}
|
||||
|
||||
async toggle(track: LikeToggleInput) {
|
||||
const id = String(track.id);
|
||||
const before = this.entries;
|
||||
const wasLiked = id in before;
|
||||
const next = { ...before };
|
||||
if (wasLiked) delete next[id];
|
||||
else {
|
||||
next[id] = {
|
||||
id,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
coverUrl: track.coverUrl,
|
||||
source: track.source,
|
||||
explicit: track.explicit,
|
||||
ownerUserId: track.ownerUserId,
|
||||
likedAt: Date.now()
|
||||
};
|
||||
}
|
||||
this.entries = next;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/likes', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(toggleRequestBody(track))
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to toggle like');
|
||||
} catch {
|
||||
this.entries = before;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const liked = new LikedStore();
|
||||
@@ -13,6 +13,7 @@ export interface PlayerTrack {
|
||||
source: TrackSourceKind;
|
||||
coverUrl?: string;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
@@ -28,6 +29,7 @@ export interface QueueItem {
|
||||
source?: TrackSourceKind;
|
||||
coverUrl?: string;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
}
|
||||
|
||||
export interface ApiTrackLike {
|
||||
@@ -38,6 +40,7 @@ export interface ApiTrackLike {
|
||||
externalId?: string | null;
|
||||
audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
||||
isExplicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
}
|
||||
|
||||
function isYouTubeTrack(track: ApiTrackLike): boolean {
|
||||
@@ -69,7 +72,8 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
||||
? youTubeThumbnailUrl(track.externalId)
|
||||
: `/api/tracks/${track.id}/cover?size=small`
|
||||
: undefined,
|
||||
explicit: track.isExplicit
|
||||
explicit: track.isExplicit,
|
||||
ownerUserId: youTube ? undefined : track.ownerUserId
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -101,7 +105,8 @@ function toTrack(item: QueueItem): PlayerTrack {
|
||||
duration: 0,
|
||||
explicit: item.explicit,
|
||||
source: item.source ?? 'local',
|
||||
coverUrl: item.coverUrl
|
||||
coverUrl: item.coverUrl,
|
||||
ownerUserId: item.ownerUserId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,6 +120,8 @@ class PlayerState {
|
||||
error = $state('');
|
||||
recentlyPlayed = $state<PlayerTrack[]>([]);
|
||||
playlists = $state<Playlist[]>([]);
|
||||
shuffle = $state(false);
|
||||
repeat = $state(false);
|
||||
|
||||
accentColor = $state<string | null>(null);
|
||||
|
||||
@@ -154,6 +161,11 @@ class PlayerState {
|
||||
});
|
||||
audio.addEventListener('ended', () => {
|
||||
this.#stopProgressLoop();
|
||||
if (this.repeat) {
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
this.next();
|
||||
});
|
||||
audio.addEventListener('error', () => {
|
||||
@@ -397,10 +409,39 @@ class PlayerState {
|
||||
next() {
|
||||
if (this.tracks.length === 0) return;
|
||||
const idx = this.#index();
|
||||
this.currentId = this.tracks[(idx + 1) % this.tracks.length].id;
|
||||
let nextIdx = (idx + 1) % this.tracks.length;
|
||||
if (this.shuffle && this.tracks.length > 1) {
|
||||
do {
|
||||
nextIdx = Math.floor(Math.random() * this.tracks.length);
|
||||
} while (nextIdx === idx);
|
||||
}
|
||||
this.currentId = this.tracks[nextIdx].id;
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
toggleShuffle() {
|
||||
this.shuffle = !this.shuffle;
|
||||
}
|
||||
|
||||
toggleRepeat() {
|
||||
this.repeat = !this.repeat;
|
||||
}
|
||||
|
||||
playQueueIndex(index: number) {
|
||||
const track = this.tracks[index];
|
||||
if (!track) return;
|
||||
if (this.currentId === track.id) {
|
||||
this.toggle();
|
||||
return;
|
||||
}
|
||||
this.currentId = track.id;
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
clearUpcoming() {
|
||||
this.tracks = this.currentId === null ? [] : [this.current];
|
||||
}
|
||||
|
||||
previous() {
|
||||
if (this.tracks.length === 0) return;
|
||||
const audio = this.#audioEl();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
let open = $state(false);
|
||||
|
||||
export const queuePanel = {
|
||||
get open() {
|
||||
return open;
|
||||
},
|
||||
toggle() {
|
||||
open = !open;
|
||||
},
|
||||
close() {
|
||||
open = false;
|
||||
},
|
||||
show() {
|
||||
open = true;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
let open = $state(false);
|
||||
|
||||
export const createPlaylistModal = {
|
||||
get open() {
|
||||
return open;
|
||||
},
|
||||
show() {
|
||||
open = true;
|
||||
},
|
||||
close() {
|
||||
open = false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
||||
|
||||
/**
|
||||
* Merges the in-memory session history (tracks played since the page loaded)
|
||||
* with the server-side listening history, de-duplicated by id and capped at `limit`.
|
||||
*/
|
||||
export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): QueueItem[] {
|
||||
const seen = new Set<string>();
|
||||
const merged: QueueItem[] = [];
|
||||
|
||||
for (const t of player.recentlyPlayed) {
|
||||
const id = String(t.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push({
|
||||
id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
source: t.source,
|
||||
coverUrl: t.coverUrl,
|
||||
explicit: t.explicit,
|
||||
ownerUserId: t.ownerUserId
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of toQueueItems(history)) {
|
||||
const id = String(item.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
merged.push({
|
||||
id,
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
source: item.source ?? 'local',
|
||||
coverUrl: item.coverUrl,
|
||||
explicit: item.explicit,
|
||||
ownerUserId: item.ownerUserId
|
||||
});
|
||||
}
|
||||
|
||||
return merged.slice(0, limit);
|
||||
}
|
||||
@@ -1,24 +1,107 @@
|
||||
const GREETINGS_BY_HOUR: { until: number; pool: string[] }[] = [
|
||||
{
|
||||
until: 6,
|
||||
pool: ['¿Aún despierto?', 'La noche también tiene banda sonora', 'Un ratito más, con música']
|
||||
},
|
||||
{
|
||||
until: 12,
|
||||
pool: ['Buenos días', 'Que empiece bien la mañana', 'Un nuevo día, una nueva lista']
|
||||
},
|
||||
{ until: 14, pool: ['Buenos días', 'El mediodía sabe mejor con música'] },
|
||||
{
|
||||
until: 19,
|
||||
pool: ['Buenas tardes', 'La tarde pide su banda sonora', 'Un respiro, con algo de música']
|
||||
},
|
||||
{ until: 23, pool: ['Buenas noches', 'Hora de bajar el ritmo', 'Que la noche te acompañe'] },
|
||||
{ until: 24, pool: ['¿Aún despierto?', 'La noche también tiene banda sonora'] }
|
||||
interface SpecialDate {
|
||||
month: number;
|
||||
day: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const SPECIAL_DATES: SpecialDate[] = [
|
||||
{ month: 1, day: 1, message: 'Nuevo año, nueva música.' },
|
||||
{ month: 2, day: 14, message: 'Hoy también hay canciones para eso.' },
|
||||
{ month: 10, day: 31, message: 'Esta noche suena diferente.' },
|
||||
{ month: 12, day: 24, message: 'Nochebuena. Que no pare la música.' },
|
||||
{ month: 12, day: 25, message: 'Feliz Navidad. Pon tu banda sonora.' },
|
||||
{ month: 12, day: 31, message: 'Último día del año. Haz que suene bien.' }
|
||||
];
|
||||
|
||||
export function pickGreeting(hour: number): string {
|
||||
const pool =
|
||||
GREETINGS_BY_HOUR.find((slot) => hour < slot.until)?.pool ?? GREETINGS_BY_HOUR[0].pool;
|
||||
const greeting = pool[Math.floor(Math.random() * pool.length)];
|
||||
return /[.!?]$/.test(greeting) ? greeting : `${greeting}.`;
|
||||
const BIRTHDAY_MESSAGE = 'Hoy toca celebrar. Tú eliges la música.';
|
||||
|
||||
const HOUR_SLOTS = [6, 9, 12, 15, 18, 21, 24];
|
||||
|
||||
const WEEKLY_SCHEDULE: string[][] = [
|
||||
[
|
||||
'Domingo de madrugada.',
|
||||
'Buenos días. Tómate el domingo con calma.',
|
||||
'Domingo. Empieza despacio.',
|
||||
'Una mañana tranquila merece buena música.',
|
||||
'Domingo por la tarde. Hora de bajar el ritmo.',
|
||||
'La semana empieza a despedirse.',
|
||||
'Una última canción antes de volver a empezar.'
|
||||
],
|
||||
[
|
||||
'La semana acaba de empezar.',
|
||||
'Buenos días. Empezamos otra semana.',
|
||||
'Lunes. Dale una banda sonora al día.',
|
||||
'Una pausa musical para este lunes.',
|
||||
'El lunes todavía no ha terminado.',
|
||||
'El lunes ya empieza a quedar atrás.',
|
||||
'Sobrevive al lunes con una última canción.'
|
||||
],
|
||||
[
|
||||
'La noche sigue sonando.',
|
||||
'Buenos días. Martes en marcha.',
|
||||
'La semana ya está cogiendo ritmo.',
|
||||
'Martes al mediodía. ¿Qué escuchamos?',
|
||||
'Una tarde cualquiera necesita música.',
|
||||
'El día empieza a bajar el ritmo.',
|
||||
'Un martes más llega a su fin.'
|
||||
],
|
||||
[
|
||||
'Mitad de semana, mitad de noche.',
|
||||
'Buenos días. Ya es miércoles.',
|
||||
'Ya estamos en el ecuador de la semana.',
|
||||
'Miércoles al mediodía. Un poco de música.',
|
||||
'La semana ya va cuesta abajo.',
|
||||
'Unas canciones para terminar el miércoles.',
|
||||
'Mitad de semana superada.'
|
||||
],
|
||||
[
|
||||
'Una noche más antes del fin de semana.',
|
||||
'Buenos días. Ya casi estamos.',
|
||||
'Jueves. El fin de semana se acerca.',
|
||||
'Una pausa musical antes de seguir.',
|
||||
'Queda poco para desconectar.',
|
||||
'Ya se empieza a notar el viernes.',
|
||||
'Una noche más y llega el fin de semana.'
|
||||
],
|
||||
[
|
||||
'La semana ha terminado. La noche continúa.',
|
||||
'Buenos días. Es viernes.',
|
||||
'Viernes. Hoy suena diferente.',
|
||||
'Ya huele a fin de semana.',
|
||||
'Últimas horas de la semana.',
|
||||
'Se acabó la semana. Dale al play.',
|
||||
'Viernes por la noche. Tú eliges la banda sonora.'
|
||||
],
|
||||
[
|
||||
'La noche es joven. Que siga sonando.',
|
||||
'Buenos días. Hoy no hay prisa.',
|
||||
'Sábado. Empieza el día a tu ritmo.',
|
||||
'Mediodía de sábado. ¿Qué ponemos?',
|
||||
'Una tarde de sábado pide música.',
|
||||
'El sábado todavía tiene mucho por delante.',
|
||||
'Esta noche puede sonar cualquier cosa.'
|
||||
]
|
||||
];
|
||||
|
||||
export interface Birthday {
|
||||
month: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `birthday` is optional because the user's date of birth isn't part of the
|
||||
* session/profile data yet. Wire it up here once that field exists upstream.
|
||||
*/
|
||||
export function pickGreeting(date: Date, birthday?: Birthday | null): string {
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
|
||||
if (birthday && birthday.month === month && birthday.day === day) return BIRTHDAY_MESSAGE;
|
||||
|
||||
const special = SPECIAL_DATES.find((d) => d.month === month && d.day === day);
|
||||
if (special) return special.message;
|
||||
|
||||
const hour = date.getHours();
|
||||
const slot = HOUR_SLOTS.findIndex((until) => hour < until);
|
||||
return WEEKLY_SCHEDULE[date.getDay()][slot === -1 ? 0 : slot];
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export interface DirectoryUser {
|
||||
id: string;
|
||||
name: string;
|
||||
handle: string;
|
||||
}
|
||||
|
||||
const DIRECTORY: DirectoryUser[] = [
|
||||
{ id: 'u1', name: 'Alex Rivera', handle: 'alexrivera' },
|
||||
{ id: 'u2', name: 'Marina Cordero', handle: 'marinac' },
|
||||
{ id: 'u3', name: 'Diego Fuentes', handle: 'dfuentes' },
|
||||
{ id: 'u4', name: 'Lucía Marés', handle: 'luciamares' },
|
||||
{ id: 'u5', name: 'Noah Bennett', handle: 'noahb' },
|
||||
{ id: 'u6', name: 'Sara Ibáñez', handle: 'saraib' },
|
||||
{ id: 'u7', name: 'Kairo', handle: 'kairo' },
|
||||
{ id: 'u8', name: 'Valentina Ortiz', handle: 'valen' }
|
||||
];
|
||||
|
||||
export function searchUsers(query: string, limit = 12): DirectoryUser[] {
|
||||
const term = query.trim().toLowerCase();
|
||||
if (!term) return [];
|
||||
return DIRECTORY.filter(
|
||||
(user) => user.name.toLowerCase().includes(term) || user.handle.toLowerCase().includes(term)
|
||||
).slice(0, limit);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { createApiClient } from '$lib/server/api';
|
||||
import type { YouTubeAlbumResult, YouTubeSong } from '$lib/types';
|
||||
|
||||
const YOUTUBE_FILLER_QUERIES = [
|
||||
export const YOUTUBE_FILLER_QUERIES = [
|
||||
'pop',
|
||||
'rock',
|
||||
'reggaeton',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const ACCENT_LIGHTNESS = 60;
|
||||
export const ACCENT_CHROMA = 0.1;
|
||||
export const ACCENT_LIGHTNESS = 74;
|
||||
export const ACCENT_CHROMA = 0.145;
|
||||
export const ACCENT_HUE = 145;
|
||||
|
||||
export const DEFAULT_ACCENT = `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${ACCENT_HUE})`;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type ThemeName = 'dark' | 'light';
|
||||
|
||||
const STORAGE_KEY = 'musify.theme';
|
||||
|
||||
function loadInitial(): ThemeName {
|
||||
if (!browser) return 'dark';
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'light' ? 'light' : 'dark';
|
||||
} catch {
|
||||
return 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeName) {
|
||||
if (!browser) return;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}
|
||||
|
||||
class ThemeModeStore {
|
||||
current = $state<ThemeName>(loadInitial());
|
||||
|
||||
constructor() {
|
||||
applyTheme(this.current);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.current = this.current === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(this.current);
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, this.current);
|
||||
} catch {
|
||||
/* localStorage unavailable (private mode, quota) — preference just won't persist */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const themeMode = new ThemeModeStore();
|
||||
@@ -1,39 +1,150 @@
|
||||
:root {
|
||||
--mf-bg: oklch(12.5% 0.004 95);
|
||||
--mf-elevated: oklch(16% 0.005 95);
|
||||
/* Dynamic tokens: overwritten at runtime from the playing track's hue.
|
||||
These are the fallback values for the default accent (hue 145) so the
|
||||
page never flashes unstyled before hydration. See theme/tokens.ts. */
|
||||
--mf-bg: oklch(0.068 0.008 145);
|
||||
--mf-elevated: oklch(0.11 0.012 145);
|
||||
--mf-sidebar-bg: linear-gradient(180deg, oklch(0.095 0.012 145), oklch(0.068 0.007 145));
|
||||
--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-logo-glow: 0 0 20px oklch(0.74 0.145 145 / 0.3);
|
||||
--mf-cover-grad: linear-gradient(150deg, oklch(0.5 0.13 145), oklch(0.16 0.035 145));
|
||||
|
||||
--mf-text: oklch(93% 0.004 95);
|
||||
--mf-text-2: oklch(78% 0.006 95);
|
||||
--mf-text-3: oklch(62% 0.007 95);
|
||||
--mf-text-4: oklch(46% 0.006 95);
|
||||
|
||||
--mf-surface: oklch(15.5% 0.005 95);
|
||||
--mf-surface-2: rgba(255, 255, 255, 0.05);
|
||||
--mf-surface-hover: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--mf-border: rgba(255, 255, 255, 0.07);
|
||||
--mf-border-strong: rgba(255, 255, 255, 0.13);
|
||||
--mf-track: rgba(255, 255, 255, 0.12);
|
||||
|
||||
--mf-accent: oklch(60% 0.1 145);
|
||||
--mf-accent-soft: color-mix(in oklch, var(--mf-accent) 80%, transparent);
|
||||
--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. */
|
||||
--mf-text: #d9d8dc;
|
||||
--mf-text-2: #8a8a8e;
|
||||
--mf-text-3: #7d7d81;
|
||||
--mf-text-4: #646468;
|
||||
|
||||
--mf-surface: rgba(255, 255, 255, 0.022);
|
||||
--mf-surface-2: rgba(255, 255, 255, 0.045);
|
||||
--mf-surface-hover: rgba(255, 255, 255, 0.06);
|
||||
|
||||
--mf-border: rgba(255, 255, 255, 0.04);
|
||||
--mf-border-strong: rgba(255, 255, 255, 0.09);
|
||||
--mf-track: rgba(255, 255, 255, 0.09);
|
||||
|
||||
--mf-scrim: #000000;
|
||||
--mf-on-art: #fefdfb;
|
||||
--mf-ink: #0a0a0d;
|
||||
--mf-cta-strong-bg: #ffffff;
|
||||
|
||||
--mf-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
|
||||
--mf-sidebar-w: 288px;
|
||||
--mf-nav-h: 60px;
|
||||
--mf-player-h: 64px;
|
||||
--mf-sidebar-w: 254px;
|
||||
--mf-queue-w: 304px;
|
||||
--mf-nav-h: 66px;
|
||||
--mf-player-h: 78px;
|
||||
--mf-safe-b: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
@media (width >= 48rem) {
|
||||
/* PlayerBar switches from its compact row to the full layout at `sm` (640px). */
|
||||
@media (width >= 40rem) {
|
||||
:root {
|
||||
--mf-nav-h: 0px;
|
||||
--mf-player-h: 92px;
|
||||
--mf-player-h: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
/* TabsBar (mobile/tablet nav) is replaced by the Sidebar at `lg` (1024px). */
|
||||
@media (width >= 64rem) {
|
||||
:root {
|
||||
--mf-nav-h: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 73.75rem) {
|
||||
:root {
|
||||
--mf-queue-w: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
/* "Modo blanco" — flips the neutral canvas/surface/text tokens to a light
|
||||
palette. Components that reference these tokens re-theme correctly;
|
||||
components hardcoding literal dark-mode hex colors do not. */
|
||||
:root[data-theme='light'] {
|
||||
--mf-bg: oklch(0.97 0.006 90);
|
||||
--mf-elevated: oklch(0.99 0.003 90);
|
||||
--mf-sidebar-bg: 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-2: #4a4a4e;
|
||||
--mf-text-3: #6b6b6f;
|
||||
--mf-text-4: #8c8c90;
|
||||
|
||||
--mf-surface: rgba(0, 0, 0, 0.035);
|
||||
--mf-surface-2: rgba(0, 0, 0, 0.06);
|
||||
--mf-surface-hover: rgba(0, 0, 0, 0.08);
|
||||
|
||||
--mf-border: rgba(0, 0, 0, 0.08);
|
||||
--mf-border-strong: rgba(0, 0, 0, 0.14);
|
||||
--mf-track: rgba(0, 0, 0, 0.12);
|
||||
|
||||
--mf-scrim: #ffffff;
|
||||
--mf-ink: #ffffff;
|
||||
--mf-cta-strong-bg: #17171a;
|
||||
|
||||
--mf-ambient:
|
||||
radial-gradient(52% 38% at 14% 0%, oklch(0.9 0.06 145 / 0.26), transparent 54%),
|
||||
radial-gradient(46% 34% at 78% 0%, oklch(0.9121 0.0497 145 / 0.2), transparent 56%);
|
||||
--mf-hero-bg: linear-gradient(
|
||||
105deg,
|
||||
oklch(0.9145 0.0476 145 / 0.34) 0%,
|
||||
oklch(0.9483 0.0186 145 / 0.11) 37%,
|
||||
oklch(0.97 0 145 / 0.03) 65%,
|
||||
rgba(255, 255, 255, 0) 92%
|
||||
);
|
||||
--mf-spotlight-bg: linear-gradient(
|
||||
100deg,
|
||||
oklch(0.9193 0.0434 145 / 0.28) 0%,
|
||||
oklch(0.9531 0.0145 145 / 0.1) 40%,
|
||||
oklch(0.97 0 145 / 0.03) 69%,
|
||||
rgba(255, 255, 255, 0) 96%
|
||||
);
|
||||
--mf-modal-glow: linear-gradient(
|
||||
135deg,
|
||||
oklch(0.9145 0.0476 145 / 0.34) 0%,
|
||||
oklch(0.9483 0.0186 145 / 0.11) 28%,
|
||||
oklch(0.97 0 145 / 0.03) 50%,
|
||||
rgba(255, 255, 255, 0) 70%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ACCENT_CHROMA, ACCENT_LIGHTNESS } from './color';
|
||||
|
||||
const L = ACCENT_LIGHTNESS / 100;
|
||||
const C = ACCENT_CHROMA;
|
||||
|
||||
function r(value: number, digits = 4): string {
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
|
||||
function tc(alpha: number, l: number, hue: number): string {
|
||||
const lightness = 0.34 - l / 380;
|
||||
const chroma = Math.max(0, 0.11 - l / 1600);
|
||||
return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`;
|
||||
}
|
||||
|
||||
// Light-mode counterpart of tc(): fades toward the light canvas (~0.97
|
||||
// lightness, 0 chroma at l=88) instead of toward black, so the "glow"
|
||||
// gradients read as a soft tint instead of a dark smudge on a light bg.
|
||||
function tcLight(alpha: number, l: number, hue: number): string {
|
||||
const depth = (88 - l) / 58;
|
||||
const lightness = 0.97 - 0.07 * depth;
|
||||
const chroma = Math.max(0, 0.06 * depth);
|
||||
return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`;
|
||||
}
|
||||
|
||||
export interface ThemeTokens {
|
||||
[cssVar: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every color in the app derives from the hue of the track in playback.
|
||||
* See design_handoff_musify_player/README.md § "Sistema de color dinámico".
|
||||
*
|
||||
* `mode` only affects the neutral canvas tokens (bg/elevated/panel/hairline):
|
||||
* these are re-applied as inline styles on every hue change, so a light-mode
|
||||
* override in theme.css alone can never win against them — the light values
|
||||
* have to come from here instead.
|
||||
*/
|
||||
export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'): ThemeTokens {
|
||||
const h = ((hue % 360) + 360) % 360;
|
||||
const h2 = (h + 40) % 360;
|
||||
const H = r(h, 1);
|
||||
const H2 = r(h2, 1);
|
||||
const light = mode === 'light';
|
||||
|
||||
return {
|
||||
'--mf-accent': `oklch(${r(L)} ${r(C)} ${H})`,
|
||||
'--mf-accent-title': `oklch(0.87 0.07 ${H})`,
|
||||
'--mf-accent-muted': `oklch(0.79 0.035 ${H})`,
|
||||
'--mf-accent-soft-bg': `oklch(${r(L)} ${r(C)} ${H} / 0.08)`,
|
||||
'--mf-accent-line': `oklch(${r(L)} ${r(C)} ${H} / 0.2)`,
|
||||
'--mf-accent-hair': `oklch(${r(L)} ${r(C)} ${H} / 0.09)`,
|
||||
'--mf-accent-btn-bg': `oklch(${r(L)} ${r(C)} ${H} / 0.15)`,
|
||||
'--mf-accent-btn-bg-hover': `oklch(${r(L)} ${r(C)} ${H} / 0.24)`,
|
||||
'--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-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-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-ambient': light
|
||||
? `radial-gradient(52% 38% at 14% 0%, ${tcLight(0.26, 30, h)}, transparent 54%), radial-gradient(46% 34% at 78% 0%, ${tcLight(0.2, 40, h)}, transparent 56%)`
|
||||
: `radial-gradient(52% 38% at 14% 0%, ${tc(0.26, 30, h)}, transparent 54%), radial-gradient(46% 34% at 78% 0%, ${tc(0.2, 40, h)}, transparent 56%)`,
|
||||
'--mf-hero-bg': light
|
||||
? `linear-gradient(105deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.11, 70, h)} 37%, ${tcLight(0.03, 88, h)} 65%, rgba(255,255,255,0) 92%)`
|
||||
: `linear-gradient(105deg, ${tc(0.34, 42, h)} 0%, ${tc(0.11, 70, h)} 37%, ${tc(0.03, 88, h)} 65%, rgba(6,6,9,0) 92%)`,
|
||||
'--mf-modal-glow': light
|
||||
? `linear-gradient(135deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.11, 70, h)} 28%, ${tcLight(0.03, 88, h)} 50%, rgba(255,255,255,0) 70%)`
|
||||
: `linear-gradient(135deg, ${tc(0.34, 42, h)} 0%, ${tc(0.11, 70, h)} 28%, ${tc(0.03, 88, h)} 50%, rgba(6,6,9,0) 70%)`,
|
||||
'--mf-spotlight-bg': light
|
||||
? `linear-gradient(100deg, ${tcLight(0.28, 46, h)} 0%, ${tcLight(0.1, 74, h)} 40%, ${tcLight(0.03, 88, h)} 69%, rgba(255,255,255,0) 96%)`
|
||||
: `linear-gradient(100deg, ${tc(0.28, 46, h)} 0%, ${tc(0.1, 74, h)} 40%, ${tc(0.03, 88, h)} 69%, rgba(6,6,9,0) 96%)`,
|
||||
'--mf-logo-grad': `linear-gradient(140deg, oklch(0.78 0.15 ${H}), oklch(0.62 0.13 ${H2}) 92%)`,
|
||||
'--mf-logo-glow': `0 0 20px oklch(${r(L)} ${r(C)} ${H} / 0.3)`,
|
||||
'--mf-cover-grad': `linear-gradient(150deg, oklch(0.5 0.13 ${H}), oklch(0.16 0.035 ${H}))`
|
||||
};
|
||||
}
|
||||
|
||||
export function applyThemeTokens(
|
||||
tokens: ThemeTokens,
|
||||
target: HTMLElement = document.documentElement
|
||||
) {
|
||||
for (const [key, value] of Object.entries(tokens)) target.style.setProperty(key, value);
|
||||
}
|
||||
@@ -22,6 +22,10 @@ export function targetArtist(target: TrackTarget): string | undefined {
|
||||
return (target.kind === 'youtube' ? target.song.artist : target.track.artist) ?? undefined;
|
||||
}
|
||||
|
||||
export function targetOwnerUserId(target: TrackTarget): string | number | undefined {
|
||||
return target.kind === 'youtube' ? undefined : (target.track.ownerUserId ?? undefined);
|
||||
}
|
||||
|
||||
export function targetExplicit(target: TrackTarget): boolean {
|
||||
return target.kind === 'youtube' ? target.song.isExplicit : (target.track.isExplicit ?? false);
|
||||
}
|
||||
@@ -76,7 +80,8 @@ export function targetForQueueItem(item: QueueItem): TrackTarget {
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
source: 'Local',
|
||||
isExplicit: item.explicit
|
||||
isExplicit: item.explicit,
|
||||
ownerUserId: item.ownerUserId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</PageHeader>
|
||||
|
||||
{#if items.length > 0}
|
||||
<MediaGrid class="mt-9">
|
||||
<MediaGrid class="mt-7 sm:mt-8">
|
||||
{#each items as album, i (album.id)}
|
||||
<AlbumCard
|
||||
id={album.id}
|
||||
@@ -55,10 +55,7 @@
|
||||
description="Crea el primero y añádele las canciones que hayas subido."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (creating = true)}>
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Crear álbum
|
||||
</Button>
|
||||
<Button onclick={() => (creating = true)}>Crear álbum</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
|
||||
(track) => track.source === 'Local' && !inAlbum.has(track.id)
|
||||
);
|
||||
|
||||
const isOwner = user !== null && Number(album.ownerUserId) === Number(user.sub);
|
||||
const isOwner = user !== null && String(album.ownerUserId) === user.sub;
|
||||
|
||||
return { album, tracks, library, isOwner, section: isOwner ? '/albums' : null };
|
||||
};
|
||||
|
||||
@@ -48,17 +48,17 @@
|
||||
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
|
||||
<PlaylistArt
|
||||
{trackIds}
|
||||
{coverUrl}
|
||||
size="large"
|
||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Álbum</p>
|
||||
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Álbum</p>
|
||||
<h1
|
||||
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
||||
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
@@ -72,10 +72,10 @@
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
|
||||
{#if playing}
|
||||
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
<Play class="h-4 w-4" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
@@ -31,18 +31,16 @@
|
||||
|
||||
<TrackTable index action={isOwner} class="mt-6 sm:mt-8">
|
||||
{#each tracks as track, i (track.id)}
|
||||
<TrackRow>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={player.current.id === queueIdForTrack(track)}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
{@const active = player.current.id === queueIdForTrack(track)}
|
||||
<TrackRow {active}>
|
||||
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
ownerUserId={track.ownerUserId}
|
||||
explicit={track.isExplicit}
|
||||
{active}
|
||||
onClick={() => playFrom(i)}
|
||||
/>
|
||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||
|
||||
@@ -28,12 +28,15 @@
|
||||
<SectionHeading title="Tus canciones subidas" />
|
||||
<TrackTable action>
|
||||
{#each library as track, i (track.id)}
|
||||
<TrackRow>
|
||||
{@const active = player.current.id === track.id}
|
||||
<TrackRow {active}>
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
ownerUserId={track.ownerUserId}
|
||||
explicit={track.isExplicit}
|
||||
{active}
|
||||
onClick={() => playFrom(i)}
|
||||
>
|
||||
{#snippet overlay()}
|
||||
|
||||
@@ -79,21 +79,21 @@
|
||||
<Page>
|
||||
<BackLink href="/explore" label="Volver a explorar" />
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
|
||||
<Cover
|
||||
trackId={album.albumId}
|
||||
src={album.thumbnailUrl}
|
||||
size="large"
|
||||
alt={album.title}
|
||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
|
||||
oncontextmenu={(e) => (albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
|
||||
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">
|
||||
{album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'}
|
||||
</p>
|
||||
<h1
|
||||
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
||||
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
|
||||
>
|
||||
{album.title}
|
||||
</h1>
|
||||
@@ -113,10 +113,10 @@
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
|
||||
{#if isCurrentQueue && player.playing}
|
||||
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
<Play class="h-4 w-4" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -132,19 +132,19 @@
|
||||
|
||||
<TrackTable index class="mt-6 sm:mt-8">
|
||||
{#each tracks as track, i (track.videoId)}
|
||||
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={player.current.id === track.videoId}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
{@const active = player.current.id === track.videoId}
|
||||
<TrackRow
|
||||
{active}
|
||||
oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}
|
||||
>
|
||||
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
|
||||
<TrackTitleCell
|
||||
trackId={track.videoId}
|
||||
title={track.title}
|
||||
artist={album.artist}
|
||||
coverSrc={album.thumbnailUrl}
|
||||
explicit={track.isExplicit}
|
||||
{active}
|
||||
onClick={() => playFrom(i)}
|
||||
/>
|
||||
<TrackMeta>{fmtTime(Number(track.durationSeconds))}</TrackMeta>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { YOUTUBE_FILLER_QUERIES } from '$lib/server/youtube';
|
||||
import {
|
||||
addAlbumToPlaylistAction,
|
||||
addTrackAction,
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
import {
|
||||
EXPLORE_ALBUMS_PAGE_SIZE,
|
||||
EXPLORE_PAGE_SIZE as PAGE_SIZE,
|
||||
YOUTUBE_FILLER_LIMIT
|
||||
EXPLORE_USERS_LIMIT
|
||||
} from '$lib/config';
|
||||
import { searchUsers } from '$lib/server/userDirectory';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||
@@ -39,10 +38,17 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
|
||||
const [tracksRes, playlistsRes, albumsRes] = await Promise.all([
|
||||
const usersPromise = query
|
||||
? api.GET('/users', {
|
||||
params: { query: { usernameSearch: query, pageNumber: 1, pageSize: EXPLORE_USERS_LIMIT } }
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
|
||||
const [tracksRes, playlistsRes, albumsRes, usersRes] = await Promise.all([
|
||||
tracksPromise,
|
||||
playlistsPromise,
|
||||
albumsPromise
|
||||
albumsPromise,
|
||||
usersPromise
|
||||
]);
|
||||
|
||||
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
|
||||
@@ -54,10 +60,8 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
tracks,
|
||||
albums: albumItems.flatMap((item) => (item.album ? [item.album] : [])),
|
||||
youtubeAlbums: albumItems.flatMap((item) => (item.youTubeAlbum ? [item.youTubeAlbum] : [])),
|
||||
users: searchUsers(query),
|
||||
youtubeFiller: query
|
||||
? Promise.resolve(null)
|
||||
: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
|
||||
users: usersRes?.data?.items ?? [],
|
||||
genres: YOUTUBE_FILLER_QUERIES,
|
||||
playlists: playlistsRes?.data?.items ?? []
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { fetchAlbumQueueItems } from '$lib/albums';
|
||||
import { fmtTime } from '$lib/format';
|
||||
@@ -10,17 +7,16 @@
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
|
||||
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte';
|
||||
import Rail from '$lib/components/ui/Rail.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config';
|
||||
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
@@ -42,10 +38,24 @@
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
|
||||
let { data, form } = $props();
|
||||
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' }
|
||||
};
|
||||
|
||||
let searchValue = $state(untrack(() => data.query));
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let { data, form } = $props();
|
||||
|
||||
let ytItems = $state<YouTubeSong[]>([]);
|
||||
let ytUnavailable = $state(false);
|
||||
@@ -71,26 +81,26 @@
|
||||
if (data.query) {
|
||||
ytItems = appendUnique(
|
||||
[],
|
||||
data.tracks.items.flatMap((item) => (item.youTubeSong ? [item.youTubeSong as YouTubeSong] : [])),
|
||||
data.tracks.items.flatMap((item) =>
|
||||
item.youTubeSong ? [item.youTubeSong as YouTubeSong] : []
|
||||
),
|
||||
(i) => i.videoId
|
||||
);
|
||||
ytUnavailable = data.tracks.youtubeUnavailable;
|
||||
return;
|
||||
} else {
|
||||
ytItems = [];
|
||||
ytUnavailable = false;
|
||||
}
|
||||
|
||||
ytItems = [];
|
||||
ytUnavailable = false;
|
||||
|
||||
let cancelled = false;
|
||||
data.youtubeFiller?.then((filler) => {
|
||||
if (cancelled || !filler) return;
|
||||
ytItems = appendUnique([], filler.items, (i) => i.videoId);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const genreTiles = $derived(
|
||||
data.genres.map((query, i) => ({
|
||||
query,
|
||||
hue: Math.round((i * 360) / data.genres.length),
|
||||
...(GENRE_INFO[query] ?? { label: query, tagline: 'Explora este estilo' })
|
||||
}))
|
||||
);
|
||||
|
||||
const songRows = $derived([
|
||||
...localItems.map((track) => ({
|
||||
target: { kind: 'local' as const, track },
|
||||
@@ -126,35 +136,10 @@
|
||||
!!data.query && !ytUnavailable && items.length === 0 && !hasAlbums && !hasUsers
|
||||
);
|
||||
|
||||
function localAlbumCaption(album: LocalAlbum) {
|
||||
return [
|
||||
album.releaseYear === null ? undefined : String(album.releaseYear),
|
||||
`${Number(album.trackCount)} ${Number(album.trackCount) === 1 ? 'canción' : 'canciones'}`
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function youtubeAlbumCaption(album: YouTubeAlbum) {
|
||||
return [album.artist, album.releaseYear === null ? undefined : String(album.releaseYear)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function buildHref(query: string) {
|
||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||
}
|
||||
|
||||
function onSearchInput(event: Event) {
|
||||
const value = (event.currentTarget as HTMLInputElement).value;
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const term = value.trim();
|
||||
if (term === data.query) return;
|
||||
goto(buildHref(term), { keepFocus: true, replaceState: true, noScroll: true });
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function togglePlay(index: number) {
|
||||
player.playOrToggle(items.map(queueItemForTarget), index);
|
||||
}
|
||||
@@ -224,87 +209,99 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Buscar</title>
|
||||
<meta name="description" content="Busca canciones, álbumes y usuarios en Musify." />
|
||||
<title>{data.query ? `Buscar «${data.query}»` : 'Descubrir'}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Busca canciones, álbumes y personas, o descubre por género en Musify."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PageHeader title="Buscar" subtitle="Encuentra canciones, álbumes y personas." />
|
||||
|
||||
<div class="mt-6">
|
||||
<Input
|
||||
type="search"
|
||||
icon={Search}
|
||||
bind:value={searchValue}
|
||||
oninput={onSearchInput}
|
||||
placeholder="¿Qué quieres reproducir?"
|
||||
{#if !data.query}
|
||||
<PageHeader
|
||||
title="Descubrir"
|
||||
subtitle="Encuentra tu próxima canción favorita explorando por género."
|
||||
/>
|
||||
</div>
|
||||
{:else if !nothingFound}
|
||||
<PageHeader title="Resultados para «{data.query}»" />
|
||||
{/if}
|
||||
|
||||
{#if form?.message}
|
||||
<Alert class="mt-4">{form.message}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if nothingFound}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
title="Sin resultados"
|
||||
description="No hay resultados para «{data.query}»."
|
||||
/>
|
||||
<div class="mx-auto max-w-115 py-[8vh] text-center">
|
||||
<h1 class="font-display text-xl font-semibold tracking-[-0.025em] text-fg">
|
||||
Sin resultados para «{data.query}»
|
||||
</h1>
|
||||
<p class="mt-2.5 text-[13px] leading-[1.65] text-fg-2">
|
||||
Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o
|
||||
por playlist.
|
||||
</p>
|
||||
<div class="mt-5.5 flex flex-wrap justify-center gap-2">
|
||||
{#each genreTiles.slice(0, 4) as genre (genre.query)}
|
||||
<a
|
||||
href={buildHref(genre.query)}
|
||||
class="rounded-full border border-white/8 px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-white/5 hover:text-fg"
|
||||
>
|
||||
{genre.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !data.query}
|
||||
<!-- GÉNEROS -->
|
||||
<div class="mt-7 grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
|
||||
{#each genreTiles as genre, i (genre.query)}
|
||||
<a
|
||||
href={buildHref(genre.query)}
|
||||
class="animate-enter group relative block h-28 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-125"
|
||||
style="animation-delay:{Math.min(i, 10) *
|
||||
40}ms; background:linear-gradient(140deg, oklch(0.24 0.07 {genre.hue}), rgba(9,9,12,.95))"
|
||||
>
|
||||
<div class="font-display text-[15.5px] font-semibold tracking-[-0.02em] text-fg">
|
||||
{genre.label}
|
||||
</div>
|
||||
<div class="mt-1.5 text-[11.5px] text-fg-3">{genre.tagline}</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasAlbums}
|
||||
<div class="mt-8">
|
||||
<div class="mt-9">
|
||||
<SectionHeading title="Álbumes" />
|
||||
<MediaGrid as="ul" min="168px" minMobile="150px">
|
||||
{#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
|
||||
<li>
|
||||
{#if entry.kind === 'local'}
|
||||
<a
|
||||
href="/albums/{entry.album.id}"
|
||||
class="group/card animate-enter block min-w-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
oncontextmenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
|
||||
>
|
||||
<div class="relative">
|
||||
<PlaylistArt
|
||||
trackIds={entry.album.coverTrackIds}
|
||||
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||
>
|
||||
{entry.album.title}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">
|
||||
{localAlbumCaption(entry.album)}
|
||||
</div>
|
||||
</a>
|
||||
<AlbumCard
|
||||
id={entry.album.id}
|
||||
title={entry.album.title}
|
||||
releaseYear={entry.album.releaseYear === null
|
||||
? undefined
|
||||
: Number(entry.album.releaseYear)}
|
||||
trackCount={Number(entry.album.trackCount)}
|
||||
trackIds={entry.album.coverTrackIds}
|
||||
index={i}
|
||||
onContextMenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
|
||||
/>
|
||||
{:else}
|
||||
<a
|
||||
<AlbumCard
|
||||
id={entry.album.albumId}
|
||||
href="/albums/external/youtube/{entry.album.albumId}"
|
||||
class="group/card animate-enter block min-w-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
oncontextmenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
|
||||
>
|
||||
<div class="relative">
|
||||
<Cover
|
||||
trackId={entry.album.albumId}
|
||||
src={entry.album.thumbnailUrl}
|
||||
size="large"
|
||||
alt={entry.album.title}
|
||||
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||
>
|
||||
{entry.album.title}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">
|
||||
{youtubeAlbumCaption(entry.album)}
|
||||
</div>
|
||||
</a>
|
||||
title={entry.album.title}
|
||||
subtitle={entry.album.artist}
|
||||
releaseYear={entry.album.releaseYear === null
|
||||
? undefined
|
||||
: Number(entry.album.releaseYear)}
|
||||
coverSrc={entry.album.thumbnailUrl}
|
||||
index={i}
|
||||
onContextMenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
|
||||
/>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
@@ -313,71 +310,78 @@
|
||||
{/if}
|
||||
|
||||
{#if hasUsers}
|
||||
<div class="mt-8">
|
||||
<div class="mt-9">
|
||||
<SectionHeading title="Usuarios" />
|
||||
<Rail>
|
||||
{#each users as u, i (u.id)}
|
||||
<div
|
||||
<a
|
||||
href="/u/{u.id}"
|
||||
class="group/artist animate-enter shrink-0"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
>
|
||||
<ArtistAvatar name={u.name} size={96} />
|
||||
<div class="mt-1 w-24 truncate text-center text-xs text-fg-3">@{u.handle}</div>
|
||||
</div>
|
||||
<ArtistAvatar name={u.name} imageUrl={u.profilePictureUrl} size={96} />
|
||||
</a>
|
||||
{/each}
|
||||
</Rail>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6">
|
||||
{#if songRows.length > 0}
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each songRows as { target: item, seconds }, i (targetId(item))}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => togglePlay(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, item)}
|
||||
aria-label="Reproducir {targetTitle(item)}"
|
||||
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"
|
||||
>
|
||||
<Cover
|
||||
trackId={targetId(item)}
|
||||
src={targetCoverSrc(item)}
|
||||
size="small"
|
||||
alt={targetTitle(item)}
|
||||
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
|
||||
{#if data.query}
|
||||
<div class="mt-9">
|
||||
{#if songRows.length > 0}
|
||||
<SectionHeading title="Canciones" />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each songRows as { target: item, seconds }, i (targetId(item))}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => togglePlay(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, item)}
|
||||
aria-label="Reproducir {targetTitle(item)}"
|
||||
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"
|
||||
>
|
||||
{#if isTargetCurrent(item)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(item)}
|
||||
<ExplicitBadge />
|
||||
<Cover
|
||||
trackId={targetId(item)}
|
||||
src={targetCoverSrc(item)}
|
||||
size="small"
|
||||
alt={targetTitle(item)}
|
||||
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
|
||||
>
|
||||
{#if isTargetCurrent(item)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(item)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-[13px] font-medium text-fg">
|
||||
{targetTitle(item)}
|
||||
</span>
|
||||
</div>
|
||||
{#if targetArtist(item)}
|
||||
<div class="truncate text-[11.5px] text-fg-3">
|
||||
Canción · {targetArtist(item)}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="truncate text-fg transition-colors group-hover/row:text-accent-soft">
|
||||
{targetTitle(item)}
|
||||
</span>
|
||||
</div>
|
||||
{#if targetArtist(item)}
|
||||
<div class="truncate text-sm text-fg-3">Canción · {targetArtist(item)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="shrink-0 text-sm text-muted tabular-nums">{fmtTime(seconds)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<span class="shrink-0 text-[11.5px] text-muted tabular-nums"
|
||||
>{fmtTime(seconds)}</span
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if hasMore}
|
||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||
{#if hasMore}
|
||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||
{/if}
|
||||
{:else if !ytUnavailable && !nothingFound}
|
||||
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." />
|
||||
{/if}
|
||||
{:else if data.query && !ytUnavailable}
|
||||
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ytUnavailable}
|
||||
<EmptyState
|
||||
|
||||
@@ -51,20 +51,28 @@
|
||||
index
|
||||
action
|
||||
meta={[
|
||||
{ label: 'Álbum', width: '160px' },
|
||||
{ label: 'Subida', width: '100px' },
|
||||
{ label: 'Escuchas', width: '100px' }
|
||||
]}
|
||||
class="mt-6 sm:mt-8"
|
||||
class="mt-7 sm:mt-8"
|
||||
>
|
||||
{#each items as track, i (track.id)}
|
||||
<TrackRow>
|
||||
{@const active = player.current.id === track.id}
|
||||
<TrackRow {active}>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={player.current.id === track.id}
|
||||
{active}
|
||||
playing={player.playing}
|
||||
onToggle={() => togglePlay(i)}
|
||||
/>
|
||||
<TrackTitleCell trackId={track.id} title={track.title} onClick={() => togglePlay(i)} />
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
{active}
|
||||
onClick={() => togglePlay(i)}
|
||||
/>
|
||||
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
||||
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
|
||||
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
|
||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
if (!locals.user) redirect(302, '/explore');
|
||||
return {};
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { player, type QueueItem } from '$lib/player/player.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
||||
import TrackRow from '$lib/components/ui/TrackRow.svelte';
|
||||
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
|
||||
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
|
||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||
|
||||
const tracks = $derived(liked.list);
|
||||
const queueItems = $derived<QueueItem[]>(
|
||||
tracks.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
source: t.source,
|
||||
coverUrl: t.coverUrl,
|
||||
explicit: t.explicit
|
||||
}))
|
||||
);
|
||||
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
|
||||
|
||||
function playFrom(index: number) {
|
||||
player.playOrToggle(queueItems, index);
|
||||
}
|
||||
|
||||
function playAll() {
|
||||
if (queueItems.length === 0) return;
|
||||
if (isCurrentQueue) player.toggle();
|
||||
else player.playQueue(queueItems, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Me gusta</title>
|
||||
<meta name="description" content="Las canciones que te gustan en Musify." />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:gap-6">
|
||||
<div
|
||||
class="grid h-36 w-36 shrink-0 place-items-center rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
|
||||
style="background:linear-gradient(150deg, oklch(0.4 0.09 330), oklch(0.18 0.04 10))"
|
||||
>
|
||||
<Heart class="h-10 w-10 text-white/90" fill="currentColor" strokeWidth={0} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Colección</p>
|
||||
<h1
|
||||
class="mt-3 font-display text-[28px] font-semibold tracking-[-0.035em] text-fg sm:text-[33px]"
|
||||
>
|
||||
Me gusta
|
||||
</h1>
|
||||
<p class="mt-3.25 text-[12.5px] text-fg-2">
|
||||
{tracks.length}
|
||||
{tracks.length === 1 ? 'canción' : 'canciones'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
|
||||
{#if isCurrentQueue && player.playing}
|
||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if tracks.length > 0}
|
||||
<TrackTable
|
||||
index
|
||||
action
|
||||
meta={[
|
||||
{ label: 'Álbum', width: '160px' },
|
||||
{ label: 'Añadida', width: '140px' }
|
||||
]}
|
||||
class="mt-6 sm:mt-8"
|
||||
>
|
||||
{#each tracks as track, i (track.id)}
|
||||
{@const active = player.current.id === track.id}
|
||||
<TrackRow {active}>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
{active}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
ownerUserId={track.ownerUserId}
|
||||
coverSrc={track.coverUrl}
|
||||
explicit={track.explicit}
|
||||
{active}
|
||||
onClick={() => playFrom(i)}
|
||||
/>
|
||||
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
||||
<TrackMeta class="hidden sm:block">{fmtDate(track.likedAt)}</TrackMeta>
|
||||
<TrackMeta>—</TrackMeta>
|
||||
<IconButton
|
||||
label="Quitar de Me gusta"
|
||||
size="sm"
|
||||
revealOnHover
|
||||
onclick={() => liked.toggle(track)}
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
</TrackRow>
|
||||
{/each}
|
||||
</TrackTable>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={Heart}
|
||||
title="Todavía no tienes canciones con me gusta"
|
||||
description="Toca el corazón en cualquier canción del reproductor para guardarla aquí."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</Page>
|
||||
@@ -67,12 +67,15 @@
|
||||
<Page>
|
||||
<BackLink href="/" label="Volver al inicio" />
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||
<MixArt {items} class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44" />
|
||||
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
|
||||
<MixArt
|
||||
{items}
|
||||
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Mezcla</p>
|
||||
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Mezcla</p>
|
||||
<h1
|
||||
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
||||
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
|
||||
>
|
||||
{mix.title}
|
||||
</h1>
|
||||
@@ -89,10 +92,10 @@
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
|
||||
{#if isCurrentQueue && player.playing}
|
||||
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
<Play class="h-4 w-4" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -108,19 +111,16 @@
|
||||
|
||||
<TrackTable index class="mt-6 sm:mt-8">
|
||||
{#each items as item, i (mixItemKey(item))}
|
||||
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={isTargetCurrent(targets[i])}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
{@const active = isTargetCurrent(targets[i])}
|
||||
<TrackRow {active} oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
|
||||
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
|
||||
<TrackTitleCell
|
||||
trackId={targetId(targets[i])}
|
||||
title={targetTitle(targets[i])}
|
||||
artist={targetArtist(targets[i])}
|
||||
coverSrc={targetCoverSrc(targets[i])}
|
||||
explicit={item.isExplicit}
|
||||
{active}
|
||||
onClick={() => playFrom(i)}
|
||||
/>
|
||||
<TrackMeta>{fmtTime(Number(item.durationSeconds))}</TrackMeta>
|
||||
|
||||
@@ -19,7 +19,30 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
|
||||
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
|
||||
|
||||
return { playlists: data };
|
||||
const tracksByPlaylist = await Promise.all(
|
||||
data.items.map((playlist) =>
|
||||
api
|
||||
.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } }
|
||||
})
|
||||
.then((res) => res.data?.items ?? [])
|
||||
)
|
||||
);
|
||||
|
||||
const items = data.items.map((playlist, i) => {
|
||||
const tracks = tracksByPlaylist[i];
|
||||
const trackCount = tracks.length;
|
||||
const durationSeconds = tracks.reduce((sum, t) => sum + Number(t.duration || 0), 0);
|
||||
return { ...playlist, trackCount, durationSeconds };
|
||||
});
|
||||
|
||||
const totals = {
|
||||
playlistCount: items.length,
|
||||
trackCount: items.reduce((sum, p) => sum + p.trackCount, 0),
|
||||
durationSeconds: items.reduce((sum, p) => sum + p.durationSeconds, 0)
|
||||
};
|
||||
|
||||
return { playlists: { ...data, items }, totals };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -30,6 +53,7 @@ export const actions: Actions = {
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
const visibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
|
||||
const cover = form.get('cover');
|
||||
|
||||
if (name === '' || name.length > MAX_NAME) {
|
||||
@@ -55,7 +79,7 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
const { data, error: err } = await api.POST('/playlists', {
|
||||
body: { name, description, pictureIntentId }
|
||||
body: { name, description, pictureIntentId, visibility }
|
||||
});
|
||||
|
||||
if (err || !data)
|
||||
|
||||
@@ -1,43 +1,21 @@
|
||||
<script lang="ts">
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||
import { fmtDurationLong } from '$lib/format';
|
||||
|
||||
const playlistNames = [
|
||||
'Mi Playlist',
|
||||
'Tus favoritos',
|
||||
'Lo mejor de hoy',
|
||||
'Para relajarse',
|
||||
'Buena vibra',
|
||||
'Modo estudio',
|
||||
'Road Trip',
|
||||
'Domingos',
|
||||
'Late Night',
|
||||
'Descubriendo música',
|
||||
'Todo un poco',
|
||||
'En bucle',
|
||||
'Mi colección',
|
||||
'Canciones para repetir',
|
||||
'Hits del momento'
|
||||
];
|
||||
const namePlaceholder = playlistNames[Math.floor(Math.random() * playlistNames.length)];
|
||||
|
||||
let { data, form } = $props();
|
||||
let { data } = $props();
|
||||
|
||||
const items = $derived(data.playlists.items);
|
||||
|
||||
let creating = $state(false);
|
||||
|
||||
function openCreate() {
|
||||
creating = true;
|
||||
}
|
||||
const totals = $derived(data.totals);
|
||||
const summary = $derived(
|
||||
`${totals.playlistCount} ${totals.playlistCount === 1 ? 'playlist' : 'playlists'} · ${totals.trackCount} ${totals.trackCount === 1 ? 'canción' : 'canciones'} · ${fmtDurationLong(totals.durationSeconds)}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -46,24 +24,24 @@
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PageHeader title="Mis listas" subtitle="Crea y organiza tus colecciones.">
|
||||
<PageHeader
|
||||
title="Mis listas"
|
||||
subtitle={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if items.length > 0}
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="h-4 w-4" strokeWidth={2.5} />
|
||||
Crear lista
|
||||
</Button>
|
||||
<Button onclick={() => createPlaylistModal.show()}>Crear lista</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
{#if items.length > 0}
|
||||
<MediaGrid class="mt-9">
|
||||
<MediaGrid min="210px" minMobile="180px" class="mt-7 sm:mt-8">
|
||||
{#each items as playlist, i (playlist.id)}
|
||||
<PlaylistCard
|
||||
id={playlist.id}
|
||||
name={playlist.name}
|
||||
description={playlist.description}
|
||||
trackCount={playlist.trackCount}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
updatedAt={playlist.updatedAt}
|
||||
index={i}
|
||||
@@ -77,22 +55,8 @@
|
||||
description="Crea la primera y añádele canciones de tu biblioteca."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Crear lista
|
||||
</Button>
|
||||
<Button onclick={() => createPlaylistModal.show()}>Crear lista</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</Page>
|
||||
|
||||
<Modal open={creating} onClose={() => (creating = false)} title="Nueva lista">
|
||||
<PlaylistForm
|
||||
action="?/create"
|
||||
{namePlaceholder}
|
||||
formMessage={form?.message}
|
||||
submitLabel="Crear"
|
||||
submittingLabel="Creando…"
|
||||
onCancel={() => (creating = false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -8,75 +8,28 @@ import {
|
||||
unwrapOrFail
|
||||
} from '$lib/server/api';
|
||||
import { uploadPresignedImage } from '$lib/server/upload';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { shuffle } from '$lib/collections';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
|
||||
const MAX_NAME = 100;
|
||||
const SUGGESTIONS_LIMIT = 20;
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||
const { allowAnonymousListening } = await parent();
|
||||
const user = optionalUser(locals, url, allowAnonymousListening);
|
||||
optionalUser(locals, url, allowAnonymousListening);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const [playlistRes, tracksRes, libraryRes, playlistsRes] = await Promise.all([
|
||||
const [playlistRes, tracksRes] = await Promise.all([
|
||||
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
|
||||
api.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } }
|
||||
}),
|
||||
user
|
||||
? api.GET('/tracks/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
user
|
||||
? api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
: Promise.resolve(null)
|
||||
})
|
||||
]);
|
||||
|
||||
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
|
||||
|
||||
const tracks = tracksRes.data?.items ?? [];
|
||||
const inPlaylist = new Set(tracks.map((t) => t.id));
|
||||
const library = (libraryRes?.data?.items ?? []).filter((t) => !inPlaylist.has(t.id));
|
||||
|
||||
const alreadyLinked = new Set(
|
||||
tracks.flatMap((t) => (t.source === 'YouTube' && t.externalId ? [t.externalId] : []))
|
||||
);
|
||||
const seeds = [...new Set(tracks.map((t) => t.artist).filter((a): a is string => !!a))];
|
||||
const seed = seeds.length > 0 ? shuffle(seeds)[0] : undefined;
|
||||
|
||||
const youtube = fetchYoutubeFiller(
|
||||
api,
|
||||
locals.accessToken,
|
||||
SUGGESTIONS_LIMIT + alreadyLinked.size,
|
||||
seed
|
||||
)
|
||||
.then((filler) => ({
|
||||
query: filler.query,
|
||||
items: (filler.items as YouTubeSong[])
|
||||
.filter((song) => !alreadyLinked.has(song.videoId))
|
||||
.slice(0, SUGGESTIONS_LIMIT)
|
||||
}))
|
||||
.catch(() => ({ query: '', items: [] as YouTubeSong[] }));
|
||||
|
||||
return {
|
||||
playlist,
|
||||
tracks,
|
||||
library,
|
||||
youtube,
|
||||
playlists: playlistsRes?.data?.items ?? []
|
||||
};
|
||||
return { playlist, tracks };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: addTrackAction,
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
|
||||
|
||||
removeTrack: async ({ request, params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
@@ -98,6 +51,7 @@ export const actions: Actions = {
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
const newVisibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
|
||||
const cover = form.get('cover');
|
||||
if (name === '' || name.length > MAX_NAME) {
|
||||
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
|
||||
@@ -119,7 +73,7 @@ export const actions: Actions = {
|
||||
|
||||
const result = await api.PUT('/playlists/{playlistId}', {
|
||||
params: { path: { playlistId: params.id } },
|
||||
body: { newName: name, newDescription: description, newPictureIntentId }
|
||||
body: { newName: name, newDescription: description, newPictureIntentId, newVisibility }
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.');
|
||||
if (failure) return failure;
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
<script lang="ts">
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import PlaylistHeader from './components/PlaylistHeader.svelte';
|
||||
import PlaylistTrackTable from './components/PlaylistTrackTable.svelte';
|
||||
import SuggestionsTable from './components/SuggestionsTable.svelte';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const playlist = $derived(data.playlist);
|
||||
const tracks = $derived(data.tracks);
|
||||
const library = $derived(data.library);
|
||||
|
||||
let editing = $state(false);
|
||||
let confirmingDelete = $state(false);
|
||||
let youtubeSuggestions = $state<YouTubeSong[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
youtubeSuggestions = [];
|
||||
data.youtube.then((result) => {
|
||||
youtubeSuggestions = result.items;
|
||||
});
|
||||
});
|
||||
|
||||
const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id));
|
||||
|
||||
@@ -47,6 +38,7 @@
|
||||
description={playlist.description}
|
||||
trackIds={tracks.map((t) => t.id)}
|
||||
updatedAt={playlist.updatedAt}
|
||||
visibility={playlist.visibility}
|
||||
playing={isCurrentQueue && player.playing}
|
||||
onPlayAll={playAll}
|
||||
onEdit={() => (editing = true)}
|
||||
@@ -55,10 +47,17 @@
|
||||
|
||||
{#if tracks.length > 0}
|
||||
<PlaylistTrackTable {tracks} />
|
||||
{/if}
|
||||
|
||||
{#if library.length > 0 || youtubeSuggestions.length > 0}
|
||||
<SuggestionsTable {library} youtube={youtubeSuggestions} playlists={data.playlists} />
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="Esta playlist todavía está vacía"
|
||||
description="Añade canciones desde tu biblioteca o explora música nueva para empezar."
|
||||
class="mt-6 sm:mt-8"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</Page>
|
||||
|
||||
@@ -67,6 +66,7 @@
|
||||
action="?/rename"
|
||||
initialName={playlist.name}
|
||||
initialDescription={playlist.description ?? ''}
|
||||
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
|
||||
coverFallbackUrl="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
@@ -79,10 +79,10 @@
|
||||
</Modal>
|
||||
|
||||
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
|
||||
<h2 class="text-lg font-semibold tracking-tight text-fg">
|
||||
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
|
||||
¿Eliminar «{playlist.name}»?
|
||||
</h2>
|
||||
<p class="mt-2 text-base text-fg-3">
|
||||
<p class="mt-2.5 text-[13px] leading-[1.65] text-fg-2">
|
||||
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
|
||||
dispositivos donde la tengas guardada.
|
||||
</p>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
@@ -13,39 +12,50 @@
|
||||
description?: string | null;
|
||||
trackIds: (string | number)[];
|
||||
updatedAt: string;
|
||||
visibility: 'Public' | 'Private';
|
||||
playing: boolean;
|
||||
onPlayAll: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { id, name, description, trackIds, updatedAt, playing, onPlayAll, onEdit, onDelete }: Props =
|
||||
$props();
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
trackIds,
|
||||
updatedAt,
|
||||
visibility,
|
||||
playing,
|
||||
onPlayAll,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<BackLink href="/playlists" label="Volver a tus listas" />
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:gap-6">
|
||||
<PlaylistArt
|
||||
playlistId={id}
|
||||
{trackIds}
|
||||
size="large"
|
||||
version={updatedAt}
|
||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Lista</p>
|
||||
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Lista</p>
|
||||
<h1
|
||||
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
||||
class="mt-3 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
|
||||
>
|
||||
{name}
|
||||
</h1>
|
||||
{#if description}
|
||||
<p class="mt-3 max-w-2xl text-sm text-fg-2 sm:text-base">{description}</p>
|
||||
{/if}
|
||||
<p class="mt-3 text-sm text-muted">
|
||||
<p class="mt-3.25 text-[12.5px] text-fg-2">
|
||||
{trackIds.length}
|
||||
{trackIds.length === 1 ? 'canción' : 'canciones'}
|
||||
{trackIds.length === 1 ? 'canción' : 'canciones'} · {visibility === 'Public'
|
||||
? 'Pública'
|
||||
: 'Privada'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,19 +63,15 @@
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
|
||||
{#if playing}
|
||||
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -31,23 +31,29 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<TrackTable index action meta={[{ label: 'Añadida', width: '140px' }]} class="mt-6 sm:mt-8">
|
||||
<TrackTable
|
||||
index
|
||||
action
|
||||
meta={[
|
||||
{ label: 'Álbum', width: '160px' },
|
||||
{ label: 'Añadida', width: '140px' }
|
||||
]}
|
||||
class="mt-6 sm:mt-8"
|
||||
>
|
||||
{#each tracks as track, i (track.id)}
|
||||
<TrackRow>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={player.current.id === queueIdForTrack(track)}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
{@const active = player.current.id === queueIdForTrack(track)}
|
||||
<TrackRow {active}>
|
||||
<TrackIndexCell index={i} playing={player.playing} onToggle={() => playFrom(i)} {active} />
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
ownerUserId={track.ownerUserId}
|
||||
coverSrc={isPendingYouTubeTrack(track) && track.externalId
|
||||
? youTubeThumbnailUrl(track.externalId)
|
||||
: undefined}
|
||||
explicit={track.isExplicit}
|
||||
{active}
|
||||
onClick={() => playFrom(i)}
|
||||
>
|
||||
{#snippet badge()}
|
||||
@@ -58,6 +64,7 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TrackTitleCell>
|
||||
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
||||
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
|
||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||
<form
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import {
|
||||
player,
|
||||
queueIdForTrack,
|
||||
type ApiTrackLike,
|
||||
type QueueItem
|
||||
} from '$lib/player/player.svelte';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
||||
import TrackRow from '$lib/components/ui/TrackRow.svelte';
|
||||
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
|
||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
|
||||
type LibraryTrack = ApiTrackLike & { duration: number | string };
|
||||
type Suggestion = { kind: 'local'; track: LibraryTrack } | { kind: 'youtube'; song: YouTubeSong };
|
||||
|
||||
interface Props {
|
||||
library: LibraryTrack[];
|
||||
youtube: YouTubeSong[];
|
||||
playlists: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
let { library, youtube, playlists }: Props = $props();
|
||||
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
|
||||
function openContextMenu(event: MouseEvent, item: Suggestion) {
|
||||
contextMenu = contextMenuStateFor(event, item);
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
function addToQueue() {
|
||||
if (!contextMenu) return;
|
||||
player.addToQueue(queueItemFor(contextMenu as Suggestion));
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
const suggestions = $derived<Suggestion[]>([
|
||||
...library.map((track) => ({ kind: 'local' as const, track })),
|
||||
...youtube.map((song) => ({ kind: 'youtube' as const, song }))
|
||||
]);
|
||||
|
||||
function idFor(item: Suggestion) {
|
||||
return item.kind === 'youtube' ? item.song.videoId : queueIdForTrack(item.track);
|
||||
}
|
||||
|
||||
function queueItemFor(item: Suggestion): QueueItem {
|
||||
return item.kind === 'youtube'
|
||||
? {
|
||||
id: item.song.videoId,
|
||||
title: item.song.title,
|
||||
artist: item.song.artist,
|
||||
source: 'youtube',
|
||||
coverUrl: item.song.thumbnailUrl,
|
||||
explicit: item.song.isExplicit
|
||||
}
|
||||
: {
|
||||
id: queueIdForTrack(item.track),
|
||||
title: item.track.title,
|
||||
artist: item.track.artist ?? undefined,
|
||||
source: 'local',
|
||||
coverUrl: `/api/tracks/${item.track.id}/cover?size=small`
|
||||
};
|
||||
}
|
||||
|
||||
function playFrom(index: number) {
|
||||
player.playOrToggle(suggestions.map(queueItemFor), index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-10 sm:mt-12">
|
||||
<SectionHeading title="Sugerencias" />
|
||||
<TrackTable action>
|
||||
{#each suggestions as item, i (idFor(item))}
|
||||
<TrackRow oncontextmenu={(e) => openContextMenu(e, item)}>
|
||||
<TrackTitleCell
|
||||
trackId={item.kind === 'youtube' ? item.song.videoId : item.track.id}
|
||||
title={item.kind === 'youtube' ? item.song.title : item.track.title}
|
||||
artist={item.kind === 'youtube' ? item.song.artist : item.track.artist}
|
||||
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
|
||||
explicit={item.kind === 'youtube' && item.song.isExplicit}
|
||||
onClick={() => playFrom(i)}
|
||||
>
|
||||
{#snippet overlay()}
|
||||
{#if player.current.id === idFor(item)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TrackTitleCell>
|
||||
<TrackMeta>
|
||||
{fmtTime(
|
||||
item.kind === 'youtube' ? item.song.durationSeconds : Number(item.track.duration)
|
||||
)}
|
||||
</TrackMeta>
|
||||
{#if item.kind === 'youtube'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addYouTubeToPlaylist"
|
||||
use:enhance={() =>
|
||||
async ({ update }) =>
|
||||
update()}
|
||||
>
|
||||
<input type="hidden" name="videoId" value={item.song.videoId} />
|
||||
<input type="hidden" name="title" value={item.song.title} />
|
||||
<input type="hidden" name="artist" value={item.song.artist} />
|
||||
<input type="hidden" name="durationSeconds" value={item.song.durationSeconds} />
|
||||
<input type="hidden" name="thumbnailUrl" value={item.song.thumbnailUrl} />
|
||||
<IconButton type="submit" label="Añadir a la playlist" size="sm">
|
||||
<Plus class="h-4 w-4" />
|
||||
</IconButton>
|
||||
</form>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addTrack"
|
||||
use:enhance={() =>
|
||||
async ({ update }) =>
|
||||
update()}
|
||||
>
|
||||
<input type="hidden" name="trackId" value={item.track.id} />
|
||||
<IconButton type="submit" label="Añadir a la playlist" size="sm">
|
||||
<Plus class="h-4 w-4" />
|
||||
</IconButton>
|
||||
</form>
|
||||
{/if}
|
||||
</TrackRow>
|
||||
{/each}
|
||||
</TrackTable>
|
||||
</div>
|
||||
|
||||
{#if contextMenu}
|
||||
<TrackContextMenu
|
||||
menu={contextMenu}
|
||||
{playlists}
|
||||
onClose={closeContextMenu}
|
||||
onAddToQueue={addToQueue}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import {
|
||||
createApiClient,
|
||||
optionalUser,
|
||||
requireAccessTokenAction,
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
|
||||
const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||
const { allowAnonymousListening } = await parent();
|
||||
const viewer = optionalUser(locals, url, allowAnonymousListening);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const [profileRes, playlistsRes] = await Promise.all([
|
||||
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
|
||||
api.GET('/playlists/users/{userId}', {
|
||||
params: {
|
||||
path: { userId: params.id },
|
||||
query: { pageNumber: 1, pageSize: PROFILE_PLAYLISTS_PAGE_SIZE, onlyPublic: true }
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const profile = unwrapOrError(profileRes, 'Usuario no encontrado.', 404);
|
||||
const playlistItems = playlistsRes.data?.items ?? [];
|
||||
|
||||
const tracksByPlaylist = await Promise.all(
|
||||
playlistItems.map((playlist) =>
|
||||
api
|
||||
.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 1 } }
|
||||
})
|
||||
.then((res) => Number(res.data?.totalItemCount ?? 0))
|
||||
)
|
||||
);
|
||||
|
||||
const playlists = playlistItems.map((playlist, i) => ({
|
||||
...playlist,
|
||||
trackCount: tracksByPlaylist[i]
|
||||
}));
|
||||
|
||||
const isOwnProfile = viewer !== null && viewer.sub === params.id;
|
||||
|
||||
const isFollowing =
|
||||
viewer && !isOwnProfile
|
||||
? await api
|
||||
.GET('/users/{id}/is-following', { params: { path: { id: params.id } } })
|
||||
.then((res) => res.data ?? false)
|
||||
.catch(() => false)
|
||||
: false;
|
||||
|
||||
return { profile, playlists, isOwnProfile, isFollowing, isAnonymous: viewer === null };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
follow: async ({ params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
await api.POST('/users/{id}/follow', { params: { path: { id: params.id } } });
|
||||
return { following: true };
|
||||
},
|
||||
|
||||
unfollow: async ({ params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
await api.DELETE('/users/{id}/follow', { params: { path: { id: params.id } } });
|
||||
return { following: false };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const profile = $derived(data.profile);
|
||||
const playlists = $derived(data.playlists);
|
||||
|
||||
let following = $state(untrack(() => data.isFollowing));
|
||||
let submitting = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (form && 'following' in form && typeof form.following === 'boolean')
|
||||
following = form.following;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{profile.name}</title>
|
||||
<meta name="description" content="Perfil de {profile.name} en Musify." />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:gap-6">
|
||||
<ArtistAvatar name={profile.name} imageUrl={profile.profilePictureUrl} size={144} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Perfil</p>
|
||||
<h1
|
||||
class="mt-3 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
|
||||
>
|
||||
{profile.name}
|
||||
</h1>
|
||||
<p class="mt-3.25 text-[12.5px] text-fg-2">
|
||||
{profile.followersCount}
|
||||
{profile.followersCount === 1 ? 'seguidor' : 'seguidores'} · {profile.followingCount} siguiendo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !data.isOwnProfile && !data.isAnonymous}
|
||||
<div class="mt-6 flex items-center gap-2.5 sm:mt-7">
|
||||
<form
|
||||
method="POST"
|
||||
action={following ? '?/unfollow' : '?/follow'}
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
submitting = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant={following ? 'secondary' : 'primary'}
|
||||
disabled={submitting}
|
||||
>
|
||||
{following ? 'Dejar de seguir' : 'Seguir'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-9">
|
||||
<h2 class="font-display text-lg font-semibold tracking-[-0.02em] text-fg">
|
||||
Playlists públicas
|
||||
</h2>
|
||||
|
||||
{#if playlists.length > 0}
|
||||
<MediaGrid min="210px" minMobile="180px" class="mt-6">
|
||||
{#each playlists as playlist, i (playlist.id)}
|
||||
<PlaylistCard
|
||||
id={playlist.id}
|
||||
name={playlist.name}
|
||||
trackCount={playlist.trackCount}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
updatedAt={playlist.updatedAt}
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="Sin playlists públicas"
|
||||
description="Este usuario todavía no ha compartido ninguna playlist pública."
|
||||
class="mt-6"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Page>
|
||||