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();
|
||||
}
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
Loading…
Reference in new issue