Las fichas de género se editan desde el panel

Lo que se escribe de un género: resumen, lugar y año de origen, tempo, rima, las
notas de métrica y compás, los rasgos de escritura, el estado, en qué países se
canta y toda la prosa repartida en secciones.

Todo en una pantalla y un solo guardado, porque es una sola cosa. Repartido en
tres formularios habría que guardar tres veces para arreglar un párrafo, y en
una transacción, porque si al reemplazar los países fallara algo no puede quedar
el texto guardado y el mapa a medias.

Los países son lo que pinta el género en el mapa, así que la pantalla lo dice y
la validación lo defiende: una sola cuna —un género nace en un sitio; si nació
en dos orillas, la cuna es la región y el otro país va como adoptado— y ningún
género publicado sin país, que se quedaría fuera del mapa y sin manera de llegar
a él.

Las secciones se editan, se reordenan y se borran marcando una casilla, que se
puede desmarcar antes de guardar. Y se puede añadir una nueva con su clave.

De las fichas importadas por un modelo sale una casilla para darlas por
revisadas. Es la diferencia entre lo que propuso una máquina y lo que ha leído
una persona, y el modelo de datos la guarda a propósito: esto se publica con el
nombre del titular.

Comprobado de punta a punta: editar en el panel, guardar, y el cambio sale en la
web pública. Dos fallos que se vieron mirando las capturas y no las pruebas: un
`td` en flex se descolgaba de su fila y descuadraba las tres columnas de la
lista, y el «cuna» de cada país acababa a diez centímetros de su país, alineado
con el del de al lado, de modo que no se sabía de quién era cuál.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 4 weeks ago
parent 4e53ba64f5
commit 3c09592e11

@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import { revisarGenero, type CambiosDelGenero } from './panel-generos';
/** Un género válido, del que cada prueba estropea una cosa. */
const bueno = (cambios: Partial<CambiosDelGenero> = {}): CambiosDelGenero => ({
nombre: 'Bolero',
resumen: 'La escuela del desamor cantado despacio.',
origenLugar: 'Santiago de Cuba',
origenAno: 1883,
tempo: 'Lento: 60–90 pulsos por minuto',
rima: 'Consonante',
metricaNota: 'Octosílabo y endecasílabo',
compasNota: '2/4',
rasgos: ['Tratamiento de usted o de tú'],
estado: 'publicado',
paises: [
{ codigo: 'CU', relacion: 'cuna' },
{ codigo: 'MX', relacion: 'adoptado' }
],
secciones: [],
nueva: null,
revisar: false,
...cambios
});
describe('revisarGenero', () => {
it('deja pasar lo que está bien', () => {
expect(revisarGenero(bueno())).toEqual({});
});
it('no admite un nombre vacío', () => {
expect(revisarGenero(bueno({ nombre: ' ' })).nombre).toBeTruthy();
});
describe('el año de origen', () => {
it('puede no saberse', () => {
expect(revisarGenero(bueno({ origenAno: null })).origenAno).toBeUndefined();
});
it('no puede estar en el futuro ni antes de 1500', () => {
expect(
revisarGenero(bueno({ origenAno: new Date().getFullYear() + 1 })).origenAno
).toBeTruthy();
expect(revisarGenero(bueno({ origenAno: 1200 })).origenAno).toBeTruthy();
});
it('rechaza lo que no es un entero', () => {
// `Number('mil ochocientos')` es NaN, y llega del formulario.
expect(revisarGenero(bueno({ origenAno: Number.NaN })).origenAno).toBeTruthy();
expect(revisarGenero(bueno({ origenAno: 1883.5 })).origenAno).toBeTruthy();
});
});
describe('los países', () => {
it('solo admite una cuna', () => {
// Un género nace en un sitio. Si nació en dos orillas —el tango—, la
// cuna es la región y el otro país va como adoptado.
const dos = bueno({
paises: [
{ codigo: 'AR', relacion: 'cuna' },
{ codigo: 'UY', relacion: 'cuna' }
]
});
expect(revisarGenero(dos).paises).toBeTruthy();
});
it('no deja publicar un género que no está en ninguno', () => {
// Sin país no sale en el mapa, y el mapa es por donde se llega.
expect(revisarGenero(bueno({ paises: [] })).paises).toBeTruthy();
});
it('pero en borrador puede no tener ninguno todavía', () => {
expect(revisarGenero(bueno({ paises: [], estado: 'borrador' }))).toEqual({});
});
});
describe('la sección nueva', () => {
const con = (nueva: CambiosDelGenero['nueva']) => revisarGenero(bueno({ nueva })).nueva;
it('pide una clave en minúsculas y sin espacios', () => {
expect(con({ clave: 'Historia del género', titulo: 'Historia', cuerpo: '' })).toBeTruthy();
expect(con({ clave: 'histórico', titulo: 'Historia', cuerpo: '' })).toBeTruthy();
expect(con({ clave: '', titulo: 'Historia', cuerpo: '' })).toBeTruthy();
});
it('y un título', () => {
expect(con({ clave: 'historia', titulo: ' ', cuerpo: 'algo' })).toBeTruthy();
});
it('con las dos cosas, pasa', () => {
expect(con({ clave: 'estructura', titulo: 'Estructura', cuerpo: '' })).toBeUndefined();
});
});
it('una sección que se queda no puede perder el título', () => {
const sinTitulo = bueno({
secciones: [{ id: 's1', titulo: '', cuerpo: 'algo', orden: 0, borrar: false }]
});
expect(revisarGenero(sinTitulo).secciones).toBeTruthy();
});
it('pero si se va a borrar, da igual cómo se llame', () => {
const borrada = bueno({
secciones: [{ id: 's1', titulo: '', cuerpo: '', orden: 0, borrar: true }]
});
expect(revisarGenero(borrada)).toEqual({});
});
});

