Una sola tabla de canciones, y la cola se puede llenar

Había dos listados: una tabla ordenable para el catálogo y una lista
aparte para los discos. Cada arreglo había que hacerlo dos veces y el
resultado era que la portada estaba en una y el año en la otra. Ahora es
un solo componente, y lo que cambia son las columnas de contexto, que se
encienden por props: dentro de un disco, la columna «Álbum» diría diez
veces lo mismo.

Solo recibe canciones. El estilo, el disco y la portada los resuelve
contra el catálogo que ya baja en `page.data`, así que quien la usa no
tiene que armar filas a mano, que era lo que obligaba a repetir código.

Cada fila lleva su portada —con la ampliada al pasar el ratón—, y debajo
del título van los géneros y las etiquetas en vez del primer verso: la
letra está entera en la ficha, y aquí lo que sirve para elegir es de qué
está hecho el tema. Las etiquetas se cortan por número, con un «+2» al
final: recortarlas con un degradado dejaba «Autoengaño» en «Autoeng», que
no parece una elección sino algo roto.

La cola ya se puede llenar. Hasta ahora solo se podía sustituir —cada
play la vaciaba y la llenaba con su contexto—, así que no había forma de
armar una escucha cogiendo temas de discos distintos. `encolar()` es la
otra mitad, y con la cola vacía arranca, porque un botón que no hace nada
es peor que no tenerlo.

Los géneros de una canción entran en el modelo: `cancion_genero` existía
en la base y no llegaba a la web. Son cosa distinta del estilo —el estilo
es cómo se ordena la obra aquí y es uno solo; los géneros son de qué está
hecha la canción y pueden ser varios— y llevan al glosario.

De paso: las letras largas van a dos columnas de 37ch, que es el mínimo
que deja caber dos y a la vez el verso más largo del catálogo —importa
afinarlo, porque en una letra dónde acaba cada verso es parte del texto—;
la página baja de 2808 a 2023 píxeles. Contacto sale de la barra de menú
y se queda solo en el pie. Y el titular pasa a «Letrista y creador
musical».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 4 weeks ago
parent 4d2a2072c2
commit fa4de8b2f6

