You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
256 lines
6.6 KiB
256 lines
6.6 KiB
<script lang="ts">
|
|
import Meta from '$lib/components/Meta.svelte';
|
|
import RejillaCanciones from '$lib/components/RejillaCanciones.svelte';
|
|
import BloqueDeEstilo from '$lib/components/BloqueDeEstilo.svelte';
|
|
import TablaCanciones from '$lib/components/TablaCanciones.svelte';
|
|
import type { Cancion } from '$lib/types';
|
|
|
|
let { data } = $props();
|
|
|
|
/** Vista elegida. Las tarjetas son para descubrir; la tabla, para comparar. */
|
|
let vista = $state<'tarjetas' | 'tabla'>('tarjetas');
|
|
let consulta = $state('');
|
|
|
|
/**
|
|
* Todos los temas, en plano. El estilo y el disco de cada uno los resuelve la
|
|
* tabla contra el catálogo; aquí solo hacen falta para buscar, y para eso se
|
|
* arma aparte un índice de texto.
|
|
*/
|
|
const filas = $derived(
|
|
data.bloques.flatMap((bloque) =>
|
|
bloque.albumes.flatMap((album) =>
|
|
album.canciones.map((cancion) => ({
|
|
cancion,
|
|
/* Todo lo que identifica un tema, junto y sin tildes: quien
|
|
recuerda «el dueto de baladas» o «desamor» tiene que llegar. */
|
|
buscable: llano(
|
|
[
|
|
cancion.titulo,
|
|
bloque.estilo.nombre,
|
|
album.titulo,
|
|
String(cancion.ano ?? ''),
|
|
...cancion.etiquetas,
|
|
...cancion.generos.map((g) => g.nombre),
|
|
...cancion.interpretes.map((i) => i.nombre)
|
|
].join(' ')
|
|
)
|
|
}))
|
|
)
|
|
)
|
|
);
|
|
|
|
/** Sin tildes y en minúsculas: quien escribe «cancion» busca «canción». */
|
|
function llano(texto: string): string {
|
|
return texto
|
|
.normalize('NFD')
|
|
.replace(/\p{Diacritic}/gu, '')
|
|
.toLowerCase();
|
|
}
|
|
|
|
const termino = $derived(llano(consulta.trim()));
|
|
const filtradas = $derived<Cancion[]>(
|
|
(termino ? filas.filter((f) => f.buscable.includes(termino)) : filas).map((f) => f.cancion)
|
|
);
|
|
const buscando = $derived(termino.length > 0);
|
|
</script>
|
|
|
|
<Meta
|
|
titulo="Música"
|
|
descripcion="La obra completa por estilos: baladas románticas, clásica, rock y más."
|
|
/>
|
|
|
|
<header class="intro">
|
|
<h1>Música</h1>
|
|
<p class="entradilla">
|
|
Todo por estilos. De cada uno se ven sus discos y sus temas destacados; el reproductor de abajo
|
|
mantiene la escucha mientras navegas.
|
|
</p>
|
|
</header>
|
|
|
|
<!--
|
|
Una sola pieza y no dos cajas con un hueco entre ellas: buscar y elegir cómo
|
|
se ve son el mismo gesto —acotar lo que hay en pantalla— y presentarlos
|
|
separados los hacía parecer dos controles sin relación. Por eso el borde es de
|
|
la barra y dentro no hay más línea que la que separa el campo de los botones.
|
|
-->
|
|
<div class="barra">
|
|
<label class="visually-hidden" for="buscar-tema">Buscar un tema</label>
|
|
<input
|
|
id="buscar-tema"
|
|
type="search"
|
|
bind:value={consulta}
|
|
placeholder="Buscar por título, estilo, disco, año o etiqueta"
|
|
autocomplete="off"
|
|
/>
|
|
|
|
<div class="vistas" role="group" aria-label="Cómo ver la música">
|
|
<button
|
|
type="button"
|
|
class="vista"
|
|
class:oro={vista === 'tarjetas'}
|
|
aria-pressed={vista === 'tarjetas'}
|
|
onclick={() => (vista = 'tarjetas')}
|
|
>
|
|
Tarjetas
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="vista"
|
|
class:oro={vista === 'tabla'}
|
|
aria-pressed={vista === 'tabla'}
|
|
onclick={() => (vista = 'tabla')}
|
|
>
|
|
Tabla
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="resultado muted" role="status">
|
|
{#if buscando}
|
|
{filtradas.length}
|
|
{filtradas.length === 1 ? 'tema' : 'temas'} para «{consulta.trim()}»
|
|
{:else}
|
|
{filas.length} temas en total
|
|
{/if}
|
|
</p>
|
|
|
|
{#if vista === 'tabla'}
|
|
<TablaCanciones canciones={filtradas} ordenable />
|
|
{:else if buscando}
|
|
<!--
|
|
Con una búsqueda en marcha, agrupar por estilo no dice nada: lo que
|
|
importa son las coincidencias, vengan del estilo que vengan.
|
|
-->
|
|
<RejillaCanciones canciones={filtradas} vacio="Ningún tema coincide." />
|
|
{:else}
|
|
{#each data.bloques as bloque (bloque.estilo.slug)}
|
|
<BloqueDeEstilo {bloque} />
|
|
{:else}
|
|
<p class="muted">Todavía no hay música publicada.</p>
|
|
{/each}
|
|
{/if}
|
|
|
|
<style>
|
|
/*
|
|
* Dos columnas: los estilos siempre a la vista y el contenido al lado. En
|
|
* pantalla estrecha los estilos suben arriba en horizontal, porque son la
|
|
* navegación de la sección y esconderlos deja la página sin salidas.
|
|
*/
|
|
.intro {
|
|
padding-block: var(--sp-7) var(--sp-4);
|
|
}
|
|
|
|
.entradilla {
|
|
max-width: 56ch;
|
|
font-size: var(--fs-lg);
|
|
}
|
|
|
|
.barra {
|
|
display: flex;
|
|
align-items: stretch;
|
|
border: 1px solid var(--c-border);
|
|
border-radius: var(--radius-boton);
|
|
background: var(--c-bg-sunken);
|
|
/* Para que el oro del botón activo no se salga por la esquina. */
|
|
overflow: hidden;
|
|
}
|
|
|
|
/*
|
|
* El foco lo acusa la pieza entera y no el campo: dentro ya no hay cajas
|
|
* sueltas que puedan llevar su propio cerco.
|
|
*/
|
|
.barra:focus-within {
|
|
border-color: var(--c-accent);
|
|
box-shadow: 0 0 0 2px color-mix(in oklab, var(--c-accent) 30%, transparent);
|
|
}
|
|
|
|
input[type='search'] {
|
|
flex: 1 1 12rem;
|
|
min-width: 0;
|
|
padding: var(--boton-relleno);
|
|
border: 0;
|
|
background: transparent;
|
|
color: inherit;
|
|
font: inherit;
|
|
font-size: var(--boton-texto);
|
|
}
|
|
|
|
input[type='search']:focus-visible {
|
|
outline: none;
|
|
}
|
|
|
|
.vistas {
|
|
display: flex;
|
|
/* La única línea de dentro: la que separa lo que se escribe de lo que se pulsa. */
|
|
border-left: 1px solid var(--c-border);
|
|
}
|
|
|
|
.vista {
|
|
padding: var(--boton-relleno);
|
|
border: 0;
|
|
background-color: transparent;
|
|
font: inherit;
|
|
font-size: var(--boton-texto);
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
transition:
|
|
background-color 0.2s var(--suave),
|
|
color 0.2s var(--suave);
|
|
}
|
|
|
|
/*
|
|
* El color del inactivo va con `:not(.oro)` y no a secas: Svelte añade su
|
|
* clase de ámbito a todo lo que escribe aquí, así que un `.vista` de este
|
|
* archivo le gana por especificidad al `.oro` global y el botón activo se
|
|
* quedaba con la letra gris encima del oro.
|
|
*/
|
|
.vista:not(.oro) {
|
|
color: var(--c-text-muted);
|
|
}
|
|
|
|
.vista + .vista {
|
|
border-left: 1px solid var(--c-border);
|
|
}
|
|
|
|
.vista:hover:not(.oro) {
|
|
background-color: var(--c-bg-raised);
|
|
color: var(--c-text);
|
|
}
|
|
|
|
/*
|
|
* El activo lleva `.oro`, que es el mismo dorado de los botones —degradado,
|
|
* filo de luz y el barrido al pasar el puntero— y no un color plano. Estaba
|
|
* al lado de un botón dorado de verdad y se notaba que no era el mismo.
|
|
*/
|
|
|
|
/* Apilada, la línea que separa deja de ser vertical. */
|
|
@media (max-width: 30rem) {
|
|
.barra {
|
|
flex-direction: column;
|
|
}
|
|
|
|
/*
|
|
* En columna, `flex-basis` mide el alto: las doce rem que dan ancho al
|
|
* campo en horizontal lo convertían en un cajón de casi doscientos
|
|
* píxeles de alto.
|
|
*/
|
|
input[type='search'] {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.vistas {
|
|
border-left: 0;
|
|
border-top: 1px solid var(--c-border);
|
|
}
|
|
|
|
.vista {
|
|
flex: 1;
|
|
}
|
|
}
|
|
|
|
.resultado {
|
|
padding-block: var(--sp-3);
|
|
font-size: var(--fs-sm);
|
|
}
|
|
</style>
|