@ -0,0 +1,309 @@
/**
* Los géneros, para el panel.
*
* Una ficha de género no es una fila: es la fila más los países donde vive y la
* prosa repartida en secciones. Se edita todo en la misma pantalla porque es
* una sola cosa —la ficha—, y repartirla en tres formularios obligaría a
* guardar tres veces para arreglar un párrafo.
*
* Lo que todavía no se edita aquí —alias, temática, instrumentación, linaje,
* personas, obras y versos— se carga por script. Se irá trayendo; lo de aquí es
* lo que tiene texto que escribir.
*/
import { and, asc, eq, inArray, sql } from 'drizzle-orm';
import { db } from './db';
import { capitulo, genero, generoPais, generoSeccion, pais } from './db/schema';
import { ESTADOS } from './db/schema/comun';
export type Estado = (typeof ESTADOS)[number];
export type Relacion = 'cuna' | 'adoptado';
export interface GeneroEnLista {
slug: string;
nombre: string;
estado: Estado;
origenLugar: string | null;
origenAno: number | null;
/** Cuántas secciones de prosa tiene escritas. */
secciones: number;
/** Si existe el capítulo que lo publica. Sin él, la ficha no sale en la web. */
tieneFicha: boolean;
/** Importado por un modelo y sin repasar por una persona. */
sinRevisar: boolean;
}
export async function listaDeGeneros(): Promise<GeneroEnLista[]> {
const filas = await db
.select({
slug: genero.slug,
nombre: genero.nombre,
estado: genero.estado,
origenLugar: genero.origenLugar,
origenAno: genero.origenAno,
origen: genero.origen,
revisadoEn: genero.revisadoEn,
capituloId: capitulo.id,
secciones: sql<number>`(
select count(*)::int from genero_seccion s where s.genero_id = ${genero.id}
)`
})
.from(genero)
.leftJoin(capitulo, eq(capitulo.generoId, genero.id))
.orderBy(asc(genero.nombre));
return filas.map(({ origen, revisadoEn, capituloId, ...resto }) => ({
...resto,
tieneFicha: capituloId !== null,
sinRevisar: origen === 'importado' && revisadoEn === null
}));
}
export interface SeccionDelGenero {
id: string;
clave: string;
titulo: string;
cuerpo: string;
orden: number;
}
export interface PaisDelGenero {
codigo: string;
nombre: string;
relacion: Relacion | null;
}
export interface GeneroParaEditar {
slug: string;
nombre: string;
resumen: string;
origenLugar: string;
origenAno: number | null;
tempo: string;
rima: string;
metricaNota: string;
compasNota: string;
/** Frases sueltas: «Tratamiento de usted, nunca coloquial». */
rasgos: string[];
estado: Estado;
origen: string;
modeloOrigen: string | null;
revisadoEn: Date | null;
secciones: SeccionDelGenero[];
/** Los 22 del mapa, con su relación si la tiene. */
paises: PaisDelGenero[];
/** El capítulo que lo publica, si existe. */
fichaSlug: string | null;
}
export async function generoParaEditar(slug: string): Promise<GeneroParaEditar | null> {
const [fila] = await db.select().from(genero).where(eq(genero.slug, slug)).limit(1);
if (!fila) return null;
const [secciones, relaciones, todosLosPaises, ficha] = await Promise.all([
db
.select()
.from(generoSeccion)
.where(eq(generoSeccion.generoId, fila.id))
.orderBy(asc(generoSeccion.orden)),
db.select().from(generoPais).where(eq(generoPais.generoId, fila.id)),
db.select().from(pais).orderBy(asc(pais.nombre)),
db.select({ slug: capitulo.slug }).from(capitulo).where(eq(capitulo.generoId, fila.id)).limit(1)
]);
const porCodigo = new Map(relaciones.map((r) => [r.paisCodigo, r.relacion]));
return {
slug: fila.slug,
nombre: fila.nombre,
resumen: fila.resumen,
origenLugar: fila.origenLugar ?? '',
origenAno: fila.origenAno,
tempo: fila.tempo ?? '',
rima: fila.rima ?? '',
metricaNota: fila.metricaNota ?? '',
compasNota: fila.compasNota ?? '',
rasgos: fila.rasgos ?? [],
estado: fila.estado,
origen: fila.origen,
modeloOrigen: fila.modeloOrigen,
revisadoEn: fila.revisadoEn,
secciones: secciones.map((s) => ({
id: s.id,
clave: s.clave,
titulo: s.titulo,
cuerpo: s.cuerpo,
orden: s.orden
})),
paises: todosLosPaises.map((p) => ({
codigo: p.codigo,
nombre: p.nombre,
relacion: porCodigo.get(p.codigo) ?? null
})),
fichaSlug: ficha[0]?.slug ?? null
};
}
export interface CambiosDelGenero {
nombre: string;
resumen: string;
origenLugar: string;
origenAno: number | null;
tempo: string;
rima: string;
metricaNota: string;
compasNota: string;
rasgos: string[];
estado: Estado;
/** Qué países y con qué relación. Reemplaza lo que hubiera. */
paises: { codigo: string; relacion: Relacion }[];
/** Secciones existentes, con lo que se haya cambiado o marcado para borrar. */
secciones: { id: string; titulo: string; cuerpo: string; orden: number; borrar: boolean }[];
/** Una sección nueva, si se ha escrito algo en ella. */
nueva: { clave: string; titulo: string; cuerpo: string } | null;
/** Marcar la ficha como repasada por una persona. */
revisar: boolean;
}
export type ErroresDelGenero = Partial<
Record<'nombre' | 'origenAno' | 'paises' | 'nueva' | 'secciones', string>
>;
/** El año actual, para no aceptar un origen en el futuro. */
const ESTE_ANO = () => new Date().getFullYear();
export function revisarGenero(cambios: CambiosDelGenero): ErroresDelGenero {
const errores: ErroresDelGenero = {};
if (cambios.nombre.trim().length < 2) errores.nombre = 'El nombre no puede quedar vacío.';
if (cambios.origenAno !== null) {
if (!Number.isInteger(cambios.origenAno)) {
errores.origenAno = 'El año es un número entero.';
} else if (cambios.origenAno < 1500 || cambios.origenAno > ESTE_ANO()) {
errores.origenAno = `El año tiene que estar entre 1500 y ${ESTE_ANO()}.`;
}
}
/*
* Dos cunas es un dato falso, no un matiz. Un género nace en un sitio y se
* adopta en varios; si de verdad nació en dos orillas —el tango— la cuna es
* la región y el otro país va como adoptado, con su nota.
*/
if (cambios.paises.filter((p) => p.relacion === 'cuna').length > 1) {
errores.paises = 'Solo puede haber un país como cuna. Los demás van como adoptado.';
}
/*
* Publicar un género que no está en ningún país lo deja fuera del mapa, que
* es por donde se llega a las fichas.
*/
if (cambios.estado === 'publicado' && cambios.paises.length === 0) {
errores.paises =
'Un género publicado tiene que estar en algún país: si no, no sale en el mapa.';
}
if (cambios.nueva) {
if (!/^[a-z][a-z0-9-]{1,40}$/.test(cambios.nueva.clave)) {
errores.nueva =
'La clave va en minúsculas, sin espacios ni acentos: «historia», «estructura».';
} else if (cambios.nueva.titulo.trim() === '') {
errores.nueva = 'La sección nueva necesita un título.';
}
}
if (cambios.secciones.some((s) => !s.borrar && s.titulo.trim() === '')) {
errores.secciones = 'Una sección no puede quedarse sin título.';
}
return errores;
}
/**
* Guarda la ficha entera en una transacción.
*
* Todo junto porque es una sola cosa: si al reemplazar los países falla algo,
* no puede quedar el texto guardado y el mapa a medias. Devuelve `false` si el
* género ha dejado de existir.
*/
export async function guardarGenero(slug: string, cambios: CambiosDelGenero): Promise<boolean> {
const [fila] = await db
.select({ id: genero.id })
.from(genero)
.where(eq(genero.slug, slug))
.limit(1);
if (!fila) return false;
const ahora = new Date();
await db.transaction(async (tx) => {
await tx
.update(genero)
.set({
nombre: cambios.nombre.trim(),
resumen: cambios.resumen.trim(),
// Los opcionales vacíos se guardan como nulo y no como cadena vacía:
// «no se sabe» y «es una cadena de cero letras» no son lo mismo, y la
// web pregunta por nulo para decidir si pinta el dato.
origenLugar: cambios.origenLugar.trim() || null,
origenAno: cambios.origenAno,
tempo: cambios.tempo.trim() || null,
rima: cambios.rima.trim() || null,
metricaNota: cambios.metricaNota.trim() || null,
compasNota: cambios.compasNota.trim() || null,
rasgos: cambios.rasgos.length > 0 ? cambios.rasgos : null,
estado: cambios.estado,
actualizadoEn: ahora,
/*
* Repasar es una firma, no una casilla que se pueda desmarcar sola: si
* ya estaba revisada se deja la fecha que tenía.
*/
...(cambios.revisar ? { revisadoEn: ahora } : {})
})
.where(eq(genero.id, fila.id));
/*
* Los países se reemplazan enteros en vez de ir comparando: son cuatro
* filas y el formulario manda el conjunto completo, así que calcular
* altas y bajas sería trabajo para llegar al mismo sitio con más formas de
* equivocarse. Se pierde la nota de cada país, que hoy no se edita aquí.
*/
await tx.delete(generoPais).where(eq(generoPais.generoId, fila.id));
if (cambios.paises.length > 0) {
await tx.insert(generoPais).values(
cambios.paises.map((p) => ({
generoId: fila.id,
paisCodigo: p.codigo,
relacion: p.relacion
}))
);
}
const aBorrar = cambios.secciones.filter((s) => s.borrar).map((s) => s.id);
if (aBorrar.length > 0) {
await tx
.delete(generoSeccion)
.where(and(eq(generoSeccion.generoId, fila.id), inArray(generoSeccion.id, aBorrar)));
}
for (const s of cambios.secciones) {
if (s.borrar) continue;
await tx
.update(generoSeccion)
.set({ titulo: s.titulo.trim(), cuerpo: s.cuerpo.trim(), orden: s.orden })
.where(and(eq(generoSeccion.id, s.id), eq(generoSeccion.generoId, fila.id)));
}
if (cambios.nueva) {
await tx.insert(generoSeccion).values({
generoId: fila.id,
clave: cambios.nueva.clave,
titulo: cambios.nueva.titulo.trim(),
cuerpo: cambios.nueva.cuerpo.trim(),
orden: cambios.secciones.filter((s) => !s.borrar).length
});
}
});
return true;
}

