From a9524fe411c7e627b0ee88f88e099f0d03093184 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 13 Sep 2026 05:15:26 +0200 Subject: [PATCH] Add global search and refine catalog navigation --- docs/busqueda-global.md | 48 ++ e2e/albumes-tabla.e2e.ts | 212 +++++++ e2e/busqueda.e2e.ts | 137 ++++ e2e/musica.e2e.ts | 35 +- e2e/navegacion.e2e.ts | 30 +- e2e/responsive.e2e.ts | 80 +++ src/lib/albumes.ts | 8 + src/lib/busqueda-modal.svelte.ts | 33 + src/lib/busqueda.spec.ts | 29 + src/lib/busqueda.ts | 50 ++ src/lib/components/BloqueDeEstilo.svelte | 4 +- src/lib/components/BotonTema.svelte | 16 +- src/lib/components/Cabecera.svelte | 590 ++++++++++++++---- .../components/DetalleTituloCancion.svelte | 4 +- src/lib/components/FormularioBusqueda.svelte | 117 ++++ src/lib/components/Icono.svelte | 12 + src/lib/components/ModalBusqueda.svelte | 121 ++++ src/lib/components/TablaAlbumes.svelte | 109 ++++ src/lib/components/TablaCanciones.svelte | 429 +++++-------- src/lib/components/TablaCatalogo.svelte | 134 ++++ src/lib/components/TarjetaAlbum.svelte | 10 +- src/lib/server/busqueda-consulta.spec.ts | 132 ++++ src/lib/server/busqueda-consulta.ts | 129 ++++ src/lib/server/busqueda.ts | 18 + src/routes/(sitio)/buscar/+page.server.ts | 9 + src/routes/(sitio)/buscar/+page.svelte | 185 ++++++ .../(sitio)/generos/glosario/+page.svelte | 2 +- src/routes/(sitio)/musica/+layout.svelte | 8 +- src/routes/(sitio)/musica/+page.svelte | 75 +-- src/routes/+layout.svelte | 2 + 30 files changed, 2294 insertions(+), 474 deletions(-) create mode 100644 docs/busqueda-global.md create mode 100644 e2e/albumes-tabla.e2e.ts create mode 100644 e2e/busqueda.e2e.ts create mode 100644 src/lib/albumes.ts create mode 100644 src/lib/busqueda-modal.svelte.ts create mode 100644 src/lib/busqueda.spec.ts create mode 100644 src/lib/busqueda.ts create mode 100644 src/lib/components/FormularioBusqueda.svelte create mode 100644 src/lib/components/ModalBusqueda.svelte create mode 100644 src/lib/components/TablaAlbumes.svelte create mode 100644 src/lib/components/TablaCatalogo.svelte create mode 100644 src/lib/server/busqueda-consulta.spec.ts create mode 100644 src/lib/server/busqueda-consulta.ts create mode 100644 src/lib/server/busqueda.ts create mode 100644 src/routes/(sitio)/buscar/+page.server.ts create mode 100644 src/routes/(sitio)/buscar/+page.svelte diff --git a/docs/busqueda-global.md b/docs/busqueda-global.md new file mode 100644 index 0000000..d99bec2 --- /dev/null +++ b/docs/busqueda-global.md @@ -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. diff --git a/e2e/albumes-tabla.e2e.ts b/e2e/albumes-tabla.e2e.ts new file mode 100644 index 0000000..b62d52f --- /dev/null +++ b/e2e/albumes-tabla.e2e.ts @@ -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('section.albumes table')!; + const canciones = document.querySelector('section.canciones table')!; + const enlaceTituloAlbum = albumes.querySelector('a.titulo')!; + const enlaceTitulo = [...canciones.querySelectorAll('a.titulo')].find( + (enlace) => enlace.textContent?.trim() === 'Ámame esta noche, nada más' + )!; + const celdaTitulo = enlaceTitulo.closest('th')!; + const enlaceAlbum = celdaTitulo.parentElement!.querySelector( + '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` }); + } + }); +} diff --git a/e2e/busqueda.e2e.ts b/e2e/busqueda.e2e.ts new file mode 100644 index 0000000..725c9f6 --- /dev/null +++ b/e2e/busqueda.e2e.ts @@ -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(); + } +}); diff --git a/e2e/musica.e2e.ts b/e2e/musica.e2e.ts index 3fa9809..96521a5 100644 --- a/e2e/musica.e2e.ts +++ b/e2e/musica.e2e.ts @@ -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(); diff --git a/e2e/navegacion.e2e.ts b/e2e/navegacion.e2e.ts index 302ce6e..0371932 100644 --- a/e2e/navegacion.e2e.ts +++ b/e2e/navegacion.e2e.ts @@ -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('/'); diff --git a/e2e/responsive.e2e.ts b/e2e/responsive.e2e.ts index 5ac1eac..b813e91 100644 --- a/e2e/responsive.e2e.ts +++ b/e2e/responsive.e2e.ts @@ -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 }); diff --git a/src/lib/albumes.ts b/src/lib/albumes.ts new file mode 100644 index 0000000..bc2663a --- /dev/null +++ b/src/lib/albumes.ts @@ -0,0 +1,8 @@ +import type { Album } from './types'; + +export const nombresTipoAlbum: Record = { + album: 'Álbum', + ep: 'EP', + single: 'Single', + coleccion: 'Colección' +}; diff --git a/src/lib/busqueda-modal.svelte.ts b/src/lib/busqueda-modal.svelte.ts new file mode 100644 index 0000000..583dd24 --- /dev/null +++ b/src/lib/busqueda-modal.svelte.ts @@ -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; +} diff --git a/src/lib/busqueda.spec.ts b/src/lib/busqueda.spec.ts new file mode 100644 index 0000000..90a1123 --- /dev/null +++ b/src/lib/busqueda.spec.ts @@ -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('

Un **artículo** con [enlace](/blog/uno)

')).toBe( + 'Un artículo con enlace' + ); + expect(resumenBusqueda('a'.repeat(500)).length).toBeLessThanOrEqual(240); + }); +}); diff --git a/src/lib/busqueda.ts b/src/lib/busqueda.ts new file mode 100644 index 0000000..7c88875 --- /dev/null +++ b/src/lib/busqueda.ts @@ -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; +} diff --git a/src/lib/components/BloqueDeEstilo.svelte b/src/lib/components/BloqueDeEstilo.svelte index 17cfb0d..6de969f 100644 --- a/src/lib/components/BloqueDeEstilo.svelte +++ b/src/lib/components/BloqueDeEstilo.svelte @@ -79,10 +79,10 @@ {#if albumes.length > 0}
- Álbumes y publicaciones + Álbumes

- 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.

{#each albumes as album, i (album.slug)} diff --git a/src/lib/components/BotonTema.svelte b/src/lib/components/BotonTema.svelte index e6ac218..e2f7b99 100644 --- a/src/lib/components/BotonTema.svelte +++ b/src/lib/components/BotonTema.svelte @@ -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'); @@ -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`} > - + {/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; diff --git a/src/lib/components/Cabecera.svelte b/src/lib/components/Cabecera.svelte index f787cdf..6b50392 100644 --- a/src/lib/components/Cabecera.svelte +++ b/src/lib/components/Cabecera.svelte @@ -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(null); + let bloqueUsuario = $state(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('#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; }); -
+
@@ -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); } } diff --git a/src/lib/components/DetalleTituloCancion.svelte b/src/lib/components/DetalleTituloCancion.svelte index 9dbedcf..d43eaef 100644 --- a/src/lib/components/DetalleTituloCancion.svelte +++ b/src/lib/components/DetalleTituloCancion.svelte @@ -60,7 +60,8 @@ diff --git a/src/lib/components/Icono.svelte b/src/lib/components/Icono.svelte index 05f98c6..9cec6ac 100644 --- a/src/lib/components/Icono.svelte +++ b/src/lib/components/Icono.svelte @@ -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'] }, diff --git a/src/lib/components/ModalBusqueda.svelte b/src/lib/components/ModalBusqueda.svelte new file mode 100644 index 0000000..b118504 --- /dev/null +++ b/src/lib/components/ModalBusqueda.svelte @@ -0,0 +1,121 @@ + + + busquedaModal.cerrar()} + onclick={(evento) => { + if (evento.target === dialogo) busquedaModal.cerrar(); + }} +> +
+

