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.
senzapaura_es/src/routes/(sitio)/blog/+page.svelte

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>

Powered by TurnKey Linux.