From fa4de8b2f6acf34abe24c75f9dd47e87dae9d0ed Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 10 Sep 2026 03:22:55 +0200 Subject: [PATCH] Una sola tabla de canciones, y la cola se puede llenar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- e2e/musica.e2e.ts | 4 +- e2e/navegacion.e2e.ts | 2 +- e2e/responsive.e2e.ts | 5 +- e2e/tienda.e2e.ts | 18 +- src/content/paginas/bio.md | 2 +- src/lib/components/BotonCola.svelte | 133 +++++ src/lib/components/Etiquetas.svelte | 20 +- src/lib/components/Icono.svelte | 15 + src/lib/components/ListaCanciones.svelte | 325 ------------ .../components/ListaCanciones.svelte.spec.ts | 107 ---- src/lib/components/TablaCanciones.svelte | 491 +++++++++++++++--- src/lib/navegacion.ts | 5 +- src/lib/player/player.svelte.ts | 36 ++ src/lib/site.ts | 4 +- src/routes/albumes/[slug]/+page.svelte | 8 +- src/routes/canciones/[slug]/+page.svelte | 53 +- src/routes/musica/+page.svelte | 51 +- 17 files changed, 713 insertions(+), 566 deletions(-) create mode 100644 src/lib/components/BotonCola.svelte delete mode 100644 src/lib/components/ListaCanciones.svelte delete mode 100644 src/lib/components/ListaCanciones.svelte.spec.ts diff --git a/e2e/musica.e2e.ts b/e2e/musica.e2e.ts index e0eeb96..a295479 100644 --- a/e2e/musica.e2e.ts +++ b/e2e/musica.e2e.ts @@ -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; diff --git a/e2e/navegacion.e2e.ts b/e2e/navegacion.e2e.ts index 898e8c9..abe456d 100644 --- a/e2e/navegacion.e2e.ts +++ b/e2e/navegacion.e2e.ts @@ -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(); diff --git a/e2e/responsive.e2e.ts b/e2e/responsive.e2e.ts index d5de119..cb2c0fa 100644 --- a/e2e/responsive.e2e.ts +++ b/e2e/responsive.e2e.ts @@ -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 }) => { diff --git a/e2e/tienda.e2e.ts b/e2e/tienda.e2e.ts index 8db213d..71de243 100644 --- a/e2e/tienda.e2e.ts +++ b/e2e/tienda.e2e.ts @@ -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. diff --git a/src/content/paginas/bio.md b/src/content/paginas/bio.md index 0cc8bd8..6869e0e 100644 --- a/src/content/paginas/bio.md +++ b/src/content/paginas/bio.md @@ -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. diff --git a/src/lib/components/BotonCola.svelte b/src/lib/components/BotonCola.svelte new file mode 100644 index 0000000..c19f1a1 --- /dev/null +++ b/src/lib/components/BotonCola.svelte @@ -0,0 +1,133 @@ + + + + + + + + + {acusado ? `«${cancion.titulo}» añadida a la cola.` : ''} + + + + diff --git a/src/lib/components/Etiquetas.svelte b/src/lib/components/Etiquetas.svelte index 041f127..9440493 100644 --- a/src/lib/components/Etiquetas.svelte +++ b/src/lib/components/Etiquetas.svelte @@ -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, diff --git a/src/lib/components/Icono.svelte b/src/lib/components/Icono.svelte index 557467b..277b2fb 100644 --- a/src/lib/components/Icono.svelte +++ b/src/lib/components/Icono.svelte @@ -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: { diff --git a/src/lib/components/ListaCanciones.svelte b/src/lib/components/ListaCanciones.svelte deleted file mode 100644 index 99f5a45..0000000 --- a/src/lib/components/ListaCanciones.svelte +++ /dev/null @@ -1,325 +0,0 @@ - - -{#if canciones.length === 0} -

Todavía no hay temas publicados en esta sección.

-{:else} -
    - {#each canciones as cancion, i (cancion.slug)} -
  1. -
    - {#if numerar && cancion.numero !== null} - - {/if} - -
    - - - asomar(e, cancion)} - onmouseleave={() => (vista = null)} - > - {#if cancion.portada} - - {:else} - - {/if} - - -
    - - {cancion.titulo} - - -
    - {#if cancion.instrumental} - Instrumental - {/if} - {#each cancion.generos as g (g.slug)} - {g.nombre} - {/each} - - {#if cancion.generos.length === 0 && cancion.etiquetas.length === 0} - {listaDeNombres(cancion.interpretes)} - {/if} -
    -
    - -
    - - - {cancion.duracion ? segundosADuracion(cancion.duracion) : '--:--'} - -
    -
  2. - {/each} -
- - {#if vista} - - - {/if} -{/if} - - diff --git a/src/lib/components/ListaCanciones.svelte.spec.ts b/src/lib/components/ListaCanciones.svelte.spec.ts deleted file mode 100644 index 51e8d46..0000000 --- a/src/lib/components/ListaCanciones.svelte.spec.ts +++ /dev/null @@ -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 { - 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(); - }); -}); diff --git a/src/lib/components/TablaCanciones.svelte b/src/lib/components/TablaCanciones.svelte index 0d783fb..da4f3bb 100644 --- a/src/lib/components/TablaCanciones.svelte +++ b/src/lib/components/TablaCanciones.svelte @@ -1,14 +1,6 @@ @@ -114,69 +205,212 @@
- {#each COLUMNAS as columna (columna.campo)} + + {#if numerar} + + {/if} + + + + {#each visibles as columna (columna.campo)} + {/each} + + - {/each} + {:else} + Duración + {/if} + + + + {#each ordenadas as fila, i (fila.cancion.slug)} - + {@const c = fila.cancion} + - - - + {/if} + + + + {#each visibles as columna (columna.campo)} + + {/each} + + - - {/each}
- 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.'}
ReproducirPortada + Pista + + {#if ordenable} + + {:else} + Título + {/if} + + {#if ordenable} + + {:else} + {columna.texto} + {/if} + - - FavoritoCola Comprar
- - {fila.cancion.titulo} - - - - {fila.estiloNombre} + + + asomar(e, c)} + onmouseleave={() => (vista = null)} + > + {#if c.portada} + + {:else} + + {/if} - - {fila.albumTitulo} - + + {#if numerar} + {c.numero ?? '—'} + {c.titulo} + +
+ {#if c.instrumental} + Instrumental + {/if} + {#each c.generos as g (g.slug)} + + {g.nombre} + + {/each} + + {#if c.etiquetas.length > CUANTAS_ETIQUETAS} + +{c.etiquetas.length - CUANTAS_ETIQUETAS} + {/if} + {#if c.generos.length === 0 && c.etiquetas.length === 0} + {listaDeNombres(c.interpretes)} + {/if} +
+
+ {#if columna.campo === 'estilo'} + {#if fila.estilo} + + {fila.estilo.nombre} + + {:else} + — + {/if} + {:else if columna.campo === 'album'} + {#if fila.album} + + {fila.album.titulo} + + {:else} + — + {/if} + {:else} + {c.ano ?? '—'} + {/if} + + {c.duracion ? segundosADuracion(c.duracion) : '—'} {fila.cancion.ano ?? '—'} - {fila.cancion.duracion ? segundosADuracion(fila.cancion.duracion) : '—'} + + + - + - +
+ + {#if vista} + + + {/if} {/if} diff --git a/src/lib/navegacion.ts b/src/lib/navegacion.ts index 605d294..3e16ec6 100644 --- a/src/lib/navegacion.ts +++ b/src/lib/navegacion.ts @@ -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. ]; diff --git a/src/lib/player/player.svelte.ts b/src/lib/player/player.svelte.ts index 0881833..6a82bc2 100644 --- a/src/lib/player/player.svelte.ts +++ b/src/lib/player/player.svelte.ts @@ -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). diff --git a/src/lib/site.ts b/src/lib/site.ts index 4efb037..a142bc7 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -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', diff --git a/src/routes/albumes/[slug]/+page.svelte b/src/routes/albumes/[slug]/+page.svelte index 9235a6f..e0a8843 100644 --- a/src/routes/albumes/[slug]/+page.svelte +++ b/src/routes/albumes/[slug]/+page.svelte @@ -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 @@ --> {:else} - + + {/if} diff --git a/src/routes/canciones/[slug]/+page.svelte b/src/routes/canciones/[slug]/+page.svelte index 4bbfe50..b67860e 100644 --- a/src/routes/canciones/[slug]/+page.svelte +++ b/src/routes/canciones/[slug]/+page.svelte @@ -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(/]/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}
-
+
{#if aDosColumnas}

Letra original · {nombreIdioma}

{/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); diff --git a/src/routes/musica/+page.svelte b/src/routes/musica/+page.svelte index e9dc7ad..ac30afe 100644 --- a/src/routes/musica/+page.svelte +++ b/src/routes/musica/+page.svelte @@ -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( + 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( + (termino ? filas.filter((f) => f.buscable.includes(termino)) : filas).map((f) => f.cancion) + ); const buscando = $derived(termino.length > 0); @@ -118,13 +115,13 @@

{#if vista === 'tabla'} - + {:else if buscando} - f.cancion)} vacio="Ningún tema coincide." /> + {:else} {#each data.bloques as bloque (bloque.estilo.slug)}