Design Prot Merge #1
@@ -785,7 +785,7 @@ Tareas:
|
|||||||
| 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) |
|
| P10 | ✅ | (pendiente) | Decisión tomada (recomendada, sin bloquear): iconos en los botones de cabecera de playlist (antes solo álbum tenía). PlaylistHeader pierde su prop `id` sin usar tras inlinearlo. Explorar: la cabecera de "Resultados para…" ahora usa el `mb-7/8` de PageHeader en vez de un `mt-5` ajustado a mano antes de los chips de filtro — gap ligeramente mayor entre el resumen y los chips (Δ menor, documentado). Sin QA visual autenticada (Docker no disponible) |
|
||||||
| P11 | ✅ | (pendiente) | CoverForm añade snippet `extra` (no está en la spec literal, solo `fields`) para el contenido a ancho completo bajo la fila portada+campos (descripción de álbum, selector de visibilidad de playlist) que no cabía en una única columna. Upload usa ImageDropzone con `onselect` (prop nueva) y se envuelve en `{#key}` para poder resetear tras publicar — Δ: el contenedor de portada pasa de 144/176px (móvil/desktop) a 144px fijo (size-cover-hero, sin variante desktop mayor), y el icono de posición vacía de 64px a 36px (icon-xl), tal como recomienda el plan. Sin QA visual autenticada (Docker no disponible) |
|
| P11 | ✅ | (pendiente) | CoverForm añade snippet `extra` (no está en la spec literal, solo `fields`) para el contenido a ancho completo bajo la fila portada+campos (descripción de álbum, selector de visibilidad de playlist) que no cabía en una única columna. Upload usa ImageDropzone con `onselect` (prop nueva) y se envuelve en `{#key}` para poder resetear tras publicar — Δ: el contenedor de portada pasa de 144/176px (móvil/desktop) a 144px fijo (size-cover-hero, sin variante desktop mayor), y el icono de posición vacía de 64px a 36px (icon-xl), tal como recomienda el plan. Sin QA visual autenticada (Docker no disponible) |
|
||||||
| P12 | ✅ | (pendiente) | PlayerExtras se fusiona en PlayerBar (volumen/cola inline); Slider sustituye a SeekBar+VolumeControl (confirmado por grep que el modo `compact` de SeekBar era código muerto). Sidebar y TabsBar migran a `appNavLinks()` (P5). Decisión tomada (sin bloquear): Sidebar gana la marca-gradiente `Logo` que no tenía antes, para consistencia con MobileHeader/login. z-index de PlayerDock/MobileHeader corregido a `z-(--z-sticky)` (son barras fijas, no menús) y SegmentedControl a `z-(--z-raised)`, tal como indica B.1.5. `glass-bar`/`theme-transition` aplicados en PlayerBar (ambas variantes), TabsBar, Queue e Inicio (hero/spotlight), sustituyendo estilos duplicados. Sin QA visual autenticada (Docker no disponible); smoke test de `/login` sin errores de consola |
|
| 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 | ⬜ | | |
|
||||||
|
|
||||||
Decisiones de producto pendientes (preguntar al usuario antes de la parte indicada):
|
Decisiones de producto pendientes (preguntar al usuario antes de la parte indicada):
|
||||||
|
|||||||
@@ -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
|
||||||
|
}));
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { contextMenuPosition, type MenuPosition } from '$lib/components/ui/ContextMenu.svelte';
|
import { contextMenuPosition, type MenuPosition } from '$lib/components/ui/ContextMenu.svelte';
|
||||||
import { player } from './player/player.svelte';
|
import { player, toQueueItem, type ApiTrackLike } from './player/player.svelte';
|
||||||
import { queueItemForTarget, type TrackTarget } from './tracks';
|
|
||||||
|
|
||||||
export type TrackMenuState = TrackTarget & MenuPosition;
|
export type TrackMenuState = { track: ApiTrackLike } & MenuPosition;
|
||||||
|
|
||||||
export function createTrackMenu() {
|
export function createTrackMenu() {
|
||||||
let state = $state<TrackMenuState | null>(null);
|
let state = $state<TrackMenuState | null>(null);
|
||||||
@@ -11,15 +10,15 @@ export function createTrackMenu() {
|
|||||||
get state() {
|
get state() {
|
||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
open(event: MouseEvent, target: TrackTarget) {
|
open(event: MouseEvent, track: ApiTrackLike) {
|
||||||
state = { ...target, ...contextMenuPosition(event) };
|
state = { track, ...contextMenuPosition(event) };
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
state = null;
|
state = null;
|
||||||
},
|
},
|
||||||
playNext() {
|
playNext() {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
player.playNextItem(queueItemForTarget(state));
|
player.playNextItem(toQueueItem(state.track));
|
||||||
state = null;
|
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
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ export function toQueueItem(track: ApiTrackLike): QueueItem {
|
|||||||
return toQueueItems([track])[0];
|
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 {
|
export function isQueueCurrent(items: { id: string | number }[]): boolean {
|
||||||
return items.some((item) => item.id === player.current.id);
|
return items.some((item) => item.id === player.current.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<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, plural } 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';
|
||||||
@@ -21,20 +21,19 @@
|
|||||||
import ContextMenu, { contextMenuPosition } from '$lib/components/ui/ContextMenu.svelte';
|
import ContextMenu, { contextMenuPosition } from '$lib/components/ui/ContextMenu.svelte';
|
||||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
import ListEnd from '@lucide/svelte/icons/list-end';
|
import ListEnd from '@lucide/svelte/icons/list-end';
|
||||||
|
import { appendUnique } from '$lib/collections';
|
||||||
|
import { genreTiles } from '$lib/genres';
|
||||||
|
import {
|
||||||
|
type SearchFilter,
|
||||||
|
showGroup,
|
||||||
|
capped,
|
||||||
|
matchPlaylists,
|
||||||
|
searchCounts,
|
||||||
|
searchChips,
|
||||||
|
findTopResult
|
||||||
|
} from '$lib/search';
|
||||||
|
|
||||||
type AlbumMenuState = { x: number; y: number; openLeft: boolean; albumId: string };
|
type AlbumMenuState = { x: number; y: number; openLeft: boolean; albumId: string };
|
||||||
import { appendUnique } from '$lib/collections';
|
|
||||||
import {
|
|
||||||
isTargetCurrent,
|
|
||||||
queueItemForTarget,
|
|
||||||
targetArtist,
|
|
||||||
targetExplicit,
|
|
||||||
targetId,
|
|
||||||
targetListensCount,
|
|
||||||
targetOwnerUserId,
|
|
||||||
targetTitle,
|
|
||||||
type TrackTarget
|
|
||||||
} from '$lib/tracks';
|
|
||||||
|
|
||||||
const TOP_RESULT_KIND_LABEL: Record<TopResult['kind'], string> = {
|
const TOP_RESULT_KIND_LABEL: Record<TopResult['kind'], string> = {
|
||||||
track: 'Canción',
|
track: 'Canción',
|
||||||
@@ -43,37 +42,8 @@
|
|||||||
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;
|
|
||||||
type SearchFilter = (typeof SEARCH_FILTERS)[number];
|
|
||||||
const SEARCH_GROUP_PREVIEW = 4;
|
|
||||||
|
|
||||||
const trackMenu = createTrackMenu();
|
const trackMenu = createTrackMenu();
|
||||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||||
let sfilter = $state<SearchFilter>('Todo');
|
let sfilter = $state<SearchFilter>('Todo');
|
||||||
@@ -96,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);
|
||||||
@@ -109,71 +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 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(() => {
|
||||||
@@ -184,11 +135,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
||||||
trackMenu.open(event, target);
|
trackMenu.open(event, track);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
||||||
@@ -262,7 +213,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mb-7 flex flex-wrap gap-1.75 sm:mb-8">
|
<div class="mb-7 flex flex-wrap gap-1.75 sm:mb-8">
|
||||||
{#each searchChips as chip (chip.label)}
|
{#each chips as chip (chip.label)}
|
||||||
<Chip
|
<Chip
|
||||||
selected={sfilter === chip.label}
|
selected={sfilter === chip.label}
|
||||||
count={chip.count}
|
count={chip.count}
|
||||||
@@ -277,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'}
|
||||||
@@ -305,16 +256,16 @@
|
|||||||
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-[-0.02em] 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>
|
||||||
@@ -322,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}
|
||||||
|
|
||||||
@@ -382,32 +327,31 @@
|
|||||||
|
|
||||||
{#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" count={searchCounts.Canciones} />
|
<SectionHeading title="Canciones" count={counts.Canciones} />
|
||||||
<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>
|
||||||
<ListRow
|
<ListRow
|
||||||
title={targetTitle(item)}
|
title={track.title}
|
||||||
subtitle={targetArtist(item)}
|
subtitle={track.artist}
|
||||||
subtitleHref={targetOwnerUserId(item)}
|
subtitleHref={track.ownerUserId}
|
||||||
explicit={targetExplicit(item)}
|
active={player.current.id === track.id}
|
||||||
active={isTargetCurrent(item)}
|
|
||||||
size="lg"
|
size="lg"
|
||||||
trackId={targetId(item)}
|
trackId={track.id}
|
||||||
onclick={() => togglePlay(i)}
|
onclick={() => togglePlay(i)}
|
||||||
oncontextmenu={(e) => openContextMenu(e, item)}
|
oncontextmenu={(e) => openContextMenu(e, track)}
|
||||||
>
|
>
|
||||||
{#snippet overlay()}
|
{#snippet overlay()}
|
||||||
{#if isTargetCurrent(item)}
|
{#if player.current.id === track.id}
|
||||||
<EqBars overlay paused={!player.playing} />
|
<EqBars overlay paused={!player.playing} />
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
|
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
|
||||||
{fmtTime(seconds)} · {fmtPlays(targetListensCount(item))}
|
{fmtTime(seconds)} · {fmtPlays(track.listensCount)}
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ListRow>
|
</ListRow>
|
||||||
@@ -427,11 +371,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasAlbums && showGroup('Álbumes')}
|
{#if hasAlbums && showGroup(sfilter, 'Álbumes')}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading title="Álbumes" count={searchCounts.Álbumes} />
|
<SectionHeading title="Álbumes" count={counts.Álbumes} />
|
||||||
<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>
|
||||||
<ListRow
|
<ListRow
|
||||||
title={album.title}
|
title={album.title}
|
||||||
@@ -452,11 +396,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasUsers && showGroup('Usuarios')}
|
{#if hasUsers && showGroup(sfilter, 'Usuarios')}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading title="Usuarios" count={searchCounts.Usuarios} />
|
<SectionHeading title="Usuarios" count={counts.Usuarios} />
|
||||||
<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>
|
||||||
<ListRow title={u.name} href="/u/{u.id}" size="lg">
|
<ListRow title={u.name} href="/u/{u.id}" size="lg">
|
||||||
{#snippet art()}
|
{#snippet art()}
|
||||||
@@ -469,11 +413,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasPlaylists && showGroup('Playlists')}
|
{#if hasPlaylists && showGroup(sfilter, 'Playlists')}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading title="Playlists" count={searchCounts.Playlists} />
|
<SectionHeading title="Playlists" count={counts.Playlists} />
|
||||||
<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>
|
||||||
<ListRow
|
<ListRow
|
||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
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, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
import {
|
||||||
|
player,
|
||||||
|
isQueueCurrent,
|
||||||
|
playAllOrToggle,
|
||||||
|
playShuffled,
|
||||||
|
toQueueItem
|
||||||
|
} from '$lib/player/player.svelte';
|
||||||
import { fmtTime, plural } from '$lib/format';
|
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';
|
||||||
@@ -14,15 +20,14 @@
|
|||||||
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
|
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
|
||||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
import { createTrackMenu } from '$lib/menus.svelte';
|
import { createTrackMenu } from '$lib/menus.svelte';
|
||||||
import { mixItemTarget } from '$lib/mixes';
|
import { mixItemTrack } from '$lib/mixes';
|
||||||
import { queueItemForTarget } 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(
|
const listTracks = $derived(
|
||||||
items.map((item) => ({
|
items.map((item) => ({
|
||||||
id: item.trackId,
|
id: item.trackId,
|
||||||
@@ -97,7 +102,7 @@
|
|||||||
tracks={listTracks}
|
tracks={listTracks}
|
||||||
columns={['plays']}
|
columns={['plays']}
|
||||||
onPlay={playFrom}
|
onPlay={playFrom}
|
||||||
oncontextmenu={(e, _track, i) => trackMenu.open(e, targets[i])}
|
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])}
|
||||||
/>
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
|
|||||||
+226
-232
@@ -3,23 +3,20 @@
|
|||||||
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, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
import {
|
||||||
|
player,
|
||||||
|
isQueueCurrent,
|
||||||
|
playAllOrToggle,
|
||||||
|
playShuffled,
|
||||||
|
toQueueItem,
|
||||||
|
trackFromQueueItem,
|
||||||
|
type ApiTrackLike
|
||||||
|
} from '$lib/player/player.svelte';
|
||||||
import { pressable } from '$lib/actions/pressable';
|
import { pressable } from '$lib/actions/pressable';
|
||||||
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
|
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
|
||||||
import { mixItemTarget } from '$lib/mixes';
|
import { mixItemTrack } from '$lib/mixes';
|
||||||
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format';
|
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format';
|
||||||
import {
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
isTargetCurrent,
|
|
||||||
queueItemForTarget,
|
|
||||||
targetArtist,
|
|
||||||
targetExplicit,
|
|
||||||
targetForQueueItem,
|
|
||||||
targetId,
|
|
||||||
targetListensCount,
|
|
||||||
targetOwnerUserId,
|
|
||||||
targetTitle,
|
|
||||||
type TrackTarget
|
|
||||||
} from '$lib/tracks';
|
|
||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||||
import MediaCard from '$lib/components/ui/MediaCard.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';
|
||||||
@@ -36,7 +33,8 @@
|
|||||||
|
|
||||||
const trackMenu = createTrackMenu();
|
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);
|
||||||
|
|
||||||
@@ -47,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;
|
||||||
@@ -60,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)
|
||||||
);
|
);
|
||||||
@@ -81,11 +79,11 @@
|
|||||||
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(isQueueCurrent(spotlightQueue));
|
const spotlightIsCurrent = $derived(isQueueCurrent(spotlightQueue));
|
||||||
const heroPlaying = $derived(spotlightIsCurrent && player.playing);
|
const heroPlaying = $derived(spotlightIsCurrent && player.playing);
|
||||||
|
|
||||||
@@ -93,7 +91,7 @@
|
|||||||
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);
|
||||||
@@ -105,14 +103,14 @@
|
|||||||
playShuffled(spotlightQueue);
|
playShuffled(spotlightQueue);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
function openContextMenu(event: MouseEvent, track: ApiTrackLike) {
|
||||||
trackMenu.open(event, target);
|
trackMenu.open(event, track);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
|
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
player.playQueue(
|
player.playQueue(
|
||||||
mix.items.map((item) => queueItemForTarget(mixItemTarget(item))),
|
mix.items.map((item) => toQueueItem(mixItemTrack(item))),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -135,9 +133,9 @@
|
|||||||
<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 theme-transition sm:px-9 sm:py-8.5"
|
class="relative overflow-hidden rounded-3xl 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"
|
||||||
@@ -163,226 +161,222 @@
|
|||||||
</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="text-display-2 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="text-display-2 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="text-display-2 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}
|
|
||||||
<ListRow
|
|
||||||
onclick={() => playContinue(i)}
|
|
||||||
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
|
|
||||||
active={isCurrent}
|
|
||||||
size="lg"
|
|
||||||
variant="card"
|
|
||||||
title={item.title}
|
|
||||||
subtitle={item.artist}
|
|
||||||
subtitleHref={item.ownerUserId}
|
|
||||||
trackId={item.id}
|
|
||||||
class="animate-enter"
|
|
||||||
style="--i:{i}"
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{@render noListeningHistory()}
|
|
||||||
{/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 theme-transition 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
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
use:pressable={() => goto(`/playlists/${spotlight.id}`)}
|
|
||||||
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
|
||||||
>
|
|
||||||
<Artwork
|
|
||||||
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
|
|
||||||
spotlight.updatedAt
|
|
||||||
)}"
|
|
||||||
trackIds={spotlight.coverTrackIds}
|
|
||||||
size="2xl"
|
|
||||||
alt={spotlight.name}
|
|
||||||
class="shrink-0"
|
|
||||||
/>
|
|
||||||
<div class="flex min-w-0 flex-col gap-3">
|
|
||||||
<p class="text-eyebrow text-fg-2">Playlist destacada</p>
|
|
||||||
<h3 class="truncate text-display-2 text-fg">
|
|
||||||
{spotlight.name}
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-fg-2">{spotlightMeta}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="mt-5 max-w-prose-sm text-sm text-fg-2">
|
|
||||||
{spotlight.description || 'Tu colección, siempre a mano.'}
|
|
||||||
</p>
|
|
||||||
<div class="mt-5 flex gap-2.5">
|
|
||||||
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
|
|
||||||
<Button variant="glass" onclick={shuffleSpotlight}>Aleatorio</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6">
|
|
||||||
{#each spotlightRows as { target, seconds }, i (targetId(target))}
|
|
||||||
<ListRow
|
<ListRow
|
||||||
onclick={() => playSpotlightTrack(i)}
|
onclick={() => playContinue(i)}
|
||||||
active={isTargetCurrent(target)}
|
oncontextmenu={(e) => openContextMenu(e, trackFromQueueItem(item))}
|
||||||
size="sm"
|
active={isCurrent}
|
||||||
title={targetTitle(target)}
|
size="lg"
|
||||||
subtitle={targetArtist(target)}
|
variant="card"
|
||||||
subtitleHref={targetOwnerUserId(target)}
|
title={item.title}
|
||||||
>
|
subtitle={item.artist}
|
||||||
{#snippet leading()}
|
subtitleHref={item.ownerUserId}
|
||||||
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums">{i + 1}</span
|
trackId={item.id}
|
||||||
>
|
class="animate-enter"
|
||||||
{/snippet}
|
style="--i:{i}"
|
||||||
{#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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
|
{@render noListeningHistory()}
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- MIXES -->
|
<!-- PLAYLIST DESTACADA -->
|
||||||
<section>
|
{#if spotlight}
|
||||||
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
|
<section>
|
||||||
{#if mixes.length > 0}
|
<div
|
||||||
<div class="grid-tiles">
|
class="grid grid-cols-1 overflow-hidden rounded-panel-lg spotlight-surface theme-transition lg:grid-cols-2"
|
||||||
{#each mixes as mix, i (mix.id)}
|
>
|
||||||
<MediaCard
|
<div class="flex flex-col justify-center p-8 sm:p-9">
|
||||||
href="/mixes/{mix.id}"
|
<div
|
||||||
title={mix.title}
|
role="button"
|
||||||
subtitle={mix.subtitle}
|
tabindex="0"
|
||||||
trackIds={mix.items.map((item) => item.trackId)}
|
use:pressable={() => goto(`/playlists/${spotlight.id}`)}
|
||||||
onPlay={(event) => playMix(mix, event)}
|
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
||||||
index={i}
|
>
|
||||||
/>
|
<Artwork
|
||||||
{/each}
|
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
|
||||||
</div>
|
spotlight.updatedAt
|
||||||
{:else}
|
)}"
|
||||||
<EmptyState
|
trackIds={spotlight.coverTrackIds}
|
||||||
icon={Shuffle}
|
size="2xl"
|
||||||
title="Todavía no tienes mezclas"
|
alt={spotlight.name}
|
||||||
description="Se generan automáticamente a partir de lo que escuchas."
|
class="shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="flex min-w-0 flex-col gap-3">
|
||||||
|
<p class="text-eyebrow text-fg-2">Playlist destacada</p>
|
||||||
|
<h3 class="truncate text-display-2 text-fg">
|
||||||
|
{spotlight.name}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-fg-2">{spotlightMeta}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-5 max-w-prose-sm text-sm text-fg-2">
|
||||||
|
{spotlight.description || 'Tu colección, siempre a mano.'}
|
||||||
|
</p>
|
||||||
|
<div class="mt-5 flex gap-2.5">
|
||||||
|
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
|
||||||
|
<Button variant="glass" onclick={shuffleSpotlight}>Aleatorio</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6">
|
||||||
|
{#each spotlightRows as { track, seconds }, i (track.id)}
|
||||||
|
<ListRow
|
||||||
|
onclick={() => playSpotlightTrack(i)}
|
||||||
|
active={player.current.id === track.id}
|
||||||
|
size="sm"
|
||||||
|
title={track.title}
|
||||||
|
subtitle={track.artist}
|
||||||
|
subtitleHref={track.ownerUserId}
|
||||||
|
>
|
||||||
|
{#snippet leading()}
|
||||||
|
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums"
|
||||||
|
>{i + 1}</span
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet trailing()}
|
||||||
|
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
|
||||||
|
{/snippet}
|
||||||
|
</ListRow>
|
||||||
|
{:else}
|
||||||
|
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- MIXES -->
|
||||||
|
<section>
|
||||||
|
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
|
||||||
|
{#if mixes.length > 0}
|
||||||
|
<div class="grid-tiles">
|
||||||
|
{#each mixes as mix, i (mix.id)}
|
||||||
|
<MediaCard
|
||||||
|
href="/mixes/{mix.id}"
|
||||||
|
title={mix.title}
|
||||||
|
subtitle={mix.subtitle}
|
||||||
|
trackIds={mix.items.map((item) => item.trackId)}
|
||||||
|
onPlay={(event) => playMix(mix, event)}
|
||||||
|
index={i}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<EmptyState
|
||||||
|
icon={Shuffle}
|
||||||
|
title="Todavía no tienes mezclas"
|
||||||
|
description="Se generan automáticamente a partir de lo que escuchas."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- POPULARES ESTA SEMANA -->
|
||||||
|
<section>
|
||||||
|
<SectionHeading title="Populares esta semana" subtitle="Lo más escuchado de tu biblioteca" />
|
||||||
|
{#if popularTracks.length > 0}
|
||||||
|
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
|
||||||
|
{#each popularTracks as track, i (track.id)}
|
||||||
|
{@const isCurrent = player.current.id === track.id}
|
||||||
|
<ListRow
|
||||||
|
onclick={() => playPopular(i)}
|
||||||
|
oncontextmenu={(e) => openContextMenu(e, track)}
|
||||||
|
active={isCurrent}
|
||||||
|
size="sm"
|
||||||
|
title={track.title}
|
||||||
|
subtitle={track.artist}
|
||||||
|
subtitleHref={track.ownerUserId}
|
||||||
|
explicit={track.isExplicit}
|
||||||
|
trackId={track.id}
|
||||||
|
>
|
||||||
|
{#snippet leading()}
|
||||||
|
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
|
||||||
|
{#if isCurrent}
|
||||||
|
<EqBars size={13} paused={!player.playing} />
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet trailing()}
|
||||||
|
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
|
||||||
|
{fmtPlays(track.listensCount)}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</ListRow>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render noListeningHistory()}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TUS PLAYLISTS -->
|
||||||
|
<section>
|
||||||
|
<SectionHeading
|
||||||
|
title="Tus playlists"
|
||||||
|
subtitle="Creadas y guardadas por ti"
|
||||||
|
href={data.playlistsHasMore ? '/playlists' : undefined}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{#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(
|
||||||
<ListRow
|
playlist.updatedAt
|
||||||
onclick={() => playPopular(i)}
|
)}"
|
||||||
oncontextmenu={(e) => openContextMenu(e, target)}
|
trackIds={playlist.coverTrackIds}
|
||||||
active={isCurrent}
|
style="--i:{i}"
|
||||||
size="sm"
|
/>
|
||||||
title={targetTitle(target)}
|
{/each}
|
||||||
subtitle={targetArtist(target)}
|
</div>
|
||||||
subtitleHref={targetOwnerUserId(target)}
|
{:else}
|
||||||
explicit={targetExplicit(target)}
|
<EmptyState
|
||||||
trackId={targetId(target)}
|
icon={ListMusic}
|
||||||
>
|
title="Todavía no tienes listas"
|
||||||
{#snippet leading()}
|
description="Crea tu primera lista y añádele canciones de tu biblioteca."
|
||||||
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
|
>
|
||||||
{#if isCurrent}
|
{#snippet actions()}
|
||||||
<EqBars size={13} paused={!player.playing} />
|
<Button href="/playlists">Crear lista</Button>
|
||||||
{:else}
|
{/snippet}
|
||||||
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
|
</EmptyState>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</section>
|
||||||
{/snippet}
|
</div>
|
||||||
{#snippet trailing()}
|
</Page>
|
||||||
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
|
|
||||||
{fmtPlays(targetListensCount(target))}
|
|
||||||
</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 playlists.length > 0}
|
|
||||||
<div class="grid-wide">
|
|
||||||
{#each playlists as playlist, i (playlist.id)}
|
|
||||||
<ListRow
|
|
||||||
onclick={() => goto(`/playlists/${playlist.id}`)}
|
|
||||||
size="lg"
|
|
||||||
variant="card"
|
|
||||||
title={playlist.name}
|
|
||||||
subtitle={playlist.description}
|
|
||||||
coverSrc="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
|
||||||
playlist.updatedAt
|
|
||||||
)}"
|
|
||||||
trackIds={playlist.coverTrackIds}
|
|
||||||
style="--i:{i}"
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<EmptyState
|
|
||||||
icon={ListMusic}
|
|
||||||
title="Todavía no tienes listas"
|
|
||||||
description="Crea tu primera lista y añádele canciones de tu biblioteca."
|
|
||||||
>
|
|
||||||
{#snippet actions()}
|
|
||||||
<Button href="/playlists">Crear lista</Button>
|
|
||||||
{/snippet}
|
|
||||||
</EmptyState>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if trackMenu.state}
|
{#if trackMenu.state}
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -203,6 +204,19 @@
|
|||||||
oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-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 {
|
@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;
|
@apply w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user