Compare commits
10
Commits
12d76ccecf
...
ec6313dda1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec6313dda1 | ||
|
|
d14e22cbf7 | ||
|
|
39e6c2d552 | ||
|
|
90f9a76628 | ||
|
|
2cf3e9f02d | ||
|
|
5dec00faf1 | ||
|
|
a7d5869c23 | ||
|
|
5a528e197e | ||
|
|
a0c9c5fca9 | ||
|
|
d858c85a72 |
+18
-4
@@ -11,11 +11,12 @@ No hay `svelte.config.js` (config inline en `vite.config.ts`) ni `tailwind.confi
|
|||||||
## Comandos
|
## Comandos
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run dev # servidor de desarrollo (puerto 5173)
|
npm run dev # servidor de desarrollo (puerto 5173)
|
||||||
npm run build
|
npm run build
|
||||||
npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio
|
npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio
|
||||||
npm run format # prettier --write .
|
npm run format # prettier --write .
|
||||||
npm run lint # prettier --check . && eslint .
|
npm run lint # prettier --check . && eslint . && npm run lint:tokens
|
||||||
|
npm run lint:tokens # scripts/check-tokens.mjs — guardrail del sistema de diseño (ver abajo)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Reglas
|
## Reglas
|
||||||
@@ -28,6 +29,19 @@ npm run lint # prettier --check . && eslint .
|
|||||||
- Enlaces a endpoints `+server.ts` (login/logout) llevan `data-sveltekit-reload`.
|
- Enlaces a endpoints `+server.ts` (login/logout) llevan `data-sveltekit-reload`.
|
||||||
- Reutiliza los tokens/recetas del sistema de diseño; extrae a `$lib/components/ui/` lo que se repita.
|
- Reutiliza los tokens/recetas del sistema de diseño; extrae a `$lib/components/ui/` lo que se repita.
|
||||||
|
|
||||||
|
## Estilos
|
||||||
|
|
||||||
|
El sistema de diseño vive en `src/routes/layout.css` (tokens `@theme` + recetas `@utility`) y `src/lib/theme/` (`theme.css` para neutros estáticos, `tokens.ts` para los tokens dinámicos por hue de usuario). `scripts/check-tokens.mjs` (`npm run lint:tokens`, parte de `lint`) falla si `src/**/*.svelte` reintroduce lo que el refactor de `docs/frontend-refactor-plan.md` (P0–P14) eliminó — léelo antes de añadir un valor arbitrario o un radio/tamaño "de fábrica".
|
||||||
|
|
||||||
|
- **Radios:** siempre `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (o `rounded-full` para píldoras/círculos). Nunca `rounded-sm/md/lg/xl/2xl/3xl` ni `rounded` a secas.
|
||||||
|
- **Tamaños cuadrados:** `size-*` en vez de `h-N w-N` repetido (icon: `size-icon-xs/sm/md/lg/xl`; portadas: `size-cover-xs…hero`).
|
||||||
|
- **Tipografía:** recetas `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`; tracking con `tracking-display`/`tracking-eyebrow`, no `tracking-[…]` suelto.
|
||||||
|
- **Espaciado:** escala de Tailwind en pasos de `.5` (evita cuartos como `.25`/`.75`; solo aceptables si el token semántico lo pide, p. ej. `mb-4.5` en `SectionHeading`).
|
||||||
|
- **Colores:** siempre `--mf-*`/`text-*`/`bg-*`/`border-*` de `theme.css`/`tokens.ts`. Nunca hex ni `rgba()` literales en un `.svelte` (esos literales solo existen como fuente de verdad dentro de `theme.css`/`tokens.ts`).
|
||||||
|
- **`style="…"` y arbitrarios `-[…]`:** solo la lista blanca de B.4 en `docs/frontend-refactor-plan.md` (stagger `--i`, hue de tile, posición de menú, anchos/alturas dinámicos vía `var(--mf-*)`, `text-[clamp(…)]`, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, paddings en `vh` para centrado vertical). Todo lo demás, a un token.
|
||||||
|
- Componentes base a reutilizar antes de crear uno nuevo: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
|
||||||
|
- Gaps conocidos y aceptados (documentados en `scripts/check-tokens.mjs`): `strokeWidth={n}` en iconos no está unificado (13 valores distintos repartidos por casi todos los componentes; migrarlo a las recetas `stroke-thin/regular/bold` de forma segura requeriría poder verificar visualmente que `lucide-svelte` respeta `stroke-width` por CSS, así que se dejó fuera de esta pasada).
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
`hooks.server.ts` valida la cookie de sesión cifrada (`mf_session`), refresca el `access_token` y puebla `locals.user` / `locals.accessToken`. Para proteger una ruta, comprueba `locals.user` en su `load` y redirige a `/login?returnTo=…`. Para el backend, envía `locals.accessToken` como Bearer hacia `API_BASE_URL`.
|
`hooks.server.ts` valida la cookie de sesión cifrada (`mf_session`), refresca el `access_token` y puebla `locals.user` / `locals.accessToken`. Para proteger una ruta, comprueba `locals.user` en su `load` y redirige a `/login?returnTo=…`. Para el backend, envía `locals.accessToken` como Bearer hacia `API_BASE_URL`.
|
||||||
|
|||||||
@@ -1,375 +0,0 @@
|
|||||||
# Plan — Unificación del sistema de diseño y limpieza del front (web-player)
|
|
||||||
|
|
||||||
> Este documento es un **plan de trabajo para otro agente/IA**, no un informe de lo ya
|
|
||||||
> hecho. Recoge conocimiento concreto (bugs reales, patrones, cifras) obtenido durante
|
|
||||||
> una sesión larga de ajustes de UI, para que quien continúe no tenga que re-descubrirlo.
|
|
||||||
> No es necesario ejecutar todo de una vez: está pensado para trabajarse página por
|
|
||||||
> página o componente por componente, en PRs pequeños y verificables.
|
|
||||||
|
|
||||||
> **Estado (sesión de seguimiento posterior):** §1–§3 y §5 de este documento (hex
|
|
||||||
> literales, tamaños de fuente arbitrarios, menú "de cristal" duplicado, componentes
|
|
||||||
> huérfanos listados) ya están resueltos. El problema había reaparecido con otra forma:
|
|
||||||
> valores de opacidad sueltos sobre `white`/`black` (`bg-white/[6%]`, `border-white/10`,
|
|
||||||
> etc.) fuera de los tokens `--mf-surface*`/`--mf-border*`, sombras/gradientes de portada
|
|
||||||
> duplicados sin token, un bloque de "cabecera de colección" reimplementado 5 veces, un
|
|
||||||
> menú de cuenta duplicado en `TopBar`/`MobileHeader`, y textos de estado vacío con
|
|
||||||
> redacción inconsistente ("Aún no..." vs "Todavía no..."). Todo eso se corrigió: se
|
|
||||||
> añadieron tokens `--shadow-cover-lg/md/sm/xs` y `--mf-liked-grad` a
|
|
||||||
> `layout.css`/`theme.css`, se migraron los literales `white`/`black` a
|
|
||||||
> `bg-surface*`/`border-line*`/`hover:bg-hover`/`text-on-art` según si el color está
|
|
||||||
> sobre el "chrome" de la app o sobre arte/gradiente saturado, se extrajeron
|
|
||||||
> `lib/components/ui/CollectionHeader.svelte` (portada + título + meta + acciones, usado
|
|
||||||
> en `AlbumHeader`, `PlaylistHeader`, `liked/+page.svelte`, `mixes/[id]/+page.svelte`,
|
|
||||||
> `albums/external/.../+page.svelte` y `u/[id]/+page.svelte`) y
|
|
||||||
> `lib/components/ui/AccountMenu.svelte` (estado de apertura + panel del menú de cuenta,
|
|
||||||
> usado en `TopBar`/`MobileHeader`), y se corrigió `--color-hover` en `layout.css`, que
|
|
||||||
> estaba hardcodeado en vez de apuntar a `--mf-surface-2` (rompía el modo claro para
|
|
||||||
> cualquier `hover:bg-hover`).
|
|
||||||
>
|
|
||||||
> **Repaso posterior a la verificación visual** (ya con sesión autenticada): se detectó
|
|
||||||
> que el fix de §2 anterior para la tarjeta de género de `explore/+page.svelte` estaba
|
|
||||||
> mal — usaba `var(--mf-ink)` como extremo oscuro del degradado, pero `--mf-ink` es el
|
|
||||||
> token de "texto sobre botón de acento" y se invierte a blanco en modo claro, rompiendo
|
|
||||||
> la tarjeta (fondo blanco en vez de degradado oscuro). Se creó un token nuevo,
|
|
||||||
> `--mf-tile-shade` (deliberadamente **sin** variante de modo claro, igual que
|
|
||||||
> `--mf-cover-grad`/`--mf-liked-grad`, porque es "arte" decorativo, no "chrome"), y se usa
|
|
||||||
> ahí en su lugar. También se cambió la línea de meta de `CollectionHeader` de
|
|
||||||
> `text-muted` a `text-fg-2` (mejor contraste en modo claro; `text-muted` rondaba ~3:1,
|
|
||||||
> por debajo de WCAG AA para texto normal) y se hizo `actions` opcional para poder migrar
|
|
||||||
> páginas sin acciones condicionales (como `u/[id]/+page.svelte`) sin renderizar un
|
|
||||||
> `<div>` vacío.
|
|
||||||
>
|
|
||||||
> Pendiente de quien retome esto: `lib/components/ui/Rail.svelte` no tiene importadores
|
|
||||||
> (confirmado con el mismo método de §5, no porque §5 lo listara — ese hallazgo es nuevo)
|
|
||||||
> — no se tocó porque no forma parte de este barrido, pero conviene confirmarlo a mano y
|
|
||||||
> borrarlo si procede. También queda pendiente repetir la verificación visual (§6) sobre
|
|
||||||
> los cambios de este repaso: tarjetas de género de Explorar, cabeceras de colección
|
|
||||||
> (contraste de la línea de meta) y `u/[id]/+page.svelte`.
|
|
||||||
|
|
||||||
## 0. Objetivo
|
|
||||||
|
|
||||||
Tres problemas, en orden de aparición histórica pero **no** de prioridad recomendada:
|
|
||||||
|
|
||||||
1. **Colores fijos en vez de variables**: muchas páginas y componentes escriben colores
|
|
||||||
literales (`text-[#8a8a8e]`, `bg-[#c6c4cb]`, tamaños `text-[13px]` sueltos, etc.) en
|
|
||||||
vez de usar los tokens del sistema (`text-fg-2`, `bg-surface`, la escala tipográfica
|
|
||||||
establecida). Esto es lo que impide que cosas como el modo claro/oscuro (ver §2)
|
|
||||||
funcionen de forma consistente, y hace que cada página tenga ligeras variaciones de
|
|
||||||
gris que deberían ser el mismo tono.
|
|
||||||
2. **Falta de componentización**: hay patrones (tarjeta de track, fila de "canción
|
|
||||||
reciente", menú desplegable tipo cristal, etc.) reimplementados a mano en varias
|
|
||||||
páginas en vez de reusar (o crear) un componente compartido. Algunos componentes ya
|
|
||||||
existentes ni siquiera se usan donde deberían.
|
|
||||||
3. **Código muerto**: imports sin usar, componentes sin importadores, clases residuales.
|
|
||||||
|
|
||||||
**Referencia canónica**: la página de Inicio (`src/routes/+page.svelte`) es el patrón a
|
|
||||||
seguir para el modo oscuro — sus tonos, jerarquía tipográfica y look general están bien
|
|
||||||
ajustados (fue la página más iterada). Cuando una página no tenga claro qué tono/tamaño
|
|
||||||
usar para algo, mirar cómo lo resuelve Inicio para lo mismo, **no inventar uno nuevo**.
|
|
||||||
Ojo: Inicio también usa hex sueltos en vez de tokens en varios sitios (ver §3) — es la
|
|
||||||
referencia de **valores/jerarquía visual**, no de "cómo escribir la clase". Cuando se
|
|
||||||
migre Inicio a tokens, los valores resultantes deben ser los mismos que ya tiene hoy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Limitación conocida: modo claro ("Modo blanco") incompleto
|
|
||||||
|
|
||||||
Se añadió un toggle de tema (`$lib/theme/mode.svelte.ts`, menú de cuenta en
|
|
||||||
`TopBar.svelte` / `MobileHeader.svelte`) que pone `data-theme="light"` en `<html>` y
|
|
||||||
persiste la preferencia en `localStorage` (`musify.theme`).
|
|
||||||
|
|
||||||
**Lo que funciona:** los tokens semánticos definidos como CSS puro en
|
|
||||||
`$lib/theme/theme.css` (`--mf-text*`, `--mf-surface*`, `--mf-border*`, `--mf-track`,
|
|
||||||
`--mf-scrim`, `--mf-ink`) tienen su contrapartida en el bloque
|
|
||||||
`:root[data-theme='light'] { ... }` al final de ese archivo, y los tokens dinámicos por
|
|
||||||
hue (`--mf-bg`, `--mf-elevated`, `--mf-sidebar-bg`, `--mf-panel-bg`, `--mf-bar-bg`,
|
|
||||||
`--mf-hairline`) se regeneran correctamente en modo claro dentro de
|
|
||||||
`buildThemeTokens(hue, mode)` (`$lib/theme/tokens.ts`).
|
|
||||||
|
|
||||||
**Gotcha importante para quien toque esto:** `+layout.svelte` reaplica esos tokens
|
|
||||||
dinámicos como **estilos inline** sobre `document.documentElement` en un `$effect` que
|
|
||||||
corre en cada cambio de hue (cada canción). Un estilo inline siempre gana sobre
|
|
||||||
cualquier regla de una hoja de estilos, así que **una variable que se anima por hue no
|
|
||||||
se puede sobrescribir con `:root[data-theme='light'] { --mf-x: ... }`** — hay que
|
|
||||||
añadirla dentro de `buildThemeTokens()` con una rama `light ? ... : ...`, no en
|
|
||||||
`theme.css`. Si en el futuro se anima algo más por hue, aplica la misma regla.
|
|
||||||
|
|
||||||
**Lo que NO funciona:** todo componente que usa un color hardcodeado (ver §3) no se
|
|
||||||
entera del cambio de tema, porque no está leyendo ningún token. En la práctica esto
|
|
||||||
significa que hoy el modo claro cambia correctamente el lienzo de fondo, la sidebar, los
|
|
||||||
paneles tipo cristal (menús, modal) y cualquier texto/superficie que ya use
|
|
||||||
`text-fg`/`bg-surface`/etc., pero deja con contraste pobre o directamente ilegible
|
|
||||||
cualquier texto en hex literal (la mayoría de títulos, subtítulos y metadatos de la
|
|
||||||
app hoy).
|
|
||||||
|
|
||||||
**No hay forma barata de arreglar esto sin la migración de §3.** El modo claro real
|
|
||||||
depende, técnicamente, en un 100%, de terminar esa migración. No merece la pena
|
|
||||||
"parchear" el modo claro sin más — es la misma tarea.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Inventario de bugs/patrones ya encontrados (usar como guía de búsqueda)
|
|
||||||
|
|
||||||
Estos son bugs concretos que se repitieron varias veces en distintos componentes esta
|
|
||||||
sesión. Antes de tocar cada página, buscar estos mismos patrones ahí:
|
|
||||||
|
|
||||||
### 2.1 Tamaño de fuente que falta → hereda 16px del navegador
|
|
||||||
|
|
||||||
Varios componentes tenían un texto sin ninguna clase `text-*`, lo que hace que use el
|
|
||||||
tamaño por defecto del navegador (16px) en vez de la escala real de la app (que va de
|
|
||||||
`10.5px` a `14px` en casi todo). Ya se corrigió en `MediaCard.svelte`,
|
|
||||||
`TrackTitleCell.svelte` y `MenuItem.svelte`. **Sigue sin corregir** en
|
|
||||||
`TrackContextMenu.svelte` (usa `text-base`/`text-sm` sueltos igual que tenía
|
|
||||||
`MenuItem` antes de arreglarlo — mismo fix: bajar a la escala `13–13.5px` y los iconos
|
|
||||||
de `h-6 w-6`/`h-5 w-5` a `h-[18px] w-[18px]` o similar).
|
|
||||||
|
|
||||||
Cómo detectarlo: cualquier `<div>`/`<span>` que pinte texto de usuario (título, nombre,
|
|
||||||
label) sin una clase `text-[…]`/`text-sm`/`text-xs` explícita es sospechoso.
|
|
||||||
|
|
||||||
### 2.2 Conflicto de clases Tailwind que se resuelve por orden interno, no por el HTML
|
|
||||||
|
|
||||||
Tailwind no respeta el orden en que escribes las clases en el `class="…"`; resuelve
|
|
||||||
conflictos de la misma propiedad (`background-color`, `font-weight`, etc.) por el orden
|
|
||||||
en que esas utilidades existen en su propia hoja de estilos generada. Esto causó bugs
|
|
||||||
reales y confusos:
|
|
||||||
|
|
||||||
- Un botón con `bg-white/[6%] ... {isCurrent ? 'bg-accent-tint' : ''}` — con `isCurrent`
|
|
||||||
true, el fondo **seguía viéndose** `bg-white/[6%]` porque esa clase "ganaba" en el
|
|
||||||
cascade aunque apareciera antes en el HTML.
|
|
||||||
- `Button.svelte` fuerza `font-semibold` en su clase base; para poner un botón en peso
|
|
||||||
normal (`variant="secondary"` con texto normal) hay que usar `class="!font-normal"`
|
|
||||||
(con el prefijo `!` de Tailwind, que sí fuerza especificidad), un `class="font-normal"`
|
|
||||||
a secas no habría ganado.
|
|
||||||
|
|
||||||
**Regla a aplicar en la auditoría**: cualquier sitio donde dos clases de la misma
|
|
||||||
propiedad CSS convivan condicional + incondicionalmente en el mismo elemento (patrón
|
|
||||||
`class="base-x ... {cond ? 'other-x' : ''}"`) hay que reescribirlo para que sea
|
|
||||||
mutuamente excluyente (`class="{cond ? 'a' : 'b'}"`), nunca acumulativo. Y cualquier
|
|
||||||
override de una clase base de un componente compartido (`Button`, etc.) debe usar `!`
|
|
||||||
si toca la misma propiedad que ya fija la clase base.
|
|
||||||
|
|
||||||
### 2.3 Iconos con brillo verde/acento en `:hover` de forma inconsistente
|
|
||||||
|
|
||||||
Varias tarjetas tenían `group-hover:text-accent-soft` en el título para dar un hover
|
|
||||||
"bonito", pero era inconsistente con el resto de la app (la mayoría de textos no
|
|
||||||
cambian de color al hover, solo cuando están realmente "activos"/reproduciéndose). Se
|
|
||||||
quitó de `MediaCard.svelte` y de la fila de "Tus playlists" en Inicio. **Quedan
|
|
||||||
pendientes** los mismos `group-hover/card:text-accent-soft` /
|
|
||||||
`group-hover/row:text-accent-soft` en `src/routes/(app)/explore/+page.svelte` (líneas
|
|
||||||
con tarjetas de álbum y filas de resultado — grep por `group-hover.*text-accent-soft`
|
|
||||||
para localizarlos exactos, las líneas se mueven con cada edición).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Migración de colores/tamaños a tokens
|
|
||||||
|
|
||||||
### 3.1 Datos de partida (medidos en esta sesión, `src/` de web-player)
|
|
||||||
|
|
||||||
```
|
|
||||||
27 archivos .svelte con al menos un text-[#…]/bg-[#…]/border-[#…]
|
|
||||||
106 apariciones totales de color hex literal en clases Tailwind
|
|
||||||
```
|
|
||||||
|
|
||||||
Los 10 valores hex más repetidos como `text-[#…]` (comando usado:
|
|
||||||
`grep -roE "(text|bg|border)-\[#[0-9a-fA-F]{3,8}\]" --include="*.svelte" . | sort | uniq -c | sort -rn`):
|
|
||||||
|
|
||||||
| Ocurrencias | Valor | Coincide EXACTO con token |
|
|
||||||
| ----------- | --------- | ------------------------------------- |
|
|
||||||
| 13 | `#8a8a8e` | `--mf-text-2` → usar `text-fg-2` |
|
|
||||||
| 9 | `#747478` | ninguno exacto (cerca de text-3/4) |
|
|
||||||
| 7 | `#d5d4d8` | ninguno exacto (cerca de `--mf-text`) |
|
|
||||||
| 7 | `#7f7f83` | ninguno exacto (cerca de text-3) |
|
|
||||||
| 6 | `#d9d8dc` | `--mf-text` → usar `text-fg` |
|
|
||||||
| 6 | `#d4d3d7` | ninguno exacto |
|
|
||||||
| 4 | `#d0cfd4` | ninguno exacto |
|
|
||||||
| 4 | `#818185` | ninguno exacto |
|
|
||||||
| 3 | `#d8d7db` | ninguno exacto |
|
|
||||||
| 2 | `#7d7d81` | `--mf-text-3` → usar `text-fg-3` |
|
|
||||||
|
|
||||||
Interpretación: hay un grupo de valores que son **copias exactas** de un token ya
|
|
||||||
existente (probablemente porque alguien copió el valor en vez de la clase) — esos son
|
|
||||||
reemplazo mecánico, bajo riesgo. Y hay un grupo mucho más grande de valores "parecidos
|
|
||||||
pero no iguales" a los 4 tonos de texto (`--mf-text`, `-2`, `-3`, `-4`) — esto sugiere
|
|
||||||
que cada componente inventó su propio gris en vez de reusar la escala, y hay que decidir
|
|
||||||
para cada uno **a qué tier semántico pertenece** (¿es un título? ¿un metadato? ¿un
|
|
||||||
label?) y mapearlo al token de esa categoría, no crear un tier nuevo por cada valor.
|
|
||||||
|
|
||||||
Solo se encontraron 3 `bg-[#…]` (en `MixTile.svelte` y `TransportControls.svelte`,
|
|
||||||
este último repite el mismo `#c6c4cb`/`#d6d5da` que tenía el botón "Reanudar" de Inicio
|
|
||||||
antes de moverlo a `Button variant="accent"` con override — es el mismo "botón claro"
|
|
||||||
reinventado dos veces; candidato a token propio, ver §4.4) y 0 `border-[#…]`.
|
|
||||||
|
|
||||||
### 3.2 Metodología sugerida (por archivo)
|
|
||||||
|
|
||||||
1. `grep -n "text-\[#\|bg-\[#\|border-\[#" archivo.svelte` para listar todos los casos.
|
|
||||||
2. Para cada uno, decidir el tier semántico (título/cuerpo/metadato/label/borde) y
|
|
||||||
sustituir por la utilidad de Tailwind ya mapeada en
|
|
||||||
`src/routes/layout.css` (`@theme` → `--color-fg`, `--color-fg-2`, `--color-fg-3`,
|
|
||||||
`--color-muted`, `--color-surface`, `--color-surface-2`, `--color-line`,
|
|
||||||
`--color-accent*`, etc.) o, si el valor es realmente un tamaño de fuente en vez de
|
|
||||||
color, a la escala ya usada en el resto de la app (10.5 / 11 / 11.5 / 12 / 12.5 / 13 /
|
|
||||||
13.5 / 14px — no inventar un tamaño intermedio nuevo salvo que Inicio ya lo use para
|
|
||||||
ese mismo tipo de texto).
|
|
||||||
3. Ejecutar `npm run check` tras cada archivo — Tailwind con clases arbitrarias no da
|
|
||||||
error de compilación aunque el token no exista, así que la única validación real es
|
|
||||||
visual: abrir la página en el navegador (Browser pane) y comparar contra Inicio.
|
|
||||||
4. Si dos o más componentes usan **el mismo valor hex no-token** para el mismo propósito
|
|
||||||
(p. ej. `#c6c4cb` como "botón claro"), considerar promoverlo a un token nuevo en
|
|
||||||
`theme.css` en vez de simplemente sustituirlo por el más parecido — ver §4.4.
|
|
||||||
5. `npm run lint` (prettier + eslint) antes de dar por cerrado cada archivo, según las
|
|
||||||
reglas del `CLAUDE.md` del proyecto.
|
|
||||||
|
|
||||||
### 3.3 Prioridad sugerida de páginas/componentes a migrar
|
|
||||||
|
|
||||||
Por impacto (páginas que ve todo usuario, no solo estados de error/edge):
|
|
||||||
|
|
||||||
1. `Sidebar.svelte`, `TopBar.svelte`, `MobileHeader.svelte` (chrome global, visible
|
|
||||||
siempre) — MobileHeader/TopBar ya tienen el menú de cuenta arreglado, falta el resto.
|
|
||||||
2. `src/routes/(app)/playlists/[id]/components/PlaylistHeader.svelte` y
|
|
||||||
`src/routes/(app)/albums/[id]/components/AlbumHeader.svelte` (comparten patrón,
|
|
||||||
arreglar juntos para no divergir otra vez).
|
|
||||||
3. `src/routes/(app)/liked/+page.svelte`, `src/routes/(app)/explore/+page.svelte`,
|
|
||||||
`src/routes/(app)/upload/+page.svelte`, `src/routes/(app)/mixes/[id]/+page.svelte`.
|
|
||||||
4. Componentes del reproductor (`lib/components/player/*.svelte`) — muchos hex propios,
|
|
||||||
pero cambian menos visualmente entre páginas, así que menor prioridad de contraste
|
|
||||||
pero igual de importantes para el modo claro (la barra del reproductor es visible en
|
|
||||||
toda la app).
|
|
||||||
5. `src/routes/+page.svelte` (Inicio) al final — es la referencia, así que migrarla
|
|
||||||
última minimiza el riesgo de que un "acabado" a medio migrar se convierta en la
|
|
||||||
referencia visual equivocada mientras se trabaja el resto.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Componentización pendiente
|
|
||||||
|
|
||||||
### 4.1 Patrón de fila de track reimplementado en vez de reusar `TrackTitleCell`
|
|
||||||
|
|
||||||
`src/routes/+page.svelte` reimplementa a mano, con su propio marcado, **tres** variantes
|
|
||||||
de "fila de canción" distintas entre sí (Continuar escuchando, Populares esta semana,
|
|
||||||
filas de la Playlist destacada) en vez de usar los componentes ya existentes
|
|
||||||
`TrackTitleCell.svelte` / `TrackRow.svelte` / `TrackMeta.svelte` /
|
|
||||||
`TrackIndexCell.svelte` que sí se usan correctamente en
|
|
||||||
`PlaylistTrackTable.svelte`/`library/+page.svelte`. Evaluar si esas tres filas de Inicio
|
|
||||||
se pueden expresar con los componentes de `TrackTable` (aunque sea sin la tabla
|
|
||||||
completa, solo reusando `TrackTitleCell` para title+cover+artist) para no mantener 4
|
|
||||||
implementaciones de "fila de canción" en paralelo.
|
|
||||||
|
|
||||||
### 4.2 `MediaCard`/`PlaylistCard`/`AlbumCard` vs. tarjetas hechas a mano
|
|
||||||
|
|
||||||
`src/routes/+page.svelte` (sección "Tus playlists") y `src/routes/(app)/explore/+page.svelte`
|
|
||||||
tienen su propia tarjeta de álbum/playlist en vez de usar `MediaCard`/`PlaylistCard`/
|
|
||||||
`AlbumCard` (`lib/components/ui/`). Antes de tocar el estilo de esas tarjetas a mano,
|
|
||||||
comprobar si pueden sustituirse directamente por el componente compartido — sale gratis
|
|
||||||
el arreglo de tipografía/color si el componente compartido ya está migrado a tokens.
|
|
||||||
|
|
||||||
### 4.3 Menú desplegable "de cristal" duplicado
|
|
||||||
|
|
||||||
`TopBar.svelte` y `MobileHeader.svelte` duplican: estado `menuOpen`, el
|
|
||||||
`onDocumentClick` para cerrar al hacer click fuera, y el marcado del panel
|
|
||||||
(`animate-pop ... backdrop-blur-[22px]` + `background-color:var(--mf-panel-bg);
|
|
||||||
background-image:var(--mf-modal-glow)`). `TrackContextMenu.svelte` tiene un menú
|
|
||||||
flotante distinto (`bg-elevated` + `border-line`, sin el efecto cristal, y con el bug de
|
|
||||||
tamaño de fuente de §2.1). Candidatos:
|
|
||||||
|
|
||||||
- Extraer un primitivo `Dropdown.svelte` o `FloatingMenu.svelte` que resuelva
|
|
||||||
posicionamiento + cierre al clicar fuera + Escape, y que ambos menús de cuenta lo usen.
|
|
||||||
- Definir la receta "panel de cristal" (el `style="background-color:var(--mf-panel-bg);
|
|
||||||
background-image:var(--mf-modal-glow)"` + `backdrop-blur-[22px]`) en una utilidad o
|
|
||||||
clase reusable en vez de repetir el `style=` inline en cada sitio, y aplicarla también
|
|
||||||
a `TrackContextMenu` para que los tres menús floating de la app se vean iguales.
|
|
||||||
|
|
||||||
### 4.4 Candidato a token nuevo: "botón claro" (`#c6c4cb`/`#d6d5da`/`#d8d7dc`)
|
|
||||||
|
|
||||||
Aparece en al menos: el antiguo botón "Reanudar" de Inicio (ya migrado a
|
|
||||||
`Button variant="accent"` con `class="!bg-white ..."`, ver commit reciente) y
|
|
||||||
`TransportControls.svelte` (botón de play principal del reproductor, sigue con hex
|
|
||||||
propio). Si el diseño quiere mantener un botón "casi blanco" como acento fuerte en
|
|
||||||
ciertos sitios (distinto del verde de acento normal), vale la pena darle un token
|
|
||||||
propio (p. ej. `--mf-cta-strong-bg`/`--mf-cta-strong-fg`) en vez de repetir el hex cada
|
|
||||||
vez, así el modo claro también puede darle un tratamiento coherente en vez de heredar el
|
|
||||||
mismo blanco fijo en ambos temas (que en modo claro quedaría invisible sobre el fondo).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Código muerto — puntos de partida
|
|
||||||
|
|
||||||
Ya se han borrado en esta rama `MobileNav.svelte` y `MixBentoTile.svelte` por no
|
|
||||||
usarse. Grep rápido (basado en "ningún otro `.svelte`/`.ts` bajo `src/` menciona el
|
|
||||||
nombre del componente fuera de su propio archivo") encontró estos candidatos
|
|
||||||
adicionales — **verificar a mano antes de borrar** (el grep no será 100% preciso: no
|
|
||||||
detecta uso vía imports dinámicos, alias, o si el componente se monta desde fuera de
|
|
||||||
`src/`):
|
|
||||||
|
|
||||||
- `src/lib/components/ui/CoverBadge.svelte`
|
|
||||||
- `src/lib/components/ui/PlayPauseOverlay.svelte`
|
|
||||||
|
|
||||||
Método para repetir esta búsqueda tras cada ronda de refactor (los nombres de
|
|
||||||
componente que queden sin importadores son limpieza segura):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
for f in $(find src/lib/components -name "*.svelte"); do
|
|
||||||
name=$(basename "$f" .svelte)
|
|
||||||
count=$(grep -rl "$name" --include="*.svelte" --include="*.ts" src | grep -v "/$name.svelte$" | wc -l)
|
|
||||||
[ "$count" -eq 0 ] && echo "$f"
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
Además de componentes huérfanos, revisar por archivo (esto no se ha auditado
|
|
||||||
sistemáticamente esta sesión, son solo los tipos de código muerto más comunes en este
|
|
||||||
código base):
|
|
||||||
|
|
||||||
- Imports de iconos (`@lucide/svelte/icons/*`) que dejaron de usarse tras quitar un
|
|
||||||
icono de un botón (pasó varias veces esta sesión con `Play`/`Pause`/`NowPlaying`;
|
|
||||||
`eslint` los marca como `no-unused-vars` — correr `npx eslint <archivo>` tras cada
|
|
||||||
cambio de marcado, no solo `svelte-check`, porque `svelte-check` no siempre lo pilla).
|
|
||||||
- Props de componentes que ya no lee nadie (ej. si se elimina el único sitio que pasaba
|
|
||||||
`description` a `PlaylistCard`, y el componente ya no la usa, quitar el prop además
|
|
||||||
del sitio que la usaba).
|
|
||||||
- Clases Tailwind puestas "por si acaso" que no tienen efecto porque una clase
|
|
||||||
posterior en el cascade las anula (ver §2.2) — no es código muerto en el sentido
|
|
||||||
clásico, pero es ruido que confunde a quien lea el archivo después.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Cómo verificar cada cambio
|
|
||||||
|
|
||||||
Del `CLAUDE.md` del proyecto (`web-player/CLAUDE.md`), aplica igual aquí:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run check # svelte-check — debe salir 0 errores
|
|
||||||
npm run lint # prettier --check . && eslint . — debe salir limpio
|
|
||||||
```
|
|
||||||
|
|
||||||
Y para lo visual, que `svelte-check`/`eslint` no cubren:
|
|
||||||
|
|
||||||
1. Levantar `musify-web` (y `musify-api` si la página necesita datos reales) con el
|
|
||||||
Browser pane / `.claude/launch.json`.
|
|
||||||
2. Abrir la página tocada y la página de Inicio **una al lado de otra** (o alternando)
|
|
||||||
para comparar tono de gris, tamaño de fuente y separaciones — no solo "que no se vea
|
|
||||||
roto", sino "que se vea igual que Inicio para el mismo tipo de elemento".
|
|
||||||
3. Si la página tiene estado activo/actual (ej. una fila de canción reproduciéndose),
|
|
||||||
probarlo — varios de los bugs de esta sesión (§2.2) solo eran visibles con el
|
|
||||||
`isCurrent`/hover activo, no en el estado por defecto.
|
|
||||||
4. Alternar el toggle de tema (menú de cuenta → "Modo blanco"/"Modo oscuro") en la
|
|
||||||
página tocada para confirmar que el texto sigue siendo legible en ambos modos.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Orden de trabajo sugerido
|
|
||||||
|
|
||||||
1. Terminar §2 (los 3 bugs conocidos que quedan sueltos: `TrackContextMenu.svelte`
|
|
||||||
tamaño de fuente, hover verde en `explore/+page.svelte`).
|
|
||||||
2. Migrar a tokens los componentes de chrome global (§3.3, punto 1) — máximo impacto
|
|
||||||
visual con menor superficie de código.
|
|
||||||
3. Extraer el "panel de cristal" y/o `Dropdown.svelte` (§4.3) **antes** de tocar
|
|
||||||
`TrackContextMenu` a mano, para no maquetarlo dos veces.
|
|
||||||
4. Migrar el resto de páginas por la prioridad de §3.3.
|
|
||||||
5. Revisar componentización (§4.1, §4.2) — mejor después de que los componentes base
|
|
||||||
(`TrackTitleCell`, `MediaCard`, etc.) ya estén en tokens, así "adoptarlos" en Inicio
|
|
||||||
no introduce una regresión visual.
|
|
||||||
6. Pasada final de código muerto (§5) con el listado de componentes/props ya
|
|
||||||
consolidado tras los pasos anteriores (habrá más candidatos a huérfanos según se
|
|
||||||
vayan unificando duplicados).
|
|
||||||
7. Migrar Inicio a tokens en último lugar (§3.3, punto 5), verificando que los valores
|
|
||||||
resultantes son pixel-iguales a los que tiene hoy (es la referencia, no debe cambiar
|
|
||||||
de aspecto, solo de cómo está escrito el código).
|
|
||||||
@@ -777,16 +777,16 @@ Tareas:
|
|||||||
| P2 | ✅ | (pendiente) | `<style>{expr}</style>` no interpola en Svelte (se trata como CSS del componente); solución: `{@html \`<style>${css}</style>\`}` |
|
| P2 | ✅ | (pendiente) | `<style>{expr}</style>` no interpola en Svelte (se trata como CSS del componente); solución: `{@html \`<style>${css}</style>\`}` |
|
||||||
| P3 | ✅ | (pendiente) | Se quitó el nombre bajo el avatar de perfil (decisión tomada: redundante con el título de CollectionHeader). Se añadió `size="fill"` a Artwork (no está en la spec) para las tarjetas de grid fluidas (AlbumCard/PlaylistCard/MixTile), ya que P9 (MediaCard) aún no existe. SearchResultRow perdió su prop `kind`/radio-por-tipo (ahora el radio lo decide Artwork/Avatar). Sin QA visual autenticada (Docker no disponible) |
|
| P3 | ✅ | (pendiente) | Se quitó el nombre bajo el avatar de perfil (decisión tomada: redundante con el título de CollectionHeader). Se añadió `size="fill"` a Artwork (no está en la spec) para las tarjetas de grid fluidas (AlbumCard/PlaylistCard/MixTile), ya que P9 (MediaCard) aún no existe. SearchResultRow perdió su prop `kind`/radio-por-tipo (ahora el radio lo decide Artwork/Avatar). Sin QA visual autenticada (Docker no disponible) |
|
||||||
| P4 | ✅ | (pendiente) | Decisión tomada sin bloquear: se elimina el botón "adelante" decorativo (TopBar) y el icono `AlignJustify` decorativo (PlayerExtras) por ser código muerto visual. `TransportControls` (shuffle/prev/next/repeat) se deja sin migrar a IconButton a propósito: son iconos sin chrome (sin fondo/hit-box), migrarlos habría añadido una píldora de hover no presente hoy — riesgo visual no verificable sin Docker |
|
| P4 | ✅ | (pendiente) | Decisión tomada sin bloquear: se elimina el botón "adelante" decorativo (TopBar) y el icono `AlignJustify` decorativo (PlayerExtras) por ser código muerto visual. `TransportControls` (shuffle/prev/next/repeat) se deja sin migrar a IconButton a propósito: son iconos sin chrome (sin fondo/hit-box), migrarlos habría añadido una píldora de hover no presente hoy — riesgo visual no verificable sin Docker |
|
||||||
| P5 | ⬜ | | |
|
| P5 | 🟡 | (pendiente) | Hecho: pressable/clickOutside, toggle.svelte.ts, plural() (+minúsculas, Δ visible), helpers de player.svelte.ts (toQueueItem/isQueueCurrent/playAllOrToggle/playShuffled, addToQueue→playNextItem), navigation.svelte.ts (searchHref/appNavLinks — appNavLinks creado pero su migración a Sidebar/TabsBar se deja para P12 tal como indica esa parte), menus.svelte.ts (createTrackMenu, migrado en Inicio/Explorar/Mezcla). **Pendiente a propósito**: la tarea 5 (eliminar el envoltorio `TrackTarget` de tracks.ts y sus 8 accesores) se difiere — es el cambio de mayor riesgo del plan (toca tipos en Inicio/Explorar/Mezcla/menús de forma transversal) y no se pudo verificar visualmente sin Docker; abordarlo junto con P13 cuando se reescriban esas páginas |
|
||||||
| P6 | ⬜ | | |
|
| P6 | ✅ | (pendiente) | z-index de AccountMenu unificado a z-(--z-menu) (antes z-20, ahora al mismo nivel que ContextMenu — Δ menor, mejora consistencia); sin QA visual autenticada (Docker no disponible) |
|
||||||
| P7 | ⬜ | | |
|
| P7 | ✅ | (pendiente) | MediaIdentity queda "puro" (sin onClick) según spec; las 6 tablas de pista (P8) envuelven su propio `use:pressable` hasta que P8 las sustituya por TrackList. Deltas documentados: fila activa de Sidebar ahora usa bg-accent-tint/text-accent-soft (antes gris neutro, ahora consistente con el resto de filas activas); tarjeta "Tus playlists" de Inicio pierde la línea "por {tu nombre}" (redundante, siempre el propio usuario) y su radio pasa de rounded-panel a rounded-art (consistencia con la tarjeta "Continuar"); filas de Queue "A continuación" pierden el opacity-80 del arte (ListRow no expone esa personalización). Sin QA visual autenticada (Docker no disponible) |
|
||||||
| P8 | ⬜ | | |
|
| P8 | ✅ | (pendiente) | Decisión tomada (recomendada por el propio plan, sin bloquear): se elimina la columna "Álbum" que siempre mostraba "—". TrackList unifica TrackTable+TrackRow+TrackIndexCell+TrackMeta; AlbumTrackTable/PlaylistTrackTable/LibraryPicker se inlinean en sus páginas y se borran. Sin QA visual autenticada (Docker no disponible) |
|
||||||
| P9 | ⬜ | | |
|
| P9 | ✅ | (pendiente) | MediaCard gana hover-zoom del arte y scrim+PlayButton condicionados a `onPlay` (antes exclusivos de MixTile); ahora se aplicarían a cualquier card futura con onPlay, consistencia intencional. Sin QA visual autenticada (Docker no disponible) |
|
||||||
| P10 | ⬜ | | |
|
| P10 | ✅ | (pendiente) | Decisión tomada (recomendada, sin bloquear): iconos en los botones de cabecera de playlist (antes solo álbum tenía). PlaylistHeader pierde su prop `id` sin usar tras inlinearlo. Explorar: la cabecera de "Resultados para…" ahora usa el `mb-7/8` de PageHeader en vez de un `mt-5` ajustado a mano antes de los chips de filtro — gap ligeramente mayor entre el resumen y los chips (Δ menor, documentado). Sin QA visual autenticada (Docker no disponible) |
|
||||||
| P11 | ⬜ | | |
|
| P11 | ✅ | (pendiente) | CoverForm añade snippet `extra` (no está en la spec literal, solo `fields`) para el contenido a ancho completo bajo la fila portada+campos (descripción de álbum, selector de visibilidad de playlist) que no cabía en una única columna. Upload usa ImageDropzone con `onselect` (prop nueva) y se envuelve en `{#key}` para poder resetear tras publicar — Δ: el contenedor de portada pasa de 144/176px (móvil/desktop) a 144px fijo (size-cover-hero, sin variante desktop mayor), y el icono de posición vacía de 64px a 36px (icon-xl), tal como recomienda el plan. Sin QA visual autenticada (Docker no disponible) |
|
||||||
| P12 | ⬜ | | |
|
| P12 | ✅ | (pendiente) | PlayerExtras se fusiona en PlayerBar (volumen/cola inline); Slider sustituye a SeekBar+VolumeControl (confirmado por grep que el modo `compact` de SeekBar era código muerto). Sidebar y TabsBar migran a `appNavLinks()` (P5). Decisión tomada (sin bloquear): Sidebar gana la marca-gradiente `Logo` que no tenía antes, para consistencia con MobileHeader/login. z-index de PlayerDock/MobileHeader corregido a `z-(--z-sticky)` (son barras fijas, no menús) y SegmentedControl a `z-(--z-raised)`, tal como indica B.1.5. `glass-bar`/`theme-transition` aplicados en PlayerBar (ambas variantes), TabsBar, Queue e Inicio (hero/spotlight), sustituyendo estilos duplicados. Sin QA visual autenticada (Docker no disponible); smoke test de `/login` sin errores de consola |
|
||||||
| P13 | ⬜ | | |
|
| P13 | ✅ | (pendiente) | Se aborda junto con esta parte la tarea 5 diferida en P5: se elimina el envoltorio `TrackTarget` de `tracks.ts` y sus 8 accesores (`targetId`/`targetTitle`/`targetArtist`/`targetOwnerUserId`/`targetExplicit`/`targetListensCount`/`isTargetCurrent`/`queueItemForTarget`/`targetForQueueItem`); Explorar, Inicio, Mezcla, `menus.svelte.ts` y `mixes.ts` pasan a usar los objetos de pista (`ApiTrackLike`/`LocalTrack`) directamente, con `toQueueItem`/`trackFromQueueItem` (nuevo, en `player.svelte.ts`) como únicos conversores. TypeScript reveló que el campo `isExplicit` no existe en el schema real de `/tracks` — el badge "explicit" de Explorar ya era un no-op antes del refactor (siempre `undefined ?? false`); se retira el binding en vez de fingir soporte. Explorar: `GENRE_INFO`/`genreTiles` → `src/lib/genres.ts`; lógica de búsqueda (`showGroup`/`capped`/`matchPlaylists`/`searchCounts`/`searchChips`/`findTopResult`) → `src/lib/search.ts` (funciones puras, parametrizadas por `sfilter`/`query` en vez de cerrar sobre el estado del componente); "Mejor resultado" unifica el botón/enlace duplicado con `<svelte:element this={...}>`. Decisión tomada sin bloquear: **no** se colapsan los 4 bloques de grupo (Canciones/Álbumes/Usuarios/Playlists) en un único `{#each groups}` — cada grupo tiene snippets, contextmenu y props de `ListRow` genuinamente distintos (menú de pista vs. de álbum, overlay solo en canciones, trailing con formato distinto), y forzar una configuración genérica habría añadido más indirección de la que elimina. Inicio: usa `Page` (antes `page-x pt-3 sm:pt-4` a mano); stats ×3 → `{#each}`; fondos de hero/spotlight (`style=` con `var(--mf-hero-bg)`/`var(--mf-spotlight-bg)`) → utilities `hero-surface`/`spotlight-surface` (nuevas, en `layout.css`) y token `--spacing-section: 2.875rem` → `gap-section` sustituye `gap-11.5`. Sin QA visual autenticada (Docker no disponible); `/explore` sin sesión redirige a `/login` porque `+layout.server.ts` exige `allowAnonymousListening` desde el backend (inalcanzable sin Docker) — comportamiento preexistente, no una regresión; smoke test de `/login` sin errores de consola |
|
||||||
| P14 | ⬜ | | |
|
| P14 | ✅ | (pendiente) | `scripts/check-tokens.mjs` (`npm run lint:tokens`, añadido a `lint`) escanea `src/**/*.svelte` y falla en: arbitrarios `-[…]` fuera de la lista blanca de B.4, `rounded-(sm/md/lg/xl/2xl/3xl)`/`rounded` a secas, pares `h-N w-N` (deberían ser `size-N`), espaciado fraccionario en cuartos (`.25`/`.75`), hex/`rgba()` literales. Se limpió el repo hasta dejarlo en 0 violaciones: ~40 pares `h-N w-N` → `size-N` (icons sobre todo, vía script puntual), ~26 espaciados en cuartos redondeados al entero más cercano (Δ≤1px, p. ej. `py-2.75`→`py-3`, `gap-1.75`→`gap-2`), 3 `rounded-sm/lg` + 1 `rounded` a secas → tokens (`rounded-tag`/`rounded-thumb`/`rounded-control`), 2 `tracking-[-0.02em]` → `tracking-display` (coincidían exactamente con el token ya existente y no se habían migrado), 1 `tracking-[0.14em]` en Explorar → receta `text-eyebrow`, `rounded-3xl` del hero de Inicio → `rounded-panel-lg` (consistente con la tarjeta de playlist destacada). Se retiraron las recetas `stroke-thin/regular/bold` (creadas en P1, 0 consumidores) — **decisión tomada, sin bloquear**: no se migra `strokeWidth={n}` (13 valores, en casi todos los componentes) a esas recetas por CSS porque no se puede verificar sin Docker que `lucide-svelte` respete `stroke-width` por clase en vez del prop, y una regresión ahí sería silenciosa en toda la app; el script documenta este gap explícitamente y no lo exige. Se abordó también junto con la tarea 5 diferida de P5 (ver P13). Detector de huérfanos: 0 componentes sin importadores (los 47 de `src/lib/components/` están todos en uso). `docs/design-system-refactor-plan.md` (plan anterior, superseded) borrado. `web-player/CLAUDE.md` gana sección "Estilos" con la lista blanca B.4, componentes base a reutilizar y el gap de `strokeWidth` documentado. Línea base recalculada (ver tabla debajo) |
|
||||||
|
|
||||||
Decisiones de producto pendientes (preguntar al usuario antes de la parte indicada):
|
Decisiones de producto pendientes (preguntar al usuario antes de la parte indicada):
|
||||||
- P0/P4: ¿`Button` primary debe verse distinto de secondary?
|
- P0/P4: ¿`Button` primary debe verse distinto de secondary?
|
||||||
@@ -797,6 +797,24 @@ Decisiones de producto pendientes (preguntar al usuario antes de la parte indica
|
|||||||
- P10: ¿iconos en los botones de cabecera de álbum **y** playlist?
|
- P10: ¿iconos en los botones de cabecera de álbum **y** playlist?
|
||||||
- P12: ¿logo con cuadrado de gradiente también en la Sidebar?
|
- P12: ¿logo con cuadrado de gradiente también en la Sidebar?
|
||||||
|
|
||||||
|
### D.1 Línea base recalculada (tras P14, cierre del refactor)
|
||||||
|
|
||||||
|
Recalculada con los mismos criterios que A.4 (grep sobre `src/`), en la misma sesión de
|
||||||
|
cierre de P14.
|
||||||
|
|
||||||
|
| Métrica | A.4 (antes) | Tras P14 |
|
||||||
|
| -------------------------------------------------- | ----------- | ------------------------------------------ |
|
||||||
|
| Componentes `.svelte` en `src/lib/components/` | 61 | 47 |
|
||||||
|
| Líneas `.svelte`/`.css`/`.ts` cliente | ~8.700 | ~8.310 (−~390; el objetivo de −1.500/−2.000 ya se había alcanzado en P3–P11, antes de que P13 añadiera `genres.ts`/`search.ts` como código nuevo bien separado) |
|
||||||
|
| Clases arbitrarias `x-[…]` | 44 | 11 (las 11 restantes, en la lista blanca de B.4) |
|
||||||
|
| Espaciados fraccionarios en cuartos (`.25`/`.75`) | ~190 apariciones | 0 (redondeados al entero más cercano, Δ≤1px) |
|
||||||
|
| Radios distintos usados | 13 | 8 (7 tokens B.1.1 + `rounded-full`) |
|
||||||
|
| `strokeWidth` distintos | 13 | 12 (fuera de alcance, ver Registro P14 y CLAUDE.md) |
|
||||||
|
| Atributos `style="…"` | 24 | 16 (en 12 archivos; todos dinámicos por instancia — stagger, posición de menú, `var(--mf-*)`, animación — ninguno con color/medida literal) |
|
||||||
|
| Pluralizaciones a mano `=== 1 ?` | 15 | 2 (1 es la propia `plural()` en `format.ts`; 1 es concordancia de participio en `library` que `plural()` no puede cubrir) |
|
||||||
|
| Errores de `eslint` | 2 | 0 |
|
||||||
|
| `npm run lint:tokens` (nuevo, P14) | — | 0 violaciones |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## E. Inventario completo (veredicto por archivo)
|
## E. Inventario completo (veredicto por archivo)
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts",
|
"gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "prettier --check . && eslint ."
|
"lint:tokens": "node scripts/check-tokens.mjs",
|
||||||
|
"lint": "prettier --check . && eslint . && npm run lint:tokens"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Guardrail contra la reintroducción de lo que P0-P14 limpió
|
||||||
|
// (ver docs/frontend-refactor-plan.md, secciones B.4 y P14). Escanea
|
||||||
|
// src/**/*.svelte en busca de valores arbitrarios y utilidades de Tailwind
|
||||||
|
// que deberían ser tokens del sistema de diseño (src/routes/layout.css).
|
||||||
|
//
|
||||||
|
// Gaps conocidos, no cubiertos aquí a propósito:
|
||||||
|
// - `strokeWidth={n}` en iconos: hay ~13 valores distintos repartidos por
|
||||||
|
// casi todos los componentes. Migrarlo a las recetas `stroke-*` requeriría
|
||||||
|
// verificar que lucide-svelte respeta `stroke-width` por CSS en vez del
|
||||||
|
// atributo del componente, y no hay forma de comprobarlo visualmente en
|
||||||
|
// esta sesión (sin Docker). Se deja fuera para no arriesgar una regresión
|
||||||
|
// silenciosa en todos los iconos de la app.
|
||||||
|
// - `text-base` sin receta: solo queda un caso real (Queue.svelte, título
|
||||||
|
// "En cola", 16px) sin equivalente en la escala text-display-*. Crear una
|
||||||
|
// receta nueva para un único consumidor sería una abstracción prematura;
|
||||||
|
// se acepta como excepción documentada en vez de forzarlo a un tamaño que
|
||||||
|
// no le corresponde.
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const files = execSync('git ls-files "src/**/*.svelte"', { encoding: 'utf8' })
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// B.4 — arbitrarios permitidos tras el refactor.
|
||||||
|
const ARBITRARY_WHITELIST = [
|
||||||
|
/^text-\[clamp\([^)]+\)\]$/, // tipografía fluida (login, Logo)
|
||||||
|
/^leading-\[[\d.]+\]$/, // leading ajustado del display gigante de login
|
||||||
|
/^grid-cols-\[auto_1fr\]$/, // upload: portada + campos
|
||||||
|
/^max-h-\[85dvh\]$/, // Modal
|
||||||
|
/^my-\[\d+d?vh\]$/, // +error.svelte: centrado vertical de página de error
|
||||||
|
/^py-\[\d+d?vh\]$/, // Explorar: centrado vertical de "sin resultados"
|
||||||
|
/^transition-\[filter\]$/, // Explorar: genre-tile hover
|
||||||
|
/^bg-\[image:var\(--mf-[\w-]+\)\]$/ // gradientes dinámicos por tema (Artwork, Logo, liked)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Radios semánticos de B.1.1 — cualquier otro rounded-* de Tailwind (o el
|
||||||
|
// `rounded` a secas de fábrica) es una regresión. `rounded-full` y los
|
||||||
|
// tokens (`rounded-tag/thumb/control/art/art-lg/panel/panel-lg`) están bien:
|
||||||
|
// el lookahead exige que no venga más texto de clase pegado con `-`.
|
||||||
|
const RAW_ROUNDED = /\brounded(-(none|xs|sm|md|lg|xl|2xl|3xl))?(?![\w-])/g;
|
||||||
|
|
||||||
|
const PAIRED_HW = /\b(?:([a-z0-9-]+:))?h-([\d.]+)\s+\1w-\2\b/g;
|
||||||
|
const QUARTER_STEP_SPACING = /\b[a-z-]+-\d+\.(25|75)\b/g;
|
||||||
|
const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g;
|
||||||
|
const RGBA_LITERAL = /\brgba?\(/g;
|
||||||
|
|
||||||
|
let violations = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const content = readFileSync(file, 'utf8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
const lineNo = i + 1;
|
||||||
|
|
||||||
|
for (const match of line.matchAll(/[a-zA-Z][a-zA-Z0-9-]*-\[[^\]]+\]/g)) {
|
||||||
|
if (!ARBITRARY_WHITELIST.some((re) => re.test(match[0]))) {
|
||||||
|
violations.push(`${file}:${lineNo} — arbitrario fuera de la lista blanca: \`${match[0]}\``);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of line.matchAll(RAW_ROUNDED)) {
|
||||||
|
violations.push(`${file}:${lineNo} — radio de Tailwind sin tokenizar: \`${match[0]}\``);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of line.matchAll(PAIRED_HW)) {
|
||||||
|
violations.push(`${file}:${lineNo} — usa \`size-${match[2]}\` en vez de \`${match[0]}\``);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of line.matchAll(QUARTER_STEP_SPACING)) {
|
||||||
|
violations.push(
|
||||||
|
`${file}:${lineNo} — espaciado fraccionario en cuartos: \`${match[0]}\` (redondear al entero más cercano)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of line.matchAll(HEX_COLOR)) {
|
||||||
|
violations.push(
|
||||||
|
`${file}:${lineNo} — color hex literal: \`${match[0]}\` (usa un token --mf-*)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of line.matchAll(RGBA_LITERAL)) {
|
||||||
|
violations.push(
|
||||||
|
`${file}:${lineNo} — color rgba() literal: \`${match[0]}\` (usa un token --mf-*)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.length > 0) {
|
||||||
|
console.error(`\n${violations.length} violación(es) del sistema de diseño:\n`);
|
||||||
|
for (const v of violations) console.error(` ${v}`);
|
||||||
|
console.error('\nVer docs/frontend-refactor-plan.md, sección B.4.\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('check-tokens: sin violaciones.');
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Action } from 'svelte/action';
|
||||||
|
|
||||||
|
export const clickOutside: Action<HTMLElement, () => void> = (node, fn) => {
|
||||||
|
function onClick(event: MouseEvent) {
|
||||||
|
if (!node.contains(event.target as Node)) fn();
|
||||||
|
}
|
||||||
|
document.addEventListener('click', onClick, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next) {
|
||||||
|
fn = next;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
document.removeEventListener('click', onClick, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Action } from 'svelte/action';
|
||||||
|
|
||||||
|
export const pressable: Action<HTMLElement, () => void> = (node, fn) => {
|
||||||
|
function onClick() {
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
event.preventDefault();
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('click', onClick);
|
||||||
|
node.addEventListener('keydown', onKeydown);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next) {
|
||||||
|
fn = next;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('click', onClick);
|
||||||
|
node.removeEventListener('keydown', onKeydown);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
const MENU_WIDTH = 240;
|
|
||||||
const SUBMENU_WIDTH = 256;
|
|
||||||
|
|
||||||
export type AlbumMenuState = { x: number; y: number; openLeft: boolean; albumId: string };
|
|
||||||
|
|
||||||
export function albumContextMenuStateFor(event: MouseEvent, albumId: string): AlbumMenuState {
|
|
||||||
event.preventDefault();
|
|
||||||
return {
|
|
||||||
albumId,
|
|
||||||
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
|
|
||||||
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
|
|
||||||
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
|
||||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
|
||||||
import ListEnd from '@lucide/svelte/icons/list-end';
|
|
||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
|
||||||
import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
menu: AlbumMenuState;
|
|
||||||
playlists: { id: string; name: string }[];
|
|
||||||
onClose: () => void;
|
|
||||||
onPlayNext: () => void;
|
|
||||||
onAddToQueue: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { menu, playlists, onClose, onPlayNext, onAddToQueue }: Props = $props();
|
|
||||||
|
|
||||||
function onWindowKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape') onClose();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onWindowKeydown} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-30"
|
|
||||||
role="presentation"
|
|
||||||
onclick={onClose}
|
|
||||||
oncontextmenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
<GlassMenu class="fixed z-40 w-60" style="left:{menu.x}px; top:{menu.y}px;">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={onPlayNext}
|
|
||||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg-2 transition hover:bg-hover"
|
|
||||||
>
|
|
||||||
<ListPlus class="h-4.5 w-4.5 shrink-0" strokeWidth={2} />
|
|
||||||
<span>Reproducir a continuación</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={onAddToQueue}
|
|
||||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg-2 transition hover:bg-hover"
|
|
||||||
>
|
|
||||||
<ListEnd class="h-4.5 w-4.5 shrink-0" strokeWidth={2} />
|
|
||||||
<span>Añadir a la cola</span>
|
|
||||||
</button>
|
|
||||||
{#if playlists.length > 0}
|
|
||||||
<div class="group/addmenu relative">
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
|
|
||||||
>
|
|
||||||
<span class="flex-1 cursor-default">Añadir álbum a una playlist</span>
|
|
||||||
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {menu.openLeft
|
|
||||||
? 'right-full pr-1.5'
|
|
||||||
: 'left-full pl-1.5'}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 max-sm:w-full"
|
|
||||||
>
|
|
||||||
<div class="max-h-80 overflow-y-auto">
|
|
||||||
{#each playlists as playlist (playlist.id)}
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/addAlbumToPlaylist"
|
|
||||||
use:enhance={() =>
|
|
||||||
({ update }) => {
|
|
||||||
onClose();
|
|
||||||
return update({ reset: false, invalidateAll: false });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="playlistId" value={playlist.id} />
|
|
||||||
<input type="hidden" name="albumId" value={menu.albumId} />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
|
|
||||||
>
|
|
||||||
<Artwork
|
|
||||||
src="/api/playlists/{playlist.id}/cover?size=small"
|
|
||||||
size="xs"
|
|
||||||
class="shrink-0"
|
|
||||||
/>
|
|
||||||
<span class="truncate text-fg-2">{playlist.name}</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="px-2 py-2.5 text-sm text-fg-3">Todavía no tienes playlists.</p>
|
|
||||||
{/if}
|
|
||||||
</GlassMenu>
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import AccountMenu from './ui/AccountMenu.svelte';
|
import AccountMenu from './ui/AccountMenu.svelte';
|
||||||
import Avatar from './ui/Avatar.svelte';
|
import Avatar from './ui/Avatar.svelte';
|
||||||
import IconButton from './ui/IconButton.svelte';
|
import IconButton from './ui/IconButton.svelte';
|
||||||
|
import Logo from './ui/Logo.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: SessionUser | null;
|
user: SessionUser | null;
|
||||||
@@ -17,43 +18,33 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
class="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-hairline bg-bg/85 page-x backdrop-blur-md sm:hidden"
|
class="sticky top-0 z-(--z-sticky) flex h-14 items-center justify-between border-b border-hairline bg-bg/85 page-x backdrop-blur-md sm:hidden"
|
||||||
>
|
>
|
||||||
<a
|
<a href="/">
|
||||||
href="/"
|
<Logo size="sm" />
|
||||||
class="flex items-center gap-2 font-display text-lg font-semibold tracking-[-0.02em] text-fg"
|
|
||||||
>
|
|
||||||
<span class="h-5.5 w-5.5 rounded-lg bg-[image:var(--mf-logo-grad)]"></span>
|
|
||||||
Musify
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="relative">
|
{#if user}
|
||||||
{#if user}
|
<AccountMenu {user} {accountUrl} width="md">
|
||||||
<AccountMenu
|
{#snippet trigger({ toggle, open })}
|
||||||
{user}
|
<button
|
||||||
{accountUrl}
|
type="button"
|
||||||
panelClass="absolute top-full right-0 mt-2 w-56 overflow-hidden"
|
onclick={toggle}
|
||||||
>
|
aria-label="Tu cuenta"
|
||||||
{#snippet trigger({ toggle, open })}
|
aria-expanded={open}
|
||||||
<button
|
class="flex items-center rounded-full"
|
||||||
type="button"
|
>
|
||||||
onclick={toggle}
|
<Avatar name={user.name} src={user.picture} size="sm" />
|
||||||
aria-label="Tu cuenta"
|
</button>
|
||||||
aria-expanded={open}
|
{/snippet}
|
||||||
class="flex items-center rounded-full"
|
{#snippet extraItems({ close })}
|
||||||
>
|
<MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} />
|
||||||
<Avatar name={user.name} src={user.picture} size="sm" />
|
<MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} />
|
||||||
</button>
|
{/snippet}
|
||||||
{/snippet}
|
</AccountMenu>
|
||||||
{#snippet extraItems({ close })}
|
{:else}
|
||||||
<MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} />
|
<IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md">
|
||||||
<MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} />
|
<LogIn class="size-icon-lg" strokeWidth={2} />
|
||||||
{/snippet}
|
</IconButton>
|
||||||
</AccountMenu>
|
{/if}
|
||||||
{:else}
|
|
||||||
<IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md">
|
|
||||||
<LogIn class="size-icon-lg" strokeWidth={2} />
|
|
||||||
</IconButton>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { isSectionActive } from '$lib/navigation.svelte';
|
import { isSectionActive, appNavLinks } from '$lib/navigation.svelte';
|
||||||
import { liked } from '$lib/player/liked.svelte';
|
import { liked } from '$lib/player/liked.svelte';
|
||||||
import { createPlaylistModal } from '$lib/playlists.svelte';
|
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
import Artwork from './ui/Artwork.svelte';
|
import ListRow from './ui/ListRow.svelte';
|
||||||
import IconButton from './ui/IconButton.svelte';
|
import IconButton from './ui/IconButton.svelte';
|
||||||
import Home from '@lucide/svelte/icons/house';
|
import Logo from './ui/Logo.svelte';
|
||||||
import Compass from '@lucide/svelte/icons/compass';
|
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
|
||||||
import Heart from '@lucide/svelte/icons/heart';
|
|
||||||
import Disc from '@lucide/svelte/icons/disc-2';
|
|
||||||
import Folder from '@lucide/svelte/icons/folder';
|
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
|
||||||
interface SidebarPlaylist {
|
interface SidebarPlaylist {
|
||||||
@@ -29,19 +23,7 @@
|
|||||||
|
|
||||||
let { user, playlists }: Props = $props();
|
let { user, playlists }: Props = $props();
|
||||||
|
|
||||||
const navLinks = $derived(
|
const navLinks = $derived(appNavLinks(user, { playlists: playlists.length, liked: liked.count }));
|
||||||
user
|
|
||||||
? [
|
|
||||||
{ href: '/', label: 'Inicio', icon: Home },
|
|
||||||
{ href: '/explore', label: 'Descubrir', icon: Compass },
|
|
||||||
{ href: '/playlists', label: 'Playlists', icon: ListMusic, count: playlists.length },
|
|
||||||
{ href: '/liked', label: 'Me gusta', icon: Heart, count: liked.count },
|
|
||||||
{ href: '/albums', label: 'Mis álbumes', icon: Disc },
|
|
||||||
{ href: '/library', label: 'Canciones subidas', icon: Folder },
|
|
||||||
{ href: '/upload', label: 'Subir música', icon: Upload }
|
|
||||||
]
|
|
||||||
: [{ href: '/explore', label: 'Descubrir', icon: Compass }]
|
|
||||||
);
|
|
||||||
|
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||||
@@ -49,10 +31,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
class="hidden shrink-0 flex-col border-r border-hairline bg-bg p-5 transition-[background] duration-500 ease-out lg:flex"
|
class="hidden shrink-0 flex-col border-r border-hairline bg-bg p-5 theme-transition lg:flex"
|
||||||
style="width:var(--mf-sidebar-w)"
|
style="width:var(--mf-sidebar-w)"
|
||||||
>
|
>
|
||||||
<a href="/" class="px-3 pb-3 font-display text-lg font-semibold tracking-tight text-fg">Musify</a>
|
<a href="/" class="px-3 pb-3">
|
||||||
|
<Logo size="sm" />
|
||||||
|
</a>
|
||||||
|
|
||||||
<nav class="flex flex-col gap-1">
|
<nav class="flex flex-col gap-1">
|
||||||
{#each navLinks as link (link.href)}
|
{#each navLinks as link (link.href)}
|
||||||
@@ -64,7 +48,7 @@
|
|||||||
? 'bg-surface-2 text-fg'
|
? 'bg-surface-2 text-fg'
|
||||||
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
||||||
>
|
>
|
||||||
<link.icon class="h-4.5 w-4.5 shrink-0" strokeWidth={1.5} />
|
<link.icon class="size-4.5 shrink-0" strokeWidth={1.5} />
|
||||||
<span class="flex-1 truncate">{link.label}</span>
|
<span class="flex-1 truncate">{link.label}</span>
|
||||||
{#if link.count}
|
{#if link.count}
|
||||||
<span class="text-xs text-muted tabular-nums">{link.count}</span>
|
<span class="text-xs text-muted tabular-nums">{link.count}</span>
|
||||||
@@ -76,7 +60,7 @@
|
|||||||
{#if user}
|
{#if user}
|
||||||
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
|
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
|
||||||
<div class="flex items-center justify-between px-3 pb-1.5">
|
<div class="flex items-center justify-between px-3 pb-1.5">
|
||||||
<span class="text-xs font-medium tracking-widest text-muted uppercase">Tus playlists</span>
|
<span class="text-eyebrow text-muted">Tus playlists</span>
|
||||||
{#if playlists.length > 0}
|
{#if playlists.length > 0}
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Crear playlist"
|
label="Crear playlist"
|
||||||
@@ -92,33 +76,24 @@
|
|||||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5">
|
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5">
|
||||||
{#each playlists as playlist (playlist.id)}
|
{#each playlists as playlist (playlist.id)}
|
||||||
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
||||||
<a
|
<ListRow
|
||||||
href="/playlists/{playlist.id}"
|
href="/playlists/{playlist.id}"
|
||||||
class="flex items-center gap-2.75 rounded-control px-3 py-1.75 transition-colors duration-150 {active
|
{active}
|
||||||
? 'bg-surface-hover'
|
size="xs"
|
||||||
: 'hover:bg-hover'}"
|
title={playlist.name}
|
||||||
>
|
coverSrc="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
|
||||||
<Artwork
|
playlist.updatedAt
|
||||||
src="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
|
)}"
|
||||||
playlist.updatedAt
|
trackIds={playlist.coverTrackIds}
|
||||||
)}"
|
class="px-3 py-2"
|
||||||
trackIds={playlist.coverTrackIds}
|
/>
|
||||||
size="xs"
|
|
||||||
class="shrink-0 opacity-90"
|
|
||||||
/>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="truncate text-sm {active ? 'text-fg' : 'text-fg-2'}">
|
|
||||||
{playlist.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => createPlaylistModal.show()}
|
onclick={() => createPlaylistModal.show()}
|
||||||
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-muted transition-colors hover:bg-hover hover:text-fg-2"
|
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-muted transition-colors hover:bg-hover hover:text-fg-2"
|
||||||
>
|
>
|
||||||
<Plus class="h-3.5 w-3.5" strokeWidth={1.8} />
|
<Plus class="size-3.5" strokeWidth={1.8} />
|
||||||
Crear tu primera lista
|
Crear tu primera lista
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { isSectionActive } from '$lib/navigation.svelte';
|
import { isSectionActive, appNavLinks } from '$lib/navigation.svelte';
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
import Home from '@lucide/svelte/icons/house';
|
|
||||||
import Compass from '@lucide/svelte/icons/compass';
|
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
|
||||||
import Heart from '@lucide/svelte/icons/heart';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: SessionUser | null;
|
user: SessionUser | null;
|
||||||
@@ -14,16 +10,7 @@
|
|||||||
|
|
||||||
let { user, class: klass = '' }: Props = $props();
|
let { user, class: klass = '' }: Props = $props();
|
||||||
|
|
||||||
const links = $derived(
|
const links = $derived(appNavLinks(user).filter((link) => link.primary));
|
||||||
user
|
|
||||||
? [
|
|
||||||
{ href: '/', label: 'Inicio', icon: Home },
|
|
||||||
{ href: '/explore', label: 'Descubrir', icon: Compass },
|
|
||||||
{ href: '/playlists', label: 'Playlists', icon: ListMusic },
|
|
||||||
{ href: '/liked', label: 'Me gusta', icon: Heart }
|
|
||||||
]
|
|
||||||
: [{ href: '/explore', label: 'Descubrir', icon: Compass }]
|
|
||||||
);
|
|
||||||
|
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||||
@@ -31,7 +18,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
class="pointer-events-auto flex items-center justify-around border-t border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-1.75 backdrop-blur-xl transition-[background] duration-500 lg:hidden {klass}"
|
class="pointer-events-auto flex items-center justify-around glass-bar px-4 py-2 lg:hidden {klass}"
|
||||||
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
|
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
|
||||||
>
|
>
|
||||||
{#each links as link (link.href)}
|
{#each links as link (link.href)}
|
||||||
@@ -39,11 +26,11 @@
|
|||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
class="flex flex-col items-center gap-1.25 rounded-xl px-2.5 py-1.5 {active
|
class="flex flex-col items-center gap-1 rounded-control px-2.5 py-1.5 {active
|
||||||
? 'text-fg'
|
? 'text-fg'
|
||||||
: 'text-fg-2'}"
|
: 'text-fg-2'}"
|
||||||
>
|
>
|
||||||
<link.icon class="h-4.25 w-4.25" strokeWidth={1.6} />
|
<link.icon class="size-4" strokeWidth={1.6} />
|
||||||
<span class="text-xs font-medium">{link.label}</span>
|
<span class="text-xs font-medium">{link.label}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
||||||
|
import { searchHref } from '$lib/navigation.svelte';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
@@ -24,13 +25,9 @@
|
|||||||
let searchInput: HTMLInputElement | undefined = $state();
|
let searchInput: HTMLInputElement | undefined = $state();
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
function buildHref(term: string) {
|
|
||||||
return term ? `/explore?q=${encodeURIComponent(term)}` : '/explore';
|
|
||||||
}
|
|
||||||
|
|
||||||
function runSearch(term: string) {
|
function runSearch(term: string) {
|
||||||
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
|
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
|
||||||
goto(buildHref(term), {
|
goto(searchHref(term), {
|
||||||
replaceState: page.url.pathname === '/explore',
|
replaceState: page.url.pathname === '/explore',
|
||||||
keepFocus: true,
|
keepFocus: true,
|
||||||
noScroll: true
|
noScroll: true
|
||||||
@@ -72,7 +69,7 @@
|
|||||||
<label
|
<label
|
||||||
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
|
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
|
||||||
>
|
>
|
||||||
<Search class="h-3.75 w-3.75 shrink-0 text-fg-3" strokeWidth={1.8} />
|
<Search class="size-4 shrink-0 text-fg-3" strokeWidth={1.8} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:this={searchInput}
|
bind:this={searchInput}
|
||||||
@@ -86,9 +83,9 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={clearQuery}
|
onclick={clearQuery}
|
||||||
aria-label="Borrar búsqueda"
|
aria-label="Borrar búsqueda"
|
||||||
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
|
class="grid size-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
|
||||||
>
|
>
|
||||||
<X class="h-3.5 w-3.5" />
|
<X class="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
@@ -96,13 +93,9 @@
|
|||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
<div class="relative shrink-0">
|
<div class="shrink-0">
|
||||||
{#if user}
|
{#if user}
|
||||||
<AccountMenu
|
<AccountMenu {user} {accountUrl}>
|
||||||
{user}
|
|
||||||
{accountUrl}
|
|
||||||
panelClass="absolute top-full right-0 z-20 mt-2 w-52 overflow-hidden"
|
|
||||||
>
|
|
||||||
{#snippet trigger({ toggle, open })}
|
{#snippet trigger({ toggle, open })}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
import type { TrackTarget } from '$lib/tracks';
|
|
||||||
|
|
||||||
export type ContextMenuState = TrackTarget & { x: number; y: number; openLeft: boolean };
|
|
||||||
|
|
||||||
const MENU_WIDTH = 240;
|
|
||||||
const SUBMENU_WIDTH = 256;
|
|
||||||
|
|
||||||
export function contextMenuStateFor(event: MouseEvent, target: TrackTarget): ContextMenuState {
|
|
||||||
event.preventDefault();
|
|
||||||
return {
|
|
||||||
...target,
|
|
||||||
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
|
|
||||||
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
|
|
||||||
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
|
||||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
|
||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
|
||||||
import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
menu: ContextMenuState;
|
|
||||||
playlists: { id: string; name: string }[];
|
|
||||||
onClose: () => void;
|
|
||||||
onAddToQueue: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { menu, playlists, onClose, onAddToQueue }: Props = $props();
|
|
||||||
|
|
||||||
function onWindowKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape') onClose();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onWindowKeydown} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-30"
|
|
||||||
role="presentation"
|
|
||||||
onclick={onClose}
|
|
||||||
oncontextmenu={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
<GlassMenu class="fixed z-40 w-60" style="left:{menu.x}px; top:{menu.y}px;">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={onAddToQueue}
|
|
||||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg-2 transition hover:bg-hover"
|
|
||||||
>
|
|
||||||
<ListPlus class="h-4.5 w-4.5 shrink-0" strokeWidth={2} />
|
|
||||||
<span>Reproducir a continuación</span>
|
|
||||||
</button>
|
|
||||||
{#if playlists.length > 0}
|
|
||||||
<div class="group/addmenu relative">
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
|
|
||||||
>
|
|
||||||
<span class="flex-1 cursor-default">Añadir a una playlist</span>
|
|
||||||
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {menu.openLeft
|
|
||||||
? 'right-full pr-1.5'
|
|
||||||
: 'left-full pl-1.5'}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 max-sm:w-full"
|
|
||||||
>
|
|
||||||
<div class="max-h-80 overflow-y-auto">
|
|
||||||
{#each playlists as playlist (playlist.id)}
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/addTrack"
|
|
||||||
use:enhance={() =>
|
|
||||||
({ update }) => {
|
|
||||||
onClose();
|
|
||||||
return update({ reset: false, invalidateAll: false });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="playlistId" value={playlist.id} />
|
|
||||||
<input type="hidden" name="trackId" value={menu.track.id} />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
|
|
||||||
>
|
|
||||||
<Artwork
|
|
||||||
src="/api/playlists/{playlist.id}/cover?size=small"
|
|
||||||
size="xs"
|
|
||||||
class="shrink-0"
|
|
||||||
/>
|
|
||||||
<span class="truncate text-fg-2">{playlist.name}</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</GlassMenu>
|
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import TrackInfo from './TrackInfo.svelte';
|
import TrackInfo from './TrackInfo.svelte';
|
||||||
import TransportControls from './TransportControls.svelte';
|
import TransportControls from './TransportControls.svelte';
|
||||||
import SeekBar from './SeekBar.svelte';
|
import Slider from '$lib/components/ui/Slider.svelte';
|
||||||
import PlayerExtras from './PlayerExtras.svelte';
|
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||||
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
||||||
|
import { fmtTime } from '$lib/format';
|
||||||
|
import Volume2 from '@lucide/svelte/icons/volume-2';
|
||||||
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
|
|
||||||
|
function seek(value: number) {
|
||||||
|
if (player.current.duration > 0) player.seekFraction(value / player.current.duration);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="animate-enter pointer-events-auto flex items-center gap-4.5 border-t border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 backdrop-blur-xl transition-[background] duration-500 sm:hidden"
|
class="animate-enter pointer-events-auto flex items-center gap-4.5 glass-bar px-4 py-3 sm:hidden"
|
||||||
>
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<TrackInfo compact />
|
<TrackInfo compact />
|
||||||
@@ -15,14 +24,46 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="animate-enter pointer-events-auto hidden items-center gap-5 border-t border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 backdrop-blur-xl transition-[background] duration-500 sm:flex"
|
class="animate-enter pointer-events-auto hidden items-center gap-5 glass-bar px-4 py-3 sm:flex"
|
||||||
>
|
>
|
||||||
<div class="w-64 min-w-0 shrink-0">
|
<div class="w-(--mf-player-side-w) min-w-0 shrink-0">
|
||||||
<TrackInfo />
|
<TrackInfo />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-0 flex-1 flex-col items-center gap-2">
|
<div class="flex min-w-0 flex-1 flex-col items-center gap-2">
|
||||||
<TransportControls />
|
<TransportControls />
|
||||||
<SeekBar />
|
<div class="flex w-full max-w-hero items-center gap-3">
|
||||||
|
<span class="min-w-8 text-right text-xs text-fg-3 tabular-nums">
|
||||||
|
{fmtTime(player.progress)}
|
||||||
|
</span>
|
||||||
|
<Slider
|
||||||
|
value={player.progress}
|
||||||
|
max={player.current.duration}
|
||||||
|
label="Barra de progreso"
|
||||||
|
oninput={seek}
|
||||||
|
variant="seek"
|
||||||
|
/>
|
||||||
|
<span class="min-w-8 text-xs text-fg-3 tabular-nums">
|
||||||
|
{fmtTime(player.current.duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
|
||||||
|
<Volume2 class="size-icon-sm shrink-0 text-fg-3" strokeWidth={1.8} />
|
||||||
|
<Slider
|
||||||
|
value={player.volume}
|
||||||
|
max={100}
|
||||||
|
label="Volumen"
|
||||||
|
oninput={(value) => player.setVolume(value)}
|
||||||
|
variant="volume"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
label="Cola de reproducción"
|
||||||
|
tone="plain"
|
||||||
|
size="xs"
|
||||||
|
pressed={queuePanel.open}
|
||||||
|
onclick={() => queuePanel.toggle()}
|
||||||
|
>
|
||||||
|
<ListMusic class="size-icon-sm" strokeWidth={1.8} />
|
||||||
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<PlayerExtras />
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex flex-col lg:left-[var(--mf-sidebar-w)]"
|
class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-sticky) flex flex-col lg:left-(--mf-sidebar-w)"
|
||||||
>
|
>
|
||||||
{#if player.error}
|
{#if player.error}
|
||||||
<div class="pointer-events-auto px-4 pb-2.5">
|
<div class="pointer-events-auto px-4 pb-2.5">
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import VolumeControl from './VolumeControl.svelte';
|
|
||||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
|
||||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
|
|
||||||
<VolumeControl />
|
|
||||||
<IconButton
|
|
||||||
label="Cola de reproducción"
|
|
||||||
tone="plain"
|
|
||||||
size="xs"
|
|
||||||
pressed={queuePanel.open}
|
|
||||||
onclick={() => queuePanel.toggle()}
|
|
||||||
>
|
|
||||||
<ListMusic class="size-icon-sm" strokeWidth={1.8} />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
import MediaIdentity from '$lib/components/ui/MediaIdentity.svelte';
|
||||||
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
|
import ListRow from '$lib/components/ui/ListRow.svelte';
|
||||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||||
import { fmtTime } from '$lib/format';
|
import { fmtTime } from '$lib/format';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
{#if queuePanel.open}
|
{#if queuePanel.open}
|
||||||
<aside
|
<aside
|
||||||
transition:fly={{ x: 24, duration: 220, easing: cubicOut }}
|
transition:fly={{ x: 24, duration: 220, easing: cubicOut }}
|
||||||
class="hidden shrink-0 flex-col border-l border-hairline bg-bg px-4 pt-5 transition-[background] duration-500 sm:flex"
|
class="hidden shrink-0 flex-col border-l border-hairline bg-bg px-4 pt-5 theme-transition sm:flex"
|
||||||
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
|
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
|
||||||
>
|
>
|
||||||
<div class="mb-5 flex items-center justify-between">
|
<div class="mb-5 flex items-center justify-between">
|
||||||
@@ -29,31 +29,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if player.current.id}
|
{#if player.current.id}
|
||||||
<p class="mb-2.75 text-xs font-medium tracking-widest text-fg-3 uppercase">Reproduciendo</p>
|
<p class="mb-3 text-eyebrow text-fg-3">Reproduciendo</p>
|
||||||
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
|
<div class="mb-6 rounded-control bg-accent-tint p-2.5">
|
||||||
<Artwork
|
<MediaIdentity
|
||||||
trackIds={[player.current.id]}
|
trackId={player.current.id}
|
||||||
size="sm"
|
title={player.current.title}
|
||||||
alt={player.current.title}
|
artist={player.current.artist || '—'}
|
||||||
class="shrink-0"
|
ownerUserId={player.current.ownerUserId}
|
||||||
|
active
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="truncate text-sm text-accent-soft">
|
|
||||||
{player.current.title}
|
|
||||||
</div>
|
|
||||||
<ArtistLink
|
|
||||||
name={player.current.artist || '—'}
|
|
||||||
ownerUserId={player.current.ownerUserId}
|
|
||||||
class="mt-0.5 text-xs text-fg-3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="mb-2.75 flex items-center justify-between gap-3">
|
<div class="mb-3 flex items-center justify-between gap-3">
|
||||||
<span class="min-w-0 flex-1 truncate text-xs font-medium tracking-widest text-fg-3 uppercase">
|
<span class="min-w-0 flex-1 truncate text-eyebrow text-fg-3"> A continuación </span>
|
||||||
A continuación
|
|
||||||
</span>
|
|
||||||
{#if upcoming.length > 0}
|
{#if upcoming.length > 0}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -68,30 +57,22 @@
|
|||||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto">
|
<div class="flex flex-1 flex-col gap-px overflow-y-auto">
|
||||||
{#each upcoming as track, i (track.id)}
|
{#each upcoming as track, i (track.id)}
|
||||||
{@const index = player.tracks.length - upcoming.length + i}
|
{@const index = player.tracks.length - upcoming.length + i}
|
||||||
<div
|
<ListRow
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
onclick={() => player.playQueueIndex(index)}
|
onclick={() => player.playQueueIndex(index)}
|
||||||
onkeydown={(e) => {
|
size="sm"
|
||||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
title={track.title}
|
||||||
e.preventDefault();
|
subtitle={track.artist || '—'}
|
||||||
player.playQueueIndex(index);
|
subtitleHref={track.ownerUserId}
|
||||||
}}
|
trackId={track.id}
|
||||||
class="flex items-center gap-3 rounded-control p-2.25 text-left hover:bg-hover"
|
class="p-2"
|
||||||
>
|
>
|
||||||
<Artwork trackIds={[track.id]} size="sm" alt={track.title} class="shrink-0 opacity-80" />
|
{#snippet trailing()}
|
||||||
<div class="min-w-0 flex-1">
|
{#if track.duration}
|
||||||
<div class="truncate text-xs text-fg">{track.title}</div>
|
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span
|
||||||
<ArtistLink
|
>
|
||||||
name={track.artist || '—'}
|
{/if}
|
||||||
ownerUserId={track.ownerUserId}
|
{/snippet}
|
||||||
class="mt-0.5 text-xs text-fg-3"
|
</ListRow>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{#if track.duration}
|
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p>
|
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { player } from '$lib/player/player.svelte';
|
|
||||||
import { fmtTime } from '$lib/format';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { compact = false }: Props = $props();
|
|
||||||
|
|
||||||
function seek(event: MouseEvent) {
|
|
||||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
||||||
player.seekFraction((event.clientX - rect.left) / rect.width);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if compact}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={seek}
|
|
||||||
aria-label="Barra de progreso"
|
|
||||||
class="relative block h-1 w-full cursor-pointer bg-track"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 left-0 bg-accent/70"
|
|
||||||
style="width:{player.progressPercent}%"
|
|
||||||
></div>
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<div class="flex w-full max-w-135 items-center gap-2.75">
|
|
||||||
<span class="min-w-8 text-right text-xs text-fg-3 tabular-nums">
|
|
||||||
{fmtTime(player.progress)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={seek}
|
|
||||||
aria-label="Barra de progreso"
|
|
||||||
class="group relative h-3.5 flex-1 cursor-pointer"
|
|
||||||
>
|
|
||||||
<div class="h-[3.5px] w-full overflow-hidden rounded-full bg-track">
|
|
||||||
<div class="h-full rounded-full bg-accent" style="width:{player.progressPercent}%"></div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<span class="min-w-8 text-xs text-fg-3 tabular-nums">
|
|
||||||
{fmtTime(player.current.duration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-w-0 items-center gap-2.5 md:gap-3.25">
|
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
|
||||||
{#if player.current.id}
|
{#if player.current.id}
|
||||||
<Artwork
|
<Artwork
|
||||||
trackIds={[player.current.id]}
|
trackIds={[player.current.id]}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
const sideClass = $derived(
|
const sideClass = $derived(
|
||||||
compact
|
compact
|
||||||
? 'grid h-10 w-10 place-items-center text-fg opacity-70 transition active:scale-90'
|
? 'grid size-10 place-items-center text-fg opacity-70 transition active:scale-90'
|
||||||
: 'flex text-fg-2 transition-colors hover:text-fg active:scale-90'
|
: 'flex text-fg-2 transition-colors hover:text-fg active:scale-90'
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
aria-label="Aleatorio"
|
aria-label="Aleatorio"
|
||||||
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
|
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
|
||||||
>
|
>
|
||||||
<Shuffle class="h-3.75 w-3.75" strokeWidth={1.8} />
|
<Shuffle class="size-4" strokeWidth={1.8} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
aria-label="Repetir"
|
aria-label="Repetir"
|
||||||
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
|
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
|
||||||
>
|
>
|
||||||
<Repeat class="h-3.75 w-3.75" strokeWidth={1.8} />
|
<Repeat class="size-4" strokeWidth={1.8} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { player } from '$lib/player/player.svelte';
|
|
||||||
import Volume2 from '@lucide/svelte/icons/volume-2';
|
|
||||||
|
|
||||||
function handleVolumeChange(event: Event) {
|
|
||||||
const target = event.currentTarget as HTMLInputElement;
|
|
||||||
player.setVolume(Number(target.value));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2.25">
|
|
||||||
<Volume2 class="h-3.75 w-3.75 shrink-0 text-fg-3" strokeWidth={1.8} />
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
value={player.volume}
|
|
||||||
oninput={handleVolumeChange}
|
|
||||||
aria-label="Volumen"
|
|
||||||
class="mf-volume"
|
|
||||||
style="background:linear-gradient(to right, var(--mf-text-2) {player.volume}%, var(--mf-track) {player.volume}%)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.mf-volume {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 84px;
|
|
||||||
height: 3.5px;
|
|
||||||
border-radius: 100px;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.mf-volume::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--mf-text);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.mf-volume::-moz-range-thumb {
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--mf-text);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -9,25 +9,21 @@
|
|||||||
import MenuItem from './MenuItem.svelte';
|
import MenuItem from './MenuItem.svelte';
|
||||||
import GlassMenu from './GlassMenu.svelte';
|
import GlassMenu from './GlassMenu.svelte';
|
||||||
import { themeMode } from '$lib/theme/mode.svelte';
|
import { themeMode } from '$lib/theme/mode.svelte';
|
||||||
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: SessionUser;
|
user: SessionUser;
|
||||||
accountUrl?: string | null;
|
accountUrl?: string | null;
|
||||||
panelClass?: string;
|
width?: 'sm' | 'md';
|
||||||
extraItems?: Snippet<[{ close: () => void }]>;
|
extraItems?: Snippet<[{ close: () => void }]>;
|
||||||
trigger: Snippet<[{ toggle: () => void; open: boolean }]>;
|
trigger: Snippet<[{ toggle: () => void; open: boolean }]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { user, accountUrl, width = 'sm', extraItems, trigger }: Props = $props();
|
||||||
user,
|
|
||||||
accountUrl,
|
const WIDTH: Record<'sm' | 'md', string> = { sm: 'w-52', md: 'w-56' };
|
||||||
panelClass = 'absolute top-full right-0 mt-2 w-52 overflow-hidden',
|
|
||||||
extraItems,
|
|
||||||
trigger
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let menuRef: HTMLDivElement | undefined = $state();
|
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
open = !open;
|
open = !open;
|
||||||
@@ -37,16 +33,18 @@
|
|||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDocumentClick(event: MouseEvent) {
|
function onWindowKeydown(event: KeyboardEvent) {
|
||||||
if (menuRef && !menuRef.contains(event.target as Node)) open = false;
|
if (event.key === 'Escape') close();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative" bind:this={menuRef}>
|
<svelte:window onkeydown={onWindowKeydown} />
|
||||||
|
|
||||||
|
<div class="relative" use:clickOutside={close}>
|
||||||
{@render trigger({ toggle, open })}
|
{@render trigger({ toggle, open })}
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<GlassMenu class={panelClass}>
|
<GlassMenu class="absolute top-full right-0 z-(--z-menu) mt-2 overflow-hidden {WIDTH[width]}">
|
||||||
<MenuItem icon={User} label="Ver perfil" href="/u/{user.sub}" onclick={close} />
|
<MenuItem icon={User} label="Ver perfil" href="/u/{user.sub}" onclick={close} />
|
||||||
{@render extraItems?.({ close })}
|
{@render extraItems?.({ close })}
|
||||||
{#if accountUrl}
|
{#if accountUrl}
|
||||||
@@ -69,5 +67,3 @@
|
|||||||
</GlassMenu>
|
</GlassMenu>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<svelte:window onclick={onDocumentClick} />
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import MediaCard from './MediaCard.svelte';
|
|
||||||
import Artwork from './Artwork.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
href?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
typeLabel?: string;
|
|
||||||
releaseYear?: number;
|
|
||||||
trackCount?: number;
|
|
||||||
trackIds?: (string | number)[];
|
|
||||||
coverSrc?: string | null;
|
|
||||||
index?: number;
|
|
||||||
onContextMenu?: (event: MouseEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
href,
|
|
||||||
subtitle,
|
|
||||||
typeLabel,
|
|
||||||
releaseYear,
|
|
||||||
trackCount,
|
|
||||||
trackIds = [],
|
|
||||||
coverSrc,
|
|
||||||
index = 0,
|
|
||||||
onContextMenu
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const meta = $derived(
|
|
||||||
[
|
|
||||||
typeLabel,
|
|
||||||
subtitle,
|
|
||||||
releaseYear ? String(releaseYear) : undefined,
|
|
||||||
trackCount === undefined
|
|
||||||
? undefined
|
|
||||||
: `${trackCount} ${trackCount === 1 ? 'canción' : 'canciones'}`
|
|
||||||
]
|
|
||||||
.filter((part) => !!part)
|
|
||||||
.join(' · ')
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<MediaCard
|
|
||||||
href={href ?? `/albums/${id}`}
|
|
||||||
{title}
|
|
||||||
subtitle={meta}
|
|
||||||
{index}
|
|
||||||
oncontextmenu={onContextMenu}
|
|
||||||
>
|
|
||||||
{#snippet art(artClass)}
|
|
||||||
<Artwork src={coverSrc} {trackIds} size="fill" alt={title} class={artClass} />
|
|
||||||
{/snippet}
|
|
||||||
</MediaCard>
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import Disc3 from '@lucide/svelte/icons/disc-3';
|
import Disc3 from '@lucide/svelte/icons/disc-3';
|
||||||
import ImageDropzone from './ImageDropzone.svelte';
|
import CoverForm from './CoverForm.svelte';
|
||||||
import Button from './Button.svelte';
|
|
||||||
import Field from './Field.svelte';
|
import Field from './Field.svelte';
|
||||||
import Input from './Input.svelte';
|
import Input from './Input.svelte';
|
||||||
import Textarea from './Textarea.svelte';
|
import Textarea from './Textarea.svelte';
|
||||||
import Alert from './Alert.svelte';
|
|
||||||
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
|
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -43,81 +40,55 @@
|
|||||||
let title = $state(untrack(() => initialTitle));
|
let title = $state(untrack(() => initialTitle));
|
||||||
let description = $state(untrack(() => initialDescription));
|
let description = $state(untrack(() => initialDescription));
|
||||||
let releaseYear = $state(untrack(() => (initialReleaseYear ? String(initialReleaseYear) : '')));
|
let releaseYear = $state(untrack(() => (initialReleaseYear ? String(initialReleaseYear) : '')));
|
||||||
let submitting = $state(false);
|
|
||||||
|
|
||||||
const latestYear = new Date().getFullYear() + 1;
|
const latestYear = new Date().getFullYear() + 1;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form
|
<CoverForm
|
||||||
method="POST"
|
|
||||||
{action}
|
{action}
|
||||||
enctype="multipart/form-data"
|
{coverFallbackUrl}
|
||||||
use:enhance={() => {
|
{requireCover}
|
||||||
submitting = true;
|
coverIcon={Disc3}
|
||||||
return async ({ update, result }) => {
|
{formMessage}
|
||||||
await update({ reset: false });
|
{submitLabel}
|
||||||
submitting = false;
|
{submittingLabel}
|
||||||
if (result.type === 'success') onSuccess?.();
|
canSubmit={title.trim() !== ''}
|
||||||
};
|
{onCancel}
|
||||||
}}
|
{onSuccess}
|
||||||
class="mt-5 w-full max-w-2xl space-y-5"
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
|
{#snippet fields()}
|
||||||
<Field>
|
<Field label="Título" for="album-title">
|
||||||
<ImageDropzone
|
<Input
|
||||||
name="cover"
|
id="album-title"
|
||||||
fallbackUrl={coverFallbackUrl}
|
name="title"
|
||||||
required={requireCover}
|
maxlength={200}
|
||||||
icon={Disc3}
|
required
|
||||||
gradient
|
bind:value={title}
|
||||||
class="h-36 w-36 rounded-2xl"
|
placeholder={titlePlaceholder}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-4">
|
<Field label="Año de publicación" for="album-year">
|
||||||
<Field label="Título" for="album-title">
|
<Input
|
||||||
<Input
|
id="album-year"
|
||||||
id="album-title"
|
name="releaseYear"
|
||||||
name="title"
|
type="number"
|
||||||
maxlength={200}
|
min={ALBUM_EARLIEST_YEAR}
|
||||||
required
|
max={latestYear}
|
||||||
bind:value={title}
|
bind:value={releaseYear}
|
||||||
placeholder={titlePlaceholder}
|
placeholder={String(new Date().getFullYear())}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
{/snippet}
|
||||||
<Field label="Año de publicación" for="album-year">
|
{#snippet extra()}
|
||||||
<Input
|
<Field label="Descripción" for="album-description">
|
||||||
id="album-year"
|
<Textarea
|
||||||
name="releaseYear"
|
id="album-description"
|
||||||
type="number"
|
name="description"
|
||||||
min={ALBUM_EARLIEST_YEAR}
|
maxlength={256}
|
||||||
max={latestYear}
|
bind:value={description}
|
||||||
bind:value={releaseYear}
|
placeholder="¿De qué va este álbum?"
|
||||||
placeholder={String(new Date().getFullYear())}
|
/>
|
||||||
/>
|
</Field>
|
||||||
</Field>
|
{/snippet}
|
||||||
</div>
|
</CoverForm>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field label="Descripción" for="album-description">
|
|
||||||
<Textarea
|
|
||||||
id="album-description"
|
|
||||||
name="description"
|
|
||||||
maxlength={256}
|
|
||||||
bind:value={description}
|
|
||||||
placeholder="¿De qué va este álbum?"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{#if formMessage}
|
|
||||||
<Alert tone="danger">{formMessage}</Alert>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3">
|
|
||||||
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
|
||||||
<Button type="submit" disabled={submitting || title.trim() === ''}>
|
|
||||||
{submitting ? submittingLabel : submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<button {type} disabled={disabled || loading} {onclick} class={classes}>
|
<button {type} disabled={disabled || loading} {onclick} class={classes}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<LoaderCircle class="h-4 w-4 animate-spin" />
|
<LoaderCircle class="size-4 animate-spin" />
|
||||||
{/if}
|
{/if}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,15 +13,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<label class="flex items-center gap-3 text-sm text-fg-2 {klass}">
|
<label class="flex items-center gap-3 text-sm text-fg-2 {klass}">
|
||||||
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
|
<span class="relative inline-grid size-5 shrink-0 place-items-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
{name}
|
{name}
|
||||||
bind:checked
|
bind:checked
|
||||||
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
|
class="peer size-5 cursor-pointer appearance-none rounded-tag border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
|
||||||
/>
|
/>
|
||||||
<Check
|
<Check
|
||||||
class="pointer-events-none absolute h-3.5 w-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
|
class="pointer-events-none absolute size-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selected?: boolean;
|
||||||
|
count?: number;
|
||||||
|
href?: string;
|
||||||
|
onclick?: () => void;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { selected = false, count, href, onclick, children }: Props = $props();
|
||||||
|
|
||||||
|
const classes = $derived(
|
||||||
|
href
|
||||||
|
? 'rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg'
|
||||||
|
: `flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${
|
||||||
|
selected
|
||||||
|
? 'bg-cta-strong text-ink'
|
||||||
|
: 'bg-surface-2 text-fg-2 hover:bg-surface-hover hover:text-fg'
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet content()}
|
||||||
|
{@render children()}
|
||||||
|
{#if count !== undefined}
|
||||||
|
<span class="tabular-nums {selected ? 'opacity-55' : 'text-muted'}">{count}</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a {href} class={classes}>{@render content()}</a>
|
||||||
|
{:else}
|
||||||
|
<button type="button" {onclick} class={classes}>{@render content()}</button>
|
||||||
|
{/if}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
eyebrow: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
meta?: string;
|
|
||||||
align?: 'center' | 'end';
|
|
||||||
cover: Snippet;
|
|
||||||
actions?: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { eyebrow, title, description, meta, align = 'end', cover, actions }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-5 sm:flex-row sm:gap-6 {align === 'end'
|
|
||||||
? 'mt-3 sm:mt-4 sm:items-end'
|
|
||||||
: 'sm:items-center'}"
|
|
||||||
>
|
|
||||||
{@render cover()}
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">{eyebrow}</p>
|
|
||||||
<h1
|
|
||||||
class="{align === 'end'
|
|
||||||
? 'mt-1.5'
|
|
||||||
: 'mt-3'} font-display text-3xl font-medium tracking-tight wrap-break-word text-fg sm:text-4xl"
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
{#if description}
|
|
||||||
<p class="mt-3 max-w-2xl text-sm text-fg-2 sm:text-base">{description}</p>
|
|
||||||
{/if}
|
|
||||||
{#if meta}
|
|
||||||
<p class="mt-3 text-sm text-fg-2">{meta}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if actions}
|
|
||||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
|
||||||
{@render actions()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import Button from './Button.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open, onClose, title, description, confirmLabel, action }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {open} {onClose} maxWidth="max-w-sm">
|
||||||
|
<h2 class="text-display-3 text-fg">{title}</h2>
|
||||||
|
<p class="mt-2.5 text-body text-fg-2">{description}</p>
|
||||||
|
<div class="mt-6 flex items-center justify-end gap-3">
|
||||||
|
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
|
||||||
|
<form method="POST" {action}>
|
||||||
|
<Button type="submit" variant="danger">{confirmLabel}</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { MENU_WIDTH, SUBMENU_WIDTH } from '$lib/config';
|
||||||
|
|
||||||
|
export interface MenuPosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
openLeft: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contextMenuPosition(event: MouseEvent): MenuPosition {
|
||||||
|
event.preventDefault();
|
||||||
|
return {
|
||||||
|
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
|
||||||
|
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
|
||||||
|
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { LucideIcon } from '@lucide/svelte';
|
||||||
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
|
import MediaIdentity from './MediaIdentity.svelte';
|
||||||
|
import GlassMenu from './GlassMenu.svelte';
|
||||||
|
import MenuItem from './MenuItem.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
openLeft: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
items: { icon: LucideIcon; label: string; onclick: () => void }[];
|
||||||
|
playlistAction?: { action: string; fields: Record<string, string>; label: string };
|
||||||
|
playlists?: { id: string; name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { x, y, openLeft, onClose, items, playlistAction, playlists = [] }: Props = $props();
|
||||||
|
|
||||||
|
function onWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onWindowKeydown} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-(--z-backdrop)"
|
||||||
|
role="presentation"
|
||||||
|
onclick={onClose}
|
||||||
|
oncontextmenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<GlassMenu class="fixed z-(--z-menu) w-(--mf-menu-w)" style="left:{x}px; top:{y}px;">
|
||||||
|
{#each items as item (item.label)}
|
||||||
|
<MenuItem icon={item.icon} label={item.label} onclick={item.onclick} />
|
||||||
|
{/each}
|
||||||
|
{#if playlistAction}
|
||||||
|
<div class="group/addmenu relative">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
|
||||||
|
>
|
||||||
|
<span class="flex-1 cursor-default">{playlistAction.label}</span>
|
||||||
|
<ChevronRight class="size-icon-sm shrink-0 text-fg-3" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="invisible absolute top-0 z-(--z-submenu) opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {openLeft
|
||||||
|
? 'right-full pr-1.5'
|
||||||
|
: 'left-full pl-1.5'}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-(--mf-submenu-w) overflow-hidden rounded-panel border border-line bg-elevated p-2 max-sm:w-full"
|
||||||
|
>
|
||||||
|
<div class="max-h-80 overflow-y-auto">
|
||||||
|
{#if playlists.length === 0}
|
||||||
|
<p class="px-2 py-2.5 text-sm text-fg-3">Todavía no tienes playlists.</p>
|
||||||
|
{/if}
|
||||||
|
{#each playlists as playlist (playlist.id)}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={playlistAction.action}
|
||||||
|
use:enhance={() =>
|
||||||
|
({ update }) => {
|
||||||
|
onClose();
|
||||||
|
return update({ reset: false, invalidateAll: false });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="playlistId" value={playlist.id} />
|
||||||
|
{#each Object.entries(playlistAction.fields) as [name, value] (name)}
|
||||||
|
<input type="hidden" {name} {value} />
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-full items-center rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
|
||||||
|
>
|
||||||
|
<MediaIdentity
|
||||||
|
title={playlist.name}
|
||||||
|
coverSrc="/api/playlists/{playlist.id}/cover?size=small"
|
||||||
|
size="xs"
|
||||||
|
titleClass="text-fg-2"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</GlassMenu>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { LucideIcon } from '@lucide/svelte';
|
||||||
|
import ImageDropzone from './ImageDropzone.svelte';
|
||||||
|
import Button from './Button.svelte';
|
||||||
|
import Field from './Field.svelte';
|
||||||
|
import Alert from './Alert.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
action: string;
|
||||||
|
coverFallbackUrl?: string;
|
||||||
|
coverIcon: LucideIcon;
|
||||||
|
requireCover?: boolean;
|
||||||
|
formMessage?: string;
|
||||||
|
submitLabel: string;
|
||||||
|
submittingLabel: string;
|
||||||
|
canSubmit: boolean;
|
||||||
|
helperText?: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
fields: Snippet;
|
||||||
|
extra?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
action,
|
||||||
|
coverFallbackUrl,
|
||||||
|
coverIcon,
|
||||||
|
requireCover = false,
|
||||||
|
formMessage,
|
||||||
|
submitLabel,
|
||||||
|
submittingLabel,
|
||||||
|
canSubmit,
|
||||||
|
helperText,
|
||||||
|
onCancel,
|
||||||
|
onSuccess,
|
||||||
|
fields,
|
||||||
|
extra
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
{action}
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
use:enhance={() => {
|
||||||
|
submitting = true;
|
||||||
|
return async ({ update, result }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
submitting = false;
|
||||||
|
if (result.type === 'success' || result.type === 'redirect') onSuccess?.();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-5 w-full max-w-4xl space-y-5"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
|
||||||
|
<Field>
|
||||||
|
<ImageDropzone
|
||||||
|
name="cover"
|
||||||
|
fallbackUrl={coverFallbackUrl}
|
||||||
|
required={requireCover}
|
||||||
|
icon={coverIcon}
|
||||||
|
gradient
|
||||||
|
size="hero"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div class="flex flex-1 flex-col gap-4">
|
||||||
|
{@render fields()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{@render extra?.()}
|
||||||
|
|
||||||
|
{#if formMessage}
|
||||||
|
<Alert tone="danger">{formMessage}</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
{#if helperText}
|
||||||
|
<p class="text-sm text-muted">{helperText}</p>
|
||||||
|
{:else}
|
||||||
|
<span></span>
|
||||||
|
{/if}
|
||||||
|
<div class="flex shrink-0 items-center gap-3">
|
||||||
|
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
||||||
|
<Button type="submit" disabled={submitting || !canSubmit}>
|
||||||
|
{submitting ? submittingLabel : submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
|
|
||||||
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
|
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
|
||||||
{#if Icon}
|
{#if Icon}
|
||||||
<Icon class="h-9 w-9 text-fg-2" strokeWidth={1.25} />
|
<Icon class="size-icon-xl text-fg-2" strokeWidth={1.25} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if title}
|
{#if title}
|
||||||
<h3 class="mt-6 font-display text-xl font-medium tracking-tight text-fg">
|
<h3 class="mt-6 text-display-3 text-fg">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -3,21 +3,41 @@
|
|||||||
size?: number;
|
size?: number;
|
||||||
barWidth?: number;
|
barWidth?: number;
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
|
overlay?: boolean;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { size = 13, barWidth = 2, paused = false, class: klass = '' }: Props = $props();
|
let {
|
||||||
|
size = 13,
|
||||||
|
barWidth = 2,
|
||||||
|
paused = false,
|
||||||
|
overlay = false,
|
||||||
|
class: klass = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const delays = [0, 0.3, 0.6];
|
const delays = [0, 0.3, 0.6];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-end justify-center gap-0.5 {klass}" style="height:{size}px">
|
{#if overlay}
|
||||||
{#each delays as delay (delay)}
|
<div class="absolute inset-0 flex items-end justify-center gap-1 bg-scrim/45 pb-2.5 {klass}">
|
||||||
<span
|
{#each delays as delay (delay)}
|
||||||
class="rounded-full bg-accent"
|
<span
|
||||||
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
class="h-3.5 w-1 origin-bottom rounded-full bg-on-art"
|
||||||
? 'paused'
|
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
||||||
: 'running'}"
|
? 'paused'
|
||||||
></span>
|
: 'running'}"
|
||||||
{/each}
|
></span>
|
||||||
</div>
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-end justify-center gap-0.5 {klass}" style="height:{size}px">
|
||||||
|
{#each delays as delay (delay)}
|
||||||
|
<span
|
||||||
|
class="rounded-full bg-accent"
|
||||||
|
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
||||||
|
? 'paused'
|
||||||
|
: 'running'}"
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="grid h-4 w-4 shrink-0 place-items-center rounded-sm bg-fg-3 text-xs leading-4 font-bold text-bg {klass}"
|
class="grid size-4 shrink-0 place-items-center rounded-tag bg-fg-3 text-xs leading-4 font-bold text-bg {klass}"
|
||||||
title="Contenido explícito"
|
title="Contenido explícito"
|
||||||
aria-label="Contenido explícito">E</span
|
aria-label="Contenido explícito">E</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
gradient?: boolean;
|
gradient?: boolean;
|
||||||
|
size?: 'default' | 'hero';
|
||||||
|
onselect?: (file: File) => void;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,9 +19,16 @@
|
|||||||
required = false,
|
required = false,
|
||||||
icon: Icon = ImageIcon,
|
icon: Icon = ImageIcon,
|
||||||
gradient = false,
|
gradient = false,
|
||||||
class: klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control'
|
size = 'default',
|
||||||
|
onselect,
|
||||||
|
class: klass = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const SIZE: Record<'default' | 'hero', string> = {
|
||||||
|
default: 'size-40 sm:size-52 rounded-control',
|
||||||
|
hero: 'size-cover-hero rounded-art-lg'
|
||||||
|
};
|
||||||
|
|
||||||
let preview = $state('');
|
let preview = $state('');
|
||||||
let fallbackFailed = $state(false);
|
let fallbackFailed = $state(false);
|
||||||
|
|
||||||
@@ -27,14 +36,16 @@
|
|||||||
|
|
||||||
function onInput(event: Event) {
|
function onInput(event: Event) {
|
||||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||||
if (file) preview = URL.createObjectURL(file);
|
if (!file) return;
|
||||||
|
preview = URL.createObjectURL(file);
|
||||||
|
onselect?.(file);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {gradient
|
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {gradient
|
||||||
? ''
|
? ''
|
||||||
: 'border border-line bg-surface hover:border-accent/50'} {klass}"
|
: 'border border-line bg-surface hover:border-accent/50'} {SIZE[size]} {klass}"
|
||||||
style={gradient && !hasImage ? 'background:var(--mf-cover-grad)' : undefined}
|
style={gradient && !hasImage ? 'background:var(--mf-cover-grad)' : undefined}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -57,6 +68,6 @@
|
|||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon class="h-9 w-9 {gradient ? 'text-on-art/70' : 'text-muted'}" strokeWidth={1.3} />
|
<Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-muted'}" strokeWidth={1.3} />
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -34,6 +34,6 @@
|
|||||||
|
|
||||||
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
|
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<LoaderCircle class="h-5 w-5 animate-spin text-muted" />
|
<LoaderCircle class="size-5 animate-spin text-muted" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
{#if Icon}
|
{#if Icon}
|
||||||
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
|
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
|
||||||
<Icon class="h-5 w-5" />
|
<Icon class="size-icon-lg" />
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<input
|
<input
|
||||||
@@ -52,8 +52,6 @@
|
|||||||
{autocomplete}
|
{autocomplete}
|
||||||
{oninput}
|
{oninput}
|
||||||
bind:value
|
bind:value
|
||||||
class="w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {Icon
|
class="field-control {Icon ? 'pr-4 pl-11' : 'px-4'} {klass}"
|
||||||
? 'pr-4 pl-11'
|
|
||||||
: 'px-4'} {klass}"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import MediaIdentity from './MediaIdentity.svelte';
|
||||||
|
import { pressable } from '$lib/actions/pressable';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
href?: string;
|
||||||
|
onclick?: () => void;
|
||||||
|
oncontextmenu?: (event: MouseEvent) => void;
|
||||||
|
active?: boolean;
|
||||||
|
size?: 'xs' | 'sm' | 'lg';
|
||||||
|
variant?: 'row' | 'card';
|
||||||
|
title: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
subtitleHref?: string | number | null;
|
||||||
|
explicit?: boolean;
|
||||||
|
trackId?: string | number;
|
||||||
|
trackIds?: (string | number)[];
|
||||||
|
coverSrc?: string | null;
|
||||||
|
art?: Snippet;
|
||||||
|
overlay?: Snippet;
|
||||||
|
leading?: Snippet;
|
||||||
|
trailing?: Snippet;
|
||||||
|
class?: string;
|
||||||
|
style?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
href,
|
||||||
|
onclick,
|
||||||
|
oncontextmenu,
|
||||||
|
active = false,
|
||||||
|
size = 'sm',
|
||||||
|
variant = 'row',
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
subtitleHref,
|
||||||
|
explicit = false,
|
||||||
|
trackId,
|
||||||
|
trackIds,
|
||||||
|
coverSrc,
|
||||||
|
art,
|
||||||
|
overlay,
|
||||||
|
leading,
|
||||||
|
trailing,
|
||||||
|
class: klass = '',
|
||||||
|
style
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const classes = $derived(
|
||||||
|
`flex items-center gap-3 text-left transition-colors ${
|
||||||
|
variant === 'card' ? 'rounded-art p-2 pr-3.5' : 'rounded-control p-2'
|
||||||
|
} ${active ? 'bg-accent-tint' : 'hover:bg-hover'} ${klass}`
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet content()}
|
||||||
|
{@render leading?.()}
|
||||||
|
<MediaIdentity
|
||||||
|
{title}
|
||||||
|
artist={subtitle}
|
||||||
|
ownerUserId={subtitleHref}
|
||||||
|
{trackId}
|
||||||
|
{trackIds}
|
||||||
|
{coverSrc}
|
||||||
|
{size}
|
||||||
|
{explicit}
|
||||||
|
{active}
|
||||||
|
{art}
|
||||||
|
{overlay}
|
||||||
|
/>
|
||||||
|
{@render trailing?.()}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a {href} {oncontextmenu} class={classes} {style}>
|
||||||
|
{@render content()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
use:pressable={onclick ?? (() => {})}
|
||||||
|
{oncontextmenu}
|
||||||
|
class={classes}
|
||||||
|
{style}
|
||||||
|
>
|
||||||
|
{@render content()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
size?: 'sm' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { size = 'sm' }: Props = $props();
|
||||||
|
|
||||||
|
const MARK: Record<'sm' | 'lg', string> = {
|
||||||
|
sm: 'size-5.5 rounded-thumb',
|
||||||
|
lg: 'size-8 rounded-control'
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEXT: Record<'sm' | 'lg', string> = {
|
||||||
|
sm: 'font-display text-lg font-semibold tracking-tight text-fg',
|
||||||
|
lg: 'font-display text-[clamp(1.5rem,3vw,2.5rem)] font-semibold tracking-tight'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="flex items-center gap-3">
|
||||||
|
<span class="{MARK[size]} bg-[image:var(--mf-logo-grad)]"></span>
|
||||||
|
<span class={TEXT[size]}>Musify</span>
|
||||||
|
</span>
|
||||||
@@ -1,30 +1,59 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
export const mediaCardArt =
|
|
||||||
'aspect-square w-full rounded-art transition duration-300 ease-out';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import Artwork from './Artwork.svelte';
|
||||||
|
import PlayButton from './PlayButton.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
href: string;
|
href: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
index?: number;
|
src?: string | null;
|
||||||
|
trackIds?: (string | number)[];
|
||||||
|
onPlay?: (event: MouseEvent) => void;
|
||||||
|
art?: Snippet;
|
||||||
oncontextmenu?: (event: MouseEvent) => void;
|
oncontextmenu?: (event: MouseEvent) => void;
|
||||||
art: Snippet<[string]>;
|
index?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { href, title, subtitle, index = 0, oncontextmenu, art }: Props = $props();
|
let {
|
||||||
|
href,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
src,
|
||||||
|
trackIds = [],
|
||||||
|
onPlay,
|
||||||
|
art,
|
||||||
|
oncontextmenu,
|
||||||
|
index = 0
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a
|
<a {href} class="group animate-enter min-w-0 text-left" style="--i:{index}" {oncontextmenu}>
|
||||||
{href}
|
<div class="relative">
|
||||||
class="group animate-enter min-w-0 text-left"
|
{#if art}
|
||||||
style="animation-delay:{index * 45}ms"
|
{@render art()}
|
||||||
{oncontextmenu}
|
{:else}
|
||||||
>
|
<Artwork
|
||||||
{@render art(mediaCardArt)}
|
{src}
|
||||||
|
{trackIds}
|
||||||
|
size="fill"
|
||||||
|
alt={title}
|
||||||
|
class="rounded-art transition duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if onPlay}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-0 rounded-art bg-linear-to-b from-transparent from-45% to-scrim/40"
|
||||||
|
></div>
|
||||||
|
<PlayButton
|
||||||
|
variant="glass"
|
||||||
|
size="sm"
|
||||||
|
onclick={onPlay}
|
||||||
|
label="Reproducir {title}"
|
||||||
|
class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="mt-2.5 truncate text-sm text-fg">{title}</div>
|
<div class="mt-2.5 truncate text-sm text-fg">{title}</div>
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
<div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div>
|
<div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
as?: 'ul' | 'div';
|
|
||||||
min?: string;
|
|
||||||
minMobile?: string;
|
|
||||||
class?: string;
|
|
||||||
children: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
as = 'div',
|
|
||||||
min = '165px',
|
|
||||||
minMobile = '150px',
|
|
||||||
class: klass = '',
|
|
||||||
children
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:element
|
|
||||||
this={as}
|
|
||||||
class="grid media-grid gap-3 sm:gap-4 {klass}"
|
|
||||||
style="--mf-grid-min:{min}; --mf-grid-min-mobile:{minMobile}"
|
|
||||||
>
|
|
||||||
{@render children()}
|
|
||||||
</svelte:element>
|
|
||||||
+22
-25
@@ -5,22 +5,25 @@
|
|||||||
import ArtistLink from './ArtistLink.svelte';
|
import ArtistLink from './ArtistLink.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
trackId: string | number;
|
trackId?: string | number;
|
||||||
|
trackIds?: (string | number)[];
|
||||||
title: string;
|
title: string;
|
||||||
artist?: string | null;
|
artist?: string | null;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | number | null;
|
||||||
coverSrc?: string | null;
|
coverSrc?: string | null;
|
||||||
size?: 'sm' | 'lg';
|
size?: 'xs' | 'sm' | 'lg';
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
titleClass?: string;
|
titleClass?: string;
|
||||||
onClick: () => void;
|
class?: string;
|
||||||
|
art?: Snippet;
|
||||||
overlay?: Snippet;
|
overlay?: Snippet;
|
||||||
badge?: Snippet;
|
badge?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
trackId,
|
trackId,
|
||||||
|
trackIds,
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
ownerUserId,
|
ownerUserId,
|
||||||
@@ -29,33 +32,27 @@
|
|||||||
explicit = false,
|
explicit = false,
|
||||||
active = false,
|
active = false,
|
||||||
titleClass = '',
|
titleClass = '',
|
||||||
onClick,
|
class: klass = '',
|
||||||
|
art,
|
||||||
overlay,
|
overlay,
|
||||||
badge
|
badge
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function onKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
||||||
event.preventDefault();
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onclick(event: MouseEvent) {
|
|
||||||
event.stopPropagation();
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="flex min-w-0 flex-1 items-center gap-3.5 {klass}">
|
||||||
role="button"
|
{#if art}
|
||||||
tabindex="0"
|
{@render art()}
|
||||||
{onclick}
|
{:else if trackId !== undefined || coverSrc || trackIds?.length}
|
||||||
onkeydown={onKeydown}
|
<Artwork
|
||||||
class="flex min-w-0 items-center gap-3.5 text-left"
|
trackIds={trackIds ?? (trackId !== undefined ? [trackId] : [])}
|
||||||
>
|
src={coverSrc}
|
||||||
<Artwork trackIds={[trackId]} src={coverSrc} {size} alt={title} class="shrink-0">
|
{size}
|
||||||
{@render overlay?.()}
|
alt={title}
|
||||||
</Artwork>
|
class="shrink-0"
|
||||||
|
>
|
||||||
|
{@render overlay?.()}
|
||||||
|
</Artwork>
|
||||||
|
{/if}
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
{#if explicit}
|
{#if explicit}
|
||||||
@@ -37,12 +37,12 @@
|
|||||||
{onclick}
|
{onclick}
|
||||||
class={classes}
|
class={classes}
|
||||||
>
|
>
|
||||||
<Icon class="h-4.5 w-4.5 shrink-0" strokeWidth={1.8} />
|
<Icon class="size-icon-md shrink-0" />
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<button {type} {onclick} class={classes}>
|
<button {type} {onclick} class={classes}>
|
||||||
<Icon class="h-4.5 w-4.5 shrink-0" strokeWidth={1.8} />
|
<Icon class="size-icon-md shrink-0" />
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Artwork from './Artwork.svelte';
|
|
||||||
import PlayButton from './PlayButton.svelte';
|
|
||||||
import { player } from '$lib/player/player.svelte';
|
|
||||||
import { queueItemForTarget } from '$lib/tracks';
|
|
||||||
import { mixItemTarget, type Mix } from '$lib/mixes';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mix: Mix;
|
|
||||||
index?: number;
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { mix, index = 0, class: klass = '' }: Props = $props();
|
|
||||||
|
|
||||||
const trackIds = $derived(mix.items.map((item) => item.trackId));
|
|
||||||
|
|
||||||
function play(event: MouseEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
const items = mix.items.map((item) => queueItemForTarget(mixItemTarget(item)));
|
|
||||||
player.playQueue(items, 0);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/mixes/{mix.id}"
|
|
||||||
class="group/tile animate-enter block cursor-pointer {klass}"
|
|
||||||
style="--i:{index}"
|
|
||||||
>
|
|
||||||
<Artwork
|
|
||||||
{trackIds}
|
|
||||||
size="fill"
|
|
||||||
alt={mix.title}
|
|
||||||
class="rounded-art transition duration-300 group-hover/tile:scale-105"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent from-45% to-scrim/40"
|
|
||||||
></div>
|
|
||||||
<PlayButton
|
|
||||||
variant="glass"
|
|
||||||
size="sm"
|
|
||||||
onclick={play}
|
|
||||||
label="Reproducir {mix.title}"
|
|
||||||
class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity duration-150 group-hover/tile:opacity-100"
|
|
||||||
/>
|
|
||||||
</Artwork>
|
|
||||||
<div class="mt-3.25 truncate text-sm font-semibold text-fg">
|
|
||||||
{mix.title}
|
|
||||||
</div>
|
|
||||||
{#if mix.subtitle}
|
|
||||||
<div class="mt-1 truncate text-xs text-fg-3">{mix.subtitle}</div>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
@@ -70,14 +70,13 @@
|
|||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div
|
<div
|
||||||
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm"
|
class="animate-fade fixed inset-0 z-(--z-modal) grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={onBackdropClick}
|
onclick={onBackdropClick}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
bind:this={panel}
|
bind:this={panel}
|
||||||
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 backdrop-blur-xl transition-[background] duration-500 outline-none sm:p-6 {panelClass}"
|
class="glass-panel animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
|
||||||
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
@@ -87,16 +86,16 @@
|
|||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
{#if eyebrow}
|
{#if eyebrow}
|
||||||
<p class="mb-2.5 text-xs font-medium tracking-widest text-muted uppercase">
|
<p class="mb-2.5 text-eyebrow text-muted">
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<h2 class="font-display text-xl font-medium tracking-[-0.02em] text-fg">
|
<h2 class="text-display-3 text-fg">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
|
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
|
||||||
<X class="h-4 w-4" />
|
<X class="size-4" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
paused?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { paused = false }: Props = $props();
|
|
||||||
|
|
||||||
const delays = [0, 0.3, 0.6];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="absolute inset-0 flex items-end justify-center gap-0.75 bg-scrim/45 pb-2.5">
|
|
||||||
{#each delays as delay (delay)}
|
|
||||||
<span
|
|
||||||
class="h-3.5 w-0.75 origin-bottom rounded-full bg-on-art"
|
|
||||||
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
|
||||||
? 'paused'
|
|
||||||
: 'running'}"
|
|
||||||
></span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
@@ -3,24 +3,59 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
eyebrow?: string;
|
||||||
class?: string;
|
description?: string;
|
||||||
|
meta?: string;
|
||||||
|
cover?: Snippet;
|
||||||
actions?: Snippet;
|
actions?: Snippet;
|
||||||
|
align?: 'center' | 'end';
|
||||||
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { title, subtitle, class: klass = '', actions }: Props = $props();
|
let {
|
||||||
|
title,
|
||||||
|
eyebrow,
|
||||||
|
description,
|
||||||
|
meta,
|
||||||
|
cover,
|
||||||
|
actions,
|
||||||
|
align = 'end',
|
||||||
|
class: klass = ''
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
|
<div class="mb-7 sm:mb-8 {klass}">
|
||||||
<div class="min-w-0">
|
<div
|
||||||
<h1 class="font-display text-3xl font-medium tracking-tight text-fg sm:text-4xl">
|
class="flex flex-col gap-5 sm:flex-row sm:gap-6 {cover
|
||||||
{title}
|
? align === 'end'
|
||||||
</h1>
|
? 'sm:items-end'
|
||||||
{#if subtitle}
|
: 'sm:items-center'
|
||||||
<p class="mt-2 text-sm text-fg-2">{subtitle}</p>
|
: 'sm:items-end sm:justify-between'}"
|
||||||
|
>
|
||||||
|
{#if cover}
|
||||||
|
{@render cover()}
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
{#if eyebrow}
|
||||||
|
<p class="text-eyebrow text-fg-2">{eyebrow}</p>
|
||||||
|
{/if}
|
||||||
|
<h1 class="text-display-1 wrap-break-word text-fg {eyebrow ? 'mt-1.5' : ''}">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{#if description}
|
||||||
|
<p class="mt-3 max-w-2xl text-body text-fg-2">{description}</p>
|
||||||
|
{/if}
|
||||||
|
{#if meta}
|
||||||
|
<p class="mt-3 text-sm text-fg-2">{meta}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if actions && !cover}
|
||||||
|
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if actions}
|
{#if actions && cover}
|
||||||
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
|
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||||
|
{@render actions()}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import MediaCard from './MediaCard.svelte';
|
|
||||||
import Artwork from './Artwork.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
trackCount: number;
|
|
||||||
trackIds: (string | number)[];
|
|
||||||
updatedAt: string;
|
|
||||||
index?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { id, name, trackCount, trackIds, updatedAt, index = 0 }: Props = $props();
|
|
||||||
|
|
||||||
const subtitle = $derived(`${trackCount} ${trackCount === 1 ? 'Canción' : 'Canciones'}`);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
|
|
||||||
{#snippet art(artClass)}
|
|
||||||
<Artwork
|
|
||||||
src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
|
|
||||||
{trackIds}
|
|
||||||
size="fill"
|
|
||||||
alt={name}
|
|
||||||
class={artClass}
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
</MediaCard>
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import ImageDropzone from './ImageDropzone.svelte';
|
import CoverForm from './CoverForm.svelte';
|
||||||
import Button from './Button.svelte';
|
|
||||||
import Field from './Field.svelte';
|
import Field from './Field.svelte';
|
||||||
import Input from './Input.svelte';
|
import Input from './Input.svelte';
|
||||||
import Textarea from './Textarea.svelte';
|
import Textarea from './Textarea.svelte';
|
||||||
import Alert from './Alert.svelte';
|
import SegmentedControl from './SegmentedControl.svelte';
|
||||||
|
|
||||||
const NAME_MAX_LENGTH = 60;
|
const NAME_MAX_LENGTH = 60;
|
||||||
const DESCRIPTION_MAX_LENGTH = 300;
|
const DESCRIPTION_MAX_LENGTH = 300;
|
||||||
@@ -44,118 +42,65 @@
|
|||||||
|
|
||||||
let name = $state(untrack(() => initialName));
|
let name = $state(untrack(() => initialName));
|
||||||
let description = $state(untrack(() => initialDescription));
|
let description = $state(untrack(() => initialDescription));
|
||||||
let submitting = $state(false);
|
|
||||||
let visibility = $state(untrack(() => initialVisibility));
|
let visibility = $state(untrack(() => initialVisibility));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form
|
<CoverForm
|
||||||
method="POST"
|
|
||||||
{action}
|
{action}
|
||||||
enctype="multipart/form-data"
|
{coverFallbackUrl}
|
||||||
use:enhance={() => {
|
coverIcon={Music}
|
||||||
submitting = true;
|
{formMessage}
|
||||||
return async ({ update, result }) => {
|
{submitLabel}
|
||||||
await update({ reset: false });
|
{submittingLabel}
|
||||||
submitting = false;
|
canSubmit={name.trim() !== ''}
|
||||||
if (result.type === 'success' || result.type === 'redirect') onSuccess?.();
|
{helperText}
|
||||||
};
|
{onCancel}
|
||||||
}}
|
{onSuccess}
|
||||||
class="mt-5 w-full max-w-4xl space-y-5"
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="visibility" value={visibility} />
|
{#snippet fields()}
|
||||||
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
|
<input type="hidden" name="visibility" value={visibility} />
|
||||||
<Field>
|
<Field label="Nombre" for="playlist-name">
|
||||||
<ImageDropzone
|
{#snippet hint()}
|
||||||
name="cover"
|
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
|
||||||
fallbackUrl={coverFallbackUrl}
|
{/snippet}
|
||||||
icon={Music}
|
<Input
|
||||||
gradient
|
id="playlist-name"
|
||||||
class="h-36 w-36 rounded-2xl"
|
name="name"
|
||||||
|
maxlength={NAME_MAX_LENGTH}
|
||||||
|
required
|
||||||
|
bind:value={name}
|
||||||
|
placeholder={namePlaceholder}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-2">
|
<Field label="Descripción" for="playlist-description" class="min-h-0 flex-1">
|
||||||
<Field label="Nombre" for="playlist-name">
|
<Textarea
|
||||||
{#snippet hint()}
|
id="playlist-description"
|
||||||
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
|
name="description"
|
||||||
{/snippet}
|
maxlength={DESCRIPTION_MAX_LENGTH}
|
||||||
<Input
|
bind:value={description}
|
||||||
id="playlist-name"
|
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
|
||||||
name="name"
|
class="flex-1"
|
||||||
maxlength={NAME_MAX_LENGTH}
|
/>
|
||||||
required
|
</Field>
|
||||||
bind:value={name}
|
{/snippet}
|
||||||
placeholder={namePlaceholder}
|
{#snippet extra()}
|
||||||
/>
|
<div class="flex items-center justify-between gap-4 rounded-art bg-surface px-4 py-3.5">
|
||||||
</Field>
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
|
||||||
<Field label="Descripción" for="playlist-description" class="min-h-0 flex-1">
|
<div class="mt-0.5 text-xs text-muted">
|
||||||
<Textarea
|
{visibility === 'private'
|
||||||
id="playlist-description"
|
? 'Solo tú la ves en tu biblioteca'
|
||||||
name="description"
|
: 'Cualquiera con el enlace puede verla'}
|
||||||
maxlength={DESCRIPTION_MAX_LENGTH}
|
</div>
|
||||||
bind:value={description}
|
|
||||||
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4 rounded-art bg-surface px-4 py-3.5">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
|
|
||||||
<div class="mt-0.5 text-xs text-muted">
|
|
||||||
{visibility === 'private'
|
|
||||||
? 'Solo tú la ves en tu biblioteca'
|
|
||||||
: 'Cualquiera con el enlace puede verla'}
|
|
||||||
</div>
|
</div>
|
||||||
|
<SegmentedControl
|
||||||
|
bind:value={visibility}
|
||||||
|
options={[
|
||||||
|
{ value: 'private', label: 'Privada' },
|
||||||
|
{ value: 'public', label: 'Pública' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
|
{/snippet}
|
||||||
<div
|
</CoverForm>
|
||||||
class="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-fg transition-transform duration-300 ease-out {visibility ===
|
|
||||||
'public'
|
|
||||||
? 'translate-x-full'
|
|
||||||
: ''}"
|
|
||||||
></div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (visibility = 'private')}
|
|
||||||
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-medium transition-colors {visibility ===
|
|
||||||
'private'
|
|
||||||
? 'text-ink'
|
|
||||||
: 'text-muted hover:text-fg-2'}"
|
|
||||||
>
|
|
||||||
Privada
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (visibility = 'public')}
|
|
||||||
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-medium transition-colors {visibility ===
|
|
||||||
'public'
|
|
||||||
? 'text-ink'
|
|
||||||
: 'text-muted hover:text-fg-2'}"
|
|
||||||
>
|
|
||||||
Pública
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if formMessage}
|
|
||||||
<Alert tone="danger">{formMessage}</Alert>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
{#if helperText}
|
|
||||||
<p class="text-sm text-muted">{helperText}</p>
|
|
||||||
{:else}
|
|
||||||
<span></span>
|
|
||||||
{/if}
|
|
||||||
<div class="flex shrink-0 items-center gap-3">
|
|
||||||
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
|
||||||
<Button type="submit" disabled={submitting || name.trim() === ''}>
|
|
||||||
{submitting ? submittingLabel : submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
import ExplicitBadge from './ExplicitBadge.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string | null;
|
|
||||||
meta?: string;
|
|
||||||
explicit?: boolean;
|
|
||||||
href?: string;
|
|
||||||
onclick?: () => void;
|
|
||||||
oncontextmenu?: (event: MouseEvent) => void;
|
|
||||||
art: Snippet<[string]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
meta,
|
|
||||||
explicit = false,
|
|
||||||
href,
|
|
||||||
onclick,
|
|
||||||
oncontextmenu,
|
|
||||||
art
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const artClass = 'relative shrink-0';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet content()}
|
|
||||||
{@render art(artClass)}
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex min-w-0 items-center gap-1.75">
|
|
||||||
{#if explicit}
|
|
||||||
<ExplicitBadge />
|
|
||||||
{/if}
|
|
||||||
<span class="truncate text-sm font-medium text-fg">{title}</span>
|
|
||||||
</div>
|
|
||||||
{#if subtitle}
|
|
||||||
<div class="mt-0.75 truncate text-xs text-fg-3">{subtitle}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if meta}
|
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums">{meta}</span>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#if href}
|
|
||||||
<a
|
|
||||||
{href}
|
|
||||||
{oncontextmenu}
|
|
||||||
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface"
|
|
||||||
>
|
|
||||||
{@render content()}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
{onclick}
|
|
||||||
{oncontextmenu}
|
|
||||||
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
{@render content()}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
@@ -1,29 +1,64 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { plural } from '$lib/format';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
count?: number;
|
||||||
|
href?: string;
|
||||||
|
linkLabel?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
actions?: Snippet;
|
actions?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { title, subtitle, class: klass = '', actions }: Props = $props();
|
let {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
count,
|
||||||
|
href,
|
||||||
|
linkLabel = 'Ver todas',
|
||||||
|
class: klass = '',
|
||||||
|
actions
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const hasActions = $derived(!!actions || count !== undefined || !!href);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet heading()}
|
{#snippet heading()}
|
||||||
<h2 class="font-display text-lg font-medium tracking-tight text-fg">
|
<h2 class="text-display-4 text-fg">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
<p class="mt-1.25 text-xs text-fg-3">{subtitle}</p>
|
<p class="mt-1 text-xs text-fg-3">{subtitle}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if actions}
|
{#snippet autoActions()}
|
||||||
|
{#if count !== undefined}
|
||||||
|
<div class="h-px flex-1 self-center bg-line"></div>
|
||||||
|
<span class="shrink-0 text-xs text-muted tabular-nums">
|
||||||
|
{plural(count, 'resultado', 'resultados')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if href}
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="shrink-0 text-xs font-medium tracking-wider text-fg-3 uppercase transition-colors hover:text-fg"
|
||||||
|
>
|
||||||
|
{linkLabel}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if hasActions}
|
||||||
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
|
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
|
||||||
<div>{@render heading()}</div>
|
<div>{@render heading()}</div>
|
||||||
{@render actions()}
|
{#if actions}
|
||||||
|
{@render actions()}
|
||||||
|
{:else}
|
||||||
|
{@render autoActions()}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mb-4 sm:mb-4.5 {klass}">
|
<div class="mb-4 sm:mb-4.5 {klass}">
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts" generics="T extends string">
|
||||||
|
interface Option<T> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
options: Option<T>[];
|
||||||
|
value: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { options, value = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
const index = $derived(options.findIndex((option) => option.value === value));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-1 left-1 rounded-full bg-fg transition-transform duration-300 ease-out"
|
||||||
|
style="width:calc((100% - 0.5rem) / {options.length}); transform:translateX(calc({index} * 100%))"
|
||||||
|
></div>
|
||||||
|
{#each options as option (option.value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (value = option.value)}
|
||||||
|
class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value ===
|
||||||
|
option.value
|
||||||
|
? 'text-ink'
|
||||||
|
: 'text-muted hover:text-fg-2'}"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
value: number;
|
||||||
|
max: number;
|
||||||
|
label: string;
|
||||||
|
oninput: (value: number) => void;
|
||||||
|
variant: 'seek' | 'volume';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value, max, label, oninput, variant }: Props = $props();
|
||||||
|
|
||||||
|
const percent = $derived(max > 0 ? (value / max) * 100 : 0);
|
||||||
|
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
oninput(Number((event.currentTarget as HTMLInputElement).value));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
{max}
|
||||||
|
{value}
|
||||||
|
oninput={handleInput}
|
||||||
|
aria-label={label}
|
||||||
|
class="mf-slider mf-slider-{variant}"
|
||||||
|
style="background:linear-gradient(to right, var(--mf-text-2) {percent}%, var(--mf-track) {percent}%)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mf-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: var(--mf-range-h);
|
||||||
|
border-radius: 100px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mf-slider-seek {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.mf-slider-volume {
|
||||||
|
width: var(--mf-volume-w);
|
||||||
|
}
|
||||||
|
.mf-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: var(--mf-range-thumb);
|
||||||
|
height: var(--mf-range-thumb);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mf-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mf-slider::-moz-range-thumb {
|
||||||
|
width: var(--mf-range-thumb);
|
||||||
|
height: var(--mf-range-thumb);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mf-text);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -24,5 +24,4 @@
|
|||||||
{placeholder}
|
{placeholder}
|
||||||
{maxlength}
|
{maxlength}
|
||||||
bind:value
|
bind:value
|
||||||
class="min-h-0 w-full resize-none rounded-control bg-surface px-4 py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {klass}"
|
class="min-h-0 field-control resize-none px-4 {klass}"></textarea>
|
||||||
></textarea>
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Play from '@lucide/svelte/icons/play';
|
|
||||||
import EqBars from './EqBars.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
index: number;
|
|
||||||
active: boolean;
|
|
||||||
playing: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { index, active, playing, onToggle }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={onToggle}
|
|
||||||
aria-label={active && playing ? 'Pausar' : 'Reproducir'}
|
|
||||||
class="relative grid h-8 w-8 place-items-center rounded text-xs text-muted tabular-nums"
|
|
||||||
>
|
|
||||||
{#if active}
|
|
||||||
<EqBars paused={!playing} />
|
|
||||||
{:else}
|
|
||||||
<span class="group-hover:hidden">{index + 1}</span>
|
|
||||||
<Play class="hidden h-3.5 w-3.5 text-fg group-hover:block" fill="currentColor" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
|
||||||
|
|
||||||
|
export interface TrackListTrack {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
artist?: string | null;
|
||||||
|
ownerUserId?: string | number | null;
|
||||||
|
isExplicit?: boolean;
|
||||||
|
explicit?: boolean;
|
||||||
|
duration?: number | string;
|
||||||
|
listensCount?: number | string;
|
||||||
|
addedAt?: string | number;
|
||||||
|
uploadedAt?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
|
||||||
|
added: { label: 'Añadida', width: '124px' },
|
||||||
|
uploaded: { label: 'Subida', width: '124px' },
|
||||||
|
plays: { label: 'Escuchas', width: '104px' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const INDEX_W = { desktop: '32px', mobile: '28px' };
|
||||||
|
const TITLE_W = { desktop: 'minmax(180px,1fr)', mobile: 'minmax(120px,1fr)' };
|
||||||
|
const DURATION_W = { desktop: '104px', mobile: '52px' };
|
||||||
|
const ACTION_W = { desktop: '36px', mobile: '32px' };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { LucideIcon } from '@lucide/svelte';
|
||||||
|
import Hash from '@lucide/svelte/icons/hash';
|
||||||
|
import Play from '@lucide/svelte/icons/play';
|
||||||
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import { fmtTime, fmtDate } from '$lib/format';
|
||||||
|
import { pressable } from '$lib/actions/pressable';
|
||||||
|
import MediaIdentity from './MediaIdentity.svelte';
|
||||||
|
import EqBars from './EqBars.svelte';
|
||||||
|
import IconButton from './IconButton.svelte';
|
||||||
|
|
||||||
|
interface RowAction {
|
||||||
|
action?: string;
|
||||||
|
onclick?: (track: TrackListTrack) => void;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tracks: TrackListTrack[];
|
||||||
|
columns?: TrackListColumn[];
|
||||||
|
index?: boolean;
|
||||||
|
onPlay: (index: number) => void;
|
||||||
|
rowAction?: RowAction;
|
||||||
|
oncontextmenu?: (event: MouseEvent, track: TrackListTrack, index: number) => void;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
tracks,
|
||||||
|
columns = [],
|
||||||
|
index = true,
|
||||||
|
onPlay,
|
||||||
|
rowAction,
|
||||||
|
oncontextmenu,
|
||||||
|
class: klass = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const gridColumns = $derived(
|
||||||
|
[
|
||||||
|
index ? INDEX_W.desktop : '',
|
||||||
|
TITLE_W.desktop,
|
||||||
|
...columns.map((c) => COLUMN_PRESET[c].width),
|
||||||
|
DURATION_W.desktop,
|
||||||
|
rowAction ? ACTION_W.desktop : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
);
|
||||||
|
|
||||||
|
const gridColumnsMobile = $derived(
|
||||||
|
[
|
||||||
|
index ? INDEX_W.mobile : '',
|
||||||
|
TITLE_W.mobile,
|
||||||
|
DURATION_W.mobile,
|
||||||
|
rowAction ? ACTION_W.mobile : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto {klass}">
|
||||||
|
<div
|
||||||
|
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-3 sm:gap-8 sm:pr-6 sm:pl-3"
|
||||||
|
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
|
||||||
|
>
|
||||||
|
{#if index}
|
||||||
|
<Hash size={16} strokeWidth={1.5} />
|
||||||
|
{/if}
|
||||||
|
<span>Título</span>
|
||||||
|
{#each columns as column (column)}
|
||||||
|
<span class="hidden text-center sm:block">{COLUMN_PRESET[column].label}</span>
|
||||||
|
{/each}
|
||||||
|
<span class="hidden text-center sm:block">Duración</span>
|
||||||
|
{#if rowAction}
|
||||||
|
<span></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex flex-col gap-0.5">
|
||||||
|
{#each tracks as track, i (track.id)}
|
||||||
|
{@const active = player.current.id === track.id}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active
|
||||||
|
? 'bg-accent-tint'
|
||||||
|
: 'hover:bg-hover'}"
|
||||||
|
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
|
||||||
|
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, track, i) : undefined}
|
||||||
|
>
|
||||||
|
{#if index}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onPlay(i)}
|
||||||
|
aria-label={active && player.playing ? 'Pausar' : 'Reproducir'}
|
||||||
|
class="relative grid size-8 place-items-center rounded-tag text-xs text-muted tabular-nums"
|
||||||
|
>
|
||||||
|
{#if active}
|
||||||
|
<EqBars paused={!player.playing} />
|
||||||
|
{:else}
|
||||||
|
<span class="group-hover:hidden">{i + 1}</span>
|
||||||
|
<Play class="hidden size-icon-sm text-fg group-hover:block" fill="currentColor" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
use:pressable={() => onPlay(i)}
|
||||||
|
onclick={(event) => event.stopPropagation()}
|
||||||
|
class="flex min-w-0 flex-1 text-left"
|
||||||
|
>
|
||||||
|
<MediaIdentity
|
||||||
|
trackId={track.id}
|
||||||
|
title={track.title}
|
||||||
|
artist={track.artist}
|
||||||
|
ownerUserId={track.ownerUserId}
|
||||||
|
explicit={track.isExplicit ?? track.explicit ?? false}
|
||||||
|
{active}
|
||||||
|
>
|
||||||
|
{#snippet overlay()}
|
||||||
|
{#if !index && active}
|
||||||
|
<EqBars overlay paused={!player.playing} />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</MediaIdentity>
|
||||||
|
</div>
|
||||||
|
{#each columns as column (column)}
|
||||||
|
<span class="hidden truncate text-center text-xs text-muted tabular-nums sm:block">
|
||||||
|
{#if column === 'plays'}
|
||||||
|
{Number(track.listensCount ?? 0)}
|
||||||
|
{:else if column === 'added' && track.addedAt}
|
||||||
|
{fmtDate(track.addedAt)}
|
||||||
|
{:else if column === 'uploaded' && track.uploadedAt}
|
||||||
|
{fmtDate(track.uploadedAt)}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
<span class="truncate text-center text-xs text-muted tabular-nums">
|
||||||
|
{fmtTime(Number(track.duration))}
|
||||||
|
</span>
|
||||||
|
{#if rowAction}
|
||||||
|
{#if rowAction.action}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={rowAction.action}
|
||||||
|
use:enhance={() =>
|
||||||
|
async ({ update }) =>
|
||||||
|
update()}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="trackId" value={track.id} />
|
||||||
|
<IconButton type="submit" label={rowAction.label} size="xs" revealOnHover>
|
||||||
|
<rowAction.icon class="size-icon-sm" />
|
||||||
|
</IconButton>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<IconButton
|
||||||
|
label={rowAction.label}
|
||||||
|
size="xs"
|
||||||
|
revealOnHover
|
||||||
|
onclick={() => rowAction.onclick?.(track)}
|
||||||
|
>
|
||||||
|
<rowAction.icon class="size-icon-sm" />
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
class?: string;
|
|
||||||
children: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { class: klass = '', children }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<span class="truncate text-center text-xs text-muted tabular-nums {klass}">
|
|
||||||
{@render children()}
|
|
||||||
</span>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getContext } from 'svelte';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
import { trackTableKey, type TrackTableContext } from './TrackTable.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
active?: boolean;
|
|
||||||
class?: string;
|
|
||||||
oncontextmenu?: (event: MouseEvent) => void;
|
|
||||||
children: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { active = false, class: klass = '', oncontextmenu, children }: Props = $props();
|
|
||||||
|
|
||||||
const table = getContext<TrackTableContext>(trackTableKey);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active
|
|
||||||
? 'bg-accent-tint'
|
|
||||||
: 'hover:bg-hover'} {klass}"
|
|
||||||
style="--mf-track-cols:{table.columns}; --mf-track-cols-mobile:{table.columnsMobile}"
|
|
||||||
{oncontextmenu}
|
|
||||||
>
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
export const trackTableKey = Symbol('track-table');
|
|
||||||
|
|
||||||
export interface TrackTableContext {
|
|
||||||
readonly columns: string;
|
|
||||||
readonly columnsMobile: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrackMetaColumn {
|
|
||||||
label: string;
|
|
||||||
width: string;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Hash } from '@lucide/svelte';
|
|
||||||
|
|
||||||
import { setContext } from 'svelte';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
index?: boolean;
|
|
||||||
action?: boolean;
|
|
||||||
meta?: TrackMetaColumn[];
|
|
||||||
class?: string;
|
|
||||||
children: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { index = false, action = false, meta = [], class: klass = '', children }: Props = $props();
|
|
||||||
|
|
||||||
const columns = $derived(
|
|
||||||
[
|
|
||||||
index ? '32px' : '',
|
|
||||||
'minmax(180px,1fr)',
|
|
||||||
...meta.map((column) => column.width),
|
|
||||||
'104px',
|
|
||||||
action ? '36px' : ''
|
|
||||||
]
|
|
||||||
.filter((width) => width !== '')
|
|
||||||
.join(' ')
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnsMobile = $derived(
|
|
||||||
[index ? '28px' : '', 'minmax(120px,1fr)', '52px', action ? '32px' : '']
|
|
||||||
.filter((width) => width !== '')
|
|
||||||
.join(' ')
|
|
||||||
);
|
|
||||||
|
|
||||||
setContext<TrackTableContext>(trackTableKey, {
|
|
||||||
get columns() {
|
|
||||||
return columns;
|
|
||||||
},
|
|
||||||
get columnsMobile() {
|
|
||||||
return columnsMobile;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="overflow-x-auto {klass}">
|
|
||||||
<div
|
|
||||||
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-3 sm:gap-8 sm:pr-6 sm:pl-3"
|
|
||||||
style="--mf-track-cols:{columns}; --mf-track-cols-mobile:{columnsMobile}"
|
|
||||||
>
|
|
||||||
{#if index}
|
|
||||||
<Hash size={16} strokeWidth={1.5} />
|
|
||||||
{/if}
|
|
||||||
<span class="transition-colors">Título</span>
|
|
||||||
{#each meta as column (column.label)}
|
|
||||||
<span class="hidden text-center transition-colors sm:block">{column.label}</span>
|
|
||||||
{/each}
|
|
||||||
<span class="hidden text-center transition-colors sm:block">Duración</span>
|
|
||||||
{#if action}
|
|
||||||
<span></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 flex flex-col gap-0.5">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -17,3 +17,5 @@ export const HOME_POPULAR_MAX = 8;
|
|||||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||||
export const LIKED_TRACKS_PAGE_SIZE = 500;
|
export const LIKED_TRACKS_PAGE_SIZE = 500;
|
||||||
export const EXPLORE_USERS_LIMIT = 12;
|
export const EXPLORE_USERS_LIMIT = 12;
|
||||||
|
export const MENU_WIDTH = 240;
|
||||||
|
export const SUBMENU_WIDTH = 256;
|
||||||
|
|||||||
@@ -18,7 +18,24 @@ export function fmtDate(value: string | number | Date): string {
|
|||||||
return dateFormatter.format(new Date(value));
|
return dateFormatter.format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function plural(n: number, one: string, many: string): string {
|
||||||
|
return `${n} ${n === 1 ? one : many}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function fmtPlays(count: number | string | undefined): string {
|
export function fmtPlays(count: number | string | undefined): string {
|
||||||
const value = Math.max(0, Math.floor(Number(count ?? 0)));
|
const value = Math.max(0, Math.floor(Number(count ?? 0)));
|
||||||
return `${value} ${value === 1 ? 'Reproducción' : 'Reproducciones'}`;
|
return plural(value, 'reproducción', 'reproducciones');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function albumMeta(releaseYear?: number, trackCount?: number): string {
|
||||||
|
return [
|
||||||
|
releaseYear ? String(releaseYear) : undefined,
|
||||||
|
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
|
||||||
|
]
|
||||||
|
.filter((part): part is string => !!part)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playlistMeta(trackCount: number): string {
|
||||||
|
return plural(trackCount, 'canción', 'canciones');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// No hay sistema de tags/géneros todavía: esta lista es un placeholder de
|
||||||
|
// cliente y cada tile enlaza a una búsqueda normal por ese término.
|
||||||
|
export const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
|
||||||
|
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
|
||||||
|
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
|
||||||
|
reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' },
|
||||||
|
'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' },
|
||||||
|
indie: { label: 'Indie', tagline: 'Voces fuera del radar' },
|
||||||
|
salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' },
|
||||||
|
'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' },
|
||||||
|
electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' },
|
||||||
|
jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' },
|
||||||
|
baladas: { label: 'Baladas', tagline: 'Para sentir con calma' },
|
||||||
|
trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' },
|
||||||
|
cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' },
|
||||||
|
'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' },
|
||||||
|
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({
|
||||||
|
query,
|
||||||
|
hue: Math.round((i * 360) / all.length),
|
||||||
|
...info
|
||||||
|
}));
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { contextMenuPosition, type MenuPosition } from '$lib/components/ui/ContextMenu.svelte';
|
||||||
|
import { player, toQueueItem, type ApiTrackLike } from './player/player.svelte';
|
||||||
|
|
||||||
|
export type TrackMenuState = { track: ApiTrackLike } & MenuPosition;
|
||||||
|
|
||||||
|
export function createTrackMenu() {
|
||||||
|
let state = $state<TrackMenuState | null>(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get state() {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
open(event: MouseEvent, track: ApiTrackLike) {
|
||||||
|
state = { track, ...contextMenuPosition(event) };
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
state = null;
|
||||||
|
},
|
||||||
|
playNext() {
|
||||||
|
if (!state) return;
|
||||||
|
player.playNextItem(toQueueItem(state.track));
|
||||||
|
state = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { components } from '$lib/api/schema';
|
import type { components } from '$lib/api/schema';
|
||||||
import type { TrackTarget } from '$lib/tracks';
|
import type { ApiTrackLike } from '$lib/player/player.svelte';
|
||||||
|
|
||||||
export type Mix = components['schemas']['MixApplicationResponse'];
|
export type Mix = components['schemas']['MixApplicationResponse'];
|
||||||
export type MixItem = components['schemas']['MixItemApplicationResponse'];
|
export type MixItem = components['schemas']['MixItemApplicationResponse'];
|
||||||
@@ -8,13 +8,11 @@ export function mixItemKey(item: MixItem): string {
|
|||||||
return `mf:${item.trackId}`;
|
return `mf:${item.trackId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mixItemTarget(item: MixItem): TrackTarget {
|
export function mixItemTrack(item: MixItem): ApiTrackLike {
|
||||||
return {
|
return {
|
||||||
track: {
|
id: item.trackId,
|
||||||
id: item.trackId,
|
title: item.title,
|
||||||
title: item.title,
|
artist: item.artist,
|
||||||
artist: item.artist,
|
listensCount: item.listensCount
|
||||||
listensCount: item.listensCount
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { afterNavigate } from '$app/navigation';
|
import { afterNavigate } from '$app/navigation';
|
||||||
|
import type { LucideIcon } from '@lucide/svelte';
|
||||||
|
import Home from '@lucide/svelte/icons/house';
|
||||||
|
import Compass from '@lucide/svelte/icons/compass';
|
||||||
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
|
import Heart from '@lucide/svelte/icons/heart';
|
||||||
|
import Disc from '@lucide/svelte/icons/disc-2';
|
||||||
|
import Folder from '@lucide/svelte/icons/folder';
|
||||||
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
|
import type { SessionUser } from './types';
|
||||||
|
|
||||||
const HISTORY_INDEX = 'sveltekit:history';
|
const HISTORY_INDEX = 'sveltekit:history';
|
||||||
|
|
||||||
@@ -54,3 +63,39 @@ export function isSectionActive(href: string, pathname: string, section?: string
|
|||||||
if (section !== undefined) return section === href;
|
if (section !== undefined) return section === href;
|
||||||
return href === '/' ? pathname === '/' : pathname.startsWith(href);
|
return href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function searchHref(query: string): string {
|
||||||
|
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppNavLink {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
count?: number;
|
||||||
|
primary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppNavCounts {
|
||||||
|
playlists?: number;
|
||||||
|
liked?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appNavLinks(user: SessionUser | null, counts: AppNavCounts = {}): AppNavLink[] {
|
||||||
|
if (!user) return [{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true }];
|
||||||
|
return [
|
||||||
|
{ href: '/', label: 'Inicio', icon: Home, primary: true },
|
||||||
|
{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true },
|
||||||
|
{
|
||||||
|
href: '/playlists',
|
||||||
|
label: 'Playlists',
|
||||||
|
icon: ListMusic,
|
||||||
|
count: counts.playlists,
|
||||||
|
primary: true
|
||||||
|
},
|
||||||
|
{ href: '/liked', label: 'Me gusta', icon: Heart, count: counts.liked, primary: true },
|
||||||
|
{ href: '/albums', label: 'Mis álbumes', icon: Disc, primary: false },
|
||||||
|
{ href: '/library', label: 'Canciones subidas', icon: Folder, primary: false },
|
||||||
|
{ href: '/upload', label: 'Subir música', icon: Upload, primary: false }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,13 +34,10 @@ class LikedStore {
|
|||||||
const items = toQueueItems(tracks);
|
const items = toQueueItems(tracks);
|
||||||
const next: Record<string, LikedTrack> = {};
|
const next: Record<string, LikedTrack> = {};
|
||||||
items.forEach((item, i) => {
|
items.forEach((item, i) => {
|
||||||
next[String(item.id)] = {
|
const id = String(item.id);
|
||||||
id: String(item.id),
|
next[id] = {
|
||||||
title: item.title,
|
...item,
|
||||||
artist: item.artist,
|
id,
|
||||||
explicit: item.explicit,
|
|
||||||
ownerUserId: item.ownerUserId,
|
|
||||||
listensCount: item.listensCount,
|
|
||||||
duration: tracks[i].duration,
|
duration: tracks[i].duration,
|
||||||
likedAt: new Date(tracks[i].createdAt).getTime()
|
likedAt: new Date(tracks[i].createdAt).getTime()
|
||||||
};
|
};
|
||||||
@@ -58,17 +55,7 @@ class LikedStore {
|
|||||||
const wasLiked = id in before;
|
const wasLiked = id in before;
|
||||||
const next = { ...before };
|
const next = { ...before };
|
||||||
if (wasLiked) delete next[id];
|
if (wasLiked) delete next[id];
|
||||||
else {
|
else next[id] = { ...track, id, likedAt: Date.now() };
|
||||||
next[id] = {
|
|
||||||
id,
|
|
||||||
title: track.title,
|
|
||||||
artist: track.artist,
|
|
||||||
explicit: track.explicit,
|
|
||||||
ownerUserId: track.ownerUserId,
|
|
||||||
duration: track.duration,
|
|
||||||
likedAt: Date.now()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
this.entries = next;
|
this.entries = next;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { DEFAULT_ACCENT } from '$lib/theme/color';
|
import { DEFAULT_ACCENT } from '$lib/theme/color';
|
||||||
import { extractAccent, type Accent } from '$lib/theme/palette';
|
import { extractAccent, type Accent } from '$lib/theme/palette';
|
||||||
|
import { shuffle } from '$lib/collections';
|
||||||
|
|
||||||
export interface PlayerTrack {
|
export interface PlayerTrack {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@@ -47,6 +48,36 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toQueueItem(track: ApiTrackLike): QueueItem {
|
||||||
|
return toQueueItems([track])[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackFromQueueItem(item: QueueItem): ApiTrackLike {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
artist: item.artist,
|
||||||
|
isExplicit: item.explicit,
|
||||||
|
ownerUserId: item.ownerUserId,
|
||||||
|
listensCount: item.listensCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isQueueCurrent(items: { id: string | number }[]): boolean {
|
||||||
|
return items.some((item) => item.id === player.current.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playAllOrToggle(items: QueueItem[]) {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
if (isQueueCurrent(items)) player.toggle();
|
||||||
|
else player.playQueue(items, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playShuffled(items: QueueItem[]) {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
player.playQueue(shuffle(items), 0);
|
||||||
|
}
|
||||||
|
|
||||||
async function readErrorMessage(res: Response): Promise<string> {
|
async function readErrorMessage(res: Response): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const body = (await res.json()) as { message?: string };
|
const body = (await res.json()) as { message?: string };
|
||||||
@@ -291,7 +322,7 @@ class PlayerState {
|
|||||||
else this.playQueue(list, index);
|
else this.playQueue(list, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
addToQueue(item: QueueItem) {
|
playNextItem(item: QueueItem) {
|
||||||
this.playNext([item]);
|
this.playNext([item]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,3 @@
|
|||||||
let open = $state(false);
|
import { createToggle } from '../toggle.svelte';
|
||||||
|
|
||||||
export const queuePanel = {
|
export const queuePanel = createToggle();
|
||||||
get open() {
|
|
||||||
return open;
|
|
||||||
},
|
|
||||||
toggle() {
|
|
||||||
open = !open;
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
open = false;
|
|
||||||
},
|
|
||||||
show() {
|
|
||||||
open = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
let open = $state(false);
|
import { createToggle } from './toggle.svelte';
|
||||||
|
|
||||||
export const createPlaylistModal = {
|
export const createPlaylistModal = createToggle();
|
||||||
get open() {
|
|
||||||
return open;
|
|
||||||
},
|
|
||||||
show() {
|
|
||||||
open = true;
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -12,28 +12,14 @@ export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): Que
|
|||||||
const id = String(t.id);
|
const id = String(t.id);
|
||||||
if (seen.has(id)) continue;
|
if (seen.has(id)) continue;
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
merged.push({
|
merged.push({ ...t, id });
|
||||||
id,
|
|
||||||
title: t.title,
|
|
||||||
artist: t.artist,
|
|
||||||
explicit: t.explicit,
|
|
||||||
ownerUserId: t.ownerUserId,
|
|
||||||
listensCount: t.listensCount
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of toQueueItems(history)) {
|
for (const item of toQueueItems(history)) {
|
||||||
const id = String(item.id);
|
const id = String(item.id);
|
||||||
if (seen.has(id)) continue;
|
if (seen.has(id)) continue;
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
merged.push({
|
merged.push({ ...item, id });
|
||||||
id,
|
|
||||||
title: item.title,
|
|
||||||
artist: item.artist,
|
|
||||||
explicit: item.explicit,
|
|
||||||
ownerUserId: item.ownerUserId,
|
|
||||||
listensCount: item.listensCount
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged.slice(0, limit);
|
return merged.slice(0, limit);
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const;
|
||||||
|
export type SearchFilter = (typeof SEARCH_FILTERS)[number];
|
||||||
|
export const SEARCH_GROUP_PREVIEW = 4;
|
||||||
|
|
||||||
|
export function showGroup(filter: SearchFilter, label: Exclude<SearchFilter, 'Todo'>) {
|
||||||
|
return filter === 'Todo' || filter === label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function capped<T>(filter: SearchFilter, list: T[]) {
|
||||||
|
return filter === 'Todo' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchPlaylists<T extends { name: string }>(playlists: T[], query: string): T[] {
|
||||||
|
return query ? playlists.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchCounts(counts: {
|
||||||
|
tracks: number;
|
||||||
|
albums: number;
|
||||||
|
playlists: number;
|
||||||
|
users: number;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
Canciones: counts.tracks,
|
||||||
|
Álbumes: counts.albums,
|
||||||
|
Playlists: counts.playlists,
|
||||||
|
Usuarios: counts.users
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchChips(counts: ReturnType<typeof searchCounts>) {
|
||||||
|
const total = counts.Canciones + counts.Álbumes + counts.Playlists + counts.Usuarios;
|
||||||
|
return SEARCH_FILTERS.map((label) => ({
|
||||||
|
label,
|
||||||
|
count: label === 'Todo' ? total : counts[label]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TopResult<Track, Album, Playlist, User> =
|
||||||
|
| { kind: 'track'; track: Track }
|
||||||
|
| { kind: 'album'; album: Album }
|
||||||
|
| { kind: 'playlist'; playlist: Playlist }
|
||||||
|
| { kind: 'user'; user: User };
|
||||||
|
|
||||||
|
export function findTopResult<
|
||||||
|
Track extends { title: string },
|
||||||
|
Album extends { title: string },
|
||||||
|
Playlist extends { name: string },
|
||||||
|
User extends { name: string }
|
||||||
|
>(params: {
|
||||||
|
query: string;
|
||||||
|
tracks: Track[];
|
||||||
|
albums: Album[];
|
||||||
|
playlists: Playlist[];
|
||||||
|
users: User[];
|
||||||
|
}): TopResult<Track, Album, Playlist, User> | null {
|
||||||
|
const q = params.query.trim().toLowerCase();
|
||||||
|
const startsWithQuery = (value: string) => value.toLowerCase().startsWith(q);
|
||||||
|
|
||||||
|
const userHit = params.users.find((u) => startsWithQuery(u.name));
|
||||||
|
if (userHit) return { kind: 'user', user: userHit };
|
||||||
|
const trackHit = params.tracks.find((track) => startsWithQuery(track.title));
|
||||||
|
if (trackHit) return { kind: 'track', track: trackHit };
|
||||||
|
const albumHit = params.albums.find((album) => startsWithQuery(album.title));
|
||||||
|
if (albumHit) return { kind: 'album', album: albumHit };
|
||||||
|
const playlistHit = params.playlists.find((p) => startsWithQuery(p.name));
|
||||||
|
if (playlistHit) return { kind: 'playlist', playlist: playlistHit };
|
||||||
|
|
||||||
|
if (params.users.length) return { kind: 'user', user: params.users[0] };
|
||||||
|
if (params.tracks.length) return { kind: 'track', track: params.tracks[0] };
|
||||||
|
if (params.albums.length) return { kind: 'album', album: params.albums[0] };
|
||||||
|
if (params.playlists.length) return { kind: 'playlist', playlist: params.playlists[0] };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export function createToggle(initial = false) {
|
||||||
|
let open = $state(initial);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get open() {
|
||||||
|
return open;
|
||||||
|
},
|
||||||
|
show() {
|
||||||
|
open = true;
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
open = false;
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
open = !open;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
|
||||||
|
|
||||||
export type TrackTarget = { track: ApiTrackLike };
|
|
||||||
|
|
||||||
export function targetId(target: TrackTarget): string | number {
|
|
||||||
return target.track.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function targetTitle(target: TrackTarget): string {
|
|
||||||
return target.track.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function targetArtist(target: TrackTarget): string | undefined {
|
|
||||||
return target.track.artist ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function targetOwnerUserId(target: TrackTarget): string | number | undefined {
|
|
||||||
return target.track.ownerUserId ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function targetExplicit(target: TrackTarget): boolean {
|
|
||||||
return target.track.isExplicit ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function targetListensCount(target: TrackTarget): number | string | undefined {
|
|
||||||
return target.track.listensCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isTargetCurrent(target: TrackTarget): boolean {
|
|
||||||
return player.current.id === target.track.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function queueItemForTarget(target: TrackTarget): QueueItem {
|
|
||||||
return toQueueItems([target.track])[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function targetForQueueItem(item: QueueItem): TrackTarget {
|
|
||||||
return {
|
|
||||||
track: {
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
artist: item.artist,
|
|
||||||
isExplicit: item.explicit,
|
|
||||||
ownerUserId: item.ownerUserId,
|
|
||||||
listensCount: item.listensCount
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
import Disc from '@lucide/svelte/icons/disc-3';
|
import Disc from '@lucide/svelte/icons/disc-3';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
import MediaCard from '$lib/components/ui/MediaCard.svelte';
|
||||||
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
|
|
||||||
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
|
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
|
||||||
import Modal from '$lib/components/ui/Modal.svelte';
|
import Modal from '$lib/components/ui/Modal.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
import { albumMeta } from '$lib/format';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -23,11 +23,11 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader title="Álbumes" subtitle="Agrupa la música que subes en discos.">
|
<PageHeader title="Álbumes" description="Agrupa la música que subes en discos.">
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<Button onclick={() => (creating = true)}>
|
<Button onclick={() => (creating = true)}>
|
||||||
<Plus class="h-4 w-4" strokeWidth={2.5} />
|
<Plus class="size-4" strokeWidth={2.5} />
|
||||||
Nuevo álbum
|
Nuevo álbum
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -35,19 +35,21 @@
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<MediaGrid class="mt-7 sm:mt-8">
|
<div class="grid-cards">
|
||||||
{#each items as album, i (album.id)}
|
{#each items as album, i (album.id)}
|
||||||
<AlbumCard
|
<MediaCard
|
||||||
id={album.id}
|
href="/albums/{album.id}"
|
||||||
title={album.title}
|
title={album.title}
|
||||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
subtitle={albumMeta(
|
||||||
trackCount={Number(album.trackCount)}
|
album.releaseYear === null ? undefined : Number(album.releaseYear),
|
||||||
|
Number(album.trackCount)
|
||||||
|
)}
|
||||||
trackIds={album.coverTrackIds}
|
trackIds={album.coverTrackIds}
|
||||||
coverSrc="/api/albums/{album.id}/cover?size=large"
|
src="/api/albums/{album.id}/cover?size=large"
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</MediaGrid>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Disc}
|
icon={Disc}
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { player, toQueueItems } from '$lib/player/player.svelte';
|
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||||
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import Play from '@lucide/svelte/icons/play';
|
||||||
|
import Pause from '@lucide/svelte/icons/pause';
|
||||||
|
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
||||||
|
import Trash from '@lucide/svelte/icons/trash';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import Modal from '$lib/components/ui/Modal.svelte';
|
import Modal from '$lib/components/ui/Modal.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||||
import Alert from '$lib/components/ui/Alert.svelte';
|
import Alert from '$lib/components/ui/Alert.svelte';
|
||||||
|
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||||
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
|
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import AlbumHeader from './components/AlbumHeader.svelte';
|
import BackLink from '$lib/components/ui/BackLink.svelte';
|
||||||
import AlbumTrackTable from './components/AlbumTrackTable.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
import LibraryPicker from './components/LibraryPicker.svelte';
|
import TrackList from '$lib/components/ui/TrackList.svelte';
|
||||||
|
import { albumMeta } from '$lib/format';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -19,12 +29,18 @@
|
|||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let confirmingDelete = $state(false);
|
let confirmingDelete = $state(false);
|
||||||
|
|
||||||
const isCurrentQueue = $derived(tracks.some((track) => track.id === player.current.id));
|
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||||
|
|
||||||
function playAll() {
|
function playAll() {
|
||||||
if (tracks.length === 0) return;
|
playAllOrToggle(toQueueItems(tracks));
|
||||||
if (isCurrentQueue) player.toggle();
|
}
|
||||||
else player.playQueue(toQueueItems(tracks), 0);
|
|
||||||
|
function playTrackFrom(index: number) {
|
||||||
|
player.playOrToggle(toQueueItems(tracks), index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playLibraryFrom(index: number) {
|
||||||
|
player.playOrToggle(toQueueItems(library), index);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -34,29 +50,76 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<AlbumHeader
|
<BackLink
|
||||||
title={album.title}
|
href={isOwner ? '/albums' : '/'}
|
||||||
description={album.description ?? undefined}
|
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
|
||||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
|
||||||
trackIds={tracks.map((track) => track.id)}
|
|
||||||
coverUrl="/api/albums/{album.id}/cover?size=large"
|
|
||||||
playing={isCurrentQueue && player.playing}
|
|
||||||
{isOwner}
|
|
||||||
onPlayAll={playAll}
|
|
||||||
onEdit={() => (editing = true)}
|
|
||||||
onDelete={() => (confirmingDelete = true)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Álbum"
|
||||||
|
title={album.title}
|
||||||
|
description={album.description ?? undefined}
|
||||||
|
meta={albumMeta(
|
||||||
|
album.releaseYear === null ? undefined : Number(album.releaseYear),
|
||||||
|
tracks.length
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{#snippet cover()}
|
||||||
|
<Artwork
|
||||||
|
src="/api/albums/{album.id}/cover?size=large"
|
||||||
|
trackIds={tracks.map((track) => track.id)}
|
||||||
|
size="hero"
|
||||||
|
alt={album.title}
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
|
||||||
|
{#if isCurrentQueue && player.playing}
|
||||||
|
<Pause class="size-4" strokeWidth={1.5} />
|
||||||
|
Pausar
|
||||||
|
{:else}
|
||||||
|
<Play class="size-4" strokeWidth={1.5} />
|
||||||
|
Reproducir
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
{#if isOwner}
|
||||||
|
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>
|
||||||
|
<SquarePencil class="size-4" strokeWidth={1.5} />
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
|
||||||
|
<Trash class="size-4" strokeWidth={1.5} />
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
{#if form?.message}
|
{#if form?.message}
|
||||||
<Alert tone="danger" class="mt-6">{form.message}</Alert>
|
<Alert tone="danger" class="mb-6">{form.message}</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if tracks.length > 0}
|
{#if tracks.length > 0}
|
||||||
<AlbumTrackTable {tracks} {isOwner} />
|
<TrackList
|
||||||
|
{tracks}
|
||||||
|
onPlay={playTrackFrom}
|
||||||
|
rowAction={isOwner
|
||||||
|
? { action: '?/removeTrack', icon: X, label: 'Quitar del álbum' }
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isOwner && library.length > 0}
|
{#if isOwner && library.length > 0}
|
||||||
<LibraryPicker {library} />
|
<div class="mt-10 sm:mt-12">
|
||||||
|
<SectionHeading title="Tus canciones subidas" />
|
||||||
|
<TrackList
|
||||||
|
tracks={library}
|
||||||
|
index={false}
|
||||||
|
onPlay={playLibraryFrom}
|
||||||
|
rowAction={{ action: '?/addTrack', icon: Plus, label: 'Añadir al álbum' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
@@ -75,17 +138,11 @@
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
|
<ConfirmDialog
|
||||||
<h2 class="text-lg font-semibold tracking-tight text-fg">
|
open={confirmingDelete}
|
||||||
¿Eliminar «{album.title}»?
|
onClose={() => (confirmingDelete = false)}
|
||||||
</h2>
|
title="¿Eliminar «{album.title}»?"
|
||||||
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
|
description="Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca."
|
||||||
Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca.
|
confirmLabel="Eliminar"
|
||||||
</p>
|
action="?/delete"
|
||||||
<div class="mt-6 flex items-center justify-end gap-3">
|
/>
|
||||||
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
|
|
||||||
<form method="POST" action="?/delete">
|
|
||||||
<Button type="submit" variant="danger">Eliminar</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Play from '@lucide/svelte/icons/play';
|
|
||||||
import Pause from '@lucide/svelte/icons/pause';
|
|
||||||
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
|
||||||
import Trash from '@lucide/svelte/icons/trash';
|
|
||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
|
||||||
import BackLink from '$lib/components/ui/BackLink.svelte';
|
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
|
||||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
releaseYear?: number;
|
|
||||||
trackIds: (string | number)[];
|
|
||||||
coverUrl?: string;
|
|
||||||
playing: boolean;
|
|
||||||
isOwner: boolean;
|
|
||||||
onPlayAll: () => void;
|
|
||||||
onEdit: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
releaseYear,
|
|
||||||
trackIds,
|
|
||||||
coverUrl,
|
|
||||||
playing,
|
|
||||||
isOwner,
|
|
||||||
onPlayAll,
|
|
||||||
onEdit,
|
|
||||||
onDelete
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const meta = $derived(
|
|
||||||
[
|
|
||||||
releaseYear ? String(releaseYear) : undefined,
|
|
||||||
`${trackIds.length} ${trackIds.length === 1 ? 'canción' : 'canciones'}`
|
|
||||||
]
|
|
||||||
.filter((part) => part !== undefined)
|
|
||||||
.join(' · ')
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<BackLink
|
|
||||||
href={isOwner ? '/albums' : '/'}
|
|
||||||
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CollectionHeader eyebrow="Álbum" {title} {description} {meta}>
|
|
||||||
{#snippet cover()}
|
|
||||||
<Artwork src={coverUrl} {trackIds} size="hero" alt={title} class="shrink-0" />
|
|
||||||
{/snippet}
|
|
||||||
{#snippet actions()}
|
|
||||||
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
|
|
||||||
{#if playing}
|
|
||||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
Pausar
|
|
||||||
{:else}
|
|
||||||
<Play class="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
Reproducir
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{#if isOwner}
|
|
||||||
<Button size="sm" variant="secondary" onclick={onEdit}>
|
|
||||||
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
Editar
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="secondary" onclick={onDelete}>
|
|
||||||
<Trash class="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
Eliminar
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
</CollectionHeader>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import X from '@lucide/svelte/icons/x';
|
|
||||||
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
|
|
||||||
import { fmtTime } from '$lib/format';
|
|
||||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
|
||||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
|
||||||
import TrackRow from '$lib/components/ui/TrackRow.svelte';
|
|
||||||
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
|
|
||||||
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
|
|
||||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
|
||||||
|
|
||||||
type AlbumTrack = ApiTrackLike & { duration: number | string; listensCount: number | string };
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tracks: AlbumTrack[];
|
|
||||||
isOwner: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { tracks, isOwner }: Props = $props();
|
|
||||||
|
|
||||||
function playFrom(index: number) {
|
|
||||||
player.playOrToggle(toQueueItems(tracks), index);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TrackTable
|
|
||||||
index
|
|
||||||
action={isOwner}
|
|
||||||
meta={[{ label: 'Escuchas', width: '100px' }]}
|
|
||||||
class="mt-6 sm:mt-8"
|
|
||||||
>
|
|
||||||
{#each tracks as track, i (track.id)}
|
|
||||||
{@const active = player.current.id === track.id}
|
|
||||||
<TrackRow {active}>
|
|
||||||
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
|
|
||||||
<TrackTitleCell
|
|
||||||
trackId={track.id}
|
|
||||||
title={track.title}
|
|
||||||
artist={track.artist}
|
|
||||||
ownerUserId={track.ownerUserId}
|
|
||||||
explicit={track.isExplicit}
|
|
||||||
{active}
|
|
||||||
onClick={() => playFrom(i)}
|
|
||||||
/>
|
|
||||||
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
|
|
||||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
|
||||||
{#if isOwner}
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/removeTrack"
|
|
||||||
use:enhance={() =>
|
|
||||||
async ({ update }) =>
|
|
||||||
update()}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="trackId" value={track.id} />
|
|
||||||
<IconButton type="submit" label="Quitar del álbum" size="xs" revealOnHover>
|
|
||||||
<X class="h-3.5 w-3.5" />
|
|
||||||
</IconButton>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
</TrackRow>
|
|
||||||
{/each}
|
|
||||||
</TrackTable>
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
|
||||||
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
|
|
||||||
import { fmtTime } from '$lib/format';
|
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
|
||||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
|
||||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
|
||||||
import TrackRow from '$lib/components/ui/TrackRow.svelte';
|
|
||||||
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
|
|
||||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
|
||||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
|
||||||
|
|
||||||
type LibraryTrack = ApiTrackLike & { duration: number | string; listensCount: number | string };
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
library: LibraryTrack[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let { library }: Props = $props();
|
|
||||||
|
|
||||||
function playFrom(index: number) {
|
|
||||||
player.playOrToggle(toQueueItems(library), index);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mt-10 sm:mt-12">
|
|
||||||
<SectionHeading title="Tus canciones subidas" />
|
|
||||||
<TrackTable action meta={[{ label: 'Escuchas', width: '100px' }]}>
|
|
||||||
{#each library as track, i (track.id)}
|
|
||||||
{@const active = player.current.id === track.id}
|
|
||||||
<TrackRow {active}>
|
|
||||||
<TrackTitleCell
|
|
||||||
trackId={track.id}
|
|
||||||
title={track.title}
|
|
||||||
artist={track.artist}
|
|
||||||
ownerUserId={track.ownerUserId}
|
|
||||||
explicit={track.isExplicit}
|
|
||||||
{active}
|
|
||||||
onClick={() => playFrom(i)}
|
|
||||||
>
|
|
||||||
{#snippet overlay()}
|
|
||||||
{#if player.current.id === track.id}
|
|
||||||
<NowPlaying paused={!player.playing} />
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
</TrackTitleCell>
|
|
||||||
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
|
|
||||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/addTrack"
|
|
||||||
use:enhance={() =>
|
|
||||||
async ({ update }) =>
|
|
||||||
update()}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="trackId" value={track.id} />
|
|
||||||
<IconButton type="submit" label="Añadir al álbum" size="xs" revealOnHover>
|
|
||||||
<Plus class="h-3.5 w-3.5" />
|
|
||||||
</IconButton>
|
|
||||||
</form>
|
|
||||||
</TrackRow>
|
|
||||||
{/each}
|
|
||||||
</TrackTable>
|
|
||||||
</div>
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player, toQueueItem } from '$lib/player/player.svelte';
|
||||||
import { fetchAlbumQueueItems } from '$lib/albums';
|
import { fetchAlbumQueueItems } from '$lib/albums';
|
||||||
import { fmtTime, fmtPlays } from '$lib/format';
|
import { fmtTime, fmtPlays, plural } from '$lib/format';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import Alert from '$lib/components/ui/Alert.svelte';
|
import Alert from '$lib/components/ui/Alert.svelte';
|
||||||
@@ -11,29 +11,29 @@
|
|||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||||
import PlayButton from '$lib/components/ui/PlayButton.svelte';
|
import PlayButton from '$lib/components/ui/PlayButton.svelte';
|
||||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
import EqBars from '$lib/components/ui/EqBars.svelte';
|
||||||
import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte';
|
import ListRow from '$lib/components/ui/ListRow.svelte';
|
||||||
|
import Chip from '$lib/components/ui/Chip.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||||
import TrackContextMenu, {
|
import { searchHref } from '$lib/navigation.svelte';
|
||||||
contextMenuStateFor,
|
import { createTrackMenu } from '$lib/menus.svelte';
|
||||||
type ContextMenuState
|
import ContextMenu, { contextMenuPosition } from '$lib/components/ui/ContextMenu.svelte';
|
||||||
} from '$lib/components/TrackContextMenu.svelte';
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
import AlbumContextMenu, {
|
import ListEnd from '@lucide/svelte/icons/list-end';
|
||||||
albumContextMenuStateFor,
|
|
||||||
type AlbumMenuState
|
|
||||||
} from '$lib/components/AlbumContextMenu.svelte';
|
|
||||||
import { appendUnique } from '$lib/collections';
|
import { appendUnique } from '$lib/collections';
|
||||||
|
import { genreTiles } from '$lib/genres';
|
||||||
import {
|
import {
|
||||||
isTargetCurrent,
|
type SearchFilter,
|
||||||
queueItemForTarget,
|
showGroup,
|
||||||
targetArtist,
|
capped,
|
||||||
targetExplicit,
|
matchPlaylists,
|
||||||
targetId,
|
searchCounts,
|
||||||
targetListensCount,
|
searchChips,
|
||||||
targetTitle,
|
findTopResult
|
||||||
type TrackTarget
|
} from '$lib/search';
|
||||||
} from '$lib/tracks';
|
|
||||||
|
type AlbumMenuState = { x: number; y: number; openLeft: boolean; albumId: string };
|
||||||
|
|
||||||
const TOP_RESULT_KIND_LABEL: Record<TopResult['kind'], string> = {
|
const TOP_RESULT_KIND_LABEL: Record<TopResult['kind'], string> = {
|
||||||
track: 'Canción',
|
track: 'Canción',
|
||||||
@@ -42,38 +42,9 @@
|
|||||||
user: 'Usuario'
|
user: 'Usuario'
|
||||||
};
|
};
|
||||||
|
|
||||||
// No hay sistema de tags/géneros todavía: esta lista es un placeholder de
|
|
||||||
// cliente y cada tile enlaza a una búsqueda normal por ese término.
|
|
||||||
const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
|
|
||||||
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
|
|
||||||
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
|
|
||||||
reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' },
|
|
||||||
'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' },
|
|
||||||
indie: { label: 'Indie', tagline: 'Voces fuera del radar' },
|
|
||||||
salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' },
|
|
||||||
'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' },
|
|
||||||
electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' },
|
|
||||||
jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' },
|
|
||||||
baladas: { label: 'Baladas', tagline: 'Para sentir con calma' },
|
|
||||||
trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' },
|
|
||||||
cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' },
|
|
||||||
'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' },
|
|
||||||
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({
|
|
||||||
query,
|
|
||||||
hue: Math.round((i * 360) / all.length),
|
|
||||||
...info
|
|
||||||
}));
|
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const;
|
const trackMenu = createTrackMenu();
|
||||||
type SearchFilter = (typeof SEARCH_FILTERS)[number];
|
|
||||||
const SEARCH_GROUP_PREVIEW = 4;
|
|
||||||
|
|
||||||
let contextMenu = $state<ContextMenuState | null>(null);
|
|
||||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||||
let sfilter = $state<SearchFilter>('Todo');
|
let sfilter = $state<SearchFilter>('Todo');
|
||||||
|
|
||||||
@@ -85,7 +56,7 @@
|
|||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
contextMenu = null;
|
trackMenu.close();
|
||||||
albumMenu = null;
|
albumMenu = null;
|
||||||
sfilter = 'Todo';
|
sfilter = 'Todo';
|
||||||
localItems = data.tracks.items.map((item) => item.track);
|
localItems = data.tracks.items.map((item) => item.track);
|
||||||
@@ -95,12 +66,12 @@
|
|||||||
|
|
||||||
const songRows = $derived(
|
const songRows = $derived(
|
||||||
localItems.map((track) => ({
|
localItems.map((track) => ({
|
||||||
target: { track } satisfies TrackTarget,
|
track,
|
||||||
seconds: Number(track.duration)
|
seconds: Number(track.duration)
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const items = $derived<TrackTarget[]>(songRows.map((row) => row.target));
|
const items = $derived(songRows.map((row) => row.track));
|
||||||
|
|
||||||
const albums = $derived(data.albums);
|
const albums = $derived(data.albums);
|
||||||
const users = $derived(data.users);
|
const users = $derived(data.users);
|
||||||
@@ -108,75 +79,52 @@
|
|||||||
const hasAlbums = $derived(albums.length > 0);
|
const hasAlbums = $derived(albums.length > 0);
|
||||||
const hasUsers = $derived(users.length > 0);
|
const hasUsers = $derived(users.length > 0);
|
||||||
|
|
||||||
const playlistMatches = $derived(
|
const playlistMatches = $derived(matchPlaylists(data.playlists, data.query));
|
||||||
data.query
|
|
||||||
? data.playlists.filter((p) => p.name.toLowerCase().includes(data.query.toLowerCase()))
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
const hasPlaylists = $derived(playlistMatches.length > 0);
|
const hasPlaylists = $derived(playlistMatches.length > 0);
|
||||||
|
|
||||||
const nothingFound = $derived(
|
const nothingFound = $derived(
|
||||||
!!data.query && items.length === 0 && !hasAlbums && !hasUsers && !hasPlaylists
|
!!data.query && items.length === 0 && !hasAlbums && !hasUsers && !hasPlaylists
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchCounts = $derived({
|
const counts = $derived(
|
||||||
Canciones: songRows.length,
|
searchCounts({
|
||||||
Álbumes: albums.length,
|
tracks: songRows.length,
|
||||||
Playlists: playlistMatches.length,
|
albums: albums.length,
|
||||||
Usuarios: users.length
|
playlists: playlistMatches.length,
|
||||||
});
|
users: users.length
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const chips = $derived(searchChips(counts));
|
||||||
const totalHits = $derived(
|
const totalHits = $derived(
|
||||||
searchCounts.Canciones + searchCounts.Álbumes + searchCounts.Playlists + searchCounts.Usuarios
|
counts.Canciones + counts.Álbumes + counts.Playlists + counts.Usuarios
|
||||||
);
|
|
||||||
const searchChips = $derived(
|
|
||||||
SEARCH_FILTERS.map((label) => ({
|
|
||||||
label,
|
|
||||||
count: label === 'Todo' ? totalHits : searchCounts[label]
|
|
||||||
}))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
type TopResult =
|
type TopResult = NonNullable<
|
||||||
| { kind: 'track'; target: TrackTarget }
|
ReturnType<
|
||||||
| { kind: 'album'; album: (typeof albums)[number] }
|
typeof findTopResult<
|
||||||
| { kind: 'playlist'; playlist: (typeof playlistMatches)[number] }
|
LocalTrack,
|
||||||
| { kind: 'user'; user: (typeof users)[number] };
|
(typeof albums)[number],
|
||||||
|
(typeof playlistMatches)[number],
|
||||||
|
(typeof users)[number]
|
||||||
|
>
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
const topResult = $derived.by((): TopResult | null => {
|
const topResult = $derived<TopResult | null>(
|
||||||
if (sfilter !== 'Todo' || !data.query) return null;
|
sfilter === 'Todo' && data.query
|
||||||
const q = data.query.trim().toLowerCase();
|
? findTopResult({
|
||||||
const startsWithQuery = (value: string) => value.toLowerCase().startsWith(q);
|
query: data.query,
|
||||||
|
tracks: items,
|
||||||
const userHit = users.find((u) => startsWithQuery(u.name));
|
albums,
|
||||||
if (userHit) return { kind: 'user', user: userHit };
|
playlists: playlistMatches,
|
||||||
const trackHit = items.find((target) => startsWithQuery(targetTitle(target)));
|
users
|
||||||
if (trackHit) return { kind: 'track', target: trackHit };
|
})
|
||||||
const albumHit = albums.find((album) => startsWithQuery(album.title));
|
: null
|
||||||
if (albumHit) return { kind: 'album', album: albumHit };
|
);
|
||||||
const playlistHit = playlistMatches.find((p) => startsWithQuery(p.name));
|
|
||||||
if (playlistHit) return { kind: 'playlist', playlist: playlistHit };
|
|
||||||
|
|
||||||
if (users.length) return { kind: 'user', user: users[0] };
|
|
||||||
if (items.length) return { kind: 'track', target: items[0] };
|
|
||||||
if (albums.length) return { kind: 'album', album: albums[0] };
|
|
||||||
if (playlistMatches.length) return { kind: 'playlist', playlist: playlistMatches[0] };
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
function showGroup(label: Exclude<SearchFilter, 'Todo'>) {
|
|
||||||
return sfilter === 'Todo' || sfilter === label;
|
|
||||||
}
|
|
||||||
|
|
||||||
function capped<T>(list: T[]) {
|
|
||||||
return sfilter === 'Todo' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildHref(query: string) {
|
|
||||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
|
||||||
}
|
|
||||||
|
|
||||||
function playTopResult() {
|
function playTopResult() {
|
||||||
if (topResult?.kind !== 'track') return;
|
if (topResult?.kind !== 'track') return;
|
||||||
player.playOrToggle([queueItemForTarget(topResult.target)], 0);
|
player.playOrToggle([toQueueItem(topResult.track)], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const topResultHref = $derived.by(() => {
|
const topResultHref = $derived.by(() => {
|
||||||
@@ -187,15 +135,15 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function togglePlay(index: number) {
|
function togglePlay(index: number) {
|
||||||
player.playOrToggle(items.map(queueItemForTarget), index);
|
player.playOrToggle(items.map(toQueueItem), index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
function openContextMenu(event: MouseEvent, track: LocalTrack) {
|
||||||
contextMenu = contextMenuStateFor(event, target);
|
trackMenu.open(event, track);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
||||||
albumMenu = albumContextMenuStateFor(event, albumId);
|
albumMenu = { ...contextMenuPosition(event), albumId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function albumPlayNext() {
|
async function albumPlayNext() {
|
||||||
@@ -212,12 +160,6 @@
|
|||||||
player.appendToQueue(await fetchAlbumQueueItems(albumId));
|
player.appendToQueue(await fetchAlbumQueueItems(albumId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addToQueue() {
|
|
||||||
if (!contextMenu) return;
|
|
||||||
player.addToQueue(queueItemForTarget(contextMenu));
|
|
||||||
contextMenu = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (loadingMore) return;
|
if (loadingMore) return;
|
||||||
loadingMore = true;
|
loadingMore = true;
|
||||||
@@ -257,39 +199,28 @@
|
|||||||
{#if !data.query}
|
{#if !data.query}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Descubrir"
|
title="Descubrir"
|
||||||
subtitle="Encuentra tu próxima canción favorita explorando por género."
|
description="Encuentra tu próxima canción favorita explorando por género."
|
||||||
/>
|
/>
|
||||||
{:else if !nothingFound}
|
{:else if !nothingFound}
|
||||||
<div>
|
<PageHeader
|
||||||
<div class="mb-2.5 text-sm font-semibold tracking-[0.16em] text-fg-3 uppercase">
|
eyebrow="Resultados de búsqueda"
|
||||||
Resultados de búsqueda
|
title="Resultados para «{data.query}»"
|
||||||
</div>
|
description="{plural(
|
||||||
<h1
|
totalHits,
|
||||||
class="font-display text-2xl font-semibold tracking-[-0.03em] text-pretty text-fg sm:text-3xl"
|
'coincidencia',
|
||||||
>
|
'coincidencias'
|
||||||
Resultados para «{data.query}»
|
)} en canciones, álbumes, playlists y usuarios"
|
||||||
</h1>
|
/>
|
||||||
<p class="mt-1.75 text-sm text-fg-2">
|
|
||||||
{totalHits}
|
|
||||||
{totalHits === 1 ? 'Coincidencia' : 'Coincidencias'} en canciones, álbumes, playlists y usuarios
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 flex flex-wrap gap-1.75">
|
<div class="mb-7 flex flex-wrap gap-2 sm:mb-8">
|
||||||
{#each searchChips as chip (chip.label)}
|
{#each chips as chip (chip.label)}
|
||||||
<button
|
<Chip
|
||||||
type="button"
|
selected={sfilter === chip.label}
|
||||||
|
count={chip.count}
|
||||||
onclick={() => (sfilter = chip.label)}
|
onclick={() => (sfilter = chip.label)}
|
||||||
class="flex items-center gap-1.75 rounded-full px-3.5 py-1.75 text-xs font-medium transition {sfilter ===
|
|
||||||
chip.label
|
|
||||||
? 'bg-cta-strong text-ink'
|
|
||||||
: 'bg-surface-2 text-fg-2 hover:bg-surface-hover hover:text-fg'}"
|
|
||||||
>
|
>
|
||||||
<span>{chip.label}</span>
|
{chip.label}
|
||||||
<span class="text-xs tabular-nums {sfilter === chip.label ? 'opacity-55' : 'text-muted'}"
|
</Chip>
|
||||||
>{chip.count}</span
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -297,9 +228,9 @@
|
|||||||
{#snippet topResultBody()}
|
{#snippet topResultBody()}
|
||||||
{#if topResult.kind === 'track'}
|
{#if topResult.kind === 'track'}
|
||||||
<Artwork
|
<Artwork
|
||||||
trackIds={[targetId(topResult.target)]}
|
trackIds={[topResult.track.id]}
|
||||||
size="xl"
|
size="xl"
|
||||||
alt={targetTitle(topResult.target)}
|
alt={topResult.track.title}
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
/>
|
/>
|
||||||
{:else if topResult.kind === 'album'}
|
{:else if topResult.kind === 'album'}
|
||||||
@@ -318,23 +249,23 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3">
|
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3">
|
||||||
<div class="text-xs leading-none font-semibold tracking-[0.14em] text-fg-3 uppercase">
|
<div class="text-eyebrow leading-none font-semibold text-fg-3">
|
||||||
Mejor resultado · {TOP_RESULT_KIND_LABEL[topResult.kind]}
|
Mejor resultado · {TOP_RESULT_KIND_LABEL[topResult.kind]}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="truncate font-display text-lg leading-none font-medium tracking-[-0.02em] text-fg"
|
class="truncate font-display text-lg leading-none font-medium tracking-display text-fg"
|
||||||
>
|
>
|
||||||
{topResult.kind === 'track'
|
{topResult.kind === 'track'
|
||||||
? targetTitle(topResult.target)
|
? topResult.track.title
|
||||||
: topResult.kind === 'album'
|
: topResult.kind === 'album'
|
||||||
? topResult.album.title
|
? topResult.album.title
|
||||||
: topResult.kind === 'playlist'
|
: topResult.kind === 'playlist'
|
||||||
? topResult.playlist.name
|
? topResult.playlist.name
|
||||||
: topResult.user.name}
|
: topResult.user.name}
|
||||||
</div>
|
</div>
|
||||||
{#if topResult.kind === 'track' && targetArtist(topResult.target)}
|
{#if topResult.kind === 'track' && topResult.track.artist}
|
||||||
<div class="truncate text-sm leading-none text-fg-3">
|
<div class="truncate text-sm leading-none text-fg-3">
|
||||||
{targetArtist(topResult.target)}
|
{topResult.track.artist}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -342,22 +273,16 @@
|
|||||||
<PlayButton as="span" size="lg" label="Reproducir" class="group-hover/top:brightness-110" />
|
<PlayButton as="span" size="lg" label="Reproducir" class="group-hover/top:brightness-110" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if topResult.kind === 'track'}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<button
|
<svelte:element
|
||||||
type="button"
|
this={topResult.kind === 'track' ? 'button' : 'a'}
|
||||||
onclick={playTopResult}
|
type={topResult.kind === 'track' ? 'button' : undefined}
|
||||||
class="group/top animate-pop mt-6.5 flex w-full items-center gap-5 rounded-panel-lg bg-surface p-4.5 text-left transition hover:bg-surface-hover"
|
href={topResult.kind === 'track' ? undefined : topResultHref}
|
||||||
>
|
onclick={topResult.kind === 'track' ? playTopResult : undefined}
|
||||||
{@render topResultBody()}
|
class="group/top animate-pop mt-6.5 flex w-full items-center gap-5 rounded-panel-lg bg-surface p-4.5 text-left transition hover:bg-surface-hover"
|
||||||
</button>
|
>
|
||||||
{:else}
|
{@render topResultBody()}
|
||||||
<a
|
</svelte:element>
|
||||||
href={topResultHref}
|
|
||||||
class="group/top animate-pop mt-6.5 flex w-full items-center gap-5 rounded-panel-lg bg-surface p-4.5 text-left transition hover:bg-surface-hover"
|
|
||||||
>
|
|
||||||
{@render topResultBody()}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -366,22 +291,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if nothingFound}
|
{#if nothingFound}
|
||||||
<div class="mx-auto max-w-115 py-[8vh] text-center">
|
<div class="mx-auto max-w-prose-sm py-[8vh] text-center">
|
||||||
<h1 class="font-display text-xl font-semibold tracking-tight text-fg">
|
<h1 class="text-display-3 text-fg">
|
||||||
Sin resultados para «{data.query}»
|
Sin resultados para «{data.query}»
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
|
<p class="mt-2.5 text-body text-fg-2">
|
||||||
Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o
|
Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o
|
||||||
por playlist.
|
por playlist.
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-5.5 flex flex-wrap justify-center gap-2">
|
<div class="mt-5.5 flex flex-wrap justify-center gap-2">
|
||||||
{#each genreTiles.slice(0, 4) as genre (genre.query)}
|
{#each genreTiles.slice(0, 4) as genre (genre.query)}
|
||||||
<a
|
<Chip href={searchHref(genre.query)}>{genre.label}</Chip>
|
||||||
href={buildHref(genre.query)}
|
|
||||||
class="rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg"
|
|
||||||
>
|
|
||||||
{genre.label}
|
|
||||||
</a>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -389,13 +309,12 @@
|
|||||||
|
|
||||||
{#if !data.query}
|
{#if !data.query}
|
||||||
<!-- GÉNEROS -->
|
<!-- GÉNEROS -->
|
||||||
<div class="mt-7 grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
|
<div class="grid-wide">
|
||||||
{#each genreTiles as genre, i (genre.query)}
|
{#each genreTiles as genre, i (genre.query)}
|
||||||
<a
|
<a
|
||||||
href={buildHref(genre.query)}
|
href={searchHref(genre.query)}
|
||||||
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-110"
|
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-panel p-4 transition-[filter] genre-tile hover:brightness-110"
|
||||||
style="animation-delay:{Math.min(i, 10) *
|
style="--i:{i}; --tile-hue:{genre.hue}"
|
||||||
40}ms; background:linear-gradient(140deg, oklch(var(--mf-tile-l) var(--mf-tile-c) {genre.hue}), oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) {genre.hue}))"
|
|
||||||
>
|
>
|
||||||
<div class="text-on-art">
|
<div class="text-on-art">
|
||||||
{genre.label}
|
{genre.label}
|
||||||
@@ -408,42 +327,34 @@
|
|||||||
|
|
||||||
{#if data.query && !nothingFound}
|
{#if data.query && !nothingFound}
|
||||||
<div class="mt-9 flex flex-col gap-9">
|
<div class="mt-9 flex flex-col gap-9">
|
||||||
{#if showGroup('Canciones')}
|
{#if showGroup(sfilter, 'Canciones')}
|
||||||
<div>
|
<div>
|
||||||
{#if songRows.length > 0}
|
{#if songRows.length > 0}
|
||||||
<SectionHeading title="Canciones">
|
<SectionHeading title="Canciones" count={counts.Canciones} />
|
||||||
{#snippet actions()}
|
|
||||||
<div class="h-px flex-1 self-center bg-line"></div>
|
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums">
|
|
||||||
{searchCounts.Canciones}
|
|
||||||
{searchCounts.Canciones === 1 ? 'Resultado' : 'Resultados'}
|
|
||||||
</span>
|
|
||||||
{/snippet}
|
|
||||||
</SectionHeading>
|
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each capped(songRows) as { target: item, seconds }, i (targetId(item))}
|
{#each capped(sfilter, songRows) as { track, seconds }, i (track.id)}
|
||||||
<li>
|
<li>
|
||||||
<SearchResultRow
|
<ListRow
|
||||||
title={targetTitle(item)}
|
title={track.title}
|
||||||
subtitle={targetArtist(item)}
|
subtitle={track.artist}
|
||||||
meta="{fmtTime(seconds)} · {fmtPlays(targetListensCount(item))}"
|
subtitleHref={track.ownerUserId}
|
||||||
explicit={targetExplicit(item)}
|
active={player.current.id === track.id}
|
||||||
|
size="lg"
|
||||||
|
trackId={track.id}
|
||||||
onclick={() => togglePlay(i)}
|
onclick={() => togglePlay(i)}
|
||||||
oncontextmenu={(e) => openContextMenu(e, item)}
|
oncontextmenu={(e) => openContextMenu(e, track)}
|
||||||
>
|
>
|
||||||
{#snippet art(artClass)}
|
{#snippet overlay()}
|
||||||
<Artwork
|
{#if player.current.id === track.id}
|
||||||
trackIds={[targetId(item)]}
|
<EqBars overlay paused={!player.playing} />
|
||||||
size="lg"
|
{/if}
|
||||||
alt={targetTitle(item)}
|
|
||||||
class="{artClass} ring-1 ring-line ring-inset"
|
|
||||||
>
|
|
||||||
{#if isTargetCurrent(item)}
|
|
||||||
<NowPlaying paused={!player.playing} />
|
|
||||||
{/if}
|
|
||||||
</Artwork>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SearchResultRow>
|
{#snippet trailing()}
|
||||||
|
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
|
||||||
|
{fmtTime(seconds)} · {fmtPlays(track.listensCount)}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</ListRow>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -460,91 +371,64 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasAlbums && showGroup('Álbumes')}
|
{#if hasAlbums && showGroup(sfilter, 'Álbumes')}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading title="Álbumes">
|
<SectionHeading title="Álbumes" count={counts.Álbumes} />
|
||||||
{#snippet actions()}
|
|
||||||
<div class="h-px flex-1 self-center bg-line"></div>
|
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums">
|
|
||||||
{searchCounts.Álbumes}
|
|
||||||
{searchCounts.Álbumes === 1 ? 'Resultado' : 'Resultados'}
|
|
||||||
</span>
|
|
||||||
{/snippet}
|
|
||||||
</SectionHeading>
|
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each capped(albums) as album (album.id)}
|
{#each capped(sfilter, albums) as album (album.id)}
|
||||||
<li>
|
<li>
|
||||||
<SearchResultRow
|
<ListRow
|
||||||
title={album.title}
|
title={album.title}
|
||||||
meta="{Number(album.trackCount)} canciones"
|
|
||||||
href="/albums/{album.id}"
|
href="/albums/{album.id}"
|
||||||
|
size="lg"
|
||||||
|
trackIds={album.coverTrackIds}
|
||||||
oncontextmenu={(e) => openAlbumMenu(e, album.id)}
|
oncontextmenu={(e) => openAlbumMenu(e, album.id)}
|
||||||
>
|
>
|
||||||
{#snippet art(artClass)}
|
{#snippet trailing()}
|
||||||
<Artwork trackIds={album.coverTrackIds} size="lg" class={artClass} />
|
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
|
||||||
|
{Number(album.trackCount)} canciones
|
||||||
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SearchResultRow>
|
</ListRow>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasUsers && showGroup('Usuarios')}
|
{#if hasUsers && showGroup(sfilter, 'Usuarios')}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading title="Usuarios">
|
<SectionHeading title="Usuarios" count={counts.Usuarios} />
|
||||||
{#snippet actions()}
|
|
||||||
<div class="h-px flex-1 self-center bg-line"></div>
|
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums">
|
|
||||||
{searchCounts.Usuarios}
|
|
||||||
{searchCounts.Usuarios === 1 ? 'Resultado' : 'Resultados'}
|
|
||||||
</span>
|
|
||||||
{/snippet}
|
|
||||||
</SectionHeading>
|
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each capped(users) as u (u.id)}
|
{#each capped(sfilter, users) as u (u.id)}
|
||||||
<li>
|
<li>
|
||||||
<SearchResultRow title={u.name} href="/u/{u.id}">
|
<ListRow title={u.name} href="/u/{u.id}" size="lg">
|
||||||
{#snippet art(artClass)}
|
{#snippet art()}
|
||||||
<Avatar name={u.name} src={u.profilePictureUrl} size="md" class={artClass} />
|
<Avatar name={u.name} src={u.profilePictureUrl} size="md" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SearchResultRow>
|
</ListRow>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasPlaylists && showGroup('Playlists')}
|
{#if hasPlaylists && showGroup(sfilter, 'Playlists')}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading title="Playlists">
|
<SectionHeading title="Playlists" count={counts.Playlists} />
|
||||||
{#snippet actions()}
|
|
||||||
<div class="h-px flex-1 self-center bg-line"></div>
|
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums">
|
|
||||||
{searchCounts.Playlists}
|
|
||||||
{searchCounts.Playlists === 1 ? 'Resultado' : 'Resultados'}
|
|
||||||
</span>
|
|
||||||
{/snippet}
|
|
||||||
</SectionHeading>
|
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each capped(playlistMatches) as playlist (playlist.id)}
|
{#each capped(sfilter, playlistMatches) as playlist (playlist.id)}
|
||||||
<li>
|
<li>
|
||||||
<SearchResultRow
|
<ListRow
|
||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
subtitle={playlist.description}
|
subtitle={playlist.description}
|
||||||
href="/playlists/{playlist.id}"
|
href="/playlists/{playlist.id}"
|
||||||
>
|
size="lg"
|
||||||
{#snippet art(artClass)}
|
coverSrc="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
||||||
<Artwork
|
playlist.updatedAt
|
||||||
src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
)}"
|
||||||
playlist.updatedAt
|
trackIds={playlist.coverTrackIds}
|
||||||
)}"
|
/>
|
||||||
trackIds={playlist.coverTrackIds}
|
|
||||||
size="lg"
|
|
||||||
class={artClass}
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
</SearchResultRow>
|
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -554,21 +438,39 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
{#if contextMenu}
|
{#if trackMenu.state}
|
||||||
<TrackContextMenu
|
<ContextMenu
|
||||||
menu={contextMenu}
|
x={trackMenu.state.x}
|
||||||
|
y={trackMenu.state.y}
|
||||||
|
openLeft={trackMenu.state.openLeft}
|
||||||
|
onClose={() => trackMenu.close()}
|
||||||
|
items={[
|
||||||
|
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => trackMenu.playNext() }
|
||||||
|
]}
|
||||||
|
playlistAction={{
|
||||||
|
action: '?/addTrack',
|
||||||
|
fields: { trackId: String(trackMenu.state.track.id) },
|
||||||
|
label: 'Añadir a una playlist'
|
||||||
|
}}
|
||||||
playlists={data.playlists}
|
playlists={data.playlists}
|
||||||
onClose={() => (contextMenu = null)}
|
|
||||||
onAddToQueue={addToQueue}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if albumMenu}
|
{#if albumMenu}
|
||||||
<AlbumContextMenu
|
<ContextMenu
|
||||||
menu={albumMenu}
|
x={albumMenu.x}
|
||||||
playlists={data.playlists}
|
y={albumMenu.y}
|
||||||
|
openLeft={albumMenu.openLeft}
|
||||||
onClose={() => (albumMenu = null)}
|
onClose={() => (albumMenu = null)}
|
||||||
onPlayNext={albumPlayNext}
|
items={[
|
||||||
onAddToQueue={albumAddToQueue}
|
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: albumPlayNext },
|
||||||
|
{ icon: ListEnd, label: 'Añadir a la cola', onclick: albumAddToQueue }
|
||||||
|
]}
|
||||||
|
playlistAction={{
|
||||||
|
action: '?/addAlbumToPlaylist',
|
||||||
|
fields: { albumId: albumMenu.albumId },
|
||||||
|
label: 'Añadir álbum a una playlist'
|
||||||
|
}}
|
||||||
|
playlists={data.playlists}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import { player, toQueueItems } from '$lib/player/player.svelte';
|
import { player, toQueueItems } from '$lib/player/player.svelte';
|
||||||
import { fmtTime, fmtDate } from '$lib/format';
|
import { plural } from '$lib/format';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
import TrackList from '$lib/components/ui/TrackList.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';
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const items = $derived(data.tracks.items);
|
const items = $derived(data.tracks.items);
|
||||||
const total = $derived(Number(data.tracks.totalItemCount));
|
const total = $derived(Number(data.tracks.totalItemCount));
|
||||||
|
const listTracks = $derived(items.map((track) => ({ ...track, uploadedAt: track.createdAt })));
|
||||||
|
|
||||||
function togglePlay(index: number) {
|
function togglePlay(index: number) {
|
||||||
player.playOrToggle(toQueueItems(items), index);
|
player.playOrToggle(toQueueItems(items), index);
|
||||||
@@ -34,12 +29,12 @@
|
|||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Tus canciones subidas"
|
title="Tus canciones subidas"
|
||||||
subtitle="{total} {total === 1 ? 'canción' : 'canciones'} subida{total === 1 ? '' : 's'}."
|
description="{plural(total, 'canción', 'canciones')} subida{total === 1 ? '' : 's'}."
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<Button href="/upload">
|
<Button href="/upload">
|
||||||
<Upload class="h-4 w-4" />
|
<Upload class="size-4" />
|
||||||
Subir canción
|
Subir canción
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -47,50 +42,12 @@
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<TrackTable
|
<TrackList
|
||||||
index
|
tracks={listTracks}
|
||||||
action
|
columns={['uploaded', 'plays']}
|
||||||
meta={[
|
onPlay={togglePlay}
|
||||||
{ label: 'Álbum', width: '76px' },
|
rowAction={{ action: '?/deleteTrack', icon: X, label: 'Borrar canción' }}
|
||||||
{ label: 'Subida', width: '124px' },
|
/>
|
||||||
{ label: 'Escuchas', width: '106px' }
|
|
||||||
]}
|
|
||||||
class="mt-7 sm:mt-8"
|
|
||||||
>
|
|
||||||
{#each items as track, i (track.id)}
|
|
||||||
{@const active = player.current.id === track.id}
|
|
||||||
<TrackRow {active}>
|
|
||||||
<TrackIndexCell
|
|
||||||
index={i}
|
|
||||||
{active}
|
|
||||||
playing={player.playing}
|
|
||||||
onToggle={() => togglePlay(i)}
|
|
||||||
/>
|
|
||||||
<TrackTitleCell
|
|
||||||
trackId={track.id}
|
|
||||||
title={track.title}
|
|
||||||
{active}
|
|
||||||
onClick={() => togglePlay(i)}
|
|
||||||
/>
|
|
||||||
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
|
||||||
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
|
|
||||||
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
|
|
||||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/deleteTrack"
|
|
||||||
use:enhance={() =>
|
|
||||||
async ({ update }) =>
|
|
||||||
update()}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="trackId" value={track.id} />
|
|
||||||
<IconButton type="submit" label="Borrar canción" size="xs" revealOnHover>
|
|
||||||
<X class="h-3.5 w-3.5" />
|
|
||||||
</IconButton>
|
|
||||||
</form>
|
|
||||||
</TrackRow>
|
|
||||||
{/each}
|
|
||||||
</TrackTable>
|
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Music}
|
icon={Music}
|
||||||
@@ -99,7 +56,7 @@
|
|||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button href="/upload">
|
<Button href="/upload">
|
||||||
<Upload class="h-4 w-4" />
|
<Upload class="size-4" />
|
||||||
Sube la primera
|
Sube la primera
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -1,39 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Heart from '@lucide/svelte/icons/heart';
|
import Heart from '@lucide/svelte/icons/heart';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import { player, type QueueItem } from '$lib/player/player.svelte';
|
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||||
import { liked } from '$lib/player/liked.svelte';
|
import { liked } from '$lib/player/liked.svelte';
|
||||||
import { fmtDate, fmtTime } from '$lib/format';
|
import { plural } from '$lib/format';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
import TrackList from '$lib/components/ui/TrackList.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 tracks = $derived(liked.list);
|
||||||
const queueItems = $derived<QueueItem[]>(
|
const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.likedAt })));
|
||||||
tracks.map((t) => ({
|
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||||
id: t.id,
|
|
||||||
title: t.title,
|
|
||||||
artist: t.artist,
|
|
||||||
explicit: t.explicit
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
|
|
||||||
|
|
||||||
function playFrom(index: number) {
|
function playFrom(index: number) {
|
||||||
player.playOrToggle(queueItems, index);
|
player.playOrToggle(tracks, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playAll() {
|
function playAll() {
|
||||||
if (queueItems.length === 0) return;
|
playAllOrToggle(tracks);
|
||||||
if (isCurrentQueue) player.toggle();
|
|
||||||
else player.playQueue(queueItems, 0);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -43,17 +29,17 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<CollectionHeader
|
<PageHeader
|
||||||
eyebrow="Colección"
|
eyebrow="Colección"
|
||||||
title="Me gusta"
|
title="Me gusta"
|
||||||
meta="{tracks.length} {tracks.length === 1 ? 'Canción' : 'Canciones'}"
|
meta={plural(tracks.length, 'canción', 'canciones')}
|
||||||
align="center"
|
align="center"
|
||||||
>
|
>
|
||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
<div
|
<div
|
||||||
class="grid h-36 w-36 shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:h-41 sm:w-41"
|
class="grid size-cover-hero shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:size-cover-hero-sm"
|
||||||
>
|
>
|
||||||
<Heart class="h-10 w-10 text-on-art/90" fill="currentColor" strokeWidth={0} />
|
<Heart class="size-icon-xl text-on-art/90" fill="currentColor" strokeWidth={0} />
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
@@ -61,52 +47,19 @@
|
|||||||
{isCurrentQueue && player.playing ? 'Pausar' : 'Reproducir'}
|
{isCurrentQueue && player.playing ? 'Pausar' : 'Reproducir'}
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CollectionHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if tracks.length > 0}
|
{#if tracks.length > 0}
|
||||||
<TrackTable
|
<TrackList
|
||||||
index
|
tracks={listTracks}
|
||||||
action
|
columns={['added', 'plays']}
|
||||||
meta={[
|
onPlay={playFrom}
|
||||||
{ label: 'Álbum', width: '76px' },
|
rowAction={{
|
||||||
{ label: 'Añadida', width: '124px' },
|
onclick: (track) => liked.toggle({ ...track, artist: track.artist ?? undefined }),
|
||||||
{ label: 'Escuchas', width: '106px' }
|
icon: X,
|
||||||
]}
|
label: 'Quitar de Me gusta'
|
||||||
class="mt-6 sm:mt-8"
|
}}
|
||||||
>
|
/>
|
||||||
{#each tracks as track, i (track.id)}
|
|
||||||
{@const active = player.current.id === track.id}
|
|
||||||
<TrackRow {active}>
|
|
||||||
<TrackIndexCell
|
|
||||||
index={i}
|
|
||||||
{active}
|
|
||||||
playing={player.playing}
|
|
||||||
onToggle={() => playFrom(i)}
|
|
||||||
/>
|
|
||||||
<TrackTitleCell
|
|
||||||
trackId={track.id}
|
|
||||||
title={track.title}
|
|
||||||
artist={track.artist}
|
|
||||||
ownerUserId={track.ownerUserId}
|
|
||||||
explicit={track.explicit}
|
|
||||||
{active}
|
|
||||||
onClick={() => playFrom(i)}
|
|
||||||
/>
|
|
||||||
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
|
||||||
<TrackMeta class="hidden sm:block">{fmtDate(track.likedAt)}</TrackMeta>
|
|
||||||
<TrackMeta class="hidden sm:block">{Number(track.listensCount ?? 0)}</TrackMeta>
|
|
||||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
|
||||||
<IconButton
|
|
||||||
label="Quitar de Me gusta"
|
|
||||||
size="xs"
|
|
||||||
revealOnHover
|
|
||||||
onclick={() => liked.toggle(track)}
|
|
||||||
>
|
|
||||||
<X class="h-3.5 w-3.5" />
|
|
||||||
</IconButton>
|
|
||||||
</TrackRow>
|
|
||||||
{/each}
|
|
||||||
</TrackTable>
|
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Heart}
|
icon={Heart}
|
||||||
|
|||||||
@@ -2,62 +2,52 @@
|
|||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import Pause from '@lucide/svelte/icons/pause';
|
import Pause from '@lucide/svelte/icons/pause';
|
||||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import {
|
||||||
import { fmtTime } from '$lib/format';
|
player,
|
||||||
import { shuffle } from '$lib/collections';
|
isQueueCurrent,
|
||||||
|
playAllOrToggle,
|
||||||
|
playShuffled,
|
||||||
|
toQueueItem
|
||||||
|
} from '$lib/player/player.svelte';
|
||||||
|
import { fmtTime, plural } from '$lib/format';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import BackLink from '$lib/components/ui/BackLink.svelte';
|
import BackLink from '$lib/components/ui/BackLink.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import Alert from '$lib/components/ui/Alert.svelte';
|
import Alert from '$lib/components/ui/Alert.svelte';
|
||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
import TrackList from '$lib/components/ui/TrackList.svelte';
|
||||||
import TrackRow from '$lib/components/ui/TrackRow.svelte';
|
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
|
||||||
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
|
import { createTrackMenu } from '$lib/menus.svelte';
|
||||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
import { mixItemTrack } from '$lib/mixes';
|
||||||
import TrackContextMenu, {
|
|
||||||
contextMenuStateFor,
|
|
||||||
type ContextMenuState
|
|
||||||
} from '$lib/components/TrackContextMenu.svelte';
|
|
||||||
import { mixItemKey, mixItemTarget } from '$lib/mixes';
|
|
||||||
import {
|
|
||||||
isTargetCurrent,
|
|
||||||
queueItemForTarget,
|
|
||||||
targetArtist,
|
|
||||||
targetId,
|
|
||||||
targetListensCount,
|
|
||||||
targetTitle
|
|
||||||
} from '$lib/tracks';
|
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const mix = $derived(data.mix);
|
const mix = $derived(data.mix);
|
||||||
const items = $derived(mix.items);
|
const items = $derived(mix.items);
|
||||||
const targets = $derived(items.map(mixItemTarget));
|
const tracks = $derived(items.map(mixItemTrack));
|
||||||
const queue = $derived(targets.map(queueItemForTarget));
|
const queue = $derived(tracks.map(toQueueItem));
|
||||||
|
const listTracks = $derived(
|
||||||
|
items.map((item) => ({
|
||||||
|
id: item.trackId,
|
||||||
|
title: item.title,
|
||||||
|
artist: item.artist,
|
||||||
|
duration: item.durationSeconds,
|
||||||
|
listensCount: item.listensCount
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const totalSeconds = $derived(
|
const totalSeconds = $derived(
|
||||||
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
|
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
|
||||||
);
|
);
|
||||||
const isCurrentQueue = $derived(targets.some(isTargetCurrent));
|
const isCurrentQueue = $derived(isQueueCurrent(queue));
|
||||||
|
|
||||||
let contextMenu = $state<ContextMenuState | null>(null);
|
const trackMenu = createTrackMenu();
|
||||||
|
|
||||||
function playFrom(index: number) {
|
function playFrom(index: number) {
|
||||||
player.playOrToggle(queue, index);
|
player.playOrToggle(queue, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playAll() {
|
|
||||||
if (queue.length === 0) return;
|
|
||||||
if (isCurrentQueue) player.toggle();
|
|
||||||
else player.playQueue(queue, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function playShuffled() {
|
|
||||||
if (queue.length === 0) return;
|
|
||||||
player.playQueue(shuffle(queue), 0);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -68,11 +58,11 @@
|
|||||||
<Page>
|
<Page>
|
||||||
<BackLink href="/" label="Volver al inicio" />
|
<BackLink href="/" label="Volver al inicio" />
|
||||||
|
|
||||||
<CollectionHeader
|
<PageHeader
|
||||||
eyebrow="Mezcla"
|
eyebrow="Mezcla"
|
||||||
title={mix.title}
|
title={mix.title}
|
||||||
description={mix.subtitle ?? undefined}
|
description={mix.subtitle ?? undefined}
|
||||||
meta="{items.length} {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}"
|
meta="{plural(items.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
|
||||||
>
|
>
|
||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
<Artwork
|
<Artwork
|
||||||
@@ -83,57 +73,53 @@
|
|||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
|
<Button size="sm" onclick={() => playAllOrToggle(queue)} disabled={queue.length === 0}>
|
||||||
{#if isCurrentQueue && player.playing}
|
{#if isCurrentQueue && player.playing}
|
||||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
<Pause class="size-4" strokeWidth={1.5} />
|
||||||
Pausar
|
Pausar
|
||||||
{:else}
|
{:else}
|
||||||
<Play class="h-4 w-4" strokeWidth={1.5} />
|
<Play class="size-4" strokeWidth={1.5} />
|
||||||
Reproducir
|
Reproducir
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onclick={playShuffled} disabled={queue.length === 0}>
|
<Button
|
||||||
<Shuffle class="h-4 w-4" />
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => playShuffled(queue)}
|
||||||
|
disabled={queue.length === 0}
|
||||||
|
>
|
||||||
|
<Shuffle class="size-4" />
|
||||||
Aleatorio
|
Aleatorio
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CollectionHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if form?.message}
|
{#if form?.message}
|
||||||
<Alert tone="danger" class="mt-4">{form.message}</Alert>
|
<Alert tone="danger" class="mb-6">{form.message}</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<TrackTable index meta={[{ label: 'Escuchas', width: '100px' }]} class="mt-6 sm:mt-8">
|
<TrackList
|
||||||
{#each items as item, i (mixItemKey(item))}
|
tracks={listTracks}
|
||||||
{@const active = isTargetCurrent(targets[i])}
|
columns={['plays']}
|
||||||
<TrackRow {active} oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
|
onPlay={playFrom}
|
||||||
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
|
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])}
|
||||||
<TrackTitleCell
|
/>
|
||||||
trackId={targetId(targets[i])}
|
|
||||||
title={targetTitle(targets[i])}
|
|
||||||
artist={targetArtist(targets[i])}
|
|
||||||
{active}
|
|
||||||
onClick={() => playFrom(i)}
|
|
||||||
/>
|
|
||||||
<TrackMeta class="hidden sm:block">{Number(targetListensCount(targets[i]) ?? 0)}</TrackMeta>
|
|
||||||
<TrackMeta>{fmtTime(Number(item.durationSeconds))}</TrackMeta>
|
|
||||||
</TrackRow>
|
|
||||||
{/each}
|
|
||||||
</TrackTable>
|
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
{#if contextMenu}
|
{#if trackMenu.state}
|
||||||
<TrackContextMenu
|
<ContextMenu
|
||||||
menu={contextMenu}
|
x={trackMenu.state.x}
|
||||||
playlists={data.playlists}
|
y={trackMenu.state.y}
|
||||||
onClose={() => (contextMenu = null)}
|
openLeft={trackMenu.state.openLeft}
|
||||||
onAddToQueue={() => {
|
onClose={() => trackMenu.close()}
|
||||||
const menu = contextMenu;
|
items={[
|
||||||
if (menu) {
|
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => trackMenu.playNext() }
|
||||||
const index = targets.findIndex((target) => targetId(target) === targetId(menu));
|
]}
|
||||||
if (index >= 0) player.addToQueue(queue[index]);
|
playlistAction={{
|
||||||
}
|
action: '?/addTrack',
|
||||||
contextMenu = null;
|
fields: { trackId: String(trackMenu.state.track.id) },
|
||||||
|
label: 'Añadir a una playlist'
|
||||||
}}
|
}}
|
||||||
|
playlists={data.playlists}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,19 +2,18 @@
|
|||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
import MediaCard from '$lib/components/ui/MediaCard.svelte';
|
||||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import { createPlaylistModal } from '$lib/playlists.svelte';
|
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||||
import { fmtDurationLong } from '$lib/format';
|
import { fmtDurationLong, plural, playlistMeta } from '$lib/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const items = $derived(data.playlists.items);
|
const items = $derived(data.playlists.items);
|
||||||
const totals = $derived(data.totals);
|
const totals = $derived(data.totals);
|
||||||
const summary = $derived(
|
const summary = $derived(
|
||||||
`${totals.playlistCount} ${totals.playlistCount === 1 ? 'Playlist' : 'Playlists'} · ${totals.trackCount} ${totals.trackCount === 1 ? 'Canción' : 'Canciones'} · ${fmtDurationLong(totals.durationSeconds)}`
|
`${plural(totals.playlistCount, 'playlist', 'playlists')} · ${plural(totals.trackCount, 'canción', 'canciones')} · ${fmtDurationLong(totals.durationSeconds)}`
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@
|
|||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Mis listas"
|
title="Mis listas"
|
||||||
subtitle={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
|
description={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
@@ -36,18 +35,20 @@
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<MediaGrid min="210px" minMobile="180px" class="mt-7 sm:mt-8">
|
<div class="grid-cards-lg">
|
||||||
{#each items as playlist, i (playlist.id)}
|
{#each items as playlist, i (playlist.id)}
|
||||||
<PlaylistCard
|
<MediaCard
|
||||||
id={playlist.id}
|
href="/playlists/{playlist.id}"
|
||||||
name={playlist.name}
|
title={playlist.name}
|
||||||
trackCount={playlist.trackCount}
|
subtitle={playlistMeta(Number(playlist.trackCount))}
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
updatedAt={playlist.updatedAt}
|
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||||
|
playlist.updatedAt
|
||||||
|
)}"
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</MediaGrid>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ListMusic}
|
icon={ListMusic}
|
||||||
|
|||||||
@@ -1,28 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import { player, toQueueItems } from '$lib/player/player.svelte';
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
import Play from '@lucide/svelte/icons/play';
|
||||||
|
import Pause from '@lucide/svelte/icons/pause';
|
||||||
|
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
||||||
|
import Trash from '@lucide/svelte/icons/trash';
|
||||||
|
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import Modal from '$lib/components/ui/Modal.svelte';
|
import Modal from '$lib/components/ui/Modal.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||||
|
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||||
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import PlaylistHeader from './components/PlaylistHeader.svelte';
|
import TrackList from '$lib/components/ui/TrackList.svelte';
|
||||||
import PlaylistTrackTable from './components/PlaylistTrackTable.svelte';
|
import { playlistMeta } from '$lib/format';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const playlist = $derived(data.playlist);
|
const playlist = $derived(data.playlist);
|
||||||
const tracks = $derived(data.tracks);
|
const tracks = $derived(data.tracks);
|
||||||
|
const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.createdAt })));
|
||||||
|
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let confirmingDelete = $state(false);
|
let confirmingDelete = $state(false);
|
||||||
|
|
||||||
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
|
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||||
|
|
||||||
function playAll() {
|
function playAll() {
|
||||||
if (tracks.length === 0) return;
|
playAllOrToggle(toQueueItems(tracks));
|
||||||
if (isCurrentQueue) player.toggle();
|
}
|
||||||
else player.playQueue(toQueueItems(tracks), 0);
|
|
||||||
|
function playFrom(index: number) {
|
||||||
|
player.playOrToggle(toQueueItems(tracks), index);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -32,27 +43,59 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<PlaylistHeader
|
<PageHeader
|
||||||
id={playlist.id}
|
eyebrow="Lista"
|
||||||
name={playlist.name}
|
title={playlist.name}
|
||||||
description={playlist.description}
|
description={playlist.description ?? undefined}
|
||||||
trackIds={tracks.map((t) => t.id)}
|
meta="{playlistMeta(tracks.length)} · {playlist.visibility === 'Public'
|
||||||
updatedAt={playlist.updatedAt}
|
? 'Pública'
|
||||||
visibility={playlist.visibility}
|
: 'Privada'}"
|
||||||
playing={isCurrentQueue && player.playing}
|
align="center"
|
||||||
onPlayAll={playAll}
|
>
|
||||||
onEdit={() => (editing = true)}
|
{#snippet cover()}
|
||||||
onDelete={() => (confirmingDelete = true)}
|
<Artwork
|
||||||
/>
|
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||||
|
playlist.updatedAt
|
||||||
|
)}"
|
||||||
|
trackIds={tracks.map((t) => t.id)}
|
||||||
|
size="hero"
|
||||||
|
alt={playlist.name}
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
|
||||||
|
{#if isCurrentQueue && player.playing}
|
||||||
|
<Pause class="size-4" strokeWidth={1.5} />
|
||||||
|
Pausar
|
||||||
|
{:else}
|
||||||
|
<Play class="size-4" strokeWidth={1.5} />
|
||||||
|
Reproducir
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>
|
||||||
|
<SquarePencil class="size-4" strokeWidth={1.5} />
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
|
||||||
|
<Trash class="size-4" strokeWidth={1.5} />
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
{#if tracks.length > 0}
|
{#if tracks.length > 0}
|
||||||
<PlaylistTrackTable {tracks} />
|
<TrackList
|
||||||
|
tracks={listTracks}
|
||||||
|
columns={['added', 'plays']}
|
||||||
|
onPlay={playFrom}
|
||||||
|
rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ListMusic}
|
icon={ListMusic}
|
||||||
title="Esta playlist todavía está vacía"
|
title="Esta playlist todavía está vacía"
|
||||||
description="Añade canciones desde tu biblioteca o explora música nueva para empezar."
|
description="Añade canciones desde tu biblioteca o explora música nueva para empezar."
|
||||||
class="mt-6 sm:mt-8"
|
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||||
@@ -78,18 +121,11 @@
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
|
<ConfirmDialog
|
||||||
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
|
open={confirmingDelete}
|
||||||
¿Eliminar «{playlist.name}»?
|
onClose={() => (confirmingDelete = false)}
|
||||||
</h2>
|
title="¿Eliminar «{playlist.name}»?"
|
||||||
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
|
description="Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los dispositivos donde la tengas guardada."
|
||||||
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
|
confirmLabel="Eliminar"
|
||||||
dispositivos donde la tengas guardada.
|
action="?/delete"
|
||||||
</p>
|
/>
|
||||||
<div class="mt-6 flex items-center justify-end gap-3">
|
|
||||||
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
|
|
||||||
<form method="POST" action="?/delete">
|
|
||||||
<Button type="submit" variant="danger">Eliminar</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
|
||||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
trackIds: (string | number)[];
|
|
||||||
updatedAt: string;
|
|
||||||
visibility: 'Public' | 'Private';
|
|
||||||
playing: boolean;
|
|
||||||
onPlayAll: () => void;
|
|
||||||
onEdit: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
trackIds,
|
|
||||||
updatedAt,
|
|
||||||
visibility,
|
|
||||||
playing,
|
|
||||||
onPlayAll,
|
|
||||||
onEdit,
|
|
||||||
onDelete
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const meta = $derived(
|
|
||||||
`${trackIds.length} ${trackIds.length === 1 ? 'Canción' : 'Canciones'} · ${
|
|
||||||
visibility === 'Public' ? 'Pública' : 'Privada'
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<CollectionHeader
|
|
||||||
eyebrow="Lista"
|
|
||||||
title={name}
|
|
||||||
description={description ?? undefined}
|
|
||||||
{meta}
|
|
||||||
align="center"
|
|
||||||
>
|
|
||||||
{#snippet cover()}
|
|
||||||
<Artwork
|
|
||||||
src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
|
|
||||||
{trackIds}
|
|
||||||
size="hero"
|
|
||||||
alt={name}
|
|
||||||
class="shrink-0"
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet actions()}
|
|
||||||
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
|
|
||||||
{playing ? 'Pausar' : 'Reproducir'}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="secondary" onclick={onEdit}>Editar</Button>
|
|
||||||
<Button size="sm" variant="secondary" onclick={onDelete}>Eliminar</Button>
|
|
||||||
{/snippet}
|
|
||||||
</CollectionHeader>
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import X from '@lucide/svelte/icons/x';
|
|
||||||
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
|
|
||||||
import { fmtTime, fmtDate } from '$lib/format';
|
|
||||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
|
||||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
|
||||||
import TrackRow from '$lib/components/ui/TrackRow.svelte';
|
|
||||||
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
|
|
||||||
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
|
|
||||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
|
||||||
|
|
||||||
type PlaylistTrack = ApiTrackLike & {
|
|
||||||
createdAt: string;
|
|
||||||
duration: number | string;
|
|
||||||
listensCount: number | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
tracks: PlaylistTrack[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let { tracks }: Props = $props();
|
|
||||||
|
|
||||||
function playFrom(index: number) {
|
|
||||||
player.playOrToggle(toQueueItems(tracks), index);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TrackTable
|
|
||||||
index
|
|
||||||
action
|
|
||||||
meta={[
|
|
||||||
{ label: 'Álbum', width: '76px' },
|
|
||||||
{ label: 'Añadida', width: '124px' },
|
|
||||||
{ label: 'Escuchas', width: '106px' }
|
|
||||||
]}
|
|
||||||
class="mt-6 sm:mt-8"
|
|
||||||
>
|
|
||||||
{#each tracks as track, i (track.id)}
|
|
||||||
{@const active = player.current.id === track.id}
|
|
||||||
<TrackRow {active}>
|
|
||||||
<TrackIndexCell index={i} playing={player.playing} onToggle={() => playFrom(i)} {active} />
|
|
||||||
<TrackTitleCell
|
|
||||||
trackId={track.id}
|
|
||||||
title={track.title}
|
|
||||||
artist={track.artist}
|
|
||||||
ownerUserId={track.ownerUserId}
|
|
||||||
explicit={track.isExplicit}
|
|
||||||
{active}
|
|
||||||
onClick={() => playFrom(i)}
|
|
||||||
/>
|
|
||||||
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
|
||||||
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
|
|
||||||
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
|
|
||||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/removeTrack"
|
|
||||||
use:enhance={() =>
|
|
||||||
async ({ update }) =>
|
|
||||||
update()}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="trackId" value={track.id} />
|
|
||||||
<IconButton type="submit" label="Quitar de la playlist" size="xs" revealOnHover>
|
|
||||||
<X class="h-3.5 w-3.5" />
|
|
||||||
</IconButton>
|
|
||||||
</form>
|
|
||||||
</TrackRow>
|
|
||||||
{/each}
|
|
||||||
</TrackTable>
|
|
||||||
@@ -3,13 +3,13 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
import MediaCard from '$lib/components/ui/MediaCard.svelte';
|
||||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
|
import { plural, playlistMeta } from '$lib/format';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -55,36 +55,40 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<CollectionHeader
|
<PageHeader
|
||||||
eyebrow="Perfil"
|
eyebrow="Perfil"
|
||||||
title={profile.name}
|
title={profile.name}
|
||||||
meta="{profile.followersCount} {profile.followersCount === 1
|
meta="{plural(
|
||||||
? 'seguidor'
|
Number(profile.followersCount),
|
||||||
: 'seguidores'} · {profile.followingCount} siguiendo"
|
'seguidor',
|
||||||
|
'seguidores'
|
||||||
|
)} · {profile.followingCount} siguiendo"
|
||||||
align="center"
|
align="center"
|
||||||
actions={canFollow ? followAction : undefined}
|
actions={canFollow ? followAction : undefined}
|
||||||
>
|
>
|
||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
<Avatar name={profile.name} src={profile.profilePictureUrl} size="hero" />
|
<Avatar name={profile.name} src={profile.profilePictureUrl} size="hero" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CollectionHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="mt-9">
|
<div>
|
||||||
<SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" />
|
<SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" />
|
||||||
|
|
||||||
{#if playlists.length > 0}
|
{#if playlists.length > 0}
|
||||||
<MediaGrid min="210px" minMobile="180px" class="mt-6">
|
<div class="mt-6 grid-cards-lg">
|
||||||
{#each playlists as playlist, i (playlist.id)}
|
{#each playlists as playlist, i (playlist.id)}
|
||||||
<PlaylistCard
|
<MediaCard
|
||||||
id={playlist.id}
|
href="/playlists/{playlist.id}"
|
||||||
name={playlist.name}
|
title={playlist.name}
|
||||||
trackCount={playlist.trackCount}
|
subtitle={playlistMeta(Number(playlist.trackCount))}
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
updatedAt={playlist.updatedAt}
|
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||||
|
playlist.updatedAt
|
||||||
|
)}"
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</MediaGrid>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ListMusic}
|
icon={ListMusic}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import Surface from '$lib/components/ui/Surface.svelte';
|
import Surface from '$lib/components/ui/Surface.svelte';
|
||||||
|
import ImageDropzone from '$lib/components/ui/ImageDropzone.svelte';
|
||||||
import Alert from '$lib/components/ui/Alert.svelte';
|
import Alert from '$lib/components/ui/Alert.svelte';
|
||||||
import Field from '$lib/components/ui/Field.svelte';
|
import Field from '$lib/components/ui/Field.svelte';
|
||||||
import Input from '$lib/components/ui/Input.svelte';
|
import Input from '$lib/components/ui/Input.svelte';
|
||||||
@@ -21,7 +22,8 @@
|
|||||||
let title = $state('');
|
let title = $state('');
|
||||||
let audioName = $state('');
|
let audioName = $state('');
|
||||||
let audioSize = $state(0);
|
let audioSize = $state(0);
|
||||||
let coverPreview = $state('');
|
let hasCover = $state(false);
|
||||||
|
let coverResetToken = $state(0);
|
||||||
let dragging = $state(false);
|
let dragging = $state(false);
|
||||||
let status = $state<Status>('idle');
|
let status = $state<Status>('idle');
|
||||||
let acceptedTerms = $state(false);
|
let acceptedTerms = $state(false);
|
||||||
@@ -38,7 +40,7 @@
|
|||||||
const busy = $derived(status === 'uploading');
|
const busy = $derived(status === 'uploading');
|
||||||
|
|
||||||
const canPublish = $derived(
|
const canPublish = $derived(
|
||||||
title.trim() !== '' && audioName !== '' && coverPreview !== '' && acceptedTerms && !busy
|
title.trim() !== '' && audioName !== '' && hasCover && acceptedTerms && !busy
|
||||||
);
|
);
|
||||||
|
|
||||||
const stepStates = $derived.by<StepState[]>(() => {
|
const stepStates = $derived.by<StepState[]>(() => {
|
||||||
@@ -79,16 +81,12 @@
|
|||||||
pickAudio(file);
|
pickAudio(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCoverInput(event: Event) {
|
|
||||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
|
||||||
if (file) coverPreview = URL.createObjectURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
title = '';
|
title = '';
|
||||||
audioName = '';
|
audioName = '';
|
||||||
audioSize = 0;
|
audioSize = 0;
|
||||||
coverPreview = '';
|
hasCover = false;
|
||||||
|
coverResetToken += 1;
|
||||||
status = 'idle';
|
status = 'idle';
|
||||||
acceptedTerms = false;
|
acceptedTerms = false;
|
||||||
errorMsg = '';
|
errorMsg = '';
|
||||||
@@ -103,23 +101,22 @@
|
|||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Subir música"
|
title="Subir música"
|
||||||
subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
|
description="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ol class="mt-7 grid gap-3 sm:mt-8 sm:grid-cols-3 sm:gap-4">
|
<ol class="grid gap-3 sm:grid-cols-3 sm:gap-4">
|
||||||
{#each steps as step, i (step.key)}
|
{#each steps as step, i (step.key)}
|
||||||
<li>
|
<li>
|
||||||
<Surface padding="sm" class={stepClass(stepStates[i])}>
|
<Surface padding="sm" class={stepClass(stepStates[i])}>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
class="grid h-7 w-7 shrink-0 place-items-center rounded-full text-sm {stepStates[
|
class="grid size-7 shrink-0 place-items-center rounded-full text-sm {stepStates[i] ===
|
||||||
i
|
'idle'
|
||||||
] === 'idle'
|
|
||||||
? 'bg-surface-2 text-fg-2'
|
? 'bg-surface-2 text-fg-2'
|
||||||
: 'bg-accent-btn text-accent-soft'}"
|
: 'bg-accent-btn text-accent-soft'}"
|
||||||
>
|
>
|
||||||
{#if stepStates[i] === 'done'}
|
{#if stepStates[i] === 'done'}
|
||||||
<Check class="h-3.5 w-3.5" strokeWidth={2.5} />
|
<Check class="size-3.5" strokeWidth={2.5} />
|
||||||
{:else}
|
{:else}
|
||||||
{i + 1}
|
{i + 1}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -134,10 +131,10 @@
|
|||||||
|
|
||||||
{#if status === 'done'}
|
{#if status === 'done'}
|
||||||
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
|
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
|
||||||
<span class="mx-auto grid h-14 w-14 place-items-center rounded-full bg-accent-soft text-ink">
|
<span class="mx-auto grid size-14 place-items-center rounded-full bg-accent-soft text-ink">
|
||||||
<Check class="h-7 w-7" strokeWidth={2.5} />
|
<Check class="size-7" strokeWidth={2.5} />
|
||||||
</span>
|
</span>
|
||||||
<h2 class="mt-6 font-display text-2xl font-semibold tracking-[-0.02em] text-fg">¡Subida!</h2>
|
<h2 class="mt-6 font-display text-2xl font-semibold tracking-display text-fg">¡Subida!</h2>
|
||||||
<p class="mt-3 text-sm text-fg-2">
|
<p class="mt-3 text-sm text-fg-2">
|
||||||
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
|
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
|
||||||
unos momentos.
|
unos momentos.
|
||||||
@@ -194,9 +191,9 @@
|
|||||||
aria-label="Seleccionar archivo de audio"
|
aria-label="Seleccionar archivo de audio"
|
||||||
/>
|
/>
|
||||||
{#if audioName === ''}
|
{#if audioName === ''}
|
||||||
<Headphones class="h-9 w-9 text-muted sm:h-10 sm:w-10" strokeWidth={1.4} />
|
<Headphones class="size-9 text-muted sm:size-10" strokeWidth={1.4} />
|
||||||
{:else}
|
{:else}
|
||||||
<Music class="h-9 w-9 text-accent-soft sm:h-10 sm:w-10" strokeWidth={1.4} />
|
<Music class="size-9 text-accent-soft sm:size-10" strokeWidth={1.4} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if audioName === ''}
|
{#if audioName === ''}
|
||||||
<p class="mt-4 text-sm font-medium text-fg">
|
<p class="mt-4 text-sm font-medium text-fg">
|
||||||
@@ -213,24 +210,15 @@
|
|||||||
|
|
||||||
<div class="grid items-start gap-6 sm:grid-cols-[auto_1fr] sm:gap-8">
|
<div class="grid items-start gap-6 sm:grid-cols-[auto_1fr] sm:gap-8">
|
||||||
<Field label="Portada">
|
<Field label="Portada">
|
||||||
<label
|
{#key coverResetToken}
|
||||||
class="relative grid h-36 w-36 cursor-pointer place-items-center overflow-hidden rounded-control transition sm:h-44 sm:w-44"
|
<ImageDropzone
|
||||||
style={coverPreview === '' ? 'background:var(--mf-cover-grad)' : undefined}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
name="cover"
|
name="cover"
|
||||||
accept="image/*"
|
icon={ImageIcon}
|
||||||
onchange={onCoverInput}
|
gradient
|
||||||
class="absolute inset-0 cursor-pointer opacity-0"
|
size="hero"
|
||||||
aria-label="Seleccionar portada"
|
onselect={() => (hasCover = true)}
|
||||||
/>
|
/>
|
||||||
{#if coverPreview !== ''}
|
{/key}
|
||||||
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
|
|
||||||
{:else}
|
|
||||||
<ImageIcon class="h-16 w-16 text-on-art/60" strokeWidth={1} />
|
|
||||||
{/if}
|
|
||||||
</label>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Título" for="title">
|
<Field label="Título" for="title">
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="animate-enter mx-auto my-[14vh] max-w-105 text-center">
|
<div class="animate-enter mx-auto my-[14vh] max-w-prose-sm text-center">
|
||||||
<h1 class="mb-2.5 font-display text-xl font-semibold tracking-[-0.02em] text-fg">
|
<h1 class="mb-2.5 text-display-3 text-fg">
|
||||||
{page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'}
|
{page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mb-6.5 text-sm leading-[1.65] text-fg-2">
|
<p class="mb-6.5 text-body text-fg-2">
|
||||||
{page.status === 404
|
{page.status === 404
|
||||||
? 'Comprueba el enlace o vuelve al inicio.'
|
? 'Comprueba el enlace o vuelve al inicio.'
|
||||||
: 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'}
|
: 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'}
|
||||||
|
|||||||
+261
-323
@@ -3,41 +3,38 @@
|
|||||||
import History from '@lucide/svelte/icons/history';
|
import History from '@lucide/svelte/icons/history';
|
||||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import { player } from '$lib/player/player.svelte';
|
|
||||||
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
|
|
||||||
import { shuffle as shuffleList } from '$lib/collections';
|
|
||||||
import { fmtTime, fmtDurationLong, fmtPlays } from '$lib/format';
|
|
||||||
import {
|
import {
|
||||||
isTargetCurrent,
|
player,
|
||||||
queueItemForTarget,
|
isQueueCurrent,
|
||||||
targetArtist,
|
playAllOrToggle,
|
||||||
targetExplicit,
|
playShuffled,
|
||||||
targetForQueueItem,
|
toQueueItem,
|
||||||
targetId,
|
trackFromQueueItem,
|
||||||
targetListensCount,
|
type ApiTrackLike
|
||||||
targetOwnerUserId,
|
} from '$lib/player/player.svelte';
|
||||||
targetTitle,
|
import { pressable } from '$lib/actions/pressable';
|
||||||
type TrackTarget
|
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
|
||||||
} from '$lib/tracks';
|
import { mixItemTrack } from '$lib/mixes';
|
||||||
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
|
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format';
|
||||||
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||||
import MixTile from '$lib/components/ui/MixTile.svelte';
|
import MediaCard from '$lib/components/ui/MediaCard.svelte';
|
||||||
import EqBars from '$lib/components/ui/EqBars.svelte';
|
import EqBars from '$lib/components/ui/EqBars.svelte';
|
||||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
import ListRow from '$lib/components/ui/ListRow.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import TrackContextMenu, {
|
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
|
||||||
contextMenuStateFor,
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
type ContextMenuState
|
import { createTrackMenu } from '$lib/menus.svelte';
|
||||||
} from '$lib/components/TrackContextMenu.svelte';
|
|
||||||
import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config';
|
import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let contextMenu = $state<ContextMenuState | null>(null);
|
const trackMenu = createTrackMenu();
|
||||||
|
|
||||||
type SpotlightRow = { target: TrackTarget; seconds: number };
|
type SpotlightTrack = Awaited<typeof data.spotlightTracks>['items'][number];
|
||||||
|
type SpotlightRow = { track: SpotlightTrack; seconds: number };
|
||||||
let spotlightRows = $state<SpotlightRow[]>([]);
|
let spotlightRows = $state<SpotlightRow[]>([]);
|
||||||
let spotlightTotal = $state(0);
|
let spotlightTotal = $state(0);
|
||||||
|
|
||||||
@@ -48,7 +45,7 @@
|
|||||||
data.spotlightTracks.then(({ items, totalCount }) => {
|
data.spotlightTracks.then(({ items, totalCount }) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
spotlightRows = items.map((track) => ({
|
spotlightRows = items.map((track) => ({
|
||||||
target: { track },
|
track,
|
||||||
seconds: Number(track.duration)
|
seconds: Number(track.duration)
|
||||||
}));
|
}));
|
||||||
spotlightTotal = totalCount;
|
spotlightTotal = totalCount;
|
||||||
@@ -61,7 +58,7 @@
|
|||||||
const mixes = $derived(data.mixes);
|
const mixes = $derived(data.mixes);
|
||||||
const playlists = $derived(data.playlists);
|
const playlists = $derived(data.playlists);
|
||||||
const spotlight = $derived(data.spotlightPlaylist);
|
const spotlight = $derived(data.spotlightPlaylist);
|
||||||
const spotlightItems = $derived(spotlightRows.map((row) => row.target));
|
const spotlightItems = $derived(spotlightRows.map((row) => row.track));
|
||||||
const spotlightDurationSeconds = $derived(
|
const spotlightDurationSeconds = $derived(
|
||||||
spotlightRows.reduce((sum, row) => sum + row.seconds, 0)
|
spotlightRows.reduce((sum, row) => sum + row.seconds, 0)
|
||||||
);
|
);
|
||||||
@@ -73,7 +70,7 @@
|
|||||||
spotlightTrackCount === 0
|
spotlightTrackCount === 0
|
||||||
? spotlightVisibilityLabel
|
? spotlightVisibilityLabel
|
||||||
: [
|
: [
|
||||||
`${spotlightTrackCount} ${spotlightTrackCount === 1 ? 'Canción' : 'Canciones'}`,
|
plural(spotlightTrackCount, 'canción', 'canciones'),
|
||||||
fmtDurationLong(spotlightDurationSeconds),
|
fmtDurationLong(spotlightDurationSeconds),
|
||||||
spotlightVisibilityLabel
|
spotlightVisibilityLabel
|
||||||
].join(' · ')
|
].join(' · ')
|
||||||
@@ -82,70 +79,74 @@
|
|||||||
const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
|
const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
|
||||||
const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24));
|
const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24));
|
||||||
|
|
||||||
const popularTargets = $derived<TrackTarget[]>(
|
const popularTracks = $derived<ApiTrackLike[]>(
|
||||||
recentPool.map(targetForQueueItem).slice(0, HOME_POPULAR_MAX)
|
recentPool.map(trackFromQueueItem).slice(0, HOME_POPULAR_MAX)
|
||||||
);
|
);
|
||||||
|
|
||||||
const spotlightQueue = $derived(spotlightItems.map(queueItemForTarget));
|
const spotlightQueue = $derived(spotlightItems.map(toQueueItem));
|
||||||
const spotlightIsCurrent = $derived(
|
const spotlightIsCurrent = $derived(isQueueCurrent(spotlightQueue));
|
||||||
spotlightQueue.length > 0 && spotlightQueue.some((item) => item.id === player.current.id)
|
|
||||||
);
|
|
||||||
const heroPlaying = $derived(spotlightIsCurrent && player.playing);
|
const heroPlaying = $derived(spotlightIsCurrent && player.playing);
|
||||||
|
|
||||||
function playContinue(index: number) {
|
function playContinue(index: number) {
|
||||||
player.playOrToggle(continueItems, index);
|
player.playOrToggle(continueItems, index);
|
||||||
}
|
}
|
||||||
function playPopular(index: number) {
|
function playPopular(index: number) {
|
||||||
player.playOrToggle(popularTargets.map(queueItemForTarget), index);
|
player.playOrToggle(popularTracks.map(toQueueItem), index);
|
||||||
}
|
}
|
||||||
function playSpotlightTrack(index: number) {
|
function playSpotlightTrack(index: number) {
|
||||||
player.playOrToggle(spotlightQueue, index);
|
player.playOrToggle(spotlightQueue, index);
|
||||||
}
|
}
|
||||||
function playSpotlight() {
|
function playSpotlight() {
|
||||||
if (spotlightQueue.length === 0) return;
|
playAllOrToggle(spotlightQueue);
|
||||||
if (spotlightIsCurrent) player.toggle();
|
|
||||||
else player.playQueue(spotlightQueue, 0);
|
|
||||||
}
|
}
|
||||||
function shuffleSpotlight() {
|
function shuffleSpotlight() {
|
||||||
if (spotlightQueue.length === 0) return;
|
playShuffled(spotlightQueue);
|
||||||
player.playQueue(shuffleList(spotlightQueue), 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRowKeydown(event: KeyboardEvent, action: () => void) {
|
function openContextMenu(event: MouseEvent, track: ApiTrackLike) {
|
||||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
trackMenu.open(event, track);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
action();
|
player.playQueue(
|
||||||
}
|
mix.items.map((item) => toQueueItem(mixItemTrack(item))),
|
||||||
|
0
|
||||||
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
);
|
||||||
contextMenu = contextMenuStateFor(event, target);
|
|
||||||
}
|
|
||||||
function addToQueue() {
|
|
||||||
if (!contextMenu) return;
|
|
||||||
player.addToQueue(queueItemForTarget(contextMenu));
|
|
||||||
contextMenu = null;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet noListeningHistory()}
|
||||||
|
<EmptyState
|
||||||
|
icon={History}
|
||||||
|
title="Todavía no has escuchado nada"
|
||||||
|
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||||
|
{/snippet}
|
||||||
|
</EmptyState>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Musify</title>
|
<title>Musify</title>
|
||||||
<meta name="description" content="Tu música, sin límites." />
|
<meta name="description" content="Tu música, sin límites." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="page-x pt-3 sm:pt-4">
|
<Page>
|
||||||
<div
|
<div
|
||||||
class="relative overflow-hidden rounded-3xl bg-[image:var(--mf-hero-bg)] px-6 py-8 transition-[background] duration-500 sm:px-9 sm:py-8.5"
|
class="relative overflow-hidden rounded-panel-lg hero-surface px-6 py-8 theme-transition sm:px-9 sm:py-8.5"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
|
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
|
||||||
>
|
>
|
||||||
<div class="animate-enter max-w-135 min-w-65">
|
<div class="animate-enter max-w-hero min-w-65">
|
||||||
<h1
|
<h1
|
||||||
class="font-display text-3xl leading-tight font-medium tracking-tight text-pretty text-fg sm:text-4xl"
|
class="font-display text-3xl leading-tight font-medium tracking-tight text-pretty text-fg sm:text-4xl"
|
||||||
>
|
>
|
||||||
{data.greeting}
|
{data.greeting}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-3.5 max-w-108 text-sm leading-[1.65] text-fg-2">
|
<p class="mt-3.5 max-w-prose-sm text-body text-fg-2">
|
||||||
Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.
|
Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
|
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
|
||||||
@@ -160,300 +161,237 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#await data.listeningStats then stats}
|
{#await data.listeningStats then stats}
|
||||||
|
{@const statsList = [
|
||||||
|
{ value: stats.tracksThisWeek, label: 'Canciones nuevas escuchadas esta semana' },
|
||||||
|
{
|
||||||
|
value: fmtDurationLong(Number(stats.secondsThisWeek)),
|
||||||
|
label: 'Tiempo de escucha esta semana'
|
||||||
|
},
|
||||||
|
{ value: stats.streakDays, label: 'Días consecutivos escuchando' }
|
||||||
|
]}
|
||||||
<div class="flex gap-6.5">
|
<div class="flex gap-6.5">
|
||||||
<div>
|
{#each statsList as stat (stat.label)}
|
||||||
<div class="font-display text-2xl font-medium tracking-tight text-fg">
|
<div>
|
||||||
{stats.tracksThisWeek}
|
<div class="text-display-2 text-fg">{stat.value}</div>
|
||||||
|
<div class="mt-1 text-xs text-fg-3">{stat.label}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs text-fg-3">Canciones nuevas escuchadas esta semana</div>
|
{/each}
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="font-display text-2xl font-medium tracking-tight text-fg">
|
|
||||||
{fmtDurationLong(Number(stats.secondsThisWeek))}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-xs text-fg-3">Tiempo de escucha esta semana</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="font-display text-2xl font-medium tracking-tight text-fg">
|
|
||||||
{stats.streakDays}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-xs text-fg-3">Días consecutivos escuchando</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-11.5 page-x pt-10 pb-8 sm:pb-10">
|
<div class="mt-10 flex flex-col gap-section">
|
||||||
<!-- CONTINUAR ESCUCHANDO -->
|
<!-- CONTINUAR ESCUCHANDO -->
|
||||||
<section>
|
|
||||||
<SectionHeading title="Continuar escuchando" subtitle="Retomalo donde lo dejaste" />
|
|
||||||
{#if continueItems.length > 0}
|
|
||||||
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
|
|
||||||
{#each continueItems as item, i (item.id)}
|
|
||||||
{@const isCurrent = player.current.id === item.id}
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
class="animate-enter rounded-art p-2.25 pr-3.5 transition-colors {isCurrent
|
|
||||||
? 'bg-accent-tint'
|
|
||||||
: 'hover:bg-hover'}"
|
|
||||||
style="animation-delay:{Math.min(i, 10) * 40}ms"
|
|
||||||
onclick={() => playContinue(i)}
|
|
||||||
onkeydown={(e) => onRowKeydown(e, () => playContinue(i))}
|
|
||||||
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
|
|
||||||
>
|
|
||||||
<TrackTitleCell
|
|
||||||
trackId={item.id}
|
|
||||||
title={item.title}
|
|
||||||
artist={item.artist}
|
|
||||||
ownerUserId={item.ownerUserId}
|
|
||||||
size="lg"
|
|
||||||
active={isCurrent}
|
|
||||||
onClick={() => playContinue(i)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<EmptyState
|
|
||||||
icon={History}
|
|
||||||
title="Todavía no has escuchado nada"
|
|
||||||
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
|
|
||||||
>
|
|
||||||
{#snippet actions()}
|
|
||||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
|
||||||
{/snippet}
|
|
||||||
</EmptyState>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- PLAYLIST DESTACADA -->
|
|
||||||
{#if spotlight}
|
|
||||||
<section>
|
<section>
|
||||||
<div
|
<SectionHeading title="Continuar escuchando" subtitle="Retomalo donde lo dejaste" />
|
||||||
class="grid grid-cols-1 overflow-hidden rounded-panel-lg transition-[background] duration-500 lg:grid-cols-2"
|
{#if continueItems.length > 0}
|
||||||
style="background-image:var(--mf-spotlight-bg), linear-gradient(to right, transparent 50%, color-mix(in oklch, var(--mf-scrim) 22%, transparent) 61%, transparent 92.5%)"
|
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
>
|
{#each continueItems as item, i (item.id)}
|
||||||
<div class="flex flex-col justify-center p-8 sm:p-9">
|
{@const isCurrent = player.current.id === item.id}
|
||||||
<div
|
<ListRow
|
||||||
role="button"
|
onclick={() => playContinue(i)}
|
||||||
tabindex="0"
|
oncontextmenu={(e) => openContextMenu(e, trackFromQueueItem(item))}
|
||||||
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
active={isCurrent}
|
||||||
onclick={() => goto(`/playlists/${spotlight.id}`)}
|
size="lg"
|
||||||
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${spotlight.id}`))}
|
variant="card"
|
||||||
>
|
title={item.title}
|
||||||
<Artwork
|
subtitle={item.artist}
|
||||||
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
|
subtitleHref={item.ownerUserId}
|
||||||
spotlight.updatedAt
|
trackId={item.id}
|
||||||
)}"
|
class="animate-enter"
|
||||||
trackIds={spotlight.coverTrackIds}
|
style="--i:{i}"
|
||||||
size="2xl"
|
|
||||||
alt={spotlight.name}
|
|
||||||
class="shrink-0"
|
|
||||||
/>
|
/>
|
||||||
<div class="flex min-w-0 flex-col gap-3">
|
{/each}
|
||||||
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">
|
|
||||||
Playlist destacada
|
|
||||||
</p>
|
|
||||||
<h3 class="truncate font-display text-2xl font-medium text-fg">
|
|
||||||
{spotlight.name}
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-fg-2">{spotlightMeta}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="mt-5 max-w-110 text-sm text-fg-2">
|
|
||||||
{spotlight.description || 'Tu colección, siempre a mano.'}
|
|
||||||
</p>
|
|
||||||
<div class="mt-5 flex gap-2.5">
|
|
||||||
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
|
|
||||||
<Button variant="glass" onclick={shuffleSpotlight}>Aleatorio</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6">
|
{:else}
|
||||||
{#each spotlightRows as { target, seconds }, i (targetId(target))}
|
{@render noListeningHistory()}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PLAYLIST DESTACADA -->
|
||||||
|
{#if spotlight}
|
||||||
|
<section>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 overflow-hidden rounded-panel-lg spotlight-surface theme-transition lg:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col justify-center p-8 sm:p-9">
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onclick={() => playSpotlightTrack(i)}
|
use:pressable={() => goto(`/playlists/${spotlight.id}`)}
|
||||||
onkeydown={(e) => {
|
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
||||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
||||||
e.preventDefault();
|
|
||||||
playSpotlightTrack(i);
|
|
||||||
}}
|
|
||||||
class="flex items-center gap-4 rounded-control p-2 text-left transition-colors {isTargetCurrent(
|
|
||||||
target
|
|
||||||
)
|
|
||||||
? 'bg-accent-tint'
|
|
||||||
: 'hover:bg-hover'}"
|
|
||||||
>
|
>
|
||||||
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums">{i + 1}</span>
|
<Artwork
|
||||||
<div class="min-w-0 flex-1">
|
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
|
||||||
<div
|
spotlight.updatedAt
|
||||||
class="truncate text-sm {isTargetCurrent(target)
|
)}"
|
||||||
? 'text-accent-soft'
|
trackIds={spotlight.coverTrackIds}
|
||||||
: 'text-fg'}"
|
size="2xl"
|
||||||
>
|
alt={spotlight.name}
|
||||||
{targetTitle(target)}
|
class="shrink-0"
|
||||||
</div>
|
/>
|
||||||
<ArtistLink
|
<div class="flex min-w-0 flex-col gap-3">
|
||||||
name={targetArtist(target)}
|
<p class="text-eyebrow text-fg-2">Playlist destacada</p>
|
||||||
ownerUserId={targetOwnerUserId(target)}
|
<h3 class="truncate text-display-2 text-fg">
|
||||||
class="mt-0.5 text-xs text-fg-3"
|
{spotlight.name}
|
||||||
/>
|
</h3>
|
||||||
|
<p class="text-sm text-fg-2">{spotlightMeta}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
<p class="mt-5 max-w-prose-sm text-sm text-fg-2">
|
||||||
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
|
{spotlight.description || 'Tu colección, siempre a mano.'}
|
||||||
|
</p>
|
||||||
|
<div class="mt-5 flex gap-2.5">
|
||||||
|
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
|
||||||
|
<Button variant="glass" onclick={shuffleSpotlight}>Aleatorio</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6">
|
||||||
|
{#each spotlightRows as { track, seconds }, i (track.id)}
|
||||||
|
<ListRow
|
||||||
|
onclick={() => playSpotlightTrack(i)}
|
||||||
|
active={player.current.id === track.id}
|
||||||
|
size="sm"
|
||||||
|
title={track.title}
|
||||||
|
subtitle={track.artist}
|
||||||
|
subtitleHref={track.ownerUserId}
|
||||||
|
>
|
||||||
|
{#snippet leading()}
|
||||||
|
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums"
|
||||||
|
>{i + 1}</span
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet trailing()}
|
||||||
|
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
|
||||||
|
{/snippet}
|
||||||
|
</ListRow>
|
||||||
|
{:else}
|
||||||
|
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- MIXES -->
|
||||||
|
<section>
|
||||||
|
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
|
||||||
|
{#if mixes.length > 0}
|
||||||
|
<div class="grid-tiles">
|
||||||
|
{#each mixes as mix, i (mix.id)}
|
||||||
|
<MediaCard
|
||||||
|
href="/mixes/{mix.id}"
|
||||||
|
title={mix.title}
|
||||||
|
subtitle={mix.subtitle}
|
||||||
|
trackIds={mix.items.map((item) => item.trackId)}
|
||||||
|
onPlay={(event) => playMix(mix, event)}
|
||||||
|
index={i}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
|
<EmptyState
|
||||||
|
icon={Shuffle}
|
||||||
|
title="Todavía no tienes mezclas"
|
||||||
|
description="Se generan automáticamente a partir de lo que escuchas."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- MIXES -->
|
<!-- POPULARES ESTA SEMANA -->
|
||||||
<section>
|
<section>
|
||||||
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
|
<SectionHeading title="Populares esta semana" subtitle="Lo más escuchado de tu biblioteca" />
|
||||||
{#if mixes.length > 0}
|
{#if popularTracks.length > 0}
|
||||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(164px,1fr))] gap-5">
|
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
|
||||||
{#each mixes as mix, i (mix.id)}
|
{#each popularTracks as track, i (track.id)}
|
||||||
<MixTile {mix} index={i} />
|
{@const isCurrent = player.current.id === track.id}
|
||||||
{/each}
|
<ListRow
|
||||||
</div>
|
onclick={() => playPopular(i)}
|
||||||
{:else}
|
oncontextmenu={(e) => openContextMenu(e, track)}
|
||||||
<EmptyState
|
active={isCurrent}
|
||||||
icon={Shuffle}
|
size="sm"
|
||||||
title="Todavía no tienes mezclas"
|
title={track.title}
|
||||||
description="Se generan automáticamente a partir de lo que escuchas."
|
subtitle={track.artist}
|
||||||
|
subtitleHref={track.ownerUserId}
|
||||||
|
explicit={track.isExplicit}
|
||||||
|
trackId={track.id}
|
||||||
|
>
|
||||||
|
{#snippet leading()}
|
||||||
|
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
|
||||||
|
{#if isCurrent}
|
||||||
|
<EqBars size={13} paused={!player.playing} />
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet trailing()}
|
||||||
|
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
|
||||||
|
{fmtPlays(track.listensCount)}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</ListRow>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render noListeningHistory()}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TUS PLAYLISTS -->
|
||||||
|
<section>
|
||||||
|
<SectionHeading
|
||||||
|
title="Tus playlists"
|
||||||
|
subtitle="Creadas y guardadas por ti"
|
||||||
|
href={data.playlistsHasMore ? '/playlists' : undefined}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{#if playlists.length > 0}
|
||||||
</section>
|
<div class="grid-wide">
|
||||||
|
{#each playlists as playlist, i (playlist.id)}
|
||||||
<!-- POPULARES ESTA SEMANA -->
|
<ListRow
|
||||||
<section>
|
onclick={() => goto(`/playlists/${playlist.id}`)}
|
||||||
<SectionHeading title="Populares esta semana" subtitle="Lo más escuchado de tu biblioteca" />
|
size="lg"
|
||||||
{#if popularTargets.length > 0}
|
variant="card"
|
||||||
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
|
title={playlist.name}
|
||||||
{#each popularTargets as target, i (targetId(target))}
|
subtitle={playlist.description}
|
||||||
{@const isCurrent = isTargetCurrent(target)}
|
coverSrc="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
class="flex items-center gap-3.5 rounded-control p-2 transition-colors {isCurrent
|
|
||||||
? 'bg-accent-tint'
|
|
||||||
: 'hover:bg-hover'}"
|
|
||||||
onclick={() => playPopular(i)}
|
|
||||||
onkeydown={(e) => onRowKeydown(e, () => playPopular(i))}
|
|
||||||
oncontextmenu={(e) => openContextMenu(e, target)}
|
|
||||||
>
|
|
||||||
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
|
|
||||||
{#if isCurrent}
|
|
||||||
<EqBars size={13} paused={!player.playing} />
|
|
||||||
{:else}
|
|
||||||
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<TrackTitleCell
|
|
||||||
trackId={targetId(target)}
|
|
||||||
title={targetTitle(target)}
|
|
||||||
artist={targetArtist(target)}
|
|
||||||
ownerUserId={targetOwnerUserId(target)}
|
|
||||||
explicit={targetExplicit(target)}
|
|
||||||
active={isCurrent}
|
|
||||||
onClick={() => playPopular(i)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
|
|
||||||
{fmtPlays(targetListensCount(target))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<EmptyState
|
|
||||||
icon={History}
|
|
||||||
title="Todavía no has escuchado nada"
|
|
||||||
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
|
|
||||||
>
|
|
||||||
{#snippet actions()}
|
|
||||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
|
||||||
{/snippet}
|
|
||||||
</EmptyState>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- TUS PLAYLISTS -->
|
|
||||||
<section>
|
|
||||||
<SectionHeading title="Tus playlists" subtitle="Creadas y guardadas por ti">
|
|
||||||
{#snippet actions()}
|
|
||||||
{#if data.playlistsHasMore}
|
|
||||||
<a
|
|
||||||
href="/playlists"
|
|
||||||
class="shrink-0 text-xs font-medium tracking-wider text-fg-3 uppercase transition-colors hover:text-fg"
|
|
||||||
>
|
|
||||||
Ver todas
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
</SectionHeading>
|
|
||||||
{#if playlists.length > 0}
|
|
||||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(258px,1fr))] gap-3.5">
|
|
||||||
{#each playlists as playlist, i (playlist.id)}
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
class="group/card animate-enter flex items-center gap-3.5 rounded-panel p-3 transition-colors hover:bg-hover"
|
|
||||||
style="--i:{i}"
|
|
||||||
onclick={() => goto(`/playlists/${playlist.id}`)}
|
|
||||||
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${playlist.id}`))}
|
|
||||||
>
|
|
||||||
<Artwork
|
|
||||||
src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
|
||||||
playlist.updatedAt
|
playlist.updatedAt
|
||||||
)}"
|
)}"
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
size="lg"
|
style="--i:{i}"
|
||||||
alt={playlist.name}
|
|
||||||
class="shrink-0"
|
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
{/each}
|
||||||
<div class="truncate text-sm tracking-tight text-fg">
|
</div>
|
||||||
{playlist.name}
|
{:else}
|
||||||
</div>
|
<EmptyState
|
||||||
{#if playlist.description}
|
icon={ListMusic}
|
||||||
<div class="mt-1 truncate text-xs text-fg-3">{playlist.description}</div>
|
title="Todavía no tienes listas"
|
||||||
{/if}
|
description="Crea tu primera lista y añádele canciones de tu biblioteca."
|
||||||
<ArtistLink
|
>
|
||||||
name={data.user?.name}
|
{#snippet actions()}
|
||||||
ownerUserId={data.user?.sub}
|
<Button href="/playlists">Crear lista</Button>
|
||||||
class="mt-1 text-xs text-fg-3"
|
{/snippet}
|
||||||
/>
|
</EmptyState>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</section>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
</Page>
|
||||||
{:else}
|
|
||||||
<EmptyState
|
|
||||||
icon={ListMusic}
|
|
||||||
title="Todavía no tienes listas"
|
|
||||||
description="Crea tu primera lista y añádele canciones de tu biblioteca."
|
|
||||||
>
|
|
||||||
{#snippet actions()}
|
|
||||||
<Button href="/playlists">Crear lista</Button>
|
|
||||||
{/snippet}
|
|
||||||
</EmptyState>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if contextMenu}
|
{#if trackMenu.state}
|
||||||
<TrackContextMenu
|
<ContextMenu
|
||||||
menu={contextMenu}
|
x={trackMenu.state.x}
|
||||||
|
y={trackMenu.state.y}
|
||||||
|
openLeft={trackMenu.state.openLeft}
|
||||||
|
onClose={() => trackMenu.close()}
|
||||||
|
items={[
|
||||||
|
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => trackMenu.playNext() }
|
||||||
|
]}
|
||||||
|
playlistAction={{
|
||||||
|
action: '?/addTrack',
|
||||||
|
fields: { trackId: String(trackMenu.state.track.id) },
|
||||||
|
label: 'Añadir a una playlist'
|
||||||
|
}}
|
||||||
{playlists}
|
{playlists}
|
||||||
onClose={() => (contextMenu = null)}
|
|
||||||
onAddToQueue={addToQueue}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
--spacing-icon-md: 1.125rem;
|
--spacing-icon-md: 1.125rem;
|
||||||
--spacing-icon-lg: 1.25rem;
|
--spacing-icon-lg: 1.25rem;
|
||||||
--spacing-icon-xl: 2.25rem;
|
--spacing-icon-xl: 2.25rem;
|
||||||
|
--spacing-section: 2.875rem;
|
||||||
|
|
||||||
--container-prose-sm: 26rem;
|
--container-prose-sm: 26rem;
|
||||||
--container-hero: 34rem;
|
--container-hero: 34rem;
|
||||||
@@ -96,16 +97,6 @@
|
|||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility stroke-thin {
|
|
||||||
stroke-width: 1.3;
|
|
||||||
}
|
|
||||||
@utility stroke-regular {
|
|
||||||
stroke-width: 1.8;
|
|
||||||
}
|
|
||||||
@utility stroke-bold {
|
|
||||||
stroke-width: 2.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
@utility text-display-1 {
|
@utility text-display-1 {
|
||||||
@apply font-display text-3xl font-medium tracking-tight sm:text-4xl;
|
@apply font-display text-3xl font-medium tracking-tight sm:text-4xl;
|
||||||
}
|
}
|
||||||
@@ -196,6 +187,29 @@
|
|||||||
--mf-dock-h: var(--mf-player-h);
|
--mf-dock-h: var(--mf-player-h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@utility genre-tile {
|
||||||
|
background: linear-gradient(
|
||||||
|
140deg,
|
||||||
|
oklch(var(--mf-tile-l) var(--mf-tile-c) var(--tile-hue)),
|
||||||
|
oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) var(--tile-hue))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@utility hero-surface {
|
||||||
|
background-image: var(--mf-hero-bg);
|
||||||
|
}
|
||||||
|
@utility spotlight-surface {
|
||||||
|
background-image:
|
||||||
|
var(--mf-spotlight-bg),
|
||||||
|
linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent 50%,
|
||||||
|
color-mix(in oklch, var(--mf-scrim) 22%, transparent) 61%,
|
||||||
|
transparent 92.5%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@utility field-control {
|
||||||
|
@apply w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
svg.lucide {
|
svg.lucide {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
import Logo from '$lib/components/ui/Logo.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -15,12 +16,7 @@
|
|||||||
<div class="pointer-events-none absolute inset-0" style="background:var(--mf-ambient)"></div>
|
<div class="pointer-events-none absolute inset-0" style="background:var(--mf-ambient)"></div>
|
||||||
|
|
||||||
<div class="relative flex w-full max-w-4xl flex-col items-center gap-12 text-center">
|
<div class="relative flex w-full max-w-4xl flex-col items-center gap-12 text-center">
|
||||||
<div class="flex items-center gap-3">
|
<Logo size="lg" />
|
||||||
<span class="h-8 w-8 rounded-xl bg-[image:var(--mf-logo-grad)]"></span>
|
|
||||||
<span class="font-display text-[clamp(1.5rem,3vw,2.5rem)] font-semibold tracking-tight">
|
|
||||||
Musify
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="animate-enter flex flex-col items-center gap-5">
|
<div class="animate-enter flex flex-col items-center gap-5">
|
||||||
<h1
|
<h1
|
||||||
@@ -32,7 +28,7 @@
|
|||||||
<p class="text-lg text-fg-2">Inicia sesión para continuar.</p>
|
<p class="text-lg text-fg-2">Inicia sesión para continuar.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex w-full max-w-80 flex-col gap-3">
|
<div class="flex w-full max-w-cta flex-col gap-3">
|
||||||
<Button href={`/auth/login?${returnParam}`} reload size="lg" class="w-full">
|
<Button href={`/auth/login?${returnParam}`} reload size="lg" class="w-full">
|
||||||
Iniciar sesión
|
Iniciar sesión
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user