Add global search and refine catalog navigation

codex/redisenio-dominio-musical
dev 4 weeks ago
parent 6089763938
commit a9524fe411

@ -0,0 +1,48 @@
# Búsqueda global
La lupa de la cabecera abre un diálogo con consulta, tipo de contenido y alcance
(títulos o títulos y contenido). Al enviar, navega a `/buscar`. Sin JavaScript,
el enlace lleva a esa misma página y el formulario GET sigue funcionando.
El formulario es compartido por el diálogo y la página. Los filtros y la página
de resultados están en la URL; pueden compartirse y se conservan al volver atrás.
Los resultados se presentan sin tarjetas ni bordes de contenido.
El buscador usa el contenedor general del sitio, alineado con la cabecera. Las
canciones muestran su portada (o la de su álbum si no tienen una propia), y los
álbumes muestran la suya. Solo se entregan imágenes marcadas como públicas; se
resuelven después de paginar y no alteran los recuentos de búsqueda.
## Fuentes y visibilidad
`src/lib/server/busqueda-consulta.ts` proyecta documentos desde PostgreSQL:
- Música: canciones principales, álbumes y estilos publicados; respeta también
la publicación del álbum y del estilo. Incluye letras y metadatos textuales.
- Artistas: fichas publicadas y su biografía.
- Blog: entradas publicadas, su texto y etiquetas.
- Taller: capítulos publicados, secciones de género y definiciones del glosario.
- Legal: únicamente la versión vigente de cada documento.
- Páginas publicadas y planes de suscripción disponibles.
Los bloques del editor sustituyen al cuerpo legado cuando existen. Se consultan
sus campos textuales, no identificadores ni URLs de medios. No se consultan
cuentas, pedidos, listas privadas ni archivos de audio. La búsqueda no concede
permisos de escucha o descarga: enlaza a las fichas públicas existentes.
## Consulta y mantenimiento
Una consulta parametrizada busca todas las palabras, prioriza coincidencias en
el título y devuelve 20 resultados por página. No descarga todo el catálogo al
navegador. Una publicación o edición se refleja en la siguiente búsqueda sin
sincronizar otro almacén.
La base actual usa `SQL_ASCII` aunque almacena texto UTF-8. Por ello no se usa
`translate()` para quitar tildes: compara bytes y produce resultados incorrectos.
Se construyen alternativas literales de acentos, escapando toda sintaxis regex
del usuario. Las pruebas PostgreSQL cubren tildes, publicación, versión legal,
bloques, entradas especiales y paginación en tablas temporales de sesión.
Esta versión consulta el contenido directamente, sin índice de búsqueda dedicado.
Si el volumen lo requiere, el siguiente paso es medir las consultas, migrar la
codificación a UTF-8 mediante un procedimiento independiente y añadir un índice
textual manteniendo el mismo contrato público de búsqueda.

@ -0,0 +1,212 @@
import { expect, test } from '@playwright/test';
test('Tabla y Tarjetas cambian a la vez álbumes y canciones', async ({ page }) => {
await page.goto('/musica');
await page.waitForSelector('html[data-hidratado]');
const albumes = page.getByRole('region', { name: 'Álbumes' });
const canciones = page.getByRole('region', { name: 'Canciones', exact: true });
const cantidadAlbumes = await albumes.locator('.tarjeta').count();
const cantidadCanciones = await canciones.locator('.tarjeta').count();
expect(cantidadAlbumes).toBeGreaterThan(0);
await page.getByRole('button', { name: 'Tabla', exact: true }).click();
await expect(albumes.getByRole('table')).toBeVisible();
await expect(albumes.locator('tbody tr')).toHaveCount(cantidadAlbumes);
await expect(canciones.locator('tbody tr')).toHaveCount(cantidadCanciones);
await expect(page.locator('.tarjeta')).toHaveCount(0);
const bordes = await page.evaluate(() => {
const filas = [...document.querySelectorAll('section.albumes tbody tr')];
return {
filas: filas.map((fila) => getComputedStyle(fila.querySelector('th, td')!).borderBottomWidth),
cancionero: getComputedStyle(document.querySelector('section.canciones')!).borderTopWidth
};
});
expect(bordes.filas.slice(0, -1).every((borde) => borde === '1px')).toBe(true);
expect(bordes.filas.at(-1)).toBe('0px');
expect(bordes.cancionero).toBe('0px');
await page
.getByRole('navigation', { name: 'Ver catálogo' })
.getByRole('link', { name: 'Todos los álbumes' })
.click();
await expect(albumes.getByRole('table')).toBeVisible();
await expect(canciones).toHaveCount(0);
await page.getByRole('button', { name: 'Tarjetas', exact: true }).click();
await expect(albumes.locator('.tarjeta')).toHaveCount(cantidadAlbumes);
await expect(albumes.getByRole('table')).toHaveCount(0);
await page
.getByRole('navigation', { name: 'Ver catálogo' })
.getByRole('link', { name: 'Todo el catálogo' })
.click();
await expect(canciones.locator('.tarjeta')).toHaveCount(cantidadCanciones);
});
test('Todos los álbumes permite elegir tabla, filtrar y abrir la ficha', async ({ page }) => {
await page.goto('/musica?vista=albumes&estilo=baladas-romanticas');
await page.waitForSelector('html[data-hidratado]');
await page.getByRole('button', { name: 'Tabla', exact: true }).click();
await page.getByLabel('Buscar en el catálogo').fill('cupido');
const tabla = page.getByRole('table', { name: 'Álbumes' });
await expect(tabla.locator('tbody tr')).toHaveCount(1);
await expect(tabla.getByRole('link', { name: 'Cupido sin flechas', exact: true })).toBeVisible();
await page.getByLabel('Buscar en el catálogo').fill('zzzinexistentezzz');
await expect(page.getByText('No hay álbumes que coincidan.')).toBeVisible();
await page.getByLabel('Buscar en el catálogo').fill('cupido');
await page.getByRole('button', { name: 'Tarjetas', exact: true }).click();
await expect(page.locator('section.albumes .tarjeta')).toHaveCount(1);
await page.getByRole('button', { name: 'Tabla', exact: true }).click();
await tabla.getByRole('link', { name: 'Cupido sin flechas', exact: true }).click();
await expect(page).toHaveURL('/albumes/cupido-sin-flechas');
});
test('el álbum mantiene su reproducción al alternar tabla y tarjetas', async ({ page }) => {
await page.goto('/musica?vista=albumes');
await page.waitForSelector('html[data-hidratado]');
await page
.locator('audio')
.evaluateAll((audios) => audios.forEach((audio) => (audio.muted = true)));
await page.getByRole('button', { name: 'Tabla', exact: true }).click();
const fila = page.getByRole('row').filter({ hasText: 'Cupido sin flechas' });
await fila.getByRole('button', { name: 'Reproducir todo' }).click();
await expect(fila.getByRole('button', { name: 'Pausar', exact: true })).toBeVisible();
const barra = page.getByRole('region', { name: 'Reproductor', exact: true });
await expect(barra).toBeVisible();
await expect
.poll(async () => Number(await page.getByLabel('Posición de reproducción').inputValue()))
.toBeGreaterThan(0);
const tema = await page
.getByRole('group', { name: 'Sonando ahora' })
.locator('a')
.first()
.getAttribute('href');
await page.getByRole('button', { name: 'Tarjetas', exact: true }).click();
const tarjeta = page
.locator('section.albumes .tarjeta')
.filter({ hasText: 'Cupido sin flechas' });
await expect(tarjeta.getByRole('button', { name: 'Pausar', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Tabla', exact: true }).click();
await expect(fila.getByRole('button', { name: 'Pausar', exact: true })).toBeVisible();
await expect(
page.getByRole('group', { name: 'Sonando ahora' }).locator('a').first()
).toHaveAttribute('href', tema!);
await fila.getByRole('button', { name: 'Pausar', exact: true }).click();
await expect(barra.getByRole('button', { name: 'Reproducir', exact: true })).toBeVisible();
});
test('las tablas comparten ancho sin partir los títulos ni alterar la jerarquía tipográfica', async ({
page
}) => {
await page.setViewportSize({ width: 1536, height: 1000 });
await page.goto('/musica');
await page.waitForSelector('html[data-hidratado]');
await page.getByRole('button', { name: 'Tabla', exact: true }).click();
const tablaAlbumes = page.getByRole('table', { name: 'Álbumes' });
const tablaCanciones = page.getByRole('table', {
name: 'Los temas. Cada encabezado ordena por su columna.'
});
const titulo = tablaCanciones.getByRole('link', {
name: 'Ámame esta noche, nada más',
exact: true
});
const fila = tablaCanciones.getByRole('row', { name: /Ámame esta noche, nada más/ });
await expect(tablaCanciones.getByRole('columnheader', { name: 'Año' })).toBeVisible();
const aspecto = await page.evaluate(() => {
const albumes = document.querySelector<HTMLElement>('section.albumes table')!;
const canciones = document.querySelector<HTMLElement>('section.canciones table')!;
const enlaceTituloAlbum = albumes.querySelector<HTMLAnchorElement>('a.titulo')!;
const enlaceTitulo = [...canciones.querySelectorAll<HTMLAnchorElement>('a.titulo')].find(
(enlace) => enlace.textContent?.trim() === 'Ámame esta noche, nada más'
)!;
const celdaTitulo = enlaceTitulo.closest<HTMLElement>('th')!;
const enlaceAlbum = celdaTitulo.parentElement!.querySelector<HTMLAnchorElement>(
'td.columna-album a'
)!;
return {
anchos: [albumes.getBoundingClientRect().width, canciones.getBoundingClientRect().width],
album: {
tamano: getComputedStyle(enlaceTituloAlbum).fontSize,
peso: getComputedStyle(enlaceTituloAlbum).fontWeight
},
titulo: {
alto: enlaceTitulo.getBoundingClientRect().height,
linea: Number.parseFloat(getComputedStyle(enlaceTitulo).lineHeight),
tamano: getComputedStyle(enlaceTitulo).fontSize,
whiteSpace: getComputedStyle(enlaceTitulo).whiteSpace,
vertical: getComputedStyle(celdaTitulo).verticalAlign
},
pesoAlbum: getComputedStyle(enlaceAlbum).fontWeight,
marco: {
overflow: getComputedStyle(canciones.closest('.tabla-catalogo-marco')!).overflowX,
cabecera: getComputedStyle(canciones.querySelector('thead th')!).position
}
};
});
expect(Math.abs(aspecto.anchos[0] - aspecto.anchos[1])).toBeLessThanOrEqual(1);
expect(aspecto.album).toEqual({ tamano: '16px', peso: '400' });
expect(aspecto.titulo.alto).toBeLessThanOrEqual(aspecto.titulo.linea + 1);
expect(aspecto.titulo.tamano).toBe('16px');
expect(aspecto.titulo.whiteSpace).toBe('nowrap');
expect(aspecto.titulo.vertical).toBe('top');
expect(aspecto.pesoAlbum).toBe('400');
expect(aspecto.marco).toEqual({ overflow: 'auto', cabecera: 'static' });
await expect(titulo).toBeVisible();
await expect(fila).toBeVisible();
const catalogo = page
.getByRole('navigation', { name: 'Ver catálogo' })
.getByRole('link', { name: 'Todo el catálogo' });
const idiomas = page
.getByRole('navigation', { name: 'Idiomas' })
.getByRole('link', { name: 'Todos los idiomas' });
const estilosActivos = await Promise.all(
[catalogo, idiomas].map((enlace) =>
enlace.evaluate((elemento) => ({
color: getComputedStyle(elemento).color,
peso: getComputedStyle(elemento).fontWeight
}))
)
);
expect(estilosActivos[0]).toEqual(estilosActivos[1]);
await page.screenshot({ path: 'test-results/catalogo-tablas-1536.png', fullPage: true });
});
for (const tema of ['claro', 'oscuro']) {
test(`la tabla de álbumes se adapta a escritorio y móvil en ${tema}`, async ({ page }) => {
await page.addInitScript((valor) => localStorage.setItem('senzapaura:tema', valor), tema);
for (const ancho of [1920, 768, 390, 320]) {
await page.setViewportSize({ width: ancho, height: 1000 });
await page.goto('/musica?vista=albumes');
await page.waitForSelector('html[data-hidratado]');
await page.getByRole('button', { name: 'Tabla', exact: true }).click();
const tabla = page.getByRole('table', { name: 'Álbumes' });
await expect(tabla).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true
);
const medidas = await tabla.evaluate((el) => ({
izquierda: el.getBoundingClientRect().left,
derecha: el.getBoundingClientRect().right,
ancho: innerWidth
}));
expect(medidas.izquierda).toBeGreaterThanOrEqual(0);
expect(medidas.derecha).toBeLessThanOrEqual(medidas.ancho);
const portada = tabla.locator('img').first();
await portada.scrollIntoViewIfNeeded();
await expect
.poll(() =>
portada.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0)
)
.toBe(true);
await page.evaluate(() => {
const seccion = document.querySelector('section.albumes')!.getBoundingClientRect();
const altoMenu = document.querySelector('.cabecera')!.getBoundingClientRect().height;
const altoBuscador = document
.querySelector('.controles-catalogo')!
.getBoundingClientRect().height;
window.scrollTo(0, scrollY + seccion.top - altoMenu - altoBuscador - 16);
});
await page.screenshot({ path: `test-results/albumes-tabla-${tema}-${ancho}.png` });
}
});
}

@ -0,0 +1,137 @@
import { expect, test } from '@playwright/test';
test('alinea la búsqueda con la cabecera y muestra portadas de canciones y álbumes', async ({
page
}) => {
for (const tema of ['claro', 'oscuro']) {
await page.addInitScript((valor) => localStorage.setItem('senzapaura:tema', valor), tema);
for (const ancho of [1920, 390]) {
await page.setViewportSize({ width: ancho, height: 1000 });
await page.goto('/buscar?q=cupido&tipo=musica');
await page.waitForSelector('html[data-hidratado]');
const izquierda = await page
.locator('.cabecera .marca')
.evaluate((el) => el.getBoundingClientRect().left);
for (const selector of [
'.pagina-busqueda h1',
'.pagina-busqueda form',
'#titulo-resultados',
'.resultados article'
]) {
const x = await page
.locator(selector)
.first()
.evaluate((el) => el.getBoundingClientRect().left);
expect(Math.abs(x - izquierda)).toBeLessThanOrEqual(1);
}
const album = page.getByRole('img', { name: 'Portada de Cupido sin flechas', exact: true });
const cancion = page.getByRole('img', {
name: 'Portada de Cuando tus manos me tocan',
exact: true
});
for (const portada of [album, cancion]) {
await portada.scrollIntoViewIfNeeded();
await expect(portada).toBeVisible();
await expect
.poll(() =>
portada.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0)
)
.toBe(true);
}
expect(await album.getAttribute('src')).not.toBe(await cancion.getAttribute('src'));
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true
);
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: `test-results/busqueda-portadas-${tema}-${ancho}.png` });
}
}
});
test('la lupa abre el popup y envía los filtros a la página de resultados', async ({ page }) => {
await page.goto('/juke-box');
await page.waitForSelector('html[data-hidratado]');
const lupa = page.getByRole('link', { name: 'Buscar en Senza Paura', exact: true });
await lupa.click();
const dialogo = page.getByRole('dialog', { name: 'Buscar en Senza Paura' });
await expect(dialogo).toBeVisible();
await expect(page).toHaveURL('/juke-box');
await expect(dialogo.getByRole('searchbox')).toBeFocused();
await page.screenshot({ path: 'test-results/busqueda-popup-escritorio.png' });
await page.keyboard.press('Escape');
await expect(dialogo).toBeHidden();
await expect(lupa).toBeFocused();
await lupa.click();
await dialogo.getByRole('searchbox').fill('privacidad');
await dialogo.getByLabel('Tipo de contenido').selectOption('legal');
await dialogo.getByRole('combobox', { name: 'Buscar en', exact: true }).selectOption('titulo');
await dialogo.getByRole('button', { name: 'Buscar', exact: true }).click();
await expect(page).toHaveURL(/\/buscar\?q=privacidad&tipo=legal&campo=titulo$/);
await expect(dialogo).toBeHidden();
await expect(page.locator('.resultados article').first()).toBeVisible();
await expect(page.locator('.resultados article .eyebrow')).toHaveText(['Documento legal']);
await expect(page.getByRole('searchbox')).toHaveValue('privacidad');
await page.screenshot({ path: 'test-results/busqueda-resultados-escritorio.png' });
await page.locator('.resultados article h3 a').click();
await expect(page).toHaveURL(/\/legal\//);
});
for (const tema of ['claro', 'oscuro']) {
test(`popup y resultados en móvil, tema ${tema}`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript((valor) => localStorage.setItem('senzapaura:tema', valor), tema);
await page.goto('/suscripciones');
await page.waitForSelector('html[data-hidratado]');
await page.getByRole('button', { name: 'Menú', exact: true }).click();
await page.getByRole('link', { name: 'Buscar en Senza Paura', exact: true }).click();
const dialogo = page.getByRole('dialog', { name: 'Buscar en Senza Paura' });
await expect(dialogo).toBeVisible();
await expect(dialogo.getByRole('searchbox')).toBeFocused();
await page.screenshot({ path: `test-results/busqueda-popup-movil-${tema}.png` });
await dialogo.getByRole('searchbox').fill('cupido');
await dialogo.getByLabel('Tipo de contenido').selectOption('musica');
await dialogo.getByRole('button', { name: 'Buscar', exact: true }).click();
await expect(page.locator('.resultados article').first()).toBeVisible();
await expect(dialogo).toBeHidden();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true
);
await page.screenshot({
path: `test-results/busqueda-resultados-movil-${tema}.png`,
fullPage: true
});
});
}
test('permite refinar la consulta y volver atrás conservando los filtros', async ({ page }) => {
await page.goto('/buscar?q=cupido&tipo=musica&campo=titulo');
await page.waitForSelector('html[data-hidratado]');
await expect(page.locator('.resultados article').first()).toBeVisible();
await page.getByRole('searchbox').fill('zzzinexistentezzz');
await page.getByRole('button', { name: 'Buscar', exact: true }).click();
await expect(page.getByText('No encontramos coincidencias', { exact: false })).toBeVisible();
await page.goBack();
await expect(page.getByRole('searchbox')).toHaveValue('cupido');
await expect(page.getByLabel('Tipo de contenido')).toHaveValue('musica');
await expect(page.getByRole('combobox', { name: 'Buscar en', exact: true })).toHaveValue(
'titulo'
);
await page.getByRole('link', { name: 'Buscar en Senza Paura', exact: true }).click();
await expect(page.getByRole('dialog').getByRole('searchbox')).toHaveValue('cupido');
});
test('el enlace y el formulario también funcionan sin JavaScript', async ({ browser, baseURL }) => {
const contexto = await browser.newContext({ javaScriptEnabled: false, baseURL });
const page = await contexto.newPage();
try {
await page.goto('/juke-box');
await page.getByRole('link', { name: 'Buscar en Senza Paura', exact: true }).click();
await expect(page).toHaveURL('/buscar');
await page.getByRole('searchbox').fill('privacidad');
await page.getByLabel('Tipo de contenido').selectOption('legal');
await page.getByRole('button', { name: 'Buscar', exact: true }).click();
await expect(page.locator('.resultados article').first()).toBeVisible();
} finally {
await contexto.close();
}
});