@ -89,7 +89,9 @@ test.describe('Página de música', () => {
await columna.getByRole('button').click();
await expect(columna).toHaveAttribute('aria-sort', 'descending');
const duraciones = await page.locator('tbody tr td:nth-child(6)').allInnerTexts();
// Por su clase y no por su posición: la tabla lleva portada, y el número
// de columna cambia con lo que se enseñe en cada página.
const duraciones = await page.locator('tbody td.duracion').allInnerTexts();
const enSegundos = duraciones.map((d) => {
const [m, s] = d.trim().split(':').map(Number);
return m * 60 + s;

@ -5,7 +5,7 @@ test.describe('Navegación', () => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Senza Paura');
await expect(page.getByText('Letrista y compositor').first()).toBeVisible();
await expect(page.getByText('Letrista y creador musical').first()).toBeVisible();
// La cabecera destaca un tema y ofrece el catálogo entero.
await page.getByRole('link', { name: 'Toda la música' }).click();

@ -90,9 +90,12 @@ test.describe('Responsive', () => {
// Por nombre y no por cuenta: en móvil no se recorta el menú, y así la
// prueba dice cuál falta en vez de que el número no cuadra.
for (const seccion of ['Música', 'Taller de letras', 'Géneros', 'Blog', 'Bio', 'Contacto']) {
for (const seccion of ['Música', 'Taller de letras', 'Géneros', 'Blog', 'Bio']) {
await expect(nav.getByRole('link', { name: seccion })).toBeVisible();
}
// Contacto no: va solo en el pie, que es donde se busca escribir.
await expect(nav.getByRole('link', { name: 'Contacto' })).toHaveCount(0);
});
test('las barras laterales bajan en vez de desaparecer', async ({ page }) => {

@ -5,15 +5,11 @@ const CANCIONES_POR_PACK = 10;
/** Añade un tema desde el listado de un álbum, por su título. */
async function anadir(page: Page, titulo: string) {
// Dentro de la lista de temas, no en cualquier lista: las migas de pan
// también son elementos de lista, y el título del disco se repite en ellas.
const fila = page
.getByRole('list', { name: 'Temas' })
.getByRole('listitem')
.filter({ hasText: titulo })
.first();
await fila.getByRole('button', { name: 'Añadir' }).click();
await expect(fila.getByRole('button', { name: 'Quitar' })).toBeVisible();
// Dentro de la tabla de temas, no en cualquier sitio: el título del disco se
// repite en las migas de pan y en la cabecera.
const fila = page.getByRole('row').filter({ hasText: titulo }).first();
await fila.getByRole('button', { name: 'Añadir', exact: true }).click();
await expect(fila.getByRole('button', { name: 'Quitar', exact: true })).toBeVisible();
}
/**
@ -31,9 +27,7 @@ async function llenarUnPack(page: Page, cuantas = CANCIONES_POR_PACK) {
if (puestas >= cuantas) break;
await page.goto(`/albumes/${album}`);
const botones = page
.getByRole('list', { name: 'Temas' })
.getByRole('button', { name: 'Añadir' });
const botones = page.getByRole('table').getByRole('button', { name: 'Añadir', exact: true });
while (puestas < cuantas && (await botones.count()) > 0) {
await botones.first().click();
// El botón cambia a «Quitar», así que la cuenta de «Añadir» baja sola.

@ -1,6 +1,6 @@
---
titulo: 'Biografía'
descripcion: 'Letrista y compositor. Escribe baladas románticas desde hace veinte años, y todo lo demás por curiosidad.'
descripcion: 'Letrista y creador musical. Escribe baladas románticas desde hace veinte años, y todo lo demás por curiosidad.'
---
Escribo canciones desde antes de saber que eso era un oficio.

@ -0,0 +1,133 @@
<script lang="ts">
/**
* Añadir un tema al final de la cola de reproducción.
*
* A diferencia de favoritos, esto no toca el servidor: la cola vive en el
* navegador y se pierde al cerrar. Por eso no hace falta cuenta y por eso es
* un botón y no un formulario.
*
* Da acuse de recibo. Sin él, pulsar «añadir a la cola» cuando ya hay algo
* sonando no cambia nada de lo que se ve —la cola está plegada— y parece que
* el botón está roto.
*/
import Icono from './Icono.svelte';
import { reproductor } from '$lib/player/player.svelte';
import { aPista } from '$lib/player/pista';
import type { Cancion } from '$lib/types';
interface Props {
cancion: Cancion;
/** Solo el icono, para una fila de una tabla. */
compacto?: boolean;
tamano?: number;
}
let { cancion, compacto = false, tamano = 18 }: Props = $props();
const pista = $derived(aPista(cancion));
const sinAudio = $derived(!pista.audio);
const yaEsta = $derived(reproductor.cola.some((p) => p.id === pista.id));
let acusado = $state(false);
let reloj: ReturnType<typeof setTimeout>;
function anadir(): void {
reproductor.encolar([pista]);
acusado = true;
clearTimeout(reloj);
reloj = setTimeout(() => (acusado = false), 1600);
}
const texto = $derived(
sinAudio
? `«${cancion.titulo}» todavía no tiene audio`
: yaEsta
? `«${cancion.titulo}» ya está en la cola`
: `Añadir «${cancion.titulo}» a la cola`
);
</script>
<!--
La envoltura está posicionada a propósito. El acuse de abajo es
`position: absolute` —lo pone `.visually-hidden`— y, sin un ancestro
posicionado cerca, su bloque contenedor acaba siendo la página entera: dentro
de una tabla que se desplaza a lo ancho, eso se sale del marco y estira el
documento trescientos píxeles a la derecha.
-->
<span class="envoltura">
<button
type="button"
class="boton"
class:compacto
class:puesta={yaEsta}
disabled={sinAudio}
title={texto}
aria-label={texto}
onclick={anadir}
>
<Icono nombre="anadir-cola" {tamano} />
{#if !compacto}
<span>{yaEsta ? 'En la cola' : 'A la cola'}</span>
{/if}
</button>
<!--
El acuse se anuncia, además de verse: quien no mira la pantalla necesita
saber que la pulsación ha hecho algo.
-->
<span class="visually-hidden" role="status">
{acusado ? `«${cancion.titulo}» añadida a la cola.` : ''}
</span>
</span>
<style>
.envoltura {
position: relative;
display: inline-flex;
}
.boton {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: transparent;
color: var(--c-text-muted);
font: inherit;
font-size: var(--boton-texto);
cursor: pointer;
transition:
color 0.2s ease,
border-color 0.2s ease;
}
.boton:hover:not(:disabled),
.boton:focus-visible {
color: var(--c-text);
border-color: var(--c-accent);
}
.boton.puesta {
color: var(--c-accent);
}
.boton:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.boton.compacto {
padding: var(--boton-relleno-corto);
font-size: var(--boton-texto-corto);
border: none;
gap: 0;
}
@media (prefers-reduced-motion: reduce) {
.boton {
transition: none;
}
}
</style>

@ -101,28 +101,14 @@
--velo: oklch(0.45 0.13 var(--tono) / 0.1);
}
/* En una fila: sin caja, solo el nombre en su color y un punto delante. */
/* La misma cápsula, más pequeña: en una fila no debe pesar más que el título. */
.menudas {
gap: var(--sp-3);
gap: var(--sp-2);
}
.menudas .etiqueta {
padding: 0;
border: 0;
padding: 0.1em 0.55em;
font-size: 0.6875rem;
letter-spacing: 0.02em;
}
.menudas li + li .etiqueta::before {
content: '·';
margin-right: var(--sp-3);
color: var(--c-text-muted);
}
.menudas a.etiqueta:hover,
.menudas a.etiqueta:focus-visible {
background: none;
text-decoration: underline;
}
a.etiqueta:hover,

@ -31,6 +31,21 @@
},
silencio: { tipo: 'linea', d: ['M11 5 6 9H2v6h4l5 4z', 'M22 9l-6 6', 'M16 9l6 6'] },
cola: { tipo: 'linea', d: ['M4 6h11', 'M4 12h11', 'M4 18h7', 'M17 11l4 3-4 3z'] },
/* Las mismas tres rayas de la cola, con un «+» en vez de la flecha. */
'anadir-cola': {
tipo: 'linea',
d: ['M4 6h11', 'M4 12h11', 'M4 18h7', 'M18 12v8', 'M14 16h8']
},
/* Una lista con una estrella: la lista propia, no la de ahora. */
lista: {
tipo: 'linea',
d: [
'M4 6h10',
'M4 12h10',
'M4 18h6',
'M17.5 12.5l1.4 2.9 3.1.4-2.3 2.2.6 3.1-2.8-1.5-2.8 1.5.6-3.1-2.3-2.2 3.1-.4z'
]
},
cerrar: { tipo: 'linea', d: ['M6 6l12 12', 'M18 6 6 18'] },
menu: { tipo: 'linea', d: ['M4 7h16', 'M4 12h16', 'M4 17h16'] },
externo: {

@ -1,325 +0,0 @@
<script lang="ts">
/**
* Listado de temas.
*
* Todas las canciones que recibe forman un unico contexto de reproduccion:
* al pulsar play sobre cualquiera, la cola se llena con la lista entera y
* sigue sonando sola hasta el final, como en un album.
*/
import { resolve } from '$app/paths';
import BotonCarrito from './BotonCarrito.svelte';
import BotonReproducir from './BotonReproducir.svelte';
import Etiquetas from './Etiquetas.svelte';
import SinPortada from './SinPortada.svelte';
import { aPistas } from '$lib/player/pista';
import { reproductor } from '$lib/player/player.svelte';
import type { Cancion } from '$lib/types';
import { listaDeNombres, segundosADuracion } from '$lib/utils/format';
interface Props {
canciones: Cancion[];
/** Numera los temas segun su posicion en el disco. */
numerar?: boolean;
}
let { canciones, numerar = true }: Props = $props();
const pistas = $derived(aPistas(canciones));
/**
* La portada ampliada al pasar el ratón por encima de la miniatura.
*
* Va en `position: fixed` y no dentro de la fila porque una fila mide 64 px de
* alto: colgada de ahí, la tarjeta se saldría de la lista y quedaría a merced
* de cualquier `overflow` de los antepasados. Fija, solo depende de la
* ventana, y por eso se puede pegar al borde sin que nada la recorte.
*
* Solo con ratón. No se abre con el teclado a propósito: haría falta un punto
* de tabulación por fila —diez en un disco— para enseñar una imagen que ya
* está, más grande, en la ficha del tema a la que lleva el título.
*/
const LADO = 260;
const AIRE = 12;
/** El lado más el relleno y la línea del título: lo que mide de alto. */
const ALTO = LADO + 46;
let vista = $state<{ src: string; titulo: string; x: number; y: number } | null>(null);
function asomar(evento: MouseEvent, cancion: Cancion): void {
if (!cancion.portada) return;
const caja = (evento.currentTarget as HTMLElement).getBoundingClientRect();
const margen = 16;
/* A la derecha si cabe; si no, al otro lado. En una ventana estrecha no
cabe en ninguno de los dos, y entonces se pega al margen izquierdo. */
const derecha = caja.right + AIRE;
const izquierda = caja.left - AIRE - LADO;
const x =
derecha + LADO <= window.innerWidth - margen
? derecha
: izquierda >= margen
? izquierda
: margen;
/* Centrada en la fila, sin salirse por arriba ni por abajo. */
const centro = caja.top + caja.height / 2 - ALTO / 2;
const y = Math.min(Math.max(centro, margen), window.innerHeight - ALTO - margen);
vista = { src: cancion.portada, titulo: cancion.titulo, x, y };
}
</script>
{#if canciones.length === 0}
<p class="muted">Todavía no hay temas publicados en esta sección.</p>
{:else}
<ol class="lista" aria-label="Temas">
{#each canciones as cancion, i (cancion.slug)}
<li class="fila" class:sonando={reproductor.esActual(cancion.slug)}>
<div class="control">
{#if numerar && cancion.numero !== null}
<span class="numero muted" aria-hidden="true">{cancion.numero}</span>
{/if}
<BotonReproducir {pistas} indice={i} />
</div>
<!--
La portada del tema, no la del disco: en un álbum donde cada canción
tiene la suya, la lista es lo único que las enseña juntas. Va muda
—`alt` vacío, `muda` en el hueco— porque el título está justo al lado
y repetirlo solo alarga la lectura con un lector de pantalla.
-->
<a
class="mini"
href={resolve('/canciones/[slug]', { slug: cancion.slug })}
tabindex="-1"
onmouseenter={(e) => asomar(e, cancion)}
onmouseleave={() => (vista = null)}
>
{#if cancion.portada}
<img src={cancion.portada} alt="" width="72" height="72" loading="lazy" />
{:else}
<SinPortada titulo={cancion.titulo} lado={72} muda />
{/if}
</a>
<div class="datos">
<a class="titulo" href={resolve('/canciones/[slug]', { slug: cancion.slug })}>
{cancion.titulo}
</a>
<!--
Géneros y etiquetas, no el primer verso. La letra ya está en la
ficha, entera; aquí lo que sirve es saber de qué está hecho cada
tema para elegir cuál abrir.
-->
<div class="senas">
{#if cancion.instrumental}
<span class="marca">Instrumental</span>
{/if}
{#each cancion.generos as g (g.slug)}
<a class="genero" href={resolve('/generos/[slug]', { slug: g.slug })}>{g.nombre}</a>
{/each}
<Etiquetas
etiquetas={cancion.etiquetas}
titulo="Etiquetas de {cancion.titulo}"
menudas
/>
{#if cancion.generos.length === 0 && cancion.etiquetas.length === 0}
<span class="muted sin">{listaDeNombres(cancion.interpretes)}</span>
{/if}
</div>
</div>
<div class="final">
<BotonCarrito {cancion} compacto />
<span class="duracion muted">
{cancion.duracion ? segundosADuracion(cancion.duracion) : '--:--'}
</span>
</div>
</li>
{/each}
</ol>
{#if vista}
<!--
`aria-hidden`: es la misma portada de la miniatura de al lado, en grande.
Para quien no la ve no añade nada, y anunciarla interrumpiría la lectura
de la lista cada vez que el ratón roza una fila.
-->
<div
class="vista-previa"
style="--x: {vista.x}px; --y: {vista.y}px; --lado: {LADO}px"
aria-hidden="true"
>
<img src={vista.src} alt="" width={LADO} height={LADO} />
<p>{vista.titulo}</p>
</div>
{/if}
{/if}
<style>
.lista {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid var(--c-border);
}
.fila {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-1);
border-bottom: 1px solid var(--c-border);
}
.mini {
display: block;
width: 72px;
line-height: 0;
}
.mini img {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: var(--radius-tarjeta);
background: var(--c-bg-sunken);
}
/*
* `pointer-events: none` para que la tarjeta no se cruce con el ratón: si lo
* capturase, al salir de la miniatura hacia ella el `mouseleave` la cerraría
* y volvería a abrirse en bucle.
*/
.vista-previa {
position: fixed;
top: var(--y);
left: var(--x);
z-index: 40;
width: var(--lado);
padding: var(--sp-2);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
box-shadow: 0 12px 32px rgb(0 0 0 / 0.45);
pointer-events: none;
}
.vista-previa img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1;
object-fit: cover;
border-radius: calc(var(--radius-tarjeta) - 2px);
}
.vista-previa p {
margin: var(--sp-2) 0 0;
font-size: var(--fs-xs);
color: var(--c-text-muted);
text-align: center;
}
.fila:hover {
background: var(--c-bg-raised);
}
.fila.sonando .titulo {
font-weight: 600;
}
.control {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.numero {
width: 1.5em;
text-align: right;
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
.datos {
min-width: 0;
}
.titulo {
text-decoration: none;
}
.titulo:hover {
text-decoration: underline;
}
/*
* En una línea y sin envolver: en una lista de diez temas, una fila que crece
* porque su canción tiene una etiqueta más rompe el ritmo de todas. Lo que no
* cabe se recorta con el degradado del final.
*/
.senas {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
margin-top: var(--sp-1);
overflow: hidden;
mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent);
}
.senas :global(.etiquetas) {
flex-wrap: nowrap;
}
.senas > *,
.senas :global(li) {
flex: none;
}
/* El género es del glosario, no del catálogo: se distingue con el oro. */
.genero {
padding: 0.2em 0.7em;
border: 1px solid var(--c-accent);
border-radius: var(--radius-boton);
font-size: var(--fs-xs);
color: var(--c-accent);
text-decoration: none;
white-space: nowrap;
}
.genero:hover,
.genero:focus-visible {
background: color-mix(in oklab, var(--c-accent) 14%, transparent);
}
.sin {
font-size: var(--fs-xs);
white-space: nowrap;
}
.marca {
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
padding: 0 0.4em;
font-size: var(--fs-xs);
color: var(--c-text-muted);
white-space: nowrap;
}
.final {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.duracion {
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
min-width: 3.2em;
text-align: right;
}
</style>

@ -1,107 +0,0 @@
import { page } from '@vitest/browser/context';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import ListaCanciones from './ListaCanciones.svelte';
import type { Cancion } from '$lib/types';
function cancion(parcial: Partial<Cancion> = {}): Cancion {
return {
slug: 'como-si-nada',
titulo: 'Como si nada',
estilo: 'baladas-romanticas',
album: 'como-si-nada',
numero: 1,
audio: 'como-si-nada/01-como-si-nada.wav',
duracion: 252,
ano: 2024,
letra: ['Senza Paura'],
musica: ['Senza Paura'],
interpretes: ['Marta Cifuentes'],
destacada: false,
etiquetas: [],
generos: [],
enlaces: {},
instrumental: false,
idioma: 'es',
inspiracion: [],
extracto: [],
versiones: [],
alaVenta: true,
registro: { reparto: [] },
videos: [],
...parcial
};
}
describe('ListaCanciones', () => {
it('pinta el titulo y la duracion de cada tema', async () => {
render(ListaCanciones, { canciones: [cancion()] });
await expect.element(page.getByRole('link', { name: 'Como si nada' })).toBeInTheDocument();
await expect.element(page.getByText('4:12')).toBeInTheDocument();
});
it('enlaza cada tema con su ficha', async () => {
render(ListaCanciones, { canciones: [cancion()] });
await expect
.element(page.getByRole('link', { name: 'Como si nada' }))
.toHaveAttribute('href', '/canciones/como-si-nada');
});
it('enseña de qué está hecho el tema, no su letra', async () => {
// El primer verso ya no va aquí: la letra está entera en la ficha, y en
// una lista lo que sirve para elegir es el género y las etiquetas.
render(ListaCanciones, {
canciones: [
cancion({
extracto: ['Entras en la casa y no preguntas nada,'],
generos: [{ slug: 'bolero', nombre: 'Bolero' }],
etiquetas: ['Desamor']
})
]
});
await expect.element(page.getByRole('link', { name: 'Bolero' })).toBeInTheDocument();
await expect.element(page.getByRole('link', { name: 'Desamor' })).toBeInTheDocument();
expect(document.body.textContent).not.toContain('Entras en la casa');
});
it('cae en los intérpretes cuando no hay género ni etiqueta', async () => {
render(ListaCanciones, {
canciones: [cancion({ generos: [], etiquetas: [], interpretes: ['Marta Cifuentes'] })]
});
await expect.element(page.getByText('Marta Cifuentes')).toBeInTheDocument();
});
it('marca las piezas instrumentales en vez de acreditar la letra', async () => {
render(ListaCanciones, {
canciones: [cancion({ slug: 'preludio-i', titulo: 'Preludio I', instrumental: true })]
});
await expect.element(page.getByText(/Instrumental/)).toBeInTheDocument();
});
it('desactiva el play de los temas sin audio', async () => {
// Se publica la ficha y la letra aunque el audio aun no exista; lo que no
// puede pasar es que el boton prometa algo que no va a sonar.
render(ListaCanciones, { canciones: [cancion({ audio: undefined })] });
await expect
.element(page.getByRole('button', { name: 'Reproducir Como si nada' }))
.toBeDisabled();
});
it('muestra la duracion desconocida como guiones', async () => {
render(ListaCanciones, { canciones: [cancion({ duracion: undefined })] });
await expect.element(page.getByText('--:--')).toBeInTheDocument();
});
it('avisa cuando no hay temas', async () => {
render(ListaCanciones, { canciones: [] });
await expect.element(page.getByText(/no hay temas publicados/i)).toBeInTheDocument();
});
});

@ -1,14 +1,6 @@
<script module lang="ts">
import type { Cancion } from '$lib/types';
/** Una canción con el contexto que hace falta para leerla en una fila. */
export interface FilaCancion {
cancion: Cancion;
estiloSlug: string;
estiloNombre: string;
albumSlug: string;
albumTitulo: string;
}
/** Columnas de contexto que se pueden encender. */
export type Contexto = 'estilo' | 'album' | 'ano';
/** Columnas por las que se puede ordenar. */
export type Campo = 'titulo' | 'estilo' | 'album' | 'ano' | 'duracion';
@ -16,54 +8,100 @@
<script lang="ts">
/**
* Todos los temas en una tabla, ordenable por cualquier columna.
* La tabla de temas. **Es la única**: la usan el catálogo, la ficha de un
* disco y la de un estilo.
*
* Es la vista de quien ya conoce el catálogo y busca algo concreto: cuál
* dura menos, qué salió en 2022, cuántos hay de rock. Las tarjetas sirven
* para descubrir; esto sirve para comparar.
* Antes había dos —una tabla ordenable para el catálogo y una lista aparte
* para los discos— y cada arreglo había que hacerlo dos veces, con el
* resultado de que la portada estaba en una y el año en la otra. Aquí la
* información es la misma siempre y lo que cambia son las columnas de
* contexto, que se encienden por props: dentro de un disco, la columna
* «Álbum» diría diez veces lo mismo.
*
* Ordenar es cosa del cliente porque el catálogo entero va compilado en el
* bundle: no hay ninguna consulta que hacer ni ninguna espera que enseñar.
* Solo recibe canciones. El estilo, el disco y la portada los resuelve contra
* el catálogo que ya baja en `page.data`, para que quien la use no tenga que
* armar filas a mano —que es lo que obligaba a repetir código en cada
* página—.
*/
import { resolve } from '$app/paths';
import BotonCarrito from './BotonCarrito.svelte';
import BotonCola from './BotonCola.svelte';
import BotonFavorito from './BotonFavorito.svelte';
import BotonReproducir from './BotonReproducir.svelte';
import Etiquetas from './Etiquetas.svelte';
import SinPortada from './SinPortada.svelte';
import { albumDeCancion, obtenerAlbum, obtenerEstilo } from '$lib/catalogo/cliente.svelte';
import { aPistas } from '$lib/player/pista';
import { segundosADuracion } from '$lib/utils/format';
import { reproductor } from '$lib/player/player.svelte';
import type { Cancion } from '$lib/types';
import { listaDeNombres, segundosADuracion } from '$lib/utils/format';
interface Props {
filas: FilaCancion[];
/** Mensaje cuando no hay nada que enseñar. */
canciones: Cancion[];
/** Numera por su posición en el disco. Fuera de un disco no significa nada. */
numerar?: boolean;
/** Qué columnas de contexto se enseñan. */
contexto?: Contexto[];
/** Ordenar pulsando los encabezados. En un disco no: manda el orden del disco. */
ordenable?: boolean;
vacio?: string;
}
let { filas, vacio = 'Ningún tema coincide.' }: Props = $props();
let {
canciones,
numerar = false,
contexto = ['estilo', 'album', 'ano'],
ordenable = false,
vacio = 'Ningún tema coincide.'
}: Props = $props();
/** Lo que hace falta para pintar una fila, resuelto una vez. */
const filas = $derived(
canciones.map((cancion) => {
const albumSlug = albumDeCancion(cancion);
return {
cancion,
estilo: obtenerEstilo(cancion.estilo),
album: albumSlug ? obtenerAlbum(albumSlug) : undefined
};
})
);
type Fila = (typeof filas)[number];
/** Las de contexto, que se encienden o se apagan. Título y duración van fijas. */
const COLUMNAS: { campo: Campo; texto: string; numerica?: boolean }[] = [
{ campo: 'titulo', texto: 'Título' },
{ campo: 'estilo', texto: 'Estilo' },
{ campo: 'album', texto: 'Álbum' },
{ campo: 'ano', texto: 'Año', numerica: true },
{ campo: 'duracion', texto: 'Duración', numerica: true }
{ campo: 'ano', texto: 'Año', numerica: true }
];
/**
* Cuáles son cifras, incluida la duración, que no está en `COLUMNAS` por ir
* siempre. Antes se preguntaba a `COLUMNAS` y, al no encontrarla, la duración
* empezaba a ordenar de menor a mayor: lo más corto arriba, que no es lo que
* se busca al pulsar esa columna.
*/
const NUMERICAS = new Set<Campo>(['ano', 'duracion']);
const visibles = $derived(COLUMNAS.filter((c) => contexto.includes(c.campo as Contexto)));
let campo = $state<Campo>('titulo');
let ascendente = $state(true);
/**
* Lo que se compara en cada columna. Un valor ausente devuelve `undefined`
* y se va al final, mande la dirección que mande: una fila sin año no es
* «el año más bajo», es una fila sin dato.
* Lo que se compara en cada columna. Un valor ausente devuelve `undefined` y
* se va al final, mande la dirección que mande: una fila sin año no es «el
* año más bajo», es una fila sin dato.
*/
function valor(fila: FilaCancion, cual: Campo): string | number | undefined {
function valor(fila: Fila, cual: Campo): string | number | undefined {
switch (cual) {
case 'titulo':
return fila.cancion.titulo;
case 'estilo':
return fila.estiloNombre;
return fila.estilo?.nombre;
case 'album':
return fila.albumTitulo;
return fila.album?.titulo;
case 'ano':
return fila.cancion.ano;
case 'duracion':
@ -71,8 +109,10 @@
}
}
const ordenadas = $derived(
[...filas].sort((a, b) => {
const ordenadas = $derived.by(() => {
if (!ordenable) return filas;
return [...filas].sort((a, b) => {
const x = valor(a, campo);
const y = valor(b, campo);
@ -90,8 +130,8 @@
return comparacion === 0
? a.cancion.titulo.localeCompare(b.cancion.titulo, 'es')
: comparacion * signo;
})
);
});
});
/** La cola es lo que se ve, en el orden en que se ve. */
const pistas = $derived(aPistas(ordenadas.map((f) => f.cancion)));
@ -104,7 +144,58 @@
campo = cual;
// Los números empiezan de mayor a menor: lo más reciente y lo más largo
// arriba es lo que se suele querer ver primero.
ascendente = !COLUMNAS.find((c) => c.campo === cual)?.numerica;
ascendente = !NUMERICAS.has(cual);
}
/* ---------------------------------------------------------------------
* La portada ampliada al pasar por encima de la miniatura
*
* Va en `position: fixed` porque una fila mide setenta y pico píxeles:
* colgada de ahí, la tarjeta se saldría de la tabla y quedaría a merced de
* cualquier `overflow` de los antepasados —y esta tabla tiene uno—. Fija,
* solo depende de la ventana.
*
* Solo con ratón. No se abre con el teclado a propósito: haría falta un
* punto de tabulación por fila para enseñar una imagen que ya está, más
* grande, en la ficha del tema a la que lleva el título.
* ------------------------------------------------------------------ */
/*
* Cuántas etiquetas caben antes de estorbar. Se cortan por número y no con un
* degradado al final: recortar «Autoengaño» a «Autoeng» no parece una
* elección, parece que algo se ha roto. El resto se cuenta con un «+2», que
* dice lo mismo sin partir ninguna palabra.
*/
const CUANTAS_ETIQUETAS = 3;
const LADO = 260;
const ALTO = LADO + 46;
const AIRE = 12;
let vista = $state<{ src: string; titulo: string; x: number; y: number } | null>(null);
function asomar(evento: MouseEvent, cancion: Cancion): void {
if (!cancion.portada) return;
const caja = (evento.currentTarget as HTMLElement).getBoundingClientRect();
const margen = 16;
/* A la derecha si cabe; si no, al otro lado. En una ventana estrecha no
cabe en ninguno de los dos, y entonces se pega al margen izquierdo. */
const derecha = caja.right + AIRE;
const izquierda = caja.left - AIRE - LADO;
const x =
derecha + LADO <= window.innerWidth - margen
? derecha
: izquierda >= margen
? izquierda
: margen;
/* Centrada en la fila, sin salirse por arriba ni por abajo. */
const centro = caja.top + caja.height / 2 - ALTO / 2;
const y = Math.min(Math.max(centro, margen), window.innerHeight - ALTO - margen);
vista = { src: cancion.portada, titulo: cancion.titulo, x, y };
}
</script>
@ -114,69 +205,212 @@
<div class="marco">
<table>
<caption class="visually-hidden">
Todos los temas. Cada encabezado ordena por su columna.
{ordenable
? 'Los temas. Cada encabezado ordena por su columna.'
: 'Los temas, en el orden del disco.'}
</caption>
<thead>
<tr>
<th scope="col" class="control"><span class="visually-hidden">Reproducir</span></th>
{#each COLUMNAS as columna (columna.campo)}
<th scope="col" class="control"><span class="visually-hidden">Portada</span></th>
{#if numerar}
<th scope="col" class="control numerica">
<span class="visually-hidden">Pista</span>
</th>
{/if}
<th
scope="col"
aria-sort={!ordenable
? undefined
: campo === 'titulo'
? ascendente
? 'ascending'
: 'descending'
: 'none'}
>
{#if ordenable}
<button type="button" onclick={() => ordenarPor('titulo')}>
Título
<span class="flecha" aria-hidden="true">
{campo === 'titulo' ? (ascendente ? '↑' : '↓') : ''}
</span>
</button>
{:else}
<span class="rotulo">Título</span>
{/if}
</th>
{#each visibles as columna (columna.campo)}
<th
scope="col"
class:numerica={columna.numerica}
aria-sort={campo === columna.campo
aria-sort={!ordenable
? undefined
: campo === columna.campo
? ascendente
? 'ascending'
: 'descending'
: 'none'}
>
{#if ordenable}
<button type="button" onclick={() => ordenarPor(columna.campo)}>
{columna.texto}
<span class="flecha" aria-hidden="true">
{campo === columna.campo ? (ascendente ? '↑' : '↓') : ''}
</span>
</button>
{:else}
<span class="rotulo">{columna.texto}</span>
{/if}
</th>
{/each}
<th
scope="col"
class="numerica"
aria-sort={!ordenable
? undefined
: campo === 'duracion'
? ascendente
? 'ascending'
: 'descending'
: 'none'}
>
<button type="button" onclick={() => ordenarPor(columna.campo)}>
{columna.texto}
>
{#if ordenable}
<button type="button" onclick={() => ordenarPor('duracion')}>
Duración
<span class="flecha" aria-hidden="true">
{campo === columna.campo ? (ascendente ? '↑' : '↓') : ''}
{campo === 'duracion' ? (ascendente ? '↑' : '↓') : ''}
</span>
</button>
</th>
{/each}
{:else}
<span class="rotulo">Duración</span>
{/if}
</th>
<th scope="col" class="control"><span class="visually-hidden">Favorito</span></th>
<th scope="col" class="control"><span class="visually-hidden">Cola</span></th>
<th scope="col" class="control"><span class="visually-hidden">Comprar</span></th>
</tr>
</thead>
<tbody>
{#each ordenadas as fila, i (fila.cancion.slug)}
<tr>
{@const c = fila.cancion}
<tr class:sonando={reproductor.esActual(c.slug)}>
<td class="control">
<BotonReproducir {pistas} indice={i} tamano={16} />
</td>
<th scope="row">
<a href={resolve('/canciones/[slug]', { slug: fila.cancion.slug })}>
{fila.cancion.titulo}
</a>
</th>
<td>
<a href={resolve('/musica/[estilo]', { estilo: fila.estiloSlug })}>
{fila.estiloNombre}
<td class="control">
<a
class="mini"
href={resolve('/canciones/[slug]', { slug: c.slug })}
tabindex="-1"
onmouseenter={(e) => asomar(e, c)}
onmouseleave={() => (vista = null)}
>
{#if c.portada}
<img src={c.portada} alt="" width="80" height="80" loading="lazy" />
{:else}
<SinPortada titulo={c.titulo} lado={80} muda />
{/if}
</a>
</td>
<td>
<a href={resolve('/albumes/[slug]', { slug: fila.albumSlug })}>
{fila.albumTitulo}
</a>
{#if numerar}
<td class="control numerica muted">{c.numero ?? '—'}</td>
{/if}
<th scope="row">
<a class="titulo" href={resolve('/canciones/[slug]', { slug: c.slug })}>{c.titulo}</a>
<!--
De qué está hecho el tema, no su primer verso: la letra está
entera en la ficha, y aquí lo que sirve para elegir es el género
y las etiquetas.
-->
<div class="senas">
{#if c.instrumental}
<span class="marca">Instrumental</span>
{/if}
{#each c.generos as g (g.slug)}
<a class="genero" href={resolve('/generos/[slug]', { slug: g.slug })}>
{g.nombre}
</a>
{/each}
<Etiquetas
etiquetas={c.etiquetas.slice(0, CUANTAS_ETIQUETAS)}
titulo="Etiquetas de {c.titulo}"
menudas
/>
{#if c.etiquetas.length > CUANTAS_ETIQUETAS}
<span class="mas muted">+{c.etiquetas.length - CUANTAS_ETIQUETAS}</span>
{/if}
{#if c.generos.length === 0 && c.etiquetas.length === 0}
<span class="muted sin">{listaDeNombres(c.interpretes)}</span>
{/if}
</div>
</th>
{#each visibles as columna (columna.campo)}
<td class:numerica={columna.numerica}>
{#if columna.campo === 'estilo'}
{#if fila.estilo}
<a href={resolve('/musica/[estilo]', { estilo: fila.estilo.slug })}>
{fila.estilo.nombre}
</a>
{:else}
—
{/if}
{:else if columna.campo === 'album'}
{#if fila.album}
<a href={resolve('/albumes/[slug]', { slug: fila.album.slug })}>
{fila.album.titulo}
</a>
{:else}
—
{/if}
{:else}
{c.ano ?? '—'}
{/if}
</td>
{/each}
<td class="numerica duracion">
{c.duracion ? segundosADuracion(c.duracion) : '—'}
</td>
<td class="numerica">{fila.cancion.ano ?? '—'}</td>
<td class="numerica">
{fila.cancion.duracion ? segundosADuracion(fila.cancion.duracion) : '—'}
<td class="control">
<BotonFavorito cancionSlug={c.slug} compacto tamano={16} />
</td>
<td class="control">
<BotonFavorito cancionSlug={fila.cancion.slug} compacto tamano={16} />
<BotonCola cancion={c} compacto tamano={16} />
</td>
<td class="control">
<BotonCarrito cancion={fila.cancion} compacto />
<BotonCarrito cancion={c} compacto />
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if vista}
<!--
`aria-hidden`: es la misma portada de la miniatura de al lado, en grande.
Para quien no la ve no añade nada, y anunciarla interrumpiría la lectura
de la tabla cada vez que el ratón roza una fila.
-->
<div
class="vista-previa"
style="--x: {vista.x}px; --y: {vista.y}px; --lado: {LADO}px"
aria-hidden="true"
>
<img src={vista.src} alt="" width={LADO} height={LADO} />
<p>{vista.titulo}</p>
</div>
{/if}
{/if}
<style>
@ -191,8 +425,7 @@
* A partir de ahí cabe entera, y el marco deja de recortar. No es un detalle:
* un ancestro con `overflow` se convierte en el contenedor de desplazamiento
* de lo que hay dentro, y el encabezado pegajoso dejaría de pegarse a la
* ventana. Con la tabla larga, ese encabezado es lo que hace legible el
* desplazamiento.
* ventana.
*/
.marco {
overflow-x: auto;
@ -208,8 +441,7 @@
/*
* Separate, no collapse: con los bordes fusionados el navegador no sabe a
* quién pertenecen, y el encabezado pegajoso se despega de la tabla y tapa
* la primera fila. Con `separate` y espaciado cero se ve igual y se queda
* donde debe.
* la primera fila.
*/
table {
width: 100%;
@ -220,7 +452,7 @@
th,
td {
padding: var(--sp-3) var(--sp-3);
padding: var(--sp-2) var(--sp-3);
text-align: left;
border-bottom: 1px solid var(--c-border);
white-space: nowrap;
@ -235,7 +467,8 @@
border-bottom: 1px solid var(--c-border);
}
thead button {
thead button,
.rotulo {
display: flex;
align-items: center;
gap: var(--sp-2);
@ -248,6 +481,9 @@
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
}
thead button {
cursor: pointer;
}
@ -265,12 +501,23 @@
tbody th {
font-weight: 400;
/* La celda del título es la única que envuelve: lleva dos líneas. */
white-space: normal;
}
/* Un punto por encima del resto de la tabla: es lo que se busca al mirarla. */
.titulo {
font-size: 0.9375rem;
}
tbody tr:hover {
background: var(--c-bg-raised);
}
tr.sonando .titulo {
font-weight: 600;
}
td a,
tbody th a {
text-decoration: none;
@ -285,8 +532,118 @@
font-variant-numeric: tabular-nums;
}
.duracion {
text-align: right;
}
.control {
width: 1%;
padding-inline: var(--sp-2);
}
.mini {
display: block;
width: 80px;
line-height: 0;
}
.mini img {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: var(--radius-tarjeta);
background: var(--c-bg-sunken);
}
/*
* En una línea y sin envolver: en una lista de diez temas, una fila que crece
* porque su canción tiene una etiqueta más rompe el ritmo de todas. Lo que no
* cabe se recorta con el degradado del final.
*/
/*
* En una línea: en una tabla de treinta temas, una fila que crece porque su
* canción tiene una etiqueta más rompe el ritmo de todas.
*/
.senas {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-top: var(--sp-1);
}
.mas {
font-size: 0.6875rem;
}
.senas :global(.etiquetas) {
flex-wrap: nowrap;
}
.senas > *,
.senas :global(li) {
flex: none;
}
/* El género es del glosario, no del catálogo: se distingue con el oro. */
.genero {
padding: 0.1em 0.6em;
border: 1px solid var(--c-accent);
border-radius: var(--radius-boton);
font-size: 0.6875rem;
color: var(--c-accent);
white-space: nowrap;
}
.genero:hover,
.genero:focus-visible {
background: color-mix(in oklab, var(--c-accent) 14%, transparent);
}
.sin,
.marca {
font-size: 0.6875rem;
white-space: nowrap;
}
.marca {
padding: 0.1em 0.6em;
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
color: var(--c-text-muted);
}
/*
* `pointer-events: none` para que la tarjeta no se cruce con el ratón: si lo
* capturase, al salir de la miniatura hacia ella el `mouseleave` la cerraría
* y volvería a abrirse en bucle.
*/
.vista-previa {
position: fixed;
top: var(--y);
left: var(--x);
z-index: 40;
width: var(--lado);
padding: var(--sp-2);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
box-shadow: 0 12px 32px rgb(0 0 0 / 0.45);
pointer-events: none;
}
.vista-previa img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1;
object-fit: cover;
border-radius: calc(var(--radius-tarjeta) - 2px);
}
.vista-previa p {
margin: var(--sp-2) 0 0;
font-size: var(--fs-xs);
color: var(--c-text-muted);
text-align: center;
}
</style>

@ -17,6 +17,7 @@ export const navegacion: { href: ResolvedPathname; texto: string }[] = [
{ href: resolve('/taller'), texto: 'Taller de letras' },
{ href: resolve('/generos'), texto: 'Géneros' },
{ href: resolve('/blog'), texto: 'Blog' },
{ href: resolve('/bio'), texto: 'Bio' },
{ href: resolve('/contacto'), texto: 'Contacto' }
{ href: resolve('/bio'), texto: 'Bio' }
// Contacto no está aquí: va solo en el pie. La barra es para lo que se viene
// a ver, y escribir no es de eso; en el pie es donde se busca.
];

@ -283,6 +283,42 @@ class Reproductor {
this.cargar(true);
}
/**
* Anade pistas al final de la cola, sin tocar lo que suena.
*
* Hasta ahora la cola solo se podia sustituir: cada play la vaciaba y la
* llenaba con su contexto, asi que no habia forma de armar una escucha
* cogiendo temas de discos distintos. Esto es la otra mitad.
*
* Si no habia nada cargado, encolar arranca: pulsar "anadir a la cola" con el
* reproductor vacio y que no pase nada seria un boton que no hace nada.
*
* Lo que ya este en la cola no se repite. Un mismo tema dos veces seguidas no
* es lo que se pide al pulsar dos veces; lo que se pide es que este.
*/
encolar(pistas: PistaReproductor[]): number {
const reproducibles = pistas.filter((p) => p.audio);
const yaEstan = new Set(this.cola.map((p) => p.id));
const nuevas = reproducibles.filter((p) => !yaEstan.has(p.id));
if (nuevas.length === 0) return 0;
if (this.cola.length === 0) {
this.reproducirLista(nuevas, 0);
return nuevas.length;
}
const antes = this.cola.length;
this.cola = [...this.cola, ...nuevas];
/*
* El orden se alarga por el final con las posiciones nuevas. En aleatorio
* tambien: barajarlo entero movería de sitio lo que aún no ha sonado, y
* quien encola espera que lo suyo vaya después, no en medio.
*/
this.orden = [...this.orden, ...nuevas.map((_, i) => antes + i)];
return nuevas.length;
}
/**
* Alterna una pista concreta: si ya es la actual, pausa o reanuda; si no,
* la pone a sonar dentro de su contexto (el album del que forma parte).

@ -13,9 +13,9 @@
export const site = {
nombre: 'Senza Paura',
/** Como se describe el artista en una linea. */
titular: 'Letrista y compositor',
titular: 'Letrista y creador musical',
descripcion:
'Letrista y compositor. Baladas románticas, y también clásica, rock y otros estilos, agrupados por bloques.',
'Letrista y creador musical. Baladas románticas, y también clásica, rock y otros estilos, agrupados por bloques.',
url: 'https://senzapaura.es',
idioma: 'es',
email: 'hola@senzapaura.es',

@ -2,7 +2,7 @@
import { resolve } from '$app/paths';
import BotonReproducir from '$lib/components/BotonReproducir.svelte';
import EnlacesPlataformas from '$lib/components/EnlacesPlataformas.svelte';
import ListaCanciones from '$lib/components/ListaCanciones.svelte';
import TablaCanciones from '$lib/components/TablaCanciones.svelte';
import RejillaCanciones from '$lib/components/RejillaCanciones.svelte';
import Meta from '$lib/components/Meta.svelte';
import SinPortada from '$lib/components/SinPortada.svelte';
@ -107,7 +107,11 @@
-->
<RejillaCanciones canciones={album.canciones} />
{:else}
<ListaCanciones canciones={album.canciones} />
<!--
Sin columna de álbum: diría diez veces el disco en el que ya estás. El
estilo tampoco, que es el mismo para todo el disco, ni el año.
-->
<TablaCanciones canciones={album.canciones} numerar contexto={[]} />
{/if}
</section>

@ -58,6 +58,22 @@
const enEspanol = $derived(cancion.idioma.split('-')[0] === 'es');
const aDosColumnas = $derived(!enEspanol && Boolean(cancion.traduccion));
/**
* Una letra larga a una columna hace la página el doble de alta de lo que
* hace falta: casi dos mil píxeles solo de versos. A partir de seis estrofas
* se parte en dos columnas, si hay ancho para ello.
*
* Seis y no cuatro porque el corte tiene que dejar algo en cada lado: con
* tres estrofas por columna ya se lee como dos bloques, y con dos parece que
* la letra se ha quedado a medias.
*
* No se hace nunca cuando hay traducción: ahí las dos columnas ya están
* ocupadas por el original y el castellano, que es un enfrentamiento verso a
* verso y no una letra partida.
*/
const estrofas = $derived((letra?.match(/<p[\s>]/g) ?? []).length);
const enColumnas = $derived(!aDosColumnas && !cancion.instrumental && estrofas >= 6);
/**
* El nombre del idioma en español, sin tabla que mantener. Si el navegador o
* el servidor no saben traducirlo, se queda el código, que sigue diciendo
@ -182,7 +198,12 @@
{#if id === 'letra'}
{#if letra}
<div class="letra" class:pareada={aDosColumnas}>
<div class="texto" class:versos={!cancion.instrumental} lang={cancion.idioma}>
<div
class="texto"
class:versos={!cancion.instrumental}
class:columnas={enColumnas}
lang={cancion.idioma}
>
{#if aDosColumnas}
<p class="rotulo-columna muted">Letra original · {nombreIdioma}</p>
{/if}
@ -404,6 +425,36 @@
line-height: 1.75;
}
/*
* Dos columnas de verso, solo con ancho de sobra: `column-width` y no
* `columns: 2` para que el navegador se quede en una sola en cuanto no
* quepan dos de 34 caracteres, sin tener que acertar el punto de corte en un
* `@media`.
*
* `break-inside: avoid` en cada estrofa es lo que hace que esto se pueda
* hacer: sin ello el corte cae en mitad de una y la letra deja de leerse.
*/
.texto.columnas {
max-width: none;
/*
* 37ch es el mínimo que deja caber dos columnas en la columna principal
* —864 px— y, a la vez, el verso más largo del catálogo, que mide 36.
* Importa afinarlo: en una letra, dónde acaba cada verso es parte del
* texto, así que una columna que doble renglones ahorra alto rompiendo
* justo lo que no se puede romper.
*
* Con `column-width` y no `columns: 2`: donde no quepan dos de 37ch, el
* navegador se queda en una sola sin que haya que acertar el punto de
* corte en un `@media`.
*/
column-width: 37ch;
column-gap: var(--sp-7);
}
.texto.columnas :global(p) {
break-inside: avoid;
}
.videos {
display: grid;
gap: var(--sp-4);

@ -2,7 +2,8 @@
import Meta from '$lib/components/Meta.svelte';
import RejillaCanciones from '$lib/components/RejillaCanciones.svelte';
import BloqueDeEstilo from '$lib/components/BloqueDeEstilo.svelte';
import TablaCanciones, { type FilaCancion } from '$lib/components/TablaCanciones.svelte';
import TablaCanciones from '$lib/components/TablaCanciones.svelte';
import type { Cancion } from '$lib/types';
let { data } = $props();
@ -11,18 +12,28 @@
let consulta = $state('');
/**
* Una fila por tema, con su estilo y su disco al lado. Sale de los bloques
* que ya vienen cargados, así que no viaja ni un byte de más.
* Todos los temas, en plano. El estilo y el disco de cada uno los resuelve la
* tabla contra el catálogo; aquí solo hacen falta para buscar, y para eso se
* arma aparte un índice de texto.
*/
const filas = $derived<FilaCancion[]>(
const filas = $derived(
data.bloques.flatMap((bloque) =>
bloque.albumes.flatMap((album) =>
album.canciones.map((cancion) => ({
cancion,
estiloSlug: bloque.estilo.slug,
estiloNombre: bloque.estilo.nombre,
albumSlug: album.slug,
albumTitulo: album.titulo
/* Todo lo que identifica un tema, junto y sin tildes: quien
recuerda «el dueto de baladas» o «desamor» tiene que llegar. */
buscable: llano(
[
cancion.titulo,
bloque.estilo.nombre,
album.titulo,
String(cancion.ano ?? ''),
...cancion.etiquetas,
...cancion.generos.map((g) => g.nombre),
...cancion.interpretes
].join(' ')
)
}))
)
)
@ -36,24 +47,10 @@
.toLowerCase();
}
/**
* Se busca en todo lo que identifica un tema, no solo en el título: quien
* recuerda «el dueto de baladas» o «desamor» tiene que llegar igual.
*/
function coincide(fila: FilaCancion, termino: string): boolean {
const campos = [
fila.cancion.titulo,
fila.estiloNombre,
fila.albumTitulo,
String(fila.cancion.ano ?? ''),
...fila.cancion.etiquetas,
...fila.cancion.interpretes
];
return campos.some((campo) => llano(campo).includes(termino));
}
const termino = $derived(llano(consulta.trim()));
const filtradas = $derived(termino ? filas.filter((f) => coincide(f, termino)) : filas);
const filtradas = $derived<Cancion[]>(
(termino ? filas.filter((f) => f.buscable.includes(termino)) : filas).map((f) => f.cancion)
);
const buscando = $derived(termino.length > 0);
</script>
@ -118,13 +115,13 @@
</p>
{#if vista === 'tabla'}
<TablaCanciones filas={filtradas} />
<TablaCanciones canciones={filtradas} ordenable />
{:else if buscando}
<!--
Con una búsqueda en marcha, agrupar por estilo no dice nada: lo que
importa son las coincidencias, vengan del estilo que vengan.
-->
<RejillaCanciones canciones={filtradas.map((f) => f.cancion)} vacio="Ningún tema coincide." />
<RejillaCanciones canciones={filtradas} vacio="Ningún tema coincide." />
{:else}
{#each data.bloques as bloque (bloque.estilo.slug)}
<BloqueDeEstilo {bloque} />

Loading…
Cancel
Save

Powered by TurnKey Linux.