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.
510 lines
12 KiB
510 lines
12 KiB
<script lang="ts">
|
|
/**
|
|
* El listado del blog.
|
|
*
|
|
* Con dos formas de verlo, como en música: tarjetas para mirar y tabla para
|
|
* buscar algo concreto. Y con la misma barra de consulta que el glosario
|
|
* —buscar y acotar por etiqueta—, para que el sitio se maneje igual en todas
|
|
* partes.
|
|
*/
|
|
import { resolve } from '$app/paths';
|
|
import Meta from '$lib/components/Meta.svelte';
|
|
import { brillo } from '$lib/acciones/brillo';
|
|
import { revelar } from '$lib/acciones/revelar';
|
|
import Etiquetas from '$lib/components/Etiquetas.svelte';
|
|
import { formatearFecha } from '$lib/utils/date';
|
|
|
|
let { data } = $props();
|
|
|
|
let consulta = $state('');
|
|
let etiquetaActiva = $state('todas');
|
|
let vista = $state<'tarjetas' | 'tabla'>('tarjetas');
|
|
|
|
/** Sin tildes y en minúsculas: quien busca «cancion» quiere «canción». */
|
|
const plano = (t: string) => t.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
|
|
|
/** Las etiquetas que existen, con cuántas entradas tiene cada una. */
|
|
const etiquetas = $derived.by(() => {
|
|
// Objeto y no `Map`: es una cuenta local que se tira al salir de aquí, y
|
|
// un `Map` dentro de estado reactivo obliga a usar el de Svelte.
|
|
const cuenta: Record<string, number> = {};
|
|
for (const entrada of data.entradas) {
|
|
for (const nombre of entrada.etiquetas) {
|
|
cuenta[nombre] = (cuenta[nombre] ?? 0) + 1;
|
|
}
|
|
}
|
|
return Object.entries(cuenta)
|
|
.map(([nombre, cuantas]) => ({ nombre, cuantas }))
|
|
.sort((a, b) => b.cuantas - a.cuantas || a.nombre.localeCompare(b.nombre, 'es'));
|
|
});
|
|
|
|
/*
|
|
* Busca en el título y en el resumen. En el cuerpo no: no viaja con el
|
|
* listado, y traerlo entero para poder buscar en él sería mandar todas las
|
|
* entradas completas para pintar una lista.
|
|
*/
|
|
const encontradas = $derived.by(() => {
|
|
const q = plano(consulta.trim());
|
|
|
|
return data.entradas.filter((e) => {
|
|
if (etiquetaActiva !== 'todas' && !e.etiquetas.includes(etiquetaActiva)) return false;
|
|
if (!q) return true;
|
|
return plano(e.titulo).includes(q) || plano(e.resumen).includes(q);
|
|
});
|
|
});
|
|
|
|
const buscando = $derived(consulta.trim() !== '' || etiquetaActiva !== 'todas');
|
|
</script>
|
|
|
|
<Meta
|
|
titulo="Blog"
|
|
descripcion="Apuntes sobre el oficio de escribir letras y sobre cómo salieron algunos temas."
|
|
/>
|
|
|
|
<div class="container">
|
|
<header class="intro">
|
|
<p class="eyebrow">Cuaderno</p>
|
|
<h1>Blog</h1>
|
|
<p class="entradilla">
|
|
Apuntes sueltos: de dónde salió un tema, qué se cayó por el camino, qué he aprendido
|
|
escribiendo en un estilo que no era el mío. Lo que en el taller está ordenado, aquí está
|
|
fechado.
|
|
</p>
|
|
<p class="feed muted">
|
|
<a href={resolve('/blog/rss.xml')}>Seguirlo por RSS</a>
|
|
</p>
|
|
</header>
|
|
|
|
<!--
|
|
Una sola pieza: buscar y elegir cómo se ve son el mismo gesto —acotar lo
|
|
que hay en pantalla—, igual que en la página de música.
|
|
-->
|
|
<div class="barra">
|
|
<label class="visually-hidden" for="buscar-entrada">Buscar una entrada</label>
|
|
<input
|
|
id="buscar-entrada"
|
|
type="search"
|
|
bind:value={consulta}
|
|
placeholder="Buscar por título o por lo que cuenta"
|
|
autocomplete="off"
|
|
/>
|
|
|
|
<div class="vistas" role="group" aria-label="Cómo ver el blog">
|
|
<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>
|
|
|
|
{#if etiquetas.length > 0}
|
|
<div class="etiquetas" role="group" aria-label="Filtrar por etiqueta">
|
|
<button
|
|
type="button"
|
|
class="filtro"
|
|
aria-pressed={etiquetaActiva === 'todas'}
|
|
onclick={() => (etiquetaActiva = 'todas')}
|
|
>
|
|
Todas
|
|
</button>
|
|
{#each etiquetas as etiqueta (etiqueta.nombre)}
|
|
<button
|
|
type="button"
|
|
class="filtro"
|
|
aria-pressed={etiquetaActiva === etiqueta.nombre}
|
|
onclick={() => (etiquetaActiva = etiqueta.nombre)}
|
|
>
|
|
{etiqueta.nombre}
|
|
<span class="cuantas">{etiqueta.cuantas}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<p class="resultado muted" role="status">
|
|
{#if buscando}
|
|
{encontradas.length}
|
|
{encontradas.length === 1 ? 'entrada' : 'entradas'}
|
|
{:else}
|
|
{data.entradas.length}
|
|
{data.entradas.length === 1 ? 'entrada' : 'entradas'} en total
|
|
{/if}
|
|
</p>
|
|
|
|
{#if encontradas.length === 0}
|
|
<p class="vacio muted">
|
|
{#if data.entradas.length === 0}
|
|
Todavía no hay ninguna entrada.
|
|
{:else}
|
|
Nada con eso. Prueba con otra palabra o quita el filtro de etiqueta.
|
|
{/if}
|
|
</p>
|
|
{:else if vista === 'tabla'}
|
|
<div class="tabla-envoltura">
|
|
<table class="tabla">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Fecha</th>
|
|
<th scope="col">Título</th>
|
|
<th scope="col">De qué va</th>
|
|
<th scope="col">Etiquetas</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each encontradas as entrada (entrada.slug)}
|
|
<tr>
|
|
<td class="celda-fecha">
|
|
<time datetime={entrada.fecha}>{formatearFecha(entrada.fecha)}</time>
|
|
</td>
|
|
<td>
|
|
<a href={resolve('/(sitio)/blog/[slug]', { slug: entrada.slug })}
|
|
>{entrada.titulo}</a
|
|
>
|
|
</td>
|
|
<td class="celda-resumen muted">{entrada.resumen}</td>
|
|
<td class="celda-etiquetas muted">{entrada.etiquetas.join(', ')}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{:else}
|
|
<ul class="entradas">
|
|
{#each encontradas as entrada, i (entrada.slug)}
|
|
<li class="tarjeta" use:revelar={Math.min(i, 8) * 45} use:brillo>
|
|
<article>
|
|
<a
|
|
class="portada"
|
|
href={resolve('/(sitio)/blog/[slug]', { slug: entrada.slug })}
|
|
tabindex="-1"
|
|
>
|
|
<img src={entrada.imagen} alt="" loading="lazy" />
|
|
</a>
|
|
|
|
<div class="cuerpo">
|
|
<p class="fecha muted">
|
|
<time datetime={entrada.fecha}>{formatearFecha(entrada.fecha)}</time>
|
|
</p>
|
|
<h2>
|
|
<a href={resolve('/(sitio)/blog/[slug]', { slug: entrada.slug })}
|
|
>{entrada.titulo}</a
|
|
>
|
|
</h2>
|
|
{#if entrada.resumen}
|
|
<p class="resumen muted">{entrada.resumen}</p>
|
|
{/if}
|
|
{#if entrada.etiquetas.length > 0}
|
|
<div class="chips">
|
|
<Etiquetas etiquetas={entrada.etiquetas} titulo="Etiquetas de {entrada.titulo}" />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</article>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.intro {
|
|
max-width: 62ch;
|
|
padding-block: var(--sp-7) var(--sp-5);
|
|
}
|
|
|
|
h1 {
|
|
font-size: var(--fs-3xl);
|
|
}
|
|
|
|
.entradilla {
|
|
font-size: var(--fs-lg);
|
|
color: var(--c-text-muted);
|
|
}
|
|
|
|
.feed {
|
|
margin-top: var(--sp-3);
|
|
font-size: var(--fs-sm);
|
|
}
|
|
|
|
/* --- La barra de consulta ------------------------------------------------ */
|
|
|
|
.barra {
|
|
display: flex;
|
|
align-items: stretch;
|
|
border: 1px solid var(--c-border);
|
|
border-radius: var(--radius-boton);
|
|
background: var(--c-bg-sunken);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.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;
|
|
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(--ease-standard),
|
|
color 0.2s var(--ease-standard);
|
|
}
|
|
|
|
/* Ver el comentario de la misma barra en la página de música. */
|
|
.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);
|
|
}
|
|
|
|
.etiquetas {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--sp-2);
|
|
padding-top: var(--sp-3);
|
|
}
|
|
|
|
.filtro {
|
|
display: inline-flex;
|
|
align-items: baseline;
|
|
gap: var(--sp-2);
|
|
padding: 0.25rem 0.7rem;
|
|
border: 1px solid var(--c-border);
|
|
border-radius: var(--radius-boton);
|
|
background: transparent;
|
|
color: var(--c-text-muted);
|
|
font: inherit;
|
|
font-size: var(--fs-xs);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.filtro:hover {
|
|
color: var(--c-text);
|
|
border-color: var(--c-text-muted);
|
|
}
|
|
|
|
.filtro[aria-pressed='true'] {
|
|
color: var(--c-accent);
|
|
border-color: color-mix(in oklab, var(--c-accent) 45%, transparent);
|
|
background: color-mix(in oklab, var(--c-accent) 10%, transparent);
|
|
}
|
|
|
|
.cuantas {
|
|
font-family: var(--font-mono);
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.resultado {
|
|
padding-block: var(--sp-3);
|
|
font-size: var(--fs-sm);
|
|
}
|
|
|
|
.vacio {
|
|
padding-block: var(--sp-6);
|
|
}
|
|
|
|
/* --- Tarjetas ------------------------------------------------------------ */
|
|
|
|
/*
|
|
* `auto-fit` y no `auto-fill`: con dos entradas, `auto-fill` crea las cuatro
|
|
* columnas que caben y deja dos vacías a la derecha. `auto-fit` colapsa las
|
|
* que sobran y lo que hay se reparte el ancho entero.
|
|
*/
|
|
.entradas {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
|
|
gap: var(--sp-4);
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0 0 var(--sp-7);
|
|
}
|
|
|
|
.entradas article {
|
|
display: grid;
|
|
grid-template-rows: auto 1fr;
|
|
height: 100%;
|
|
}
|
|
|
|
/*
|
|
* La portada, con proporción fija. Sin ella, dos imágenes de distinto alto
|
|
* descuadran la fila entera y las tarjetas dejan de alinearse.
|
|
*/
|
|
.portada {
|
|
display: block;
|
|
aspect-ratio: 16 / 9;
|
|
overflow: hidden;
|
|
background: var(--c-bg-sunken);
|
|
}
|
|
|
|
.portada img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
|
|
.cuerpo {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--sp-2);
|
|
padding: var(--sp-4);
|
|
}
|
|
|
|
.fecha {
|
|
margin: 0;
|
|
font-family: var(--font-mono);
|
|
font-size: var(--fs-xs);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
|
|
h2 {
|
|
margin: 0;
|
|
font-size: var(--fs-xl);
|
|
}
|
|
|
|
h2 a {
|
|
text-decoration: none;
|
|
transition: color 0.2s ease;
|
|
}
|
|
|
|
h2 a:hover,
|
|
h2 a:focus-visible {
|
|
color: var(--c-accent);
|
|
}
|
|
|
|
.resumen {
|
|
margin: 0;
|
|
font-size: var(--fs-sm);
|
|
}
|
|
|
|
.chips {
|
|
margin-top: auto;
|
|
padding-top: var(--sp-2);
|
|
}
|
|
|
|
/* --- Tabla --------------------------------------------------------------- */
|
|
|
|
/* Que la tabla se desplace dentro de su caja y no arrastre la página. */
|
|
.tabla-envoltura {
|
|
overflow-x: auto;
|
|
padding-bottom: var(--sp-7);
|
|
}
|
|
|
|
.tabla {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: var(--fs-sm);
|
|
}
|
|
|
|
.tabla th {
|
|
text-align: left;
|
|
padding: var(--sp-2) var(--sp-3);
|
|
border-bottom: 1px solid var(--c-border);
|
|
font-size: var(--fs-xs);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
color: var(--c-text-muted);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tabla td {
|
|
padding: var(--sp-3);
|
|
border-bottom: 1px solid var(--c-border);
|
|
vertical-align: baseline;
|
|
}
|
|
|
|
.tabla td a {
|
|
font-family: var(--font-display);
|
|
font-size: var(--fs-lg);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.celda-fecha {
|
|
white-space: nowrap;
|
|
font-family: var(--font-mono);
|
|
font-size: var(--fs-xs);
|
|
color: var(--c-text-muted);
|
|
}
|
|
|
|
.celda-resumen {
|
|
min-width: 24ch;
|
|
}
|
|
|
|
.celda-etiquetas {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
@media (max-width: 30rem) {
|
|
.barra {
|
|
flex-direction: column;
|
|
}
|
|
|
|
input[type='search'] {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.vistas {
|
|
border-left: 0;
|
|
border-top: 1px solid var(--c-border);
|
|
}
|
|
|
|
.vista {
|
|
flex: 1;
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
h2 a,
|
|
.vista {
|
|
transition: none;
|
|
}
|
|
}
|
|
</style>
|