@ -33,7 +33,7 @@ export interface FilaDelPanel {
* `resolve()`, contra su propio mapa de rutas, y así una que se escriba mal
* deja de compilar en vez de dar un 404 al pulsarla.
*/
pantalla: 'capitulos' | null;
pantalla: 'capitulos' | 'generos' | null;
}
export interface GrupoDelPanel {
@ -185,7 +185,7 @@ export async function estadoDelContenido(): Promise<GrupoDelPanel[]> {
unidad: 'géneros',
detalle: null,
pendiente: falta(n.generos_sin_ficha, 'sin ficha', 'generos'),
pantalla: null
pantalla: 'generos'
},
{
/*

@ -28,7 +28,8 @@
*/
const menu = [
{ href: resolve('/(dentro)'), texto: 'El contenido' },
{ href: resolve('/(dentro)/capitulos'), texto: 'Capítulos' }
{ href: resolve('/(dentro)/capitulos'), texto: 'Capítulos' },
{ href: resolve('/(dentro)/generos'), texto: 'Géneros' }
];
const aqui = (href: string) =>

@ -20,7 +20,10 @@
* la consulta: así una ruta que se escriba mal deja de compilar en vez de dar
* un 404 al pulsarla.
*/
const PANTALLAS = { capitulos: resolve('/(dentro)/capitulos') } as const;
const PANTALLAS = {
capitulos: resolve('/(dentro)/capitulos'),
generos: resolve('/(dentro)/generos')
} as const;
</script>
<svelte:head>

@ -0,0 +1,6 @@
import { listaDeGeneros } from '$lib/server/panel-generos';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return { generos: await listaDeGeneros() };
};

@ -0,0 +1,233 @@
<script lang="ts">
/**
* Los géneros.
*
* Cada fila dice lo único que hace falta saber desde fuera: si está publicado,
* de dónde es, cuánta prosa tiene escrita y si existe el capítulo que lo saca
* en la web. Un género sin ficha está en la base y no está en el sitio, y eso
* no se ve por ningún otro sitio.
*/
import { resolve } from '$app/paths';
let { data } = $props();
let consulta = $state('');
const plano = (t: string) => t.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
const encontrados = $derived.by(() => {
const q = plano(consulta.trim());
if (!q) return data.generos;
return data.generos.filter((g) =>
plano(`${g.nombre} ${g.slug} ${g.origenLugar ?? ''}`).includes(q)
);
});
const ESTADO: Record<string, string> = {
borrador: 'Borrador',
publicado: 'Publicado',
retirado: 'Retirado'
};
/**
* «Santiago de Cuba, 1883» y no «Santiago de Cuba, 1883, 1883».
*
* El lugar de origen es texto libre y a menudo ya trae la fecha dentro
* —«México, tras la Revolución (años veinte y treinta)»—. Añadirle el año
* siempre repetía el dato o lo contradecía.
*/
function procedencia(lugar: string | null, ano: number | null): string {
if (!lugar) return ano ? String(ano) : 'sin lugar de origen';
if (!ano || lugar.includes(String(ano))) return lugar;
return `${lugar}, ${ano}`;
}
</script>
<svelte:head>
<title>Géneros · Panel</title>
</svelte:head>
<header>
<h1>Géneros</h1>
<p class="entradilla">
De dónde viene cada estilo, en qué países se canta y toda la prosa de su ficha.
</p>
</header>
<div class="buscador">
<label class="visually-hidden" for="buscar">Buscar un género</label>
<input
id="buscar"
type="search"
bind:value={consulta}
placeholder="Buscar por nombre o por lugar"
autocomplete="off"
/>
<p class="cuenta muted" role="status">{encontrados.length} de {data.generos.length}</p>
</div>
{#if encontrados.length === 0}
<p class="vacio muted">Nada con «{consulta.trim()}».</p>
{:else}
<table>
<tbody>
{#each encontrados as g (g.slug)}
<tr>
<th scope="row">
<a href={resolve('/(dentro)/generos/[slug]', { slug: g.slug })}>{g.nombre}</a>
<span class="donde muted">{procedencia(g.origenLugar, g.origenAno)}</span>
</th>
<td>
<!--
Las pastillas en su propia caja y no en el `td`: un `td` en flex
deja de comportarse como celda y su borde inferior se descuelga
del de la fila. Se vio en la captura, con las tres columnas
cortadas a distinta altura.
-->
<div class="marcas">
<span class="pastilla" data-estado={g.estado}>{ESTADO[g.estado] ?? g.estado}</span>
{#if !g.tieneFicha}
<span class="pastilla" data-aviso="si">Sin ficha</span>
{/if}
{#if g.sinRevisar}
<span class="pastilla" data-aviso="si">Sin revisar</span>
{/if}
</div>
</td>
<td class="secciones muted">
{g.secciones}
{g.secciones === 1 ? 'sección' : 'secciones'}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<style>
header {
max-width: 62ch;
padding-bottom: var(--sp-5);
}
h1 {
margin: 0 0 var(--sp-2);
font-size: var(--fs-2xl);
}
.entradilla {
margin: 0;
color: var(--c-text-muted);
}
.buscador {
display: flex;
align-items: center;
gap: var(--sp-4);
padding-bottom: var(--sp-5);
}
input[type='search'] {
flex: 1 1 auto;
min-width: 0;
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg-sunken);
color: inherit;
font: inherit;
font-size: var(--boton-texto);
}
input[type='search']:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
.cuenta {
flex: 0 0 auto;
margin: 0;
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--c-border);
text-align: left;
vertical-align: baseline;
}
tbody th {
font-weight: 500;
}
.donde {
display: block;
margin-top: 0.15rem;
font-size: var(--fs-xs);
font-weight: 400;
}
.marcas {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
/*
* El estado en pastilla, y los avisos en el color del dato. Publicado va en
* gris: es lo normal, y si los tres llevaran color ninguno destacaría.
*/
.pastilla {
display: inline-block;
padding: 0.15rem 0.55rem;
border: 1px solid var(--c-border);
border-radius: 999px;
font-size: var(--fs-xs);
white-space: nowrap;
color: var(--c-text-muted);
}
.pastilla[data-estado='borrador'],
.pastilla[data-aviso='si'] {
border-color: color-mix(in oklab, var(--c-dato) 45%, transparent);
color: var(--c-dato);
}
.pastilla[data-estado='retirado'] {
border-color: color-mix(in oklab, var(--c-danger) 45%, transparent);
color: var(--c-danger);
}
.secciones {
width: 1%;
text-align: right;
white-space: nowrap;
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
.vacio {
padding-block: var(--sp-6);
}
@media (max-width: 44rem) {
th,
td {
padding: var(--sp-3) var(--sp-2);
}
.secciones {
display: none;
}
}
</style>

@ -0,0 +1,97 @@
import { error, fail } from '@sveltejs/kit';
import {
generoParaEditar,
guardarGenero,
revisarGenero,
type CambiosDelGenero,
type Estado,
type Relacion
} from '$lib/server/panel-generos';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const genero = await generoParaEditar(params.slug);
if (!genero) error(404, 'No hay ningún género con ese slug.');
return { genero };
};
/** Un año vacío es «no se sabe», no un cero. */
function anoDe(valor: FormDataEntryValue | null): number | null {
const texto = String(valor ?? '').trim();
if (texto === '') return null;
return Number(texto);
}
/** Una frase por línea, sin las vacías: es como se escribe una lista a mano. */
function lineasDe(valor: FormDataEntryValue | null): string[] {
return String(valor ?? '')
.split('\n')
.map((l) => l.trim())
.filter((l) => l !== '');
}
export const actions: Actions = {
default: async ({ request, params }) => {
const datos = await request.formData();
/*
* Los países vienen en dos campos: qué casillas están marcadas y, de esas,
* cuál es la cuna. Se cruzan aquí en vez de mandar un solo campo por país
* porque una casilla sin marcar no se envía, y así el conjunto que llega
* es exactamente el que se ve en pantalla.
*/
const cuna = String(datos.get('cuna') ?? '');
const paises = datos.getAll('pais').map((codigo) => ({
codigo: String(codigo),
relacion: (String(codigo) === cuna ? 'cuna' : 'adoptado') as Relacion
}));
/*
* Las secciones viajan con su identificador en el nombre del campo. Se
* recorren las que el formulario declara en `seccion`, y no las de la base,
* para que borrar una en pantalla la borre de verdad.
*/
const secciones = datos.getAll('seccion').map((valor) => {
const id = String(valor);
return {
id,
titulo: String(datos.get(`titulo:${id}`) ?? ''),
cuerpo: String(datos.get(`cuerpo:${id}`) ?? ''),
orden: Number(datos.get(`orden:${id}`) ?? 0),
borrar: datos.get(`borrar:${id}`) !== null
};
});
const claveNueva = String(datos.get('nueva:clave') ?? '').trim();
const tituloNuevo = String(datos.get('nueva:titulo') ?? '').trim();
const cuerpoNuevo = String(datos.get('nueva:cuerpo') ?? '').trim();
// Solo cuenta como sección nueva si se ha escrito algo en ella.
const hayNueva = claveNueva !== '' || tituloNuevo !== '' || cuerpoNuevo !== '';
const cambios: CambiosDelGenero = {
nombre: String(datos.get('nombre') ?? ''),
resumen: String(datos.get('resumen') ?? ''),
origenLugar: String(datos.get('origenLugar') ?? ''),
origenAno: anoDe(datos.get('origenAno')),
tempo: String(datos.get('tempo') ?? ''),
rima: String(datos.get('rima') ?? ''),
metricaNota: String(datos.get('metricaNota') ?? ''),
compasNota: String(datos.get('compasNota') ?? ''),
rasgos: lineasDe(datos.get('rasgos')),
estado: String(datos.get('estado') ?? '') as Estado,
paises,
secciones,
nueva: hayNueva ? { clave: claveNueva, titulo: tituloNuevo, cuerpo: cuerpoNuevo } : null,
revisar: datos.get('revisar') !== null
};
const errores = revisarGenero(cambios);
if (Object.keys(errores).length > 0) return fail(400, { errores });
const guardado = await guardarGenero(params.slug, cambios);
if (!guardado) error(404, 'Ese género ha dejado de existir mientras lo editabas.');
return { guardado: true };
}
};

@ -0,0 +1,621 @@
<script lang="ts">
/**
* Editar una ficha de género.
*
* Todo en una pantalla y un solo guardado, porque es una sola cosa: los datos
* del género, dónde se canta y la prosa. Repartido en tres formularios habría
* que guardar tres veces para arreglar un párrafo.
*
* El orden de la pantalla es el orden en que se escribe una ficha: primero
* qué es y de dónde viene, luego dónde se canta —que es lo que la pone en el
* mapa— y al final el texto, que es lo largo.
*/
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { site } from '$lib/site';
let { data, form } = $props();
const copia = () => ({
nombre: data.genero.nombre,
resumen: data.genero.resumen,
origenLugar: data.genero.origenLugar,
origenAno: data.genero.origenAno,
tempo: data.genero.tempo,
rima: data.genero.rima,
metricaNota: data.genero.metricaNota,
compasNota: data.genero.compasNota,
// Los rasgos se editan como texto, una frase por línea: es como se
// escriben, y un campo por frase con botones de añadir y quitar sería
// mucho aparato para una lista de cuatro renglones.
rasgos: data.genero.rasgos.join('\n'),
estado: data.genero.estado,
paises: data.genero.paises.filter((p) => p.relacion !== null).map((p) => p.codigo),
cuna: data.genero.paises.find((p) => p.relacion === 'cuna')?.codigo ?? '',
secciones: data.genero.secciones.map((s) => ({ ...s, borrar: false })),
nueva: { clave: '', titulo: '', cuerpo: '' },
revisar: false
});
let campos = $state(untrack(copia));
let guardando = $state(false);
const errores = $derived(form?.errores ?? {});
const enLaWeb = $derived(`${site.url}/generos/${data.genero.slug}`);
/** La cuna tiene que estar entre los países marcados. */
const paisesMarcados = $derived(new Set(campos.paises));
function alDesmarcar(codigo: string) {
if (campos.cuna === codigo) campos.cuna = '';
}
let formulario: HTMLFormElement;
function atajo(evento: KeyboardEvent) {
if (evento.key !== 's' || !(evento.ctrlKey || evento.metaKey)) return;
if (guardando) return;
evento.preventDefault();
formulario.requestSubmit();
}
</script>
<svelte:window onkeydown={atajo} />
<svelte:head>
<title>{data.genero.nombre} · Panel</title>
</svelte:head>
<p class="volver">
<a href={resolve('/(dentro)/generos')}>← Géneros</a>
</p>
<form
method="POST"
bind:this={formulario}
use:enhance={() => {
guardando = true;
return async ({ update }) => {
await update({ reset: false });
guardando = false;
// Si guardó, la pantalla vuelve a salir de la base: las secciones
// borradas ya no están y la nueva tiene su identificador.
if (form?.guardado) campos = copia();
};
}}
>
<header>
<div class="quees">
<h1>
<label class="visually-hidden" for="nombre">Nombre</label>
<input id="nombre" name="nombre" bind:value={campos.nombre} required />
</h1>
<p class="donde muted">
<span class="slug">{data.genero.slug}</span>
{#if data.genero.fichaSlug}
·
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={enLaWeb} target="_blank" rel="noreferrer">verlo en la web</a>
{:else}
· <span class="aviso-linea">sin capítulo: no sale en la web</span>
{/if}
{#if data.genero.origen === 'importado'}
· importado{#if data.genero.modeloOrigen}
de {data.genero.modeloOrigen}{/if}{#if !data.genero.revisadoEn}, sin revisar{/if}
{/if}
</p>
{#if errores.nombre}<p class="fallo">{errores.nombre}</p>{/if}
</div>
<div class="acciones">
<span class="estado-guardado" role="status">
{#if guardando}
Guardando…
{:else if form?.guardado}
Guardado
{/if}
</span>
<button type="submit" class="btn btn--primary" disabled={guardando}>Guardar</button>
</div>
</header>
<section>
<h2>Qué es y de dónde viene</h2>
<p class="campo">
<label for="resumen">Resumen</label>
<span class="ayuda muted">Una línea. Es lo que sale en el buscador y en el mapa.</span>
<textarea id="resumen" name="resumen" rows="2" bind:value={campos.resumen}></textarea>
</p>
<div class="rejilla">
<p class="campo">
<label for="origenLugar">Lugar de origen</label>
<input id="origenLugar" name="origenLugar" bind:value={campos.origenLugar} />
</p>
<p class="campo">
<label for="origenAno">Año</label>
<input
id="origenAno"
name="origenAno"
type="number"
min="1500"
step="1"
bind:value={campos.origenAno}
aria-invalid={!!errores.origenAno}
/>
</p>
<p class="campo">
<label for="estado">Estado</label>
<select id="estado" name="estado" bind:value={campos.estado}>
<option value="borrador">Borrador</option>
<option value="publicado">Publicado</option>
<option value="retirado">Retirado</option>
</select>
</p>
</div>
{#if errores.origenAno}<p class="fallo">{errores.origenAno}</p>{/if}
<div class="rejilla">
<p class="campo">
<label for="tempo">Tempo</label>
<input id="tempo" name="tempo" bind:value={campos.tempo} placeholder="60–80 bpm" />
</p>
<p class="campo">
<label for="rima">Rima</label>
<input id="rima" name="rima" bind:value={campos.rima} placeholder="Consonante, ABAB" />
</p>
</div>
<div class="rejilla dos">
<p class="campo">
<label for="metricaNota">Métrica</label>
<span class="ayuda muted">El matiz que no cabe en el vocabulario.</span>
<input id="metricaNota" name="metricaNota" bind:value={campos.metricaNota} />
</p>
<p class="campo">
<label for="compasNota">Compás</label>
<span class="ayuda muted">«4/4 con clave 2-3», y lo que haga falta.</span>
<input id="compasNota" name="compasNota" bind:value={campos.compasNota} />
</p>
</div>
<p class="campo">
<label for="rasgos">Rasgos de escritura</label>
<span class="ayuda muted">
Una frase por línea: «Tratamiento de usted o de tú, nunca coloquial».
</span>
<textarea id="rasgos" name="rasgos" rows="4" bind:value={campos.rasgos}></textarea>
</p>
</section>
<section>
<h2>Dónde se canta</h2>
<p class="ayuda muted">
Esto es lo que pinta el género en el mapa. La cuna es una sola: donde nació. Los demás países
van como adoptados.
</p>
{#if errores.paises}<p class="fallo">{errores.paises}</p>{/if}
<ul class="paises">
{#each data.genero.paises as p (p.codigo)}
<li class:puesto={paisesMarcados.has(p.codigo)}>
<label>
<input
type="checkbox"
name="pais"
value={p.codigo}
bind:group={campos.paises}
onchange={() => alDesmarcar(p.codigo)}
/>
{p.nombre}
</label>
<label class="cuna" class:invisible={!paisesMarcados.has(p.codigo)}>
<input type="radio" name="cuna" value={p.codigo} bind:group={campos.cuna} />
cuna
</label>
</li>
{/each}
</ul>
</section>
<section>
<h2>La ficha</h2>
<p class="ayuda muted">
La prosa, en secciones. Cada una es un apartado con su encabezado en la página del género.
Markdown, y Ctrl+S guarda.
</p>
{#if errores.secciones}<p class="fallo">{errores.secciones}</p>{/if}
{#each campos.secciones as s, i (s.id)}
<article class="seccion" class:borrada={s.borrar}>
<input type="hidden" name="seccion" value={s.id} />
<div class="cabecera">
<p class="campo crece">
<label class="visually-hidden" for="titulo-{s.id}">Título de la sección</label>
<input id="titulo-{s.id}" name="titulo:{s.id}" bind:value={s.titulo} />
</p>
<p class="campo corto">
<label class="visually-hidden" for="orden-{s.id}">Orden</label>
<input
id="orden-{s.id}"
name="orden:{s.id}"
type="number"
min="0"
step="1"
bind:value={s.orden}
title="Orden en la página"
/>
</p>
<label class="quitar">
<input type="checkbox" name="borrar:{s.id}" bind:checked={s.borrar} />
Borrar
</label>
</div>
<p class="campo">
<label class="visually-hidden" for="cuerpo-{s.id}">Texto de {s.titulo}</label>
<textarea id="cuerpo-{s.id}" name="cuerpo:{s.id}" bind:value={s.cuerpo}></textarea>
</p>
<p class="clave muted">
<code>{s.clave}</code>
· sección {i + 1} de {campos.secciones.length}
</p>
</article>
{/each}
<article class="seccion nueva">
<h3>Añadir una sección</h3>
{#if errores.nueva}<p class="fallo">{errores.nueva}</p>{/if}
<div class="cabecera">
<p class="campo corto">
<label for="nueva-clave">Clave</label>
<input
id="nueva-clave"
name="nueva:clave"
bind:value={campos.nueva.clave}
placeholder="historia"
/>
</p>
<p class="campo crece">
<label for="nueva-titulo">Título</label>
<input id="nueva-titulo" name="nueva:titulo" bind:value={campos.nueva.titulo} />
</p>
</div>
<p class="campo">
<label class="visually-hidden" for="nueva-cuerpo">Texto de la sección nueva</label>
<textarea
id="nueva-cuerpo"
name="nueva:cuerpo"
rows="4"
bind:value={campos.nueva.cuerpo}
placeholder="Se deja en blanco si todavía no hay nada que escribir."></textarea>
</p>
</article>
</section>
{#if data.genero.origen === 'importado' && !data.genero.revisadoEn}
<p class="revisar">
<label>
<input type="checkbox" name="revisar" bind:checked={campos.revisar} />
Dar la ficha por revisada
</label>
<span class="ayuda muted">
Esta ficha la propuso un modelo. Marcarlo deja constancia de que la ha leído una persona; es
lo que distingue lo importado de lo comprobado.
</span>
</p>
{/if}
</form>
<style>
.volver {
margin: 0 0 var(--sp-4);
font-size: var(--fs-sm);
}
form {
display: grid;
gap: var(--sp-6);
}
header {
display: flex;
align-items: start;
justify-content: space-between;
gap: var(--sp-5);
flex-wrap: wrap;
}
.quees {
flex: 1 1 20rem;
display: grid;
gap: var(--sp-2);
min-width: 0;
}
.acciones {
display: flex;
align-items: center;
gap: var(--sp-3);
padding-top: 0.35rem;
}
.estado-guardado {
font-size: var(--fs-sm);
color: var(--c-text-muted);
white-space: nowrap;
}
h1 {
margin: 0;
font-size: var(--fs-2xl);
}
/* El nombre se edita donde se lee; la caja sale al pasar por encima. */
#nombre {
width: 100%;
padding: 0.2rem 0.4rem;
margin-left: -0.4rem;
border: 1px solid transparent;
border-radius: var(--radius-boton);
background: none;
color: inherit;
font: inherit;
}
#nombre:hover {
border-color: var(--c-border);
}
#nombre:focus-visible {
border-color: var(--c-accent);
background: var(--c-bg-sunken);
outline: none;
}
.donde {
margin: 0;
font-size: var(--fs-sm);
}
.slug {
font-family: var(--font-mono);
font-size: var(--fs-xs);
}
.aviso-linea {
color: var(--c-dato);
}
section {
display: grid;
gap: var(--sp-4);
padding-top: var(--sp-5);
border-top: 1px solid var(--c-border);
}
h2 {
margin: 0;
font-size: var(--fs-xl);
}
h3 {
margin: 0;
font-size: var(--fs-base, 1rem);
}
.campo {
display: grid;
gap: 0.25rem;
margin: 0;
min-width: 0;
}
label {
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
.ayuda {
margin: 0;
font-size: var(--fs-xs);
line-height: 1.45;
max-width: 62ch;
}
input:not(#nombre),
select,
textarea {
width: 100%;
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg-sunken);
color: inherit;
font: inherit;
font-size: var(--boton-texto);
}
input[type='checkbox'],
input[type='radio'] {
width: auto;
padding: 0;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
[aria-invalid='true'] {
border-color: var(--c-danger);
}
textarea {
resize: vertical;
}
/* Los datos cortos, en fila: cada uno mide lo que mide su contenido. */
.rejilla {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
gap: var(--sp-4);
}
.rejilla.dos {
grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
}
/*
* Los veintidós países en columnas, que caben de una vez. En una lista
* vertical habría que bajar media pantalla para llegar a Venezuela.
*/
.paises {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr));
gap: 0.15rem var(--sp-5);
}
/*
* El «cuna» va pegado al nombre del país, no al final de la columna.
* Repartido con `space-between` acababa a diez centímetros de su país y
* alineado con el de al lado: no se sabía de quién era cuál.
*/
.paises li {
display: flex;
align-items: baseline;
justify-content: start;
gap: var(--sp-3);
padding: 0.25rem 0;
}
.paises label {
display: flex;
align-items: baseline;
gap: 0.5rem;
color: var(--c-text-muted);
cursor: pointer;
/* «República Dominicana» partido en dos líneas descuadraba toda la fila. */
white-space: nowrap;
}
.paises li.puesto > label:first-child {
color: var(--c-text);
}
.cuna {
font-size: var(--fs-xs);
}
/*
* La cuna de un país sin marcar no se quita del árbol, se esconde: quitándola
* la fila se encoge y la lista entera baila al marcar una casilla.
*/
.invisible {
visibility: hidden;
}
.seccion {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
}
/* Marcada para borrar: se ve que va a desaparecer, pero se puede deshacer. */
.seccion.borrada {
opacity: 0.45;
}
.cabecera {
display: flex;
align-items: end;
gap: var(--sp-3);
flex-wrap: wrap;
}
.crece {
flex: 1 1 14rem;
}
.corto {
flex: 0 0 9rem;
}
.quitar {
display: flex;
align-items: center;
gap: 0.4rem;
padding-bottom: 0.55rem;
font-size: var(--fs-sm);
cursor: pointer;
}
.seccion textarea {
min-height: 14rem;
font-family: var(--font-mono);
font-size: var(--fs-sm);
line-height: 1.65;
}
.clave {
margin: 0;
font-size: var(--fs-xs);
}
.seccion.nueva {
border-style: dashed;
background: none;
}
.revisar {
display: grid;
gap: 0.35rem;
margin: 0;
padding: var(--sp-4);
border: 1px solid color-mix(in oklab, var(--c-dato) 40%, transparent);
border-radius: var(--radius-tarjeta);
}
.revisar > label {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--c-text);
cursor: pointer;
}
.fallo {
margin: 0;
color: var(--c-danger);
font-size: var(--fs-sm);
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.