Buscar en Senza Paura

+

+ Música, historias y recursos. Encuentra lo que buscas en todo el sitio. +

+ {#if busquedaModal.abierto} + + {/if} + +
+
+ + diff --git a/src/lib/components/TablaAlbumes.svelte b/src/lib/components/TablaAlbumes.svelte new file mode 100644 index 0000000..c8edbc8 --- /dev/null +++ b/src/lib/components/TablaAlbumes.svelte @@ -0,0 +1,109 @@ + + + + Álbumes + + + Reproducir + Portada + Título + Año + Temas + Duración + + + + {#each filas as { album, estilo, pistas } (album.slug)} + {@const duracion = album.duracionTotal > 0 ? segundosADuracion(album.duracionTotal) : '—'} + reproductor.esActual(pista.id))}> + + + + + + {album.titulo} +

+ {nombresTipoAlbum[album.tipo]} + {#if estilo} + + {estilo.nombre} + {/if} +

+

+ {ano(album.fecha)} · {album.canciones.length} + {album.canciones.length === 1 ? 'tema' : 'temas'} + {#if album.duracionTotal > 0} + · {duracion}{/if} +

+ + {ano(album.fecha)} + {album.canciones.length} + {duracion} + + {/each} + +
+ + diff --git a/src/lib/components/TablaCanciones.svelte b/src/lib/components/TablaCanciones.svelte index 549c9cf..520c425 100644 --- a/src/lib/components/TablaCanciones.svelte +++ b/src/lib/components/TablaCanciones.svelte @@ -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}

{vacio}

{:else} -
- - - - - - - {#if numerar} - + + + + + + + {#if numerar} + + {/if} + + + {#each visibles as columna (columna.campo)} + {/each} - {#each visibles as columna (columna.campo)} - + + + + + + + + + {#each ordenadas as fila, i (fila.cancion.slug + ':' + (fila.cancion.versionEnLanzamiento ?? 'principal'))} + {@const c = fila.cancion} + + + + - + {#if numerar} + + {/if} - - - - - - - - {#each ordenadas as fila, i (fila.cancion.slug + ':' + (fila.cancion.versionEnLanzamiento ?? 'principal'))} - {@const c = fila.cancion} - - + - - - {#if numerar} - - {/if} - - - - {#each visibles as columna (columna.campo)} - - {/each} - - + {/each} - - - - - {/each} - -
- {ordenable - ? 'Los temas. Cada encabezado ordena por su columna.' - : 'Los temas, en el orden del disco.'} -
ReproducirPortada - Pista -
+ {ordenable + ? 'Los temas. Cada encabezado ordena por su columna.' + : 'Los temas, en el orden del disco.'} +
ReproducirPortada + Pista + + {#if ordenable} + + {:else} + Título {/if} + {#if ordenable} - {:else} - Título + {columna.texto} {/if} + {#if ordenable} + + {:else} + Duración + {/if} + FavoritoColaComprar
+ + + asomar(e, c)} + onmouseleave={() => (vista = null)} > - {#if ordenable} - + {#if c.portada} + {:else} - {columna.texto} + {/if} - - {/each} + + - {#if ordenable} - - {:else} - Duración - {/if} - {c.numero ?? '—'}FavoritoColaComprar
- - + + - asomar(e, c)} - onmouseleave={() => (vista = null)} - > - {#if c.portada} - + {#each visibles as columna (columna.campo)} + + {#if columna.campo === 'estilo'} + {#if fila.estilo} + + {fila.estilo.nombre} + {:else} - + — {/if} - - {c.numero ?? '—'} - - - {#if columna.campo === 'estilo'} - {#if fila.estilo} - - {fila.estilo.nombre} - - {:else} - — - {/if} - {:else if columna.campo === 'album'} - {#if fila.album} - - {fila.album.titulo} - - {:else} - — - {/if} + {:else if columna.campo === 'album'} + {#if fila.album} + + {fila.album.titulo} + {:else} - {c.ano ?? '—'} + — {/if} - - {c.duracion ? segundosADuracion(c.duracion) : '—'} + {:else} + {c.ano ?? '—'} + {/if} - - - - - -
-
+ + {c.duracion ? segundosADuracion(c.duracion) : '—'} + + + + + + + + + + + + + {/each} + + {#if vista} + + {resultado.titulo} + + {#if resultado.resumen}

{resultado.resumen}

{/if} + + + + {/each} + + {:else} +

+ No encontramos coincidencias con estos filtros. Prueba con otras palabras o selecciona + «Todo el contenido». +

+ {/if} + {#if paginas > 1} + + {/if} + {/if} +
+ + + diff --git a/src/routes/(sitio)/generos/glosario/+page.svelte b/src/routes/(sitio)/generos/glosario/+page.svelte index b454d86..c13e92b 100644 --- a/src/routes/(sitio)/generos/glosario/+page.svelte +++ b/src/routes/(sitio)/generos/glosario/+page.svelte @@ -159,7 +159,7 @@
    {#each terminos as termino (termino.slug)} -
  • +
  • - - {/if} + +