@ -1,18 +1,6 @@
import { expect, test } from '@playwright/test';
test.describe('Página de música', () => {
test('la lupa de la cabecera abre y enfoca la búsqueda del catálogo', async ({ page }) => {
await page.goto('/');
await page.locator('html[data-hidratado]').waitFor();
const buscar = page.getByRole('link', { name: 'Buscar en Música' });
await expect(buscar).toBeVisible();
await buscar.click();
await expect(page).toHaveURL('/musica#buscar-tema');
await expect(page.getByRole('searchbox', { name: 'Buscar en el catálogo' })).toBeFocused();
});
test('los estilos están siempre a la vista, con su recuento', async ({ page }) => {
await page.goto('/musica');
@ -25,6 +13,7 @@ test.describe('Página de música', () => {
test('las etiquetas se despliegan bajo el buscador con su propio toggle', async ({ page }) => {
await page.setViewportSize({ width: 1000, height: 800 });
await page.goto('/musica?vista=canciones&idioma=es');
await page.waitForSelector('html[data-hidratado]');
const lateral = page.locator('aside.lateral');
const nube = page.getByRole('navigation', { name: 'Filtrar por etiqueta' });
@ -80,7 +69,7 @@ test.describe('Página de música', () => {
await expect(page).toHaveURL('/musica?vista=canciones');
await expect(todas).toHaveAttribute('aria-current', 'page');
await expect(page.getByRole('heading', { level: 2, name: 'Canciones' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Álbumes y publicaciones' })).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Álbumes' })).toHaveCount(0);
await expect(page.getByRole('status')).toContainText('11 canciones');
await page
@ -88,7 +77,7 @@ test.describe('Página de música', () => {
.getByRole('link', { name: /Baladas románticas/ })
.click();
await expect(page).toHaveURL('/musica?vista=canciones&estilo=baladas-romanticas');
await expect(page.getByRole('heading', { name: 'Álbumes y publicaciones' })).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Álbumes' })).toHaveCount(0);
});
test('buscar y elegir vista son una sola pieza', async ({ page }) => {
@ -122,6 +111,7 @@ test.describe('Página de música', () => {
test('la vista activa lleva el oro de los botones, no un color plano', async ({ page }) => {
await page.goto('/musica');
await page.waitForSelector('html[data-hidratado]');
const oro = page.locator('.vista.oro');
await expect(oro).toHaveText('Tarjetas');
@ -143,8 +133,10 @@ test.describe('Página de música', () => {
expect(pintura.color).not.toBe(inactivo);
// Y cambia con la vista.
await page.getByRole('button', { name: 'Tabla' }).click();
await expect(page.locator('.vista.oro')).toHaveText('Tabla');
const tabla = page.getByRole('button', { name: 'Tabla' });
await tabla.click();
await expect(tabla).toHaveAttribute('aria-pressed', 'true');
await expect(tabla).toHaveClass(/oro/);
});
test('en un móvil la barra se apila sin estirar el campo', async ({ page }) => {
@ -161,9 +153,11 @@ test.describe('Página de música', () => {
test('la tabla ordena por la columna que se pulsa', async ({ page }) => {
await page.goto('/musica');
await page.waitForSelector('html[data-hidratado]');
await page.getByRole('button', { name: 'Tabla' }).click();
const columna = page.getByRole('columnheader', { name: 'Duración' });
const tabla = page.locator('section.canciones table');
const columna = tabla.getByRole('columnheader', { name: 'Duración' });
await expect(columna).toHaveAttribute('aria-sort', 'none');
// Las cifras empiezan de mayor a menor: lo más largo arriba.
@ -172,7 +166,7 @@ test.describe('Página de música', () => {
// 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 duraciones = await tabla.locator('tbody td.duracion').allInnerTexts();
const enSegundos = duraciones.map((d) => {
const [m, s] = d.trim().split(':').map(Number);
return m * 60 + s;
@ -219,7 +213,7 @@ test.describe('Página de música', () => {
const albumes = page.locator('section.albumes');
await expect(
albumes.getByRole('heading', { level: 2, name: 'Álbumes y publicaciones' })
albumes.getByRole('heading', { level: 2, name: 'Álbumes' })
).toBeVisible();
await expect(albumes.getByText('Álbum', { exact: true })).toBeVisible();
await expect(albumes.getByText('Colección', { exact: true })).toBeVisible();
@ -347,7 +341,7 @@ test.describe('Página de música', () => {
await page.goto('/musica');
const catalogo = page.locator('section.albumes');
await expect(
catalogo.getByRole('heading', { level: 2, name: 'Álbumes y publicaciones' })
catalogo.getByRole('heading', { level: 2, name: 'Álbumes' })
).toBeVisible();
const visibles = (
await catalogo
@ -521,6 +515,7 @@ test.describe('Etiquetas', () => {
test('las etiquetas del desplegable filtran temas de discos distintos', async ({ page }) => {
await page.goto('/musica');
await page.waitForSelector('html[data-hidratado]');
const nube = page.getByRole('navigation', { name: 'Filtrar por etiqueta' });
await page.getByRole('button', { name: 'Etiquetas', exact: true }).click();
await expect(nube).toBeVisible();

@ -36,11 +36,39 @@ test.describe('Navegación', () => {
test('el botón de tema cierra el menú', async ({ page }) => {
await page.goto('/');
const ultimo = page.locator('header .cuenta > *').last();
const ultimo = page.locator('header .utilidades-derecha > *').last();
await expect(ultimo).toHaveRole('button');
await expect(ultimo).toHaveAttribute('aria-label', /tema/);
});
test('las utilidades de la cabecera son iconos agrupados a la derecha', async ({ page }) => {
await page.goto('/');
const utilidades = page.locator('header .utilidades-derecha');
await expect(utilidades).toBeVisible();
const buscar = utilidades.getByRole('link', { name: 'Buscar en Senza Paura' });
const carrito = utilidades.getByRole('link', { name: /^Carrito/ });
await expect(buscar).toBeVisible();
await expect(carrito).toBeVisible();
await expect(utilidades.getByRole('button', { name: /tema/ })).toBeVisible();
await expect(buscar.locator('svg')).toHaveAttribute('width', '24');
await expect(carrito.locator('svg')).toHaveAttribute('width', '24');
});
test('mantiene la navegación completa y en una sola fila a mil píxeles', async ({ page }) => {
await page.setViewportSize({ width: 1000, height: 800 });
await page.goto('/musica');
await expect(page.locator('header .alternar')).toBeHidden();
await expect(page.getByRole('navigation', { name: 'Principal' })).toBeVisible();
const filas = await page.locator('header .fila').evaluate((elemento) => {
const estilo = getComputedStyle(elemento);
return { flexWrap: estilo.flexWrap, alto: elemento.getBoundingClientRect().height };
});
expect(filas.flexWrap).toBe('nowrap');
expect(filas.alto).toBeLessThan(80);
});
test('el logo de cabecera usa blanco plano en oscuro y azul plano en claro', async ({ page }) => {
await page.goto('/');

@ -81,13 +81,39 @@ test.describe('Responsive', () => {
test('en móvil la navegación se despliega y sigue completa', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 });
await page.goto('/');
await expect(page.locator('html')).toHaveAttribute('data-hidratado', '');
const nav = page.getByRole('navigation', { name: 'Principal' });
await expect(nav).toBeHidden();
const antes = await page.evaluate(() => ({
altoCabecera: document.querySelector('header')!.getBoundingClientRect().height,
inicioContenido: document.querySelector('main')!.getBoundingClientRect().top
}));
await page.getByRole('button', { name: 'Menú' }).click();
await expect(nav).toBeVisible();
const aspecto = await nav.getByRole('link', { name: 'Música' }).evaluate((enlace) => {
const caja = enlace.getBoundingClientRect();
const cabecera = enlace.closest('header');
if (!cabecera) throw new Error('Cabecera ausente');
return {
fontSize: getComputedStyle(enlace).fontSize,
centro: caja.left + caja.width / 2,
fondo: getComputedStyle(cabecera).backgroundColor
};
});
expect(Number.parseFloat(aspecto.fontSize)).toBeGreaterThanOrEqual(18);
expect(Math.abs(aspecto.centro - 375 / 2)).toBeLessThan(2);
expect(aspecto.fondo).not.toBe('rgba(0, 0, 0, 0)');
const despues = await page.evaluate(() => ({
altoCabecera: document.querySelector('header')!.getBoundingClientRect().height,
inicioContenido: document.querySelector('main')!.getBoundingClientRect().top
}));
expect(despues.altoCabecera).toBeCloseTo(antes.altoCabecera, 0);
expect(despues.inicioContenido).toBeCloseTo(antes.inicioContenido, 0);
// 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', 'Músicos y grupos', 'Taller de letra', 'Blog']) {
@ -100,6 +126,60 @@ test.describe('Responsive', () => {
await expect(nav.getByRole('link', { name: 'Contacto' })).toHaveCount(0);
});
test('el burger separa el acceso, las utilidades y el tema', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 });
await page.goto('/');
await expect(page.locator('html')).toHaveAttribute('data-hidratado', '');
await page.getByRole('button', { name: 'Menú' }).click();
const entrar = page.locator('.acciones-movil').getByRole('link', { name: 'Entrar' });
const buscar = page
.locator('.utilidades-movil')
.getByRole('link', { name: 'Buscar en Senza Paura' });
const carrito = page.locator('.utilidades-movil').getByRole('link', { name: /^Carrito/ });
const tema = page.locator('.tema-movil').getByRole('button', { name: /tema/ });
await expect(entrar).toBeVisible();
await expect(buscar).toBeVisible();
await expect(carrito).toBeVisible();
await expect(tema).toBeVisible();
const posiciones = await page.evaluate(() => {
const caja = (selector: string) => document.querySelector(selector)!.getBoundingClientRect();
return {
utilidades: caja('.utilidades-movil'),
buscar: caja('.utilidades-movil a:first-child'),
carrito: caja('.utilidades-movil a:last-child'),
tema: caja('.tema-movil'),
botonTema: caja('.tema-movil button'),
fuenteNavegacion: getComputedStyle(document.querySelector('.nav a')!).fontFamily,
fuenteAccion: getComputedStyle(document.querySelector('.opcion-menu-movil')!).fontFamily,
tamanoNavegacion: getComputedStyle(document.querySelector('.nav a')!).fontSize,
tamanoAccion: getComputedStyle(document.querySelector('.opcion-menu-movil')!).fontSize
};
});
expect(posiciones.carrito.top).toBeGreaterThanOrEqual(posiciones.buscar.bottom);
expect(posiciones.tema.top).toBeGreaterThan(posiciones.utilidades.bottom);
expect(posiciones.botonTema.width).toBe(56);
expect(posiciones.botonTema.height).toBe(56);
expect(posiciones.fuenteNavegacion).toBe(posiciones.fuenteAccion);
expect(posiciones.tamanoNavegacion).toBe(posiciones.tamanoAccion);
});
test('en tema claro el menú móvil cubre el contenido con el fondo del tema', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 800 });
await page.goto('/favoritos');
await expect(page.locator('html')).toHaveAttribute('data-hidratado', '');
await page.getByRole('button', { name: 'Menú' }).click();
await page.getByRole('button', { name: 'Cambiar al tema claro' }).click();
const colores = await page.locator('#menu-cabecera').evaluate((menu) => ({
menu: getComputedStyle(menu).backgroundColor,
pagina: getComputedStyle(document.body).backgroundColor
}));
expect(colores.menu).toBe(colores.pagina);
await expect(page.getByRole('navigation', { name: 'Principal' })).toBeVisible();
});
test('las barras laterales bajan en vez de desaparecer', async ({ page }) => {
// Son la navegación de su sección: esconderlas deja la página sin salidas.
await page.setViewportSize({ width: 375, height: 800 });

@ -0,0 +1,8 @@
import type { Album } from './types';
export const nombresTipoAlbum: Record<Album['tipo'], string> = {
album: 'Álbum',
ep: 'EP',
single: 'Single',
coleccion: 'Colección'
};

@ -0,0 +1,33 @@
class ControlDeBusqueda {
abierto = $state(false);
#origen: HTMLElement | null = null;
abrir(origen: HTMLElement): void {
this.#origen = origen;
this.abierto = true;
}
cerrar(devolverFoco = true): void {
this.abierto = false;
if (devolverFoco) this.#origen?.focus();
this.#origen = null;
}
}
export const busquedaModal = new ControlDeBusqueda();
/** Conserva el enlace real para abrir otra pestaña o navegar sin JavaScript. */
export function pedirBusqueda(evento: MouseEvent): boolean {
if (
evento.defaultPrevented ||
evento.button !== 0 ||
evento.metaKey ||
evento.ctrlKey ||
evento.shiftKey ||
evento.altKey
)
return false;
evento.preventDefault();
busquedaModal.abrir(evento.currentTarget as HTMLElement);
return true;
}

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { leerFiltrosBusqueda, parametrosBusqueda, resumenBusqueda } from './busqueda';
describe('Filtros de búsqueda global', () => {
it('normaliza y limita los parámetros recibidos por URL', () => {
expect(
leerFiltrosBusqueda(
new URLSearchParams('q=++dos+++palabras++&tipo=admin&campo=sql&pagina=-1')
)
).toEqual({ q: 'dos palabras', tipo: 'todo', campo: 'todo', pagina: 1 });
expect(
leerFiltrosBusqueda(new URLSearchParams({ q: 'a'.repeat(200), pagina: 'NaN' })).q
).toHaveLength(160);
expect(leerFiltrosBusqueda(new URLSearchParams('pagina=NaN')).pagina).toBe(1);
});
it('conserva los filtros al cambiar de página', () => {
const filtros = leerFiltrosBusqueda(new URLSearchParams('q=café&tipo=blog&campo=titulo'));
expect(leerFiltrosBusqueda(new URLSearchParams(parametrosBusqueda(filtros, 3)))).toEqual({
...filtros,
pagina: 3
});
});
it('muestra extractos de texto, sin marcado y con longitud acotada', () => {
expect(resumenBusqueda('<p>Un **artículo** con [enlace](/blog/uno)</p>')).toBe(
'Un artículo con enlace'
);
expect(resumenBusqueda('a'.repeat(500)).length).toBeLessThanOrEqual(240);
});
});

@ -0,0 +1,50 @@
export const TIPOS_BUSQUEDA = [
{ valor: 'todo', nombre: 'Todo el contenido' },
{ valor: 'musica', nombre: 'Música' },
{ valor: 'artistas', nombre: 'Músicos y grupos' },
{ valor: 'blog', nombre: 'Blog' },
{ valor: 'taller', nombre: 'Taller y géneros' },
{ valor: 'legal', nombre: 'Contenido legal' },
{ valor: 'paginas', nombre: 'Páginas y suscripciones' }
] as const;
export type TipoBusqueda = (typeof TIPOS_BUSQUEDA)[number]['valor'];
export interface FiltrosBusqueda {
q: string;
tipo: TipoBusqueda;
campo: 'todo' | 'titulo';
pagina: number;
}
export const LONGITUD_BUSQUEDA = 160;
export const RESULTADOS_POR_PAGINA = 20;
export function leerFiltrosBusqueda(parametros: URLSearchParams): FiltrosBusqueda {
const tipo = parametros.get('tipo');
const pagina = Number(parametros.get('pagina') ?? 1);
return {
q: (parametros.get('q') ?? '').trim().replace(/\s+/g, ' ').slice(0, LONGITUD_BUSQUEDA),
tipo: TIPOS_BUSQUEDA.find((opcion) => opcion.valor === tipo)?.valor ?? 'todo',
campo: parametros.get('campo') === 'titulo' ? 'titulo' : 'todo',
pagina: Number.isSafeInteger(pagina) ? Math.min(1000, Math.max(1, pagina)) : 1
};
}
export function parametrosBusqueda(filtros: FiltrosBusqueda, pagina = 1): string {
const parametros = new URLSearchParams({ q: filtros.q });
if (filtros.tipo !== 'todo') parametros.set('tipo', filtros.tipo);
if (filtros.campo !== 'todo') parametros.set('campo', filtros.campo);
if (pagina > 1) parametros.set('pagina', String(pagina));
return parametros.toString();
}
/** Texto de apoyo, nunca HTML procedente del contenido. */
export function resumenBusqueda(texto: string): string {
const limpio = texto
.replace(/<[^>]*>/g, ' ')
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[#*`_>|]/g, '')
.replace(/\s+/g, ' ')
.trim();
return limpio.length > 240 ? `${limpio.slice(0, 237).trimEnd()}…` : limpio;
}

@ -79,10 +79,10 @@
{#if albumes.length > 0}
<section class="albumes" aria-labelledby="albumes-{estilo.slug}">
<svelte:element this={enPagina ? 'h2' : 'h3'} id="albumes-{estilo.slug}">
Álbumes y publicaciones
Álbumes
</svelte:element>
<p class="descripcion-seccion muted">
Cada tarjeta reúne una publicación completa; debajo están sus canciones por separado.
Cada tarjeta reúne un lanzamiento completo; debajo están sus canciones por separado.
</p>
<div class="rejilla">
{#each albumes as album, i (album.slug)}

@ -18,6 +18,15 @@
tema.sincronizar();
});
interface Props {
/** Tamaño visible del icono, sin alterar el área pulsable. */
tamano?: number;
/** Lado del área pulsable en píxeles. */
lado?: number;
}
let { tamano = 18, lado = 36 }: Props = $props();
const aOscuro = $derived(tema.actual === 'claro');
</script>
@ -28,8 +37,9 @@
onclick={() => tema.alternar()}
aria-label={aOscuro ? 'Cambiar al tema oscuro' : 'Cambiar al tema claro'}
title={aOscuro ? 'Tema oscuro' : 'Tema claro'}
style={`--lado-boton-tema: ${lado}px`}
>
<Icono nombre={aOscuro ? 'luna' : 'sol'} tamano={18} />
<Icono nombre={aOscuro ? 'luna' : 'sol'} {tamano} />
</button>
{/if}
@ -38,8 +48,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
width: var(--lado-boton-tema, 2.25rem);
height: var(--lado-boton-tema, 2.25rem);
border: none;
border-radius: 999px;
background: transparent;

@ -2,6 +2,7 @@
/** Cabecera del sitio: marca, navegación principal, carrito y cuenta. */
import { page } from '$app/state';
import { pedirAcceso } from '$lib/acceso.svelte';
import { pedirBusqueda } from '$lib/busqueda-modal.svelte';
import { resolve } from '$app/paths';
import BotonTema from './BotonTema.svelte';
import Icono from './Icono.svelte';
@ -10,7 +11,9 @@
import { navegacion } from '$lib/navegacion';
let menuAbierto = $state(false);
let menuUsuarioAbierto = $state(false);
let cabecera = $state<HTMLElement | null>(null);
let bloqueUsuario = $state<HTMLElement | null>(null);
/**
* Si la página se ha movido del todo arriba.
@ -75,29 +78,38 @@
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
}
/** Si el catálogo ya está abierto, la lupa lleva el foco al campo sin recargar. */
function buscarEnCatalogo(evento: MouseEvent): void {
menuAbierto = false;
if (page.url.pathname !== '/musica') return;
const campo = document.querySelector<HTMLInputElement>('#buscar-tema');
if (!campo) return;
evento.preventDefault();
campo.scrollIntoView({ block: 'start', behavior: 'smooth' });
campo.focus({ preventScroll: true });
}
const enCarrito = $derived(page.data.cantidadCarrito ?? page.data.carrito?.length ?? 0);
const usuario = $derived(page.data.usuario);
/* El menú de cuenta se cierra al perder el contexto que lo abrió. */
$effect(() => {
if (!menuUsuarioAbierto) return;
function cerrarSiSePulsaFuera(evento: PointerEvent) {
if (!bloqueUsuario?.contains(evento.target as Node)) menuUsuarioAbierto = false;
}
function cerrarConEscape(evento: KeyboardEvent) {
if (evento.key === 'Escape') menuUsuarioAbierto = false;
}
document.addEventListener('pointerdown', cerrarSiSePulsaFuera);
window.addEventListener('keydown', cerrarConEscape);
return () => {
document.removeEventListener('pointerdown', cerrarSiSePulsaFuera);
window.removeEventListener('keydown', cerrarConEscape);
};
});
// Al navegar, el menú móvil se cierra solo.
$effect(() => {
void page.url.pathname;
menuAbierto = false;
menuUsuarioAbierto = false;
});
</script>
<header class="cabecera" class:desplazada bind:this={cabecera}>
<header class="cabecera" class:desplazada class:menu-abierto={menuAbierto} bind:this={cabecera}>
<div class="container fila">
<a class="marca" href={resolve('/')}>
<LogoSenzaPaura lado={38} plano />
@ -109,82 +121,232 @@
class="alternar"
onclick={() => (menuAbierto = !menuAbierto)}
aria-expanded={menuAbierto}
aria-controls="navegacion-principal"
aria-controls="menu-cabecera"
>
<Icono nombre={menuAbierto ? 'cerrar' : 'menu'} etiqueta="Menú" />
<Icono nombre={menuAbierto ? 'cerrar' : 'menu'} tamano={24} etiqueta="Menú" />
</button>
<nav id="navegacion-principal" class="nav" class:abierto={menuAbierto} aria-label="Principal">
<ul class="principal">
{#each navegacion as enlace (enlace.href)}
<li>
<a href={enlace.href} aria-current={activa(enlace.href) ? 'page' : undefined}>
{enlace.texto}
<div id="menu-cabecera" class="menu-contenido" class:abierto={menuAbierto}>
<nav id="navegacion-principal" class="nav" aria-label="Principal">
<ul class="principal">
{#each navegacion as enlace (enlace.href)}
<li>
<a href={enlace.href} aria-current={activa(enlace.href) ? 'page' : undefined}>
{enlace.texto}
</a>
</li>
{/each}
</ul>
</nav>
<div class="acciones-movil">
<div class="separador-menu" aria-hidden="true"></div>
<div class="acciones-usuario-movil">
{#if usuario}
<a
class="opcion-menu-movil"
href={resolve('/favoritos')}
aria-current={activa('/favoritos') ? 'page' : undefined}
>
<Icono nombre="corazon" tamano={24} />
<span>Favoritos</span>
</a>
<a
class="opcion-menu-movil"
href={resolve('/listas')}
aria-current={activa('/listas') ? 'page' : undefined}
>
<Icono nombre="lista" tamano={24} />
<span>Listas de reproducción</span>
</a>
<a
class="opcion-menu-movil"
href={resolve('/juke-box')}
aria-current={activa('/juke-box') ? 'page' : undefined}
>
<Icono nombre="nota" tamano={24} />
<span>Juke-box</span>
</a>
<a
class="opcion-menu-movil"
href={resolve('/cuenta')}
aria-current={activa('/cuenta') ? 'page' : undefined}
>
<Icono nombre="usuario" tamano={24} />
<span>Mi cuenta</span>
</a>
{:else}
<a
class="opcion-menu-movil"
href={resolve('/entrar')}
onclick={(evento) => {
if (pedirAcceso(evento, page.url.pathname + page.url.search, 'cuenta'))
menuAbierto = false;
}}
>
<Icono nombre="usuario" tamano={24} />
<span>Entrar</span>
</a>
</li>
{/each}
</ul>
</nav>
<div class="cuenta" class:abierto={menuAbierto}>
<a
class="buscar-catalogo"
href="{resolve('/musica')}#buscar-tema"
aria-label="Buscar en Música"
title="Buscar en Música"
onclick={buscarEnCatalogo}
>
<Icono nombre="buscar" tamano={19} />
</a>
<a
class="jukebox"
href={resolve('/juke-box')}
aria-current={activa('/juke-box') ? 'page' : undefined}
>
<Icono nombre="nota" tamano={17} />
Juke-box
</a>
<a
class="carrito"
href={resolve('/carrito')}
aria-current={activa('/carrito') ? 'page' : undefined}
>
Carrito
{#if enCarrito > 0}
<span class="contador" aria-label="{enCarrito} artículos">{enCarrito}</span>
{/if}
</a>
{#if usuario}
<a href={resolve('/listas')} aria-current={activa('/listas') ? 'page' : undefined}>
Listas
</a>
<a href={resolve('/favoritos')} aria-current={activa('/favoritos') ? 'page' : undefined}>
Favoritos
</a>
<a href={resolve('/cuenta')} aria-current={activa('/cuenta') ? 'page' : undefined}>
Mi cuenta
</a>
{:else}
<!--
{/if}
</div>
<div class="separador-menu" aria-hidden="true"></div>
<div class="utilidades-movil">
<a
class="opcion-menu-movil"
href={resolve('/buscar')}
aria-label="Buscar en Senza Paura"
aria-haspopup="dialog"
onclick={(evento) => {
pedirBusqueda(evento);
menuAbierto = false;
}}
>
<Icono nombre="buscar" tamano={24} />
<span>Buscar</span>
</a>
<a
class="opcion-menu-movil carrito"
href={resolve('/carrito')}
aria-label={enCarrito > 0 ? `Carrito, ${enCarrito} artículos` : 'Carrito'}
aria-current={activa('/carrito') ? 'page' : undefined}
>
<Icono nombre="carrito" tamano={24} />
<span>Carrito</span>
{#if enCarrito > 0}
<span class="contador" aria-hidden="true">{enCarrito}</span>
{/if}
</a>
</div>
<div class="separador-menu" aria-hidden="true"></div>
<div class="tema-movil">
<BotonTema tamano={30} lado={56} />
</div>
</div>
<div class="cuenta">
<div class="acciones-personales">
{#if usuario}
<div class="bloque-usuario" bind:this={bloqueUsuario}>
<button
type="button"
class="accion-icono activar-usuario"
aria-label="Abrir opciones de cuenta"
aria-haspopup="menu"
aria-expanded={menuUsuarioAbierto}
aria-controls="menu-usuario"
onclick={() => (menuUsuarioAbierto = !menuUsuarioAbierto)}
>
<Icono nombre="usuario" tamano={24} />
</button>
{#if menuUsuarioAbierto}
<div
id="menu-usuario"
class="menu-usuario"
role="menu"
aria-label="Opciones de cuenta"
>
<a
href={resolve('/favoritos')}
role="menuitem"
aria-current={activa('/favoritos') ? 'page' : undefined}
onclick={() => (menuUsuarioAbierto = false)}
>
<Icono nombre="corazon" tamano={20} />
<span>Favoritos</span>
</a>
<a
href={resolve('/listas')}
role="menuitem"
aria-current={activa('/listas') ? 'page' : undefined}
onclick={() => (menuUsuarioAbierto = false)}
>
<Icono nombre="lista" tamano={20} />
<span>Listas de reproducción</span>
</a>
<a
href={resolve('/juke-box')}
role="menuitem"
aria-current={activa('/juke-box') ? 'page' : undefined}
onclick={() => (menuUsuarioAbierto = false)}
>
<Icono nombre="nota" tamano={20} />
<span>Juke-box</span>
</a>
<a
href={resolve('/cuenta')}
role="menuitem"
aria-current={activa('/cuenta') ? 'page' : undefined}
onclick={() => (menuUsuarioAbierto = false)}
>
<Icono nombre="usuario" tamano={20} />
<span>Mi cuenta</span>
</a>
</div>
{/if}
</div>
{:else}
<a
class="accion-icono jukebox"
href={resolve('/juke-box')}
aria-label="Juke-box"
title="Juke-box"
aria-current={activa('/juke-box') ? 'page' : undefined}
>
<Icono nombre="nota" tamano={24} />
</a>
<!--
Sigue siendo un enlace a /entrar, no un botón: sin JavaScript lleva a
la página, que funciona igual. Con JavaScript se queda aquí y abre el
diálogo, porque entrar no debería sacarte de donde estabas.
-->
<a
href={resolve('/entrar')}
onclick={(evento) => {
if (pedirAcceso(evento, page.url.pathname + page.url.search, 'cuenta'))
menuAbierto = false;
}}
>
Entrar
</a>
{/if}
<BotonTema />
<a
href={resolve('/entrar')}
onclick={(evento) => {
if (pedirAcceso(evento, page.url.pathname + page.url.search, 'cuenta'))
menuAbierto = false;
}}
>
Entrar
</a>
{/if}
</div>
<div class="utilidades-derecha">
<a
class="accion-icono buscar-contenido"
href={resolve('/buscar')}
aria-label="Buscar en Senza Paura"
title="Buscar en Senza Paura"
aria-haspopup="dialog"
onclick={(evento) => {
pedirBusqueda(evento);
}}
>
<Icono nombre="buscar" tamano={24} />
</a>
<a
class="accion-icono carrito"
href={resolve('/carrito')}
aria-label={enCarrito > 0 ? `Carrito, ${enCarrito} artículos` : 'Carrito'}
title="Carrito"
aria-current={activa('/carrito') ? 'page' : undefined}
>
<Icono nombre="carrito" tamano={24} />
{#if enCarrito > 0}
<span class="contador" aria-hidden="true">{enCarrito}</span>
{/if}
</a>
<BotonTema tamano={24} />
</div>
</div>
</div>
</div>
</header>
@ -274,7 +436,7 @@
align-items: center;
gap: var(--sp-4);
padding-block: var(--sp-4);
flex-wrap: wrap;
flex-wrap: nowrap;
}
.marca {
@ -308,6 +470,16 @@
transform: translateY(0.07em);
}
.menu-contenido {
display: flex;
align-items: center;
gap: var(--sp-4);
}
.acciones-movil {
display: none;
}
.nav .principal {
display: flex;
gap: var(--sp-5);
@ -350,26 +522,83 @@
border-left: 1px solid var(--c-border);
}
.carrito {
display: inline-flex;
.acciones-personales {
display: flex;
align-items: center;
gap: var(--sp-2);
gap: var(--sp-4);
}
.jukebox {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
.bloque-usuario {
position: relative;
}
.buscar-catalogo {
.cuenta .accion-icono {
display: inline-grid;
min-width: 2rem;
min-height: 2rem;
width: 2rem;
height: 2rem;
padding: 0;
place-items: center;
}
.activar-usuario {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
}
.activar-usuario[aria-expanded='true'] {
color: var(--c-accent);
}
.menu-usuario {
position: absolute;
top: calc(100% + var(--sp-2));
right: 0;
z-index: 2;
display: grid;
min-width: 14rem;
gap: 0.125rem;
padding: var(--sp-2);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
box-shadow: 0 1rem 2.5rem color-mix(in oklab, black 30%, transparent);
}
.menu-usuario a {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 0.625rem var(--sp-3);
border-bottom: 0;
border-radius: var(--radius-boton);
font-size: var(--fs-lg);
line-height: 1.25;
white-space: nowrap;
}
.menu-usuario a:hover,
.menu-usuario a[aria-current='page'] {
border-bottom-color: transparent;
background: var(--c-bg-sunken);
color: var(--c-text);
}
.utilidades-derecha {
display: flex;
align-items: center;
gap: var(--sp-1);
margin-left: var(--sp-1);
}
.carrito {
position: relative;
}
.contador {
position: absolute;
top: -0.35rem;
right: -0.45rem;
display: inline-flex;
align-items: center;
justify-content: center;
@ -386,10 +615,14 @@
.alternar {
display: none;
padding: var(--sp-2);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
width: 2.75rem;
height: 2.75rem;
padding: 0;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
}
@ -399,48 +632,177 @@
}
}
/*
* En anchos intermedios sigue apareciendo la navegación completa. Solo se
* reduce el aire entre elementos; convertir aquí toda la cabecera en un
* botón de menú desperdicia una anchura en la que todavía cabe cómodamente.
*/
@media (max-width: 72rem) {
.fila {
gap: var(--sp-2);
}
.marca {
font-size: var(--fs-xl);
gap: var(--sp-1);
}
.menu-contenido {
gap: var(--sp-2);
}
.nav .principal {
gap: var(--sp-3);
}
.cuenta {
gap: var(--sp-2);
padding-left: var(--sp-2);
}
.acciones-personales {
gap: var(--sp-2);
}
.nav a,
.cuenta a {
font-size: var(--fs-base);
}
}
@media (max-width: 56rem) {
/*
* Con el menú abierto la cabecera crece; en una pantalla baja podría no
* caber, así que se le deja desplazarse por dentro en vez de tapar la
* página entera.
* El desplegable sale del flujo y vive en su propia capa debajo de la
* barra. Abrirlo no cambia la altura de la cabecera ni desplaza la página.
*/
.cabecera {
max-height: 100dvh;
overflow-y: auto;
.cabecera.menu-abierto {
background-image: none;
background-color: var(--c-bg);
backdrop-filter: none;
}
:global(:root[data-tema='claro']) .cabecera.menu-abierto {
background-color: var(--c-bg);
}
.alternar {
display: inline-flex;
}
.nav,
.fila {
flex-wrap: nowrap;
}
.menu-contenido {
position: absolute;
top: 100%;
left: 0;
right: 0;
display: none;
height: calc(100dvh - var(--altura-cabecera));
overflow-y: auto;
flex-direction: column;
align-items: stretch;
gap: 0;
padding: clamp(var(--sp-6), 7vh, 4rem) var(--sp-4)
max(var(--sp-5), env(safe-area-inset-bottom));
background: var(--c-bg);
backdrop-filter: none;
}
:global(:root[data-tema='claro']) .menu-contenido {
background: var(--c-bg);
}
.menu-contenido.abierto {
display: flex;
}
.cuenta {
display: none;
}
.acciones-movil {
display: flex;
width: 100%;
flex-direction: column;
gap: var(--sp-4);
margin-top: auto;
}
.separador-menu {
height: 1px;
background: var(--c-border);
}
.acciones-usuario-movil {
display: grid;
gap: var(--sp-2);
}
.opcion-menu-movil {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-3);
min-height: 3.5rem;
padding: var(--sp-3) var(--sp-4);
border-bottom: 0;
font-size: var(--fs-xl);
font-weight: 500;
text-decoration: none;
}
.opcion-menu-movil:hover,
.opcion-menu-movil[aria-current='page'] {
border-bottom-color: transparent;
color: var(--c-accent);
}
.utilidades-movil {
display: grid;
gap: var(--sp-2);
}
.utilidades-movil .opcion-menu-movil {
position: relative;
}
.tema-movil {
display: flex;
justify-content: center;
}
.nav,
.acciones-movil {
width: 100%;
}
.nav.abierto {
.nav {
display: block;
flex: none;
}
.nav .principal {
flex-direction: column;
gap: var(--sp-3);
padding-top: var(--sp-3);
align-items: center;
gap: var(--sp-2);
padding: 0;
}
.cuenta {
padding-left: 0;
border-left: none;
border-top: 1px solid var(--c-border);
padding-top: var(--sp-3);
margin-top: var(--sp-3);
.nav a {
display: flex;
min-height: 3.5rem;
align-items: center;
justify-content: center;
font-family: var(--font-body);
font-size: var(--fs-xl);
font-weight: 500;
line-height: 1.25;
}
.cuenta.abierto {
display: flex;
gap: var(--sp-4);
.nav a[aria-current='page'] {
color: var(--c-accent);
}
}
</style>

@ -60,7 +60,8 @@
<style>
.titulo {
font-size: 0.9375rem;
display: inline-block;
white-space: nowrap;
}
.sonando {
font-weight: 600;
@ -79,6 +80,7 @@
font-size: var(--fs-xs);
line-height: 1.25;
color: var(--c-text-muted);
white-space: nowrap;
}
.clasificacion a {

@ -0,0 +1,117 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { LONGITUD_BUSQUEDA, TIPOS_BUSQUEDA, type FiltrosBusqueda } from '$lib/busqueda';
import Icono from './Icono.svelte';
let { filtros, prefijo }: { filtros: FiltrosBusqueda; prefijo: string } = $props();
</script>
<form action={resolve('/buscar')} method="GET" role="search" aria-label="Búsqueda global">
<label for="{prefijo}-consulta">¿Qué estás buscando?</label>
<div class="campo-busqueda">
<input
id="{prefijo}-consulta"
name="q"
type="search"
value={filtros.q}
placeholder="Una canción, un artículo, una palabra…"
required
minlength="2"
maxlength={LONGITUD_BUSQUEDA}
/>
<button type="submit" class="btn btn--primary"
><Icono nombre="buscar" tamano={18} />Buscar</button
>
</div>
<div class="filtros">
<label>
Tipo de contenido
<select name="tipo" value={filtros.tipo}>
{#each TIPOS_BUSQUEDA as tipo (tipo.valor)}
<option value={tipo.valor}>{tipo.nombre}</option>
{/each}
</select>
</label>
<label>
Buscar en
<select name="campo" value={filtros.campo}>
<option value="todo">Títulos y contenido</option>
<option value="titulo">Solo títulos y nombres</option>
</select>
</label>
</div>
</form>
<style>
form {
display: grid;
gap: var(--sp-2);
min-width: 0;
}
label {
display: grid;
gap: var(--sp-2);
font-size: var(--fs-sm);
font-weight: 500;
}
.campo-busqueda {
display: flex;
min-width: 0;
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg-sunken);
}
.campo-busqueda:focus-within {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
input {
width: 100%;
min-width: 0;
min-height: 3rem;
padding: var(--sp-3);
border: 0;
border-radius: var(--radius-boton) 0 0 var(--radius-boton);
background: transparent;
color: var(--c-text);
font: inherit;
}
input:focus-visible {
outline: none;
}
.campo-busqueda button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
flex: none;
margin: -1px;
border-radius: 0 var(--radius-boton) var(--radius-boton) 0;
}
.filtros {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-4);
margin-top: var(--sp-4);
}
select {
width: 100%;
min-width: 0;
min-height: 2.75rem;
padding: var(--sp-2);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg-sunken);
color: var(--c-text);
font: inherit;
font-weight: 400;
}
@media (max-width: 34rem) {
.filtros {
grid-template-columns: minmax(0, 1fr);
}
.campo-busqueda button {
padding-inline: var(--sp-3);
}
}
</style>

@ -51,6 +51,18 @@
'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'
]
},
usuario: {
tipo: 'linea',
d: ['M20 21a8 8 0 0 0-16 0', 'M12 13a5 5 0 1 0 0-10 5 5 0 0 0 0 10Z']
},
carrito: {
tipo: 'linea',
d: [
'M3 4h2l2.2 10.2a2 2 0 0 0 2 1.6h7.9a2 2 0 0 0 1.9-1.4L21 7H6',
'M10 21a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z',
'M18 21a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z'
]
},
cerrar: { tipo: 'linea', d: ['M6 6l12 12', 'M18 6 6 18'] },
menu: { tipo: 'linea', d: ['M4 7h16', 'M4 12h16', 'M4 17h16'] },
buscar: { tipo: 'linea', d: ['M21 21l-4.35-4.35', 'M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z'] },

@ -0,0 +1,121 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { busquedaModal } from '$lib/busqueda-modal.svelte';
import { leerFiltrosBusqueda } from '$lib/busqueda';
import FormularioBusqueda from './FormularioBusqueda.svelte';
import Icono from './Icono.svelte';
let dialogo = $state<HTMLDialogElement | null>(null);
const filtros = $derived(
leerFiltrosBusqueda(
page.url.pathname === resolve('/buscar') ? page.url.searchParams : new URLSearchParams()
)
);
afterNavigate(() => busquedaModal.cerrar(false));
$effect(() => {
if (!dialogo) return;
if (busquedaModal.abierto && !dialogo.open) {
dialogo.showModal();
dialogo.querySelector<HTMLInputElement>('input[type="search"]')?.focus();
} else if (!busquedaModal.abierto && dialogo.open) dialogo.close();
});
</script>
<dialog
bind:this={dialogo}
aria-labelledby="titulo-busqueda-modal"
aria-describedby="descripcion-busqueda-modal"
onclose={() => busquedaModal.cerrar()}
onclick={(evento) => {
if (evento.target === dialogo) busquedaModal.cerrar();
}}
>
<div class="panel">
<h2 id="titulo-busqueda-modal">Buscar en Senza Paura</h2>
<p id="descripcion-busqueda-modal">
Música, historias y recursos. Encuentra lo que buscas en todo el sitio.
</p>
{#if busquedaModal.abierto}
<FormularioBusqueda {filtros} prefijo="busqueda-modal" />
{/if}
<button
type="button"
class="cerrar"
aria-label="Cerrar búsqueda"
onclick={() => busquedaModal.cerrar()}
>
<Icono nombre="cerrar" />
</button>
</div>
</dialog>
<style>
dialog {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
margin: 0;
padding: var(--sp-4);
border: 0;
background: transparent;
color: var(--c-text);
overflow: auto;
place-items: center;
}
dialog[open] {
display: grid;
}
dialog::backdrop {
background: rgb(3 8 20 / 72%);
backdrop-filter: blur(4px);
}
.panel {
position: relative;
width: 100%;
max-width: 44rem;
min-width: 0;
padding: var(--sp-6);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
box-shadow: 0 24px 64px rgb(0 0 0 / 35%);
}
h2 {
margin: 0;
padding-right: var(--sp-5);
font-size: var(--fs-xl);
}
p {
margin: var(--sp-3) 0 var(--sp-5);
max-width: 52ch;
color: var(--c-text-muted);
}
.cerrar {
position: absolute;
top: var(--sp-2);
right: var(--sp-2);
display: grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
padding: 0;
border: 0;
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
}
.cerrar:hover {
color: var(--c-text);
}
@media (max-width: 34rem) {
dialog {
padding: var(--sp-3);
}
.panel {
padding: var(--sp-6) var(--sp-4) var(--sp-4);
}
}
</style>

@ -0,0 +1,109 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { AlbumConCanciones } from '$lib/types';
import { nombresTipoAlbum } from '$lib/albumes';
import { obtenerEstilo } from '$lib/catalogo/cliente.svelte';
import { aPistas } from '$lib/player/pista';
import { reproductor } from '$lib/player/player.svelte';
import { ano } from '$lib/utils/date';
import { segundosADuracion } from '$lib/utils/format';
import BotonReproducir from './BotonReproducir.svelte';
import SinPortada from './SinPortada.svelte';
import TablaCatalogo from './TablaCatalogo.svelte';
let { albumes }: { albumes: AlbumConCanciones[] } = $props();
const filas = $derived(
albumes.map((album) => ({
album,
estilo: obtenerEstilo(album.estilo),
pistas: aPistas(album.canciones)
}))
);
</script>
<TablaCatalogo clase="tabla-albumes">
<caption class="visually-hidden">Álbumes</caption>
<thead>
<tr>
<th scope="col" class="control"><span class="visually-hidden">Reproducir</span></th>
<th scope="col" class="control"><span class="visually-hidden">Portada</span></th>
<th scope="col" class="columna-titulo"><span class="rotulo">Título</span></th>
<th scope="col" class="numerica"><span class="rotulo">Año</span></th>
<th scope="col" class="numerica"><span class="rotulo">Temas</span></th>
<th scope="col" class="numerica"><span class="rotulo">Duración</span></th>
</tr>
</thead>
<tbody>
{#each filas as { album, estilo, pistas } (album.slug)}
{@const duracion = album.duracionTotal > 0 ? segundosADuracion(album.duracionTotal) : '—'}
<tr class:sonando={pistas.some((pista) => reproductor.esActual(pista.id))}>
<td class="control"><BotonReproducir {pistas} modo="album" tamano={16} /></td>
<td class="control">
<a
class="mini"
href={resolve('/(sitio)/albumes/[slug]', { slug: album.slug })}
tabindex="-1"
aria-hidden="true"
>
{#if album.portada}
<img src={album.portada} alt="" width="80" height="80" loading="lazy" />
{:else}
<SinPortada titulo={album.titulo} lado={80} muda />
{/if}
</a>
</td>
<th scope="row" class="detalle columna-titulo">
<a class="titulo" href={resolve('/(sitio)/albumes/[slug]', { slug: album.slug })}
>{album.titulo}</a
>
<p class="contexto muted">
{nombresTipoAlbum[album.tipo]}
{#if estilo}
<span aria-hidden="true"> · </span>
<a href={resolve('/(sitio)/musica/[estilo]', { estilo: estilo.slug })}
>{estilo.nombre}</a
>
{/if}
</p>
<p class="meta-movil muted">
{ano(album.fecha)} · {album.canciones.length}
{album.canciones.length === 1 ? 'tema' : 'temas'}
{#if album.duracionTotal > 0}
· {duracion}{/if}
</p>
</th>
<td class="numerica">{ano(album.fecha)}</td>
<td class="numerica">{album.canciones.length}</td>
<td class="numerica">{duracion}</td>
</tr>
{/each}
</tbody>
</TablaCatalogo>
<style>
.detalle {
min-width: 0;
}
.contexto {
margin: var(--sp-1) 0 0;
font-size: var(--fs-xs);
line-height: 1.25;
}
.contexto a {
color: inherit;
}
.meta-movil {
display: none;
margin: var(--sp-1) 0 0;
font-size: var(--fs-xs);
font-variant-numeric: tabular-nums;
}
@container tabla-catalogo (max-width: 42rem) {
.numerica {
display: none;
}
.meta-movil {
display: block;
}
}
</style>

@ -30,6 +30,7 @@
import BotonReproducir from './BotonReproducir.svelte';
import DetalleTituloCancion from './DetalleTituloCancion.svelte';
import SinPortada from './SinPortada.svelte';
import TablaCatalogo from './TablaCatalogo.svelte';
import { albumDeCancion, obtenerAlbum, obtenerEstilo } from '$lib/catalogo/cliente.svelte';
import { aPistas } from '$lib/player/pista';
import { reproductor } from '$lib/player/player.svelte';
@ -201,177 +202,177 @@
{#if ordenadas.length === 0}
<p class="muted vacio">{vacio}</p>
{:else}
<div class="marco">
<table>
<caption class="visually-hidden">
{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>
<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>
<TablaCatalogo clase="tabla-canciones">
<caption class="visually-hidden">
{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>
<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"
class="columna-titulo"
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="columna-{columna.campo}"
class:numerica={columna.numerica}
aria-sort={!ordenable
? undefined
: campo === 'titulo'
: campo === columna.campo
? ascendente
? 'ascending'
: 'descending'
: 'none'}
>
{#if ordenable}
<button type="button" onclick={() => ordenarPor('titulo')}>
Título
<button type="button" onclick={() => ordenarPor(columna.campo)}>
{columna.texto}
<span class="flecha" aria-hidden="true">
{campo === 'titulo' ? (ascendente ? '↑' : '↓') : ''}
{campo === columna.campo ? (ascendente ? '↑' : '↓') : ''}
</span>
</button>
{:else}
<span class="rotulo">Título</span>
<span class="rotulo">{columna.texto}</span>
{/if}
</th>
{/each}
{#each visibles as columna (columna.campo)}
<th
scope="col"
class:numerica={columna.numerica}
aria-sort={!ordenable
? undefined
: campo === columna.campo
? ascendente
? 'ascending'
: 'descending'
: 'none'}
<th
scope="col"
class="numerica"
aria-sort={!ordenable
? undefined
: campo === 'duracion'
? ascendente
? 'ascending'
: 'descending'
: 'none'}
>
{#if ordenable}
<button type="button" onclick={() => ordenarPor('duracion')}>
Duración
<span class="flecha" aria-hidden="true">
{campo === 'duracion' ? (ascendente ? '↑' : '↓') : ''}
</span>
</button>
{: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 + ':' + (fila.cancion.versionEnLanzamiento ?? 'principal'))}
{@const c = fila.cancion}
<tr class:sonando={reproductor.esActual(pistas[i].id)}>
<td class="control">
<BotonReproducir {pistas} indice={i} tamano={16} />
</td>
<td class="control">
<a
class="mini"
href={resolve('/(sitio)/canciones/[slug]', { slug: c.slug })}
tabindex="-1"
onmouseenter={(e) => asomar(e, c)}
onmouseleave={() => (vista = null)}
>
{#if ordenable}
<button type="button" onclick={() => ordenarPor(columna.campo)}>
{columna.texto}
<span class="flecha" aria-hidden="true">
{campo === columna.campo ? (ascendente ? '↑' : '↓') : ''}
</span>
</button>
{#if c.portada}
<img src={c.portada} alt="" width="80" height="80" loading="lazy" />
{:else}
<span class="rotulo">{columna.texto}</span>
<SinPortada titulo={c.titulo} lado={80} muda />
{/if}
</th>
{/each}
</a>
</td>
<th
scope="col"
class="numerica"
aria-sort={!ordenable
? undefined
: campo === 'duracion'
? ascendente
? 'ascending'
: 'descending'
: 'none'}
>
{#if ordenable}
<button type="button" onclick={() => ordenarPor('duracion')}>
Duración
<span class="flecha" aria-hidden="true">
{campo === 'duracion' ? (ascendente ? '↑' : '↓') : ''}
</span>
</button>
{:else}
<span class="rotulo">Duración</span>
{/if}
</th>
{#if numerar}
<td class="control numerica muted">{c.numero ?? '—'}</td>
{/if}
<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 + ':' + (fila.cancion.versionEnLanzamiento ?? 'principal'))}
{@const c = fila.cancion}
<tr class:sonando={reproductor.esActual(pistas[i].id)}>
<td class="control">
<BotonReproducir {pistas} indice={i} tamano={16} />
</td>
<th scope="row" class="columna-titulo">
<DetalleTituloCancion
{c}
estilo={fila.estilo}
sonando={reproductor.esActual(pistas[i].id)}
/>
</th>
<td class="control">
<a
class="mini"
href={resolve('/(sitio)/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" />
{#each visibles as columna (columna.campo)}
<td class="columna-{columna.campo}" class:numerica={columna.numerica}>
{#if columna.campo === 'estilo'}
{#if fila.estilo}
<a href={resolve('/(sitio)/musica/[estilo]', { estilo: fila.estilo.slug })}>
{fila.estilo.nombre}
</a>
{:else}
<SinPortada titulo={c.titulo} lado={80} muda />
—
{/if}
</a>
</td>
{#if numerar}
<td class="control numerica muted">{c.numero ?? '—'}</td>
{/if}
<th scope="row">
<DetalleTituloCancion
{c}
estilo={fila.estilo}
sonando={reproductor.esActual(pistas[i].id)}
/>
</th>
{#each visibles as columna (columna.campo)}
<td class:numerica={columna.numerica}>
{#if columna.campo === 'estilo'}
{#if fila.estilo}
<a href={resolve('/(sitio)/musica/[estilo]', { estilo: fila.estilo.slug })}>
{fila.estilo.nombre}
</a>
{:else}
—
{/if}
{:else if columna.campo === 'album'}
{#if fila.album}
<a href={resolve('/(sitio)/albumes/[slug]', { slug: fila.album.slug })}>
{fila.album.titulo}
</a>
{:else}
—
{/if}
{:else if columna.campo === 'album'}
{#if fila.album}
<a href={resolve('/(sitio)/albumes/[slug]', { slug: fila.album.slug })}>
{fila.album.titulo}
</a>
{:else}
{c.ano ?? '—'}
—
{/if}
</td>
{/each}
<td class="numerica duracion">
{c.duracion ? segundosADuracion(c.duracion) : '—'}
{:else}
{c.ano ?? '—'}
{/if}
</td>
{/each}
<td class="control">
<BotonFavorito cancionSlug={c.slug} compacto tamano={16} />
</td>
<td class="control">
<BotonCola cancion={c} compacto tamano={16} />
</td>
<td class="control">
<BotonCarrito cancion={c} compacto />
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<td class="numerica duracion">
{c.duracion ? segundosADuracion(c.duracion) : '—'}
</td>
<td class="control">
<BotonFavorito cancionSlug={c.slug} compacto tamano={16} />
</td>
<td class="control">
<BotonCola cancion={c} compacto tamano={16} />
</td>
<td class="control">
<BotonCarrito cancion={c} compacto />
</td>
</tr>
{/each}
</tbody>
</TablaCatalogo>
{#if vista}
<!--
@ -396,149 +397,25 @@
}
/*
* En pantalla estrecha la tabla se desplaza dentro de su marco: dejar que
* empuje el ancho de la página rompe todo lo demás.
*
* 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.
*/
.marco {
/*
* Aísla la anchura intrínseca de la tabla. Chromium incluía sus columnas
* ocultas en el ancho del documento aunque el propio marco ya desplazaba,
* creando scroll horizontal en móvil fuera de la tabla.
*/
contain: layout;
overflow-x: auto;
margin-bottom: var(--sp-7);
}
@media (min-width: 64rem) {
.marco {
contain: none;
overflow-x: visible;
}
}
/*
* 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.
*/
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: var(--fs-sm);
}
th,
td {
padding: var(--sp-2) var(--sp-3);
text-align: left;
border-bottom: 1px solid var(--c-border);
white-space: nowrap;
}
thead th {
padding: 0;
background: var(--c-bg);
border-bottom: 1px solid var(--c-border);
}
/*
* Un `sticky` dentro de `.marco` cuando este tiene `overflow-x: auto`
* desplaza cada `th` por la altura de la cabecera sin mover el `thead`: el
* resultado tapa la primera canción. Solo se activa cuando el marco deja de
* ser un contenedor de desplazamiento horizontal.
* En el ancho habitual del catálogo, «Estilo» repite exactamente el dato
* que ya aparece debajo del título. Se retira esa repetición antes de
* comprimir el título o apilar sus etiquetas; «Año» conserva su columna.
* En una zona realmente ancha se ven las cinco columnas completas.
*/
@media (min-width: 64rem) {
thead th {
position: sticky;
top: var(--altura-cabecera);
z-index: 1;
@container tabla-catalogo (max-width: 62rem) {
.columna-estilo {
display: none;
}
}
thead button,
.rotulo {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: var(--sp-3);
border: none;
background: none;
color: var(--c-text-muted);
font: inherit;
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
}
thead button {
cursor: pointer;
}
thead button:hover,
th[aria-sort]:not([aria-sort='none']) button {
color: var(--c-text);
}
/* Ancho fijo para que la flecha no desplace el rótulo al aparecer. */
.flecha {
display: inline-block;
width: 1ch;
color: var(--c-accent);
/* El bloque descriptivo empieza junto al borde superior de la portada. */
tbody .columna-titulo {
vertical-align: top;
}
tbody th {
.columna-album,
.columna-album a {
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. */
tbody tr:hover {
background: var(--c-bg-raised);
}
td a {
text-decoration: none;
}
td a:hover {
text-decoration: underline;
}
.numerica {
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);
}
/*

@ -0,0 +1,134 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children, clase = '' }: { children: Snippet; clase?: string } = $props();
</script>
<div class="ancho-tabla">
<div class="marco tabla-catalogo-marco">
<table class="tabla-catalogo {clase}">{@render children()}</table>
</div>
</div>
<style>
/* La adaptación depende del espacio disponible, no del ancho de la ventana. */
.ancho-tabla {
container: tabla-catalogo / inline-size;
width: 100%;
min-width: 0;
}
.marco {
contain: layout;
width: 100%;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-family: var(--font-body);
font-size: var(--fs-sm);
}
table :global(th),
table :global(td) {
padding: var(--sp-2);
text-align: left;
vertical-align: middle;
}
table :global(thead th) {
padding: 0;
background: var(--c-bg);
font-weight: 500;
border-bottom: 1px solid var(--c-border);
}
/* Separadores dentro de las tablas, nunca un cierre bajo la última fila. */
table :global(tbody tr:not(:last-child) > *) {
border-bottom: 1px solid var(--c-border);
}
table :global(tbody th) {
font-weight: 400;
}
table :global(tbody th),
table :global(tbody td) {
white-space: nowrap;
}
table :global(tbody tr:hover) {
background: var(--c-bg-raised);
}
table :global(tbody tr.sonando) {
background: color-mix(in oklab, var(--c-focus) 8%, transparent);
}
table :global(thead button),
table :global(thead .rotulo) {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: var(--sp-3);
margin: 0;
border: 0;
background: none;
color: var(--c-text-muted);
font: inherit;
font-size: var(--fs-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
}
table :global(thead button) {
cursor: pointer;
}
table :global(thead button:hover),
table :global(th[aria-sort]:not([aria-sort='none']) button) {
color: var(--c-text);
}
table :global(.flecha) {
display: inline-block;
width: 1ch;
color: var(--c-accent);
}
table :global(.numerica) {
white-space: nowrap;
text-align: right;
font-variant-numeric: tabular-nums;
}
table :global(.numerica > button),
table :global(.numerica > .rotulo) {
justify-content: flex-end;
}
table :global(.control) {
width: 1%;
padding-inline: var(--sp-1);
}
table :global(.mini) {
display: block;
width: 80px;
line-height: 0;
}
table :global(.mini img) {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1;
object-fit: contain;
border-radius: var(--radius-tarjeta);
}
table :global(a) {
text-decoration: none;
}
table :global(a:hover) {
text-decoration: underline;
}
table :global(.titulo) {
font-size: 1rem;
font-weight: 400;
color: var(--c-text);
}
table :global(tr.sonando .titulo) {
font-weight: 600;
}
@container tabla-catalogo (max-width: 42rem) {
table :global(.mini) {
width: 56px;
}
}
</style>

@ -14,6 +14,7 @@
import type { AlbumConCanciones } from '$lib/types';
import { ano } from '$lib/utils/date';
import { duracionLarga } from '$lib/utils/format';
import { nombresTipoAlbum } from '$lib/albumes';
interface Props {
album: AlbumConCanciones;
@ -28,13 +29,6 @@
let { album, retraso = 0, compacta = false, nivelTitulo = 3 }: Props = $props();
const pistas = $derived(aPistas(album.canciones));
const etiquetasTipo: Record<string, string> = {
album: 'Álbum',
ep: 'EP',
single: 'Single',
coleccion: 'Colección'
};
</script>
<article class="tarjeta" class:compacta use:revelar={retraso} use:brillo>
@ -54,7 +48,7 @@
<div class="tarjeta__cuerpo">
<div class="tarjeta__cabecera">
<p class="tipo eyebrow">{etiquetasTipo[album.tipo] ?? album.tipo}</p>
<p class="tipo eyebrow">{nombresTipoAlbum[album.tipo]}</p>
<div class="tarjeta__reproduccion tarjeta__encima">
<BotonReproducir {pistas} modo="album" variante="solido" tamano={16} />
</div>

@ -0,0 +1,132 @@
/** Tablas de sesión sin restricciones ni secuencias: no modifica el catálogo real. */
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import pg from 'pg';
import { PgDialect } from 'drizzle-orm/pg-core';
import { leerFiltrosBusqueda } from '$lib/busqueda';
import { consultaBusqueda, type RespuestaBusqueda } from './busqueda-consulta';
describe.runIf(!!process.env.TEST_DATABASE_URL)('Búsqueda global en PostgreSQL', () => {
let cliente: pg.Client;
const dialecto = new PgDialect();
async function buscar(
q: string,
parametros: Record<string, string> = {}
): Promise<RespuestaBusqueda> {
const consulta = dialecto.sqlToQuery(
consultaBusqueda(leerFiltrosBusqueda(new URLSearchParams({ q, ...parametros })))
);
return (await cliente.query(consulta.sql, consulta.params)).rows[0];
}
beforeAll(async () => {
cliente = new pg.Client({ connectionString: process.env.TEST_DATABASE_URL });
await cliente.connect();
for (const tabla of [
'estilo',
'medio',
'album',
'cancion',
'cancion_etiqueta',
'etiqueta',
'cancion_interprete',
'artista',
'entrada',
'entrada_etiqueta',
'capitulo',
'genero_seccion',
'termino',
'acepcion',
'documento_legal',
'version_legal',
'pagina',
'plan_suscripcion'
]) {
await cliente.query(
`CREATE TEMP TABLE ${tabla} AS SELECT * FROM public.${tabla} WITH NO DATA`
);
}
await cliente.query(`
INSERT INTO medio(id,clave,clase,publico) VALUES ('pc','images/cancion.webp','imagen',true),('pa','imagenes/album.webp','imagen',true),('privada','imagenes/privada.webp','imagen',false);
INSERT INTO estilo(id,slug,nombre,estado) VALUES ('e','aurora','Aurora','publicado');
INSERT INTO album(id,slug,titulo,estilo_id,estado) VALUES ('a','aurora','Aurora','e','publicado'),('b','invisible','Invisible','e','borrador');
INSERT INTO cancion(id,slug,titulo,estilo_id,album_id,estado,version_de_id) VALUES
('c','aurora','Aurora','e','a','publicado',null),('d','invisible','Invisible','e','b','publicado',null),
('v','version','Invisible','e','a','publicado','c');
UPDATE album SET portada_id = 'pa' WHERE id = 'a';
UPDATE cancion SET portada_id = 'pc' WHERE id = 'c';
INSERT INTO cancion(id,slug,titulo,estilo_id,album_id,estado,portada_id) VALUES
('heredada','heredada','Portada heredada','e','a','publicado',null),
('sin','sin','Sin imagen','e',null,'publicado',null),
('privada','privada','Imagen privada','e',null,'publicado','privada');
INSERT INTO artista(id,slug,nombre,estado) VALUES ('ar','aurora','Aurora','publicado'),('ad','invisible','Invisible','borrador');
INSERT INTO entrada(id,slug,titulo,estado,cuerpo,secciones) VALUES
('en','aurora','Aurora','publicado','obsoletosecreto','[{"id":"idsecreto","tipo":"texto","texto":"Café y poesía","url":"https://urlsecreta.invalid"}]'),
('ed','invisible','Invisible','borrador','Invisible','[]');
INSERT INTO capitulo(id,slug,titulo,tipo,estado,cuerpo) VALUES ('ca','aurora','Aurora','principio','publicado','Rima y verso');
INSERT INTO termino(id,slug,termino) VALUES ('te','aurora','Aurora');
INSERT INTO acepcion(id,termino_id,numero,definicion) VALUES ('ac','te',1,'Una definición');
INSERT INTO documento_legal(id,slug,titulo,version_vigente_id) VALUES ('le','aurora','Aurora','actual');
INSERT INTO version_legal(id,documento_id,cuerpo) VALUES ('actual','le','Condición vigente'),('vieja','le','historicosecreto');
INSERT INTO pagina(id,slug,titulo,estado,cuerpo) VALUES ('pa','bio','Aurora','publicado','Biografía'),('pd','invisible','Invisible','borrador','Invisible');
INSERT INTO plan_suscripcion(id,codigo,nombre,estado,prestaciones) VALUES ('pl','aurora','Aurora','disponible','["Escucha"]');
INSERT INTO pagina(id,slug,titulo,estado) SELECT 'pag' || n, 'paginable-' || n, 'Paginable ' || lpad(n::text,2,'0'), 'publicado' FROM generate_series(1,25) n;
`);
}, 30000);
afterAll(async () => {
await cliente?.end();
});
it('encuentra todos los dominios públicos, con sus destinos', async () => {
const r = await buscar('aurora');
expect(new Set(r.resultados.map((v) => v.tipo))).toEqual(
new Set(['musica', 'artistas', 'blog', 'taller', 'legal', 'paginas'])
);
expect(r.resultados.some((v) => v.href === '/bio')).toBe(true);
expect(r.resultados.some((v) => v.href === '/generos/glosario#termino-aurora')).toBe(true);
});
it('filtra por contenido y distingue búsqueda en cuerpo de solo títulos', async () => {
expect((await buscar('CAFE poesia', { tipo: 'blog' })).resultados.map((r) => r.slug)).toEqual([
'aurora'
]);
expect((await buscar('cafe', { campo: 'titulo' })).total).toBe(0);
expect((await buscar('aurora', { tipo: 'legal' })).resultados.map((r) => r.tipo)).toEqual([
'legal'
]);
});
it('devuelve la portada propia, hereda la del álbum y no expone imágenes privadas', async () => {
const { resultados } = await buscar('aurora');
expect(resultados.find((r) => r.href === '/canciones/aurora')?.portada).toBe(
'/images/cancion.webp'
);
expect(resultados.find((r) => r.href === '/albumes/aurora')?.portada).toBe(
'/imagenes/album.webp'
);
expect((await buscar('portada heredada')).resultados[0]?.portada).toBe('/imagenes/album.webp');
expect((await buscar('sin imagen')).resultados[0]?.portada).toBeNull();
expect((await buscar('imagen privada')).resultados[0]?.portada).toBeNull();
expect(resultados.find((r) => r.tipo === 'legal')?.portada).toBeNull();
});
it('excluye borradores, canciones de álbumes ocultos y versiones duplicadas', async () => {
expect((await buscar('invisible')).total).toBe(0);
});
it('solo indexa la versión legal vigente y los textos editoriales efectivos', async () => {
expect((await buscar('condicion vigente', { tipo: 'legal' })).total).toBe(1);
for (const palabra of ['historicosecreto', 'obsoletosecreto', 'idsecreto', 'urlsecreta']) {
expect((await buscar(palabra)).total).toBe(0);
}
});
it('trata comillas y comodines como texto literal', async () => {
expect((await buscar("aurora' OR 1=1--")).total).toBe(0);
expect((await buscar('%_')).total).toBe(0);
expect((await buscar('.*')).total).toBe(0);
expect((await buscar('(a+)+')).total).toBe(0);
});
it('pagina sin duplicados y ajusta las páginas fuera de rango', async () => {
const primera = await buscar('paginable');
const ultima = await buscar('paginable', { pagina: '999' });
expect(primera.total).toBe(25);
expect(primera.resultados).toHaveLength(20);
expect(ultima.pagina).toBe(2);
expect(ultima.resultados).toHaveLength(5);
expect(new Set([...primera.resultados, ...ultima.resultados].map((r) => r.href)).size).toBe(25);
});
});

@ -0,0 +1,129 @@
import { sql, type SQL } from 'drizzle-orm';
import { RESULTADOS_POR_PAGINA, type FiltrosBusqueda, type TipoBusqueda } from '$lib/busqueda';
/** Los bloques sustituyen al texto legado, igual que en las fichas públicas. */
function editorial(bloques: SQL, respaldo: SQL): SQL {
return sql`case when jsonb_array_length(${bloques}) > 0 then
(select string_agg(concat_ws(' ', b->>'titulo', b->>'texto', b->>'codigo', b->>'pie', b->>'alt'), ' ')
from jsonb_array_elements(${bloques}) b)
else ${respaldo} end`;
}
/** Solo proyecciones públicas: ni archivos de audio, ni datos de cuenta, ni borradores. */
const fuentes = [
sql`select 'musica' as tipo, 'Canción' as clase, c.slug, c.titulo,
'/canciones/' || c.slug as href,
concat_ws(' · ', a.titulo, e.nombre, c.idioma) as resumen,
concat_ws(' ', c.letra, ${editorial(sql`c.inspiracion_secciones`, sql`c.inspiracion`)},
c.ano, c.idioma, e.nombre, a.titulo,
(select string_agg(t.nombre, ' ') from cancion_etiqueta ce join etiqueta t on t.id = ce.etiqueta_id where ce.cancion_id = c.id),
(select string_agg(ar.nombre, ' ') from cancion_interprete ci join artista ar on ar.id = ci.artista_id where ci.cancion_id = c.id and ar.estado = 'publicado')) as cuerpo,
coalesce(c.portada_id, a.portada_id) as portada_id
from cancion c join estilo e on e.id = c.estilo_id left join album a on a.id = c.album_id
where c.estado = 'publicado' and c.version_de_id is null and e.estado = 'publicado'
and (c.album_id is null or a.estado = 'publicado')`,
sql`select 'musica', 'Álbum', a.slug, a.titulo, '/albumes/' || a.slug, a.resumen,
concat_ws(' ', ${editorial(sql`a.secciones`, sql`a.cuerpo`)}, e.nombre, a.fecha), a.portada_id
from album a join estilo e on e.id = a.estilo_id
where a.estado = 'publicado' and e.estado = 'publicado'`,
sql`select 'musica', 'Estilo', slug, nombre, '/musica/' || slug, resumen, cuerpo, null
from estilo where estado = 'publicado'`,
sql`select 'artistas', 'Músico / grupo', slug, nombre, '/interpretes/' || slug, descripcion_corta,
concat_ws(' ', ${editorial(sql`secciones`, sql`biografia`)}, lugar_origen), null
from artista where estado = 'publicado'`,
sql`select 'blog', 'Blog', en.slug, en.titulo, '/blog/' || en.slug, en.resumen,
concat_ws(' ', ${editorial(sql`en.secciones`, sql`en.cuerpo`)},
(select string_agg(t.nombre, ' ') from entrada_etiqueta et join etiqueta t on t.id = et.etiqueta_id where et.entrada_id = en.id)), null
from entrada en where en.estado = 'publicado'`,
sql`select 'taller', case when c.tipo = 'ficha' then 'Ficha de género' else 'Taller de letras' end,
c.slug, c.titulo, (case when c.tipo = 'ficha' then '/generos/' else '/taller/' end) || c.slug,
c.resumen, concat_ws(' ', c.cuerpo,
(select string_agg(concat_ws(' ', gs.titulo, gs.cuerpo), ' ')
from genero_seccion gs where gs.genero_id = c.genero_id)), null
from capitulo c where c.estado = 'publicado'`,
sql`select 'taller', 'Glosario', t.slug, t.termino, '/generos/glosario#termino-' || t.slug,
string_agg(a.definicion, ' ' order by a.numero),
string_agg(concat_ws(' ', a.ampliacion, a.ejemplo, a.categoria), ' ' order by a.numero), null
from termino t join acepcion a on a.termino_id = t.id group by t.id, t.slug, t.termino`,
sql`select 'legal', 'Documento legal', d.slug, d.titulo, '/legal/' || d.slug, d.resumen, v.cuerpo, null
from documento_legal d join version_legal v on v.id = d.version_vigente_id and v.documento_id = d.id`,
sql`select 'paginas', 'Página', slug, titulo,
case when slug = 'bio' then '/bio' else '/paginas/' || slug end, descripcion, cuerpo, null
from pagina where estado = 'publicado'`,
sql`select 'paginas', 'Suscripción', codigo, nombre, '/suscripciones', coalesce(descripcion, ''),
concat_ws(' ', 'suscripción', (select string_agg(p, ' ') from jsonb_array_elements_text(prestaciones) p)), null
from plan_suscripcion where estado = 'disponible'`
];
/**
* Alternativas literales, no clases multibyte: funcionan también en la base
* existente SQL_ASCII, donde translate() trata los acentos UTF-8 como bytes.
* Se escapa toda sintaxis de expresión regular introducida por el usuario.
*/
function patronLiteral(texto: string): string {
const variantes: Record<string, string> = {
a: 'aáàäâã',
e: 'eéèëê',
i: 'iíìïî',
o: 'oóòöôõ',
u: 'uúùüû',
n: 'nñ',
c: 'cç'
};
return [...texto.toLowerCase().normalize('NFD').replace(/\p{M}/gu, '')]
.map((letra) => {
const grupo = variantes[letra];
if (grupo) return `(?:${[...new Set(grupo + grupo.toUpperCase())].join('|')})`;
return letra.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
})
.join('');
}
export interface ResultadoBusqueda {
tipo: Exclude<TipoBusqueda, 'todo'>;
clase: string;
slug: string;
titulo: string;
href: string;
resumen: string;
portada: string | null;
}
export interface RespuestaBusqueda {
total: number;
pagina: number;
resultados: ResultadoBusqueda[];
}
/** Una consulta parametrizada; el servidor pagina antes de enviar datos al cliente. */
export function consultaBusqueda(filtros: FiltrosBusqueda): SQL {
const texto =
filtros.campo === 'titulo' ? sql`titulo` : sql`concat_ws(' ', titulo, resumen, cuerpo)`;
const coincidencias = filtros.q
.split(/\s+/)
.map((palabra) => sql`lower(coalesce(${texto}, '')) ~ ${patronLiteral(palabra)}`);
const frase = patronLiteral(filtros.q);
return sql`with documentos as (${sql.join(fuentes, sql` union all `)}),
coincidencias as (
select tipo, clase, slug, titulo, href, portada_id,
left(coalesce(nullif(resumen, ''), cuerpo, ''), 1200) as resumen,
case when lower(titulo) ~ ${`^${frase}$`} then 3
when lower(titulo) ~ ${frase} then 2
else 1 end as relevancia
from documentos where ${sql.join(coincidencias, sql` and `)}
and (${filtros.tipo} = 'todo' or tipo = ${filtros.tipo})
), recuento as (
select count(*)::int as total,
least(${filtros.pagina}, greatest(1, ceil(count(*)::numeric / ${RESULTADOS_POR_PAGINA})))::int as pagina
from coincidencias
), seleccion as (
select * from coincidencias order by relevancia desc, titulo, tipo, href
limit ${RESULTADOS_POR_PAGINA} offset (select (pagina - 1) * ${RESULTADOS_POR_PAGINA} from recuento)
)
select total, pagina, coalesce((select jsonb_agg(
jsonb_build_object('tipo', tipo, 'clase', s.clase, 'slug', slug, 'titulo', titulo, 'href', href, 'resumen', resumen,
'portada', '/' || p.clave)
order by relevancia desc, titulo, tipo, href) from seleccion s
left join medio p on p.id = s.portada_id and p.clase = 'imagen' and p.publico), '[]'::jsonb) as resultados
from recuento`;
}

@ -0,0 +1,18 @@
import { db } from './db';
import { consultaBusqueda, type RespuestaBusqueda } from './busqueda-consulta';
import { resumenBusqueda, type FiltrosBusqueda } from '$lib/busqueda';
export async function buscarContenido(filtros: FiltrosBusqueda): Promise<RespuestaBusqueda> {
if (filtros.q.length < 2) return { total: 0, pagina: 1, resultados: [] };
const { rows } = await db.execute<RespuestaBusqueda & Record<string, unknown>>(
consultaBusqueda(filtros)
);
const respuesta = rows[0];
return {
...respuesta,
resultados: respuesta.resultados.map((resultado) => ({
...resultado,
resumen: resumenBusqueda(resultado.resumen)
}))
};
}

@ -0,0 +1,9 @@
import { leerFiltrosBusqueda } from '$lib/busqueda';
import { buscarContenido } from '$lib/server/busqueda';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const filtros = leerFiltrosBusqueda(url.searchParams);
const busqueda = await buscarContenido(filtros);
return { filtros: { ...filtros, pagina: busqueda.pagina }, busqueda };
};

@ -0,0 +1,185 @@
<script lang="ts">
import { base, resolve } from '$app/paths';
import { parametrosBusqueda, RESULTADOS_POR_PAGINA } from '$lib/busqueda';
import FormularioBusqueda from '$lib/components/FormularioBusqueda.svelte';
import Meta from '$lib/components/Meta.svelte';
let { data } = $props();
const paginas = $derived(Math.ceil(data.busqueda.total / RESULTADOS_POR_PAGINA));
</script>
<Meta
titulo={data.filtros.q ? `Búsqueda: ${data.filtros.q}` : 'Buscar'}
descripcion="Busca música, artistas, artículos, recursos del taller y contenido legal en Senza Paura."
sinIndexar
/>
<div class="container pagina-busqueda">
<header>
<h1>Buscar</h1>
<p class="entradilla muted">Explora todo el contenido de Senza Paura.</p>
</header>
{#key JSON.stringify(data.filtros)}
<FormularioBusqueda filtros={data.filtros} prefijo="busqueda-pagina" />
{/key}
<section class="resultados" aria-labelledby="titulo-resultados">
{#if data.filtros.q.length < 2}
<h2 id="titulo-resultados">¿Por dónde empezamos?</h2>
<p class="muted">
Escribe al menos dos caracteres. Puedes acotar por tipo de contenido o buscar solo en los
títulos.
</p>
{:else}
<h2 id="titulo-resultados">Resultados para «{data.filtros.q}»</h2>
<p class="muted" role="status">
{data.busqueda.total}
{data.busqueda.total === 1 ? 'resultado' : 'resultados'}{#if paginas > 1}
· Página {data.busqueda.pagina} de {paginas}{/if}
</p>
{#if data.busqueda.resultados.length}
<ol>
{#each data.busqueda.resultados as resultado (`${resultado.tipo}:${resultado.clase}:${resultado.slug}`)}
<li>
<article class:con-portada={!!resultado.portada}>
{#if resultado.portada}
<img
class="portada"
src="{base}{resultado.portada}"
alt="Portada de {resultado.titulo}"
width="96"
height="96"
loading="lazy"
/>
{/if}
<div class="detalle">
<p class="eyebrow">{resultado.clase}</p>
<h3>
<!-- Destino dinámico construido por el servidor; base se aplica aquí. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href="{base}{resultado.href}">{resultado.titulo}</a>
</h3>
{#if resultado.resumen}<p class="resumen muted">{resultado.resumen}</p>{/if}
</div>
</article>
</li>
{/each}
</ol>
{:else}
<p class="vacio muted">
No encontramos coincidencias con estos filtros. Prueba con otras palabras o selecciona
«Todo el contenido».
</p>
{/if}
{#if paginas > 1}
<nav class="paginacion" aria-label="Páginas de resultados">
{#if data.busqueda.pagina > 1}<a
class="btn"
href="{resolve('/buscar')}?{parametrosBusqueda(
data.filtros,
data.busqueda.pagina - 1
)}"
rel="prev">← Anterior</a
>{/if}
<span class="muted">{data.busqueda.pagina} / {paginas}</span>
{#if data.busqueda.pagina < paginas}<a
class="btn"
href="{resolve('/buscar')}?{parametrosBusqueda(
data.filtros,
data.busqueda.pagina + 1
)}"
rel="next">Siguiente →</a
>{/if}
</nav>
{/if}
{/if}
</section>
</div>
<style>
.pagina-busqueda {
padding-block: var(--sp-7) var(--sp-8);
}
header {
margin-bottom: var(--sp-5);
}
h1 {
margin: 0;
}
.entradilla {
margin: var(--sp-3) 0 0;
font-size: var(--fs-lg);
}
.resultados {
margin-top: var(--sp-7);
}
h2 {
margin-bottom: var(--sp-2);
font-size: var(--fs-xl);
overflow-wrap: anywhere;
}
ol {
display: grid;
gap: var(--sp-6);
margin: var(--sp-6) 0;
padding: 0;
list-style: none;
}
.eyebrow {
margin: 0 0 var(--sp-2);
color: var(--c-accent);
}
.con-portada {
display: grid;
grid-template-columns: 6rem minmax(0, 1fr);
align-items: center;
gap: var(--sp-4);
}
.portada {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1;
object-fit: contain;
border-radius: var(--radius-boton);
}
.detalle {
min-width: 0;
}
@media (max-width: 34rem) {
.con-portada {
grid-template-columns: 4.5rem minmax(0, 1fr);
gap: var(--sp-3);
}
}
h3 {
margin: 0;
font-size: var(--fs-lg);
font-family: var(--font-body);
font-weight: 600;
overflow-wrap: anywhere;
}
h3 a {
text-decoration: none;
}
h3 a:hover {
text-decoration: underline;
}
.resumen {
margin: var(--sp-2) 0 0;
max-width: 76ch;
overflow-wrap: anywhere;
}
.vacio {
max-width: 60ch;
margin-top: var(--sp-5);
}
.paginacion {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: var(--sp-4);
margin-top: var(--sp-6);
}
</style>

@ -159,7 +159,7 @@
<ul class="terminos">
{#each terminos as termino (termino.slug)}
<li>
<li id="termino-{termino.slug}">
<button type="button" class="palabra" data-termino={termino.slug}>
{termino.termino}
<span class="materia-de">

@ -277,7 +277,8 @@
}
.lateral a[aria-current='page'] {
color: var(--c-text);
color: var(--c-accent);
font-weight: 600;
border-left-color: var(--c-accent);
}
@ -303,11 +304,6 @@
border-left: 0;
}
.lista-idiomas a[aria-current='page'] {
color: var(--c-accent);
font-weight: 600;
}
.lista-idiomas .todos-idiomas {
grid-column: 1 / -1;
}

@ -5,6 +5,7 @@
import RejillaCanciones from '$lib/components/RejillaCanciones.svelte';
import TarjetaAlbum from '$lib/components/TarjetaAlbum.svelte';
import TablaCanciones from '$lib/components/TablaCanciones.svelte';
import TablaAlbumes from '$lib/components/TablaAlbumes.svelte';
import type { AlbumConCanciones, Cancion } from '$lib/types';
import { grabacionesUnicas } from '$lib/catalogo/consultas';
import { etiquetas as etiquetasDelCatalogo } from '$lib/catalogo/cliente.svelte';
@ -16,19 +17,11 @@
let { data } = $props();
/** Vista elegida. Las tarjetas son para descubrir; la tabla, para comparar. */
let presentacionCanciones = $state<'tarjetas' | 'tabla'>('tarjetas');
let presentacionCatalogo = $state<'tarjetas' | 'tabla'>('tarjetas');
let consulta = $state('');
let mostrarEtiquetas = $state(page.url.searchParams.has('etiqueta'));
const etiquetas = $derived(etiquetasDelCatalogo());
/* La lupa de la cabecera llega con este fragmento: enfoca el buscador una vez montado. */
$effect(() => {
if (page.url.hash !== '#buscar-tema') return;
requestAnimationFrame(() => {
document.querySelector<HTMLInputElement>('#buscar-tema')?.focus({ preventScroll: true });
});
});
/** Un solo catálogo de lanzamientos; los bloques solo transportan su estilo. */
const albumes = $derived(
data.bloques.flatMap((bloque) =>
@ -167,7 +160,7 @@
<header class="intro">
<h1>Música</h1>
<p class="entradilla">
Todos los álbumes, publicaciones y canciones del catálogo. Usa los estilos o idiomas de la
Todos los álbumes y canciones del catálogo. Usa los estilos o idiomas de la
izquierda y el desplegable de etiquetas del buscador para acotar la colección.
</p>
<p><a href={resolve('/interpretes')}>Conocer a los intérpretes →</a></p>
@ -192,26 +185,24 @@
/>
<div class="vistas" role="group" aria-label="Vista y filtros del catálogo">
{#if mostrarCanciones}
<button
type="button"
class="vista"
class:oro={presentacionCanciones === 'tarjetas'}
aria-pressed={presentacionCanciones === 'tarjetas'}
onclick={() => (presentacionCanciones = 'tarjetas')}
>
Tarjetas
</button>
<button
type="button"
class="vista"
class:oro={presentacionCanciones === 'tabla'}
aria-pressed={presentacionCanciones === 'tabla'}
onclick={() => (presentacionCanciones = 'tabla')}
>
Tabla
</button>
{/if}
<button
type="button"
class="vista"
class:oro={presentacionCatalogo === 'tarjetas'}
aria-pressed={presentacionCatalogo === 'tarjetas'}
onclick={() => (presentacionCatalogo = 'tarjetas')}
>
Tarjetas
</button>
<button
type="button"
class="vista"
class:oro={presentacionCatalogo === 'tabla'}
aria-pressed={presentacionCatalogo === 'tabla'}
onclick={() => (presentacionCatalogo = 'tabla')}
>
Tabla
</button>
<button
type="button"
class="vista"
@ -275,18 +266,22 @@
<header class="cabecera-seccion">
<div>
<p class="eyebrow">Discografía</p>
<h2 id="titulo-albumes">Álbumes y publicaciones</h2>
<h2 id="titulo-albumes">Álbumes</h2>
</div>
</header>
{#if albumesFiltrados.length > 0}
<div class="rejilla-albumes">
{#each albumesFiltrados as album, i (album.slug)}
<TarjetaAlbum {album} compacta retraso={Math.min(i, 8) * 45} />
{/each}
</div>
{#if presentacionCatalogo === 'tabla'}
<TablaAlbumes albumes={albumesFiltrados} />
{:else}
<div class="rejilla-albumes">
{#each albumesFiltrados as album, i (album.slug)}
<TarjetaAlbum {album} compacta retraso={Math.min(i, 8) * 45} />
{/each}
</div>
{/if}
{:else}
<p class="muted">No hay álbumes ni publicaciones que coincidan.</p>
<p class="muted">No hay álbumes que coincidan.</p>
{/if}
</section>
{/if}
@ -300,7 +295,7 @@
</div>
</header>
{#if presentacionCanciones === 'tabla'}
{#if presentacionCatalogo === 'tabla'}
<TablaCanciones canciones={cancionesFiltradas} ordenable />
{:else}
<RejillaCanciones
@ -516,10 +511,6 @@
padding-block: var(--sp-4) var(--sp-7);
}
.canciones {
border-top: 1px solid var(--c-border);
}
.cabecera-seccion {
display: flex;
align-items: end;

@ -6,6 +6,7 @@
import BarraReproductor from '$lib/components/player/BarraReproductor.svelte';
import ModalAcceso from '$lib/components/ModalAcceso.svelte';
import ModalContacto from '$lib/components/ModalContacto.svelte';
import ModalBusqueda from '$lib/components/ModalBusqueda.svelte';
import { reproductor } from '$lib/player/player.svelte';
import { page } from '$app/state';
import { RUTA_ESPERA } from '$lib/rutas';
@ -86,6 +87,7 @@
-->
<ModalAcceso />
<ModalContacto />
<ModalBusqueda />
{/if}
<style>

Loading…
Cancel
Save

Powered by TurnKey Linux.