El panel ya edita, y enseña todo el contenido y no siete filas

Un panel donde no se puede editar nada no es un panel.

**Se editan los capítulos** —los principios del taller y las fichas de género,
que son la misma tabla con un `tipo` distinto—: título, resumen, texto en
Markdown, estado y orden. Se guarda con el botón o con Ctrl+S, y sale publicado
al momento: el caché del Markdown tiene por clave el propio texto, así que un
cambio se invalida solo.

El botón de guardar va arriba y no al pie. Al pie tenía que ser flotante para
seguir a la vista mientras se escribe, y flotando se pintaba encima de la línea
que estabas escribiendo; se vio en la captura.

**La portada enseñaba siete cosas de las cuarenta largas que hay.** Ahora sale
todo el contenido agrupado —taller y géneros, glosario, catálogo, editorial—,
con su desglose y lo que falta en cada uno. Lo que no aparece en un panel no
existe: nadie se iba a acordar de que los metros y los compases siguen vacíos.
Fuera queda la explotación —pedidos, facturas, carritos—, que no se escribe.

Y los letristas ya se ven. Están en `persona` porque el papel no es de la
persona sino de su relación con cada género —`genero_persona.papel`, con clave
primaria (género, persona, papel)—, y la misma persona es letrista de uno y
compositora de otro. La página pública de letristas es una vista sobre eso. Lo
que estaba mal es que el panel no lo dijera: ahora la fila de Personas desglosa
15 letristas · 2 compositores · 1 intérprete · 4 fundadores.

Un menú, que tampoco había. Solo con lo que ya tiene pantalla: un menú con la
mitad de las entradas muertas es peor que uno corto.

`npm run db:admin -- correo --crear` para el arranque en frío. Sin eso no había
forma de nombrar al primer administrador: el script exigía que la cuenta ya
existiera, y en una base recién puesta no existe ninguna.

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

@ -3,6 +3,7 @@
*
* npm run db:admin # quién administra ahora
* npm run db:admin -- hola@senzapaura.es # se lo da
* npm run db:admin -- hola@… --crear # y crea la cuenta si no existe
* npm run db:admin -- hola@… --quitar # se lo quita
*
* Es un script y no una pantalla del panel a propósito: el primer administrador
@ -10,14 +11,18 @@
* mientras administre una sola persona, una pantalla para gestionar permisos
* sería mantener un sistema de permisos para un usuario.
*
* No crea la cuenta. Se da el rol a alguien que ya ha entrado alguna vez, para
* que el rol no pueda quedarse esperando en una dirección mal escrita.
* Por defecto **no crea la cuenta**: se le da el rol a alguien que ya ha entrado
* alguna vez, y así el permiso no puede quedarse esperando en una dirección mal
* escrita. `--crear` es para el arranque en frío —una base recién puesta donde
* todavía no ha entrado nadie—, y por eso hay que pedirlo: escribir mal el
* correo con `--crear` da de alta una cuenta administradora que no es de nadie.
*/
import pg from 'pg';
const argumentos = process.argv.slice(2);
const quitar = argumentos.includes('--quitar');
const crear = argumentos.includes('--crear');
const email = argumentos
.find((a) => !a.startsWith('--'))
?.trim()
@ -54,14 +59,33 @@ const { rows } = await c.query(
);
if (rows.length === 0) {
console.log(`No hay ninguna cuenta con «${email}».`);
console.log('Tiene que haber entrado al menos una vez para poder darle el rol.');
if (quitar) {
console.log(`No hay ninguna cuenta con «${email}», así que no administra.`);
await c.end();
process.exit(0);
}
if (!crear) {
console.log(`No hay ninguna cuenta con «${email}».`);
console.log('Tiene que haber entrado al menos una vez para poder darle el rol,');
console.log('o pásale --crear si es el primer administrador de esta base.');
await c.end();
process.exit(1);
}
const { rows: creada } = await c.query(
`insert into usuario (id, email, rol) values (gen_random_uuid(), $1, 'admin')
returning email`,
[email]
);
console.log(`${creada[0].email} creado y administrando.`);
console.log('Entra por el panel: te mandará un código de un solo uso a ese correo.');
await c.end();
process.exit(1);
process.exit(0);
}
console.log(
quitar ? `${rows[0].email} ya no administra.` : `${rows[0].email} administra: /admin ya le abre.`
quitar ? `${rows[0].email} ya no administra.` : `${rows[0].email} ya administra el sitio.`
);
/*

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { revisarCapitulo, type CambiosDelCapitulo } from './panel-capitulos';
/** Un capítulo válido, del que cada prueba estropea una cosa. */
const bueno = (cambios: Partial<CambiosDelCapitulo> = {}): CambiosDelCapitulo => ({
titulo: 'El estribillo',
resumen: 'Lo que se repite y por qué.',
cuerpo: 'Un estribillo es lo que queda cuando la canción se acaba.',
estado: 'publicado',
orden: 3,
...cambios
});
describe('revisarCapitulo', () => {
it('deja pasar lo que está bien', () => {
expect(revisarCapitulo(bueno())).toEqual({});
});
it('no admite un título vacío', () => {
expect(revisarCapitulo(bueno({ titulo: '' })).titulo).toBeTruthy();
expect(revisarCapitulo(bueno({ titulo: ' ' })).titulo).toBeTruthy();
});
it('no deja publicar un capítulo sin texto', () => {
// Es el error que de verdad se comete: se crea la ficha, se pone
// «publicado» para verla, y queda en la web una página en blanco.
expect(revisarCapitulo(bueno({ cuerpo: ' ' })).cuerpo).toBeTruthy();
});
it('pero en borrador se puede escribir a medias', () => {
// Escribir a medias es lo normal mientras no está publicado, y avisar de
// ello en cada guardado convertiría el aviso en ruido.
expect(revisarCapitulo(bueno({ cuerpo: '', estado: 'borrador' }))).toEqual({});
});
it('rechaza un estado que no existe', () => {
// Llega del formulario, y lo que llega del formulario lo escribe cualquiera.
expect(
revisarCapitulo(bueno({ estado: 'inventado' as CambiosDelCapitulo['estado'] })).estado
).toBeTruthy();
});
it('el orden es un entero y no negativo', () => {
expect(revisarCapitulo(bueno({ orden: -1 })).orden).toBeTruthy();
expect(revisarCapitulo(bueno({ orden: 1.5 })).orden).toBeTruthy();
// `Number('')` es 0 y `Number('hola')` es NaN: los dos llegan del formulario.
expect(revisarCapitulo(bueno({ orden: Number.NaN })).orden).toBeTruthy();
expect(revisarCapitulo(bueno({ orden: 0 })).orden).toBeUndefined();
});
});

@ -0,0 +1,148 @@
/**
* Los capítulos, para el panel.
*
* Un capítulo es a la vez uno de los principios del taller y una ficha de
* género: la misma tabla con un `tipo` distinto. Se editan igual, así que la
* pantalla es una sola.
*
* Estas consultas viven aparte de las del sitio porque preguntan otra cosa. Las
* del sitio traen solo lo publicado y ya compuesto en HTML; aquí hace falta lo
* que está a medias, y el Markdown en crudo, que es lo que se escribe.
*/
import { asc, eq } from 'drizzle-orm';
import { db } from './db';
import { capitulo, genero } from './db/schema';
import { ESTADOS } from './db/schema/comun';
export type Estado = (typeof ESTADOS)[number];
export interface CapituloEnLista {
slug: string;
titulo: string;
tipo: 'principio' | 'ficha';
estado: Estado;
orden: number;
/** Cuánto se ha escrito, en caracteres. Un cero canta más que un estado. */
largo: number;
actualizadoEn: Date | null;
}
/** Todos, publicados o no, en el orden en que se leen. */
export async function listaDeCapitulos(): Promise<CapituloEnLista[]> {
const filas = await db
.select({
slug: capitulo.slug,
titulo: capitulo.titulo,
tipo: capitulo.tipo,
estado: capitulo.estado,
orden: capitulo.orden,
cuerpo: capitulo.cuerpo,
actualizadoEn: capitulo.actualizadoEn
})
.from(capitulo)
.orderBy(asc(capitulo.tipo), asc(capitulo.orden), asc(capitulo.titulo));
// El cuerpo no viaja entero a una lista: se mide aquí y se tira.
return filas.map(({ cuerpo, ...resto }) => ({ ...resto, largo: cuerpo.length }));
}
export interface CapituloParaEditar {
slug: string;
titulo: string;
tipo: 'principio' | 'ficha';
estado: Estado;
orden: number;
resumen: string;
cuerpo: string;
/** El género del que es ficha, si lo es. */
genero: { slug: string; nombre: string } | null;
actualizadoEn: Date | null;
}
export async function capituloParaEditar(slug: string): Promise<CapituloParaEditar | null> {
const [fila] = await db
.select({
slug: capitulo.slug,
titulo: capitulo.titulo,
tipo: capitulo.tipo,
estado: capitulo.estado,
orden: capitulo.orden,
resumen: capitulo.resumen,
cuerpo: capitulo.cuerpo,
actualizadoEn: capitulo.actualizadoEn,
generoSlug: genero.slug,
generoNombre: genero.nombre
})
.from(capitulo)
.leftJoin(genero, eq(capitulo.generoId, genero.id))
.where(eq(capitulo.slug, slug))
.limit(1);
if (!fila) return null;
const { generoSlug, generoNombre, ...resto } = fila;
return {
...resto,
genero: generoSlug && generoNombre ? { slug: generoSlug, nombre: generoNombre } : null
};
}
export interface CambiosDelCapitulo {
titulo: string;
resumen: string;
cuerpo: string;
estado: Estado;
orden: number;
}
/** Qué está mal, por campo. Vacío si todo cuadra. */
export type ErroresDelCapitulo = Partial<Record<keyof CambiosDelCapitulo, string>>;
/**
* Comprueba lo que se va a guardar.
*
* Aquí y no solo en el navegador: un `required` en el formulario lo salta
* cualquiera, y lo que llega al servidor es lo que se escribe en la base.
*/
export function revisarCapitulo(cambios: CambiosDelCapitulo): ErroresDelCapitulo {
const errores: ErroresDelCapitulo = {};
if (cambios.titulo.trim().length < 2) errores.titulo = 'El título no puede quedar vacío.';
if (!ESTADOS.includes(cambios.estado)) errores.estado = 'Ese estado no existe.';
if (!Number.isInteger(cambios.orden) || cambios.orden < 0) {
errores.orden = 'El orden es un número entero, y empieza en cero.';
}
/*
* Publicar con el cuerpo vacío es el error que de verdad se comete: se crea
* la ficha, se pone «publicado» para verla, y queda en la web una página en
* blanco con su título. En borrador, en cambio, escribir a medias es lo
* normal y no se avisa de nada.
*/
if (cambios.estado === 'publicado' && cambios.cuerpo.trim() === '') {
errores.cuerpo = 'No se puede publicar un capítulo sin texto.';
}
return errores;
}
/** Devuelve `false` si ese slug no existe. */
export async function guardarCapitulo(slug: string, cambios: CambiosDelCapitulo): Promise<boolean> {
const hechas = await db
.update(capitulo)
.set({
titulo: cambios.titulo.trim(),
resumen: cambios.resumen.trim(),
// El cuerpo NO se recorta por dentro: los saltos de línea de un verso
// son contenido. Solo se quitan los espacios de los extremos.
cuerpo: cambios.cuerpo.trim(),
estado: cambios.estado,
orden: cambios.orden,
actualizadoEn: new Date()
})
.where(eq(capitulo.slug, slug))
.returning({ slug: capitulo.slug });
return hechas.length > 0;
}

@ -1,160 +1,359 @@
/**
* Lo que sabe la portada del panel.
* El mapa del contenido, para la portada del panel.
*
* No es un cuadro de mando: no hay visitas que enseñar ni ventas que celebrar.
* Lo que contesta es «qué falta», que es la pregunta que se hace quien entra
* aquí. Por eso cada sección trae dos números —lo que hay y lo que está a
* medias— y, cuando lo hay, **los nombres**: saber que un género no tiene ficha
* obliga a ir a buscar cuál, y esa búsqueda la puede hacer la consulta.
* Contesta a «qué hay y qué falta», que es la pregunta de quien entra aquí.
*
* Sale **todo** lo que es contenido, agrupado como se piensa y no como está
* repartido en tablas. La primera versión enseñaba siete filas de las
* cuarenta largas que hay, y lo que no aparece en un panel no existe: nadie va
* a acordarse de que los metros y los compases siguen vacíos si no se lo dicen.
*
* Lo que no sale es la explotación —pedidos, facturas, carritos, sesiones—.
* Eso no se escribe, pasa; y mezclarlo con lo que se escribe convierte esta
* pantalla en una lista de tablas.
*/
import { sql } from 'drizzle-orm';
import { db } from './db';
/** Una fila de la portada: una sección del contenido y su estado. */
export interface SeccionDelPanel {
/** Una fila: una cosa del contenido y su estado. */
export interface FilaDelPanel {
titulo: string;
/** Cuántas cosas hay en total. */
cuantas: number;
/** Cómo se llaman: «géneros», «capítulos». */
unidad: string;
/** Lo que está a medias, si lo hay. Es lo que da trabajo. */
pendiente: {
cuantas: number;
texto: string;
/** Los primeros por nombre; si son más, la fila dice cuántos quedan. */
cuales: string[];
} | null;
/** A dónde lleva, o `null` mientras esa pantalla no exista. */
href: string | null;
/** El desglose que hace falta para entender el número. */
detalle: string | null;
/** Lo que está a medias. Es lo que da trabajo. */
pendiente: { cuantas: number; texto: string; cuales: string[] } | null;
/**
* Qué pantalla la edita, o `null` mientras no exista ninguna.
*
* Un nombre y no una dirección: las direcciones las resuelve el panel con
* `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;
}
export interface GrupoDelPanel {
titulo: string;
/** Qué se hace en este grupo, en una línea. */
nota: string;
filas: FilaDelPanel[];
}
/** Cuántos nombres se enseñan antes de resumir en «y N más». */
const NOMBRES_A_LA_VISTA = 4;
/**
* Todos los recuentos en una consulta, y todos los nombres en otra.
*
* Dos viajes y no catorce. Ninguna de las subconsultas depende del resultado de
* otra, así que pedirlas por separado solo añadiría idas y venidas para pintar
* una tabla de siete filas.
*/
export async function estadoDelContenido(): Promise<SeccionDelPanel[]> {
export async function estadoDelContenido(): Promise<GrupoDelPanel[]> {
const [recuentos, nombres] = await Promise.all([
db.execute<Record<string, number>>(sql`
select
(select count(*) from capitulo)::int as capitulos,
(select count(*) from capitulo where tipo = 'principio')::int as principios,
(select count(*) from capitulo
where tipo = 'principio' and estado <> 'publicado')::int as principios_a_medias,
(select count(*) from capitulo where tipo = 'ficha')::int as fichas,
(select count(*) from capitulo
where tipo = 'ficha' and estado <> 'publicado')::int as fichas_a_medias,
(select count(*) from capitulo where estado <> 'publicado')::int as capitulos_a_medias,
(select count(*) from genero)::int as generos,
(select count(*) from genero g
where not exists (select 1 from capitulo c where c.genero_id = g.id))::int
as generos_sin_ficha,
(select count(*) from persona)::int as personas,
(select count(*) from persona where revisado_en is null and origen = 'importado')::int
as personas_sin_revisar,
(select count(distinct persona_id) from genero_persona where papel = 'letrista')::int
as letristas,
(select count(distinct persona_id) from genero_persona where papel = 'compositor')::int
as compositores,
(select count(distinct persona_id) from genero_persona where papel = 'interprete')::int
as interpretes,
(select count(distinct persona_id) from genero_persona where papel = 'fundador')::int
as fundadores,
(select count(*) from obra_referencia)::int as obras,
(select count(*) from obra_referencia o
where not exists (select 1 from obra_referencia_enlace e
where e.obra_id = o.id))::int as obras_sin_enlace,
(select count(*) from verso)::int as versos,
(select count(*) from instrumento)::int as instrumentos,
(select count(*) from metro)::int as metros,
(select count(*) from compas)::int as compases,
(select count(*) from pais)::int as paises,
(select count(*) from termino)::int as terminos,
(select count(*) from acepcion)::int as acepciones,
(select count(*) from termino_variante)::int as variantes,
(select count(*) from termino t
where not exists (select 1 from termino_variante v where v.termino_id = t.id))::int
as terminos_sin_variante,
(select count(*) from capitulo_acepcion where not confirmado)::int as marcas_sin_confirmar,
(select count(*) from estilo)::int as estilos,
(select count(*) from album)::int as albumes,
(select count(*) from cancion where version_de_id is null)::int as canciones,
(select count(*) from cancion
where version_de_id is null and estado <> 'publicado')::int as canciones_a_medias,
(select count(*) from cancion where version_de_id is not null)::int as versiones,
(select count(*) from cancion where version_de_id is null and estado <> 'publicado')::int
as canciones_a_medias,
(select count(*) from cancion c where version_de_id is null
and not exists (select 1 from autoria a where a.cancion_id = c.id))::int
as canciones_sin_autoria,
(select count(*) from registro_obra)::int as registros,
(select count(*) from etiqueta)::int as etiquetas,
(select count(*) from video)::int as videos,
(select count(*) from medio)::int as medios,
(select count(*) from entrada)::int as entradas,
(select count(*) from entrada where estado <> 'publicado')::int as entradas_a_medias,
(select count(*) from persona)::int as personas,
(select count(*) from persona where revisado_en is null and origen = 'importado')::int
as personas_sin_revisar
(select count(*) from pagina)::int as paginas,
(select count(*) from documento_legal)::int as legales,
(select count(*) from redireccion)::int as redirecciones
`),
/*
* Cada rama trae solo las primeras. El `limit` va dentro de cada
* paréntesis y no fuera: puesto al final, cortaría el total y una sección
* con muchas pendientes dejaría a las demás sin ninguna.
* Los nombres de lo que está a medias. Cada rama trae solo los primeros:
* el `limit` va dentro de cada paréntesis porque puesto al final cortaría
* el total y una sección con muchas dejaría a las demás sin ninguno.
*/
db.execute<{ que: string; nombre: string }>(sql`
(select 'principios' as que, titulo as nombre from capitulo
where tipo = 'principio' and estado <> 'publicado'
order by orden limit ${NOMBRES_A_LA_VISTA})
union all
(select 'fichas', titulo from capitulo
where tipo = 'ficha' and estado <> 'publicado'
order by orden limit ${NOMBRES_A_LA_VISTA})
(select 'capitulos' as que, titulo as nombre from capitulo
where estado <> 'publicado' order by orden limit ${NOMBRES_A_LA_VISTA})
union all
(select 'generos', g.nombre from genero g
where not exists (select 1 from capitulo c where c.genero_id = g.id)
order by g.nombre limit ${NOMBRES_A_LA_VISTA})
union all
(select 'personas', nombre from persona
where revisado_en is null and origen = 'importado'
order by nombre limit ${NOMBRES_A_LA_VISTA})
union all
(select 'obras', titulo from obra_referencia o
where not exists (select 1 from obra_referencia_enlace e where e.obra_id = o.id)
order by titulo limit ${NOMBRES_A_LA_VISTA})
union all
(select 'canciones', titulo from cancion
where version_de_id is null and estado <> 'publicado'
order by titulo limit ${NOMBRES_A_LA_VISTA})
union all
(select 'autoria', titulo from cancion c
where version_de_id is null
and not exists (select 1 from autoria a where a.cancion_id = c.id)
order by titulo limit ${NOMBRES_A_LA_VISTA})
union all
(select 'entradas', titulo from entrada
where estado <> 'publicado' order by titulo limit ${NOMBRES_A_LA_VISTA})
union all
(select 'personas', nombre from persona
where revisado_en is null and origen = 'importado'
order by nombre limit ${NOMBRES_A_LA_VISTA})
`)
]);
const n = recuentos.rows[0];
const cualesDe = (que: string) => nombres.rows.filter((f) => f.que === que).map((f) => f.nombre);
/** Lo pendiente solo se cuenta cuando lo hay: un cero no es una tarea. */
const pendiente = (cuantas: number, texto: string, que?: string) =>
const falta = (cuantas: number, texto: string, que?: string) =>
cuantas > 0 ? { cuantas, texto, cuales: que ? cualesDe(que) : [] } : null;
/** Una tabla vacía es una tarea, no un dato: se dice que está sin cargar. */
const vacia = (cuantas: number) =>
cuantas === 0 ? { cuantas: 1, texto: 'sin cargar', cuales: [] } : null;
/**
* «1 intérprete» y no «1 intérpretes».
*
* Parece una tontería y no lo es: el panel de un letrista escrito con faltas
* de concordancia dice de él más de lo que dice del panel.
*/
const cuentaDe = (n: number, singular: string, plural: string) =>
`${n} ${n === 1 ? singular : plural}`;
return [
{
titulo: 'Los principios',
cuantas: n.principios,
unidad: 'capítulos',
pendiente: pendiente(n.principios_a_medias, 'sin publicar', 'principios'),
href: null
},
{
titulo: 'Fichas de género',
cuantas: n.fichas,
unidad: 'fichas',
pendiente: pendiente(n.fichas_a_medias, 'sin publicar', 'fichas'),
href: null
},
{
titulo: 'Géneros',
cuantas: n.generos,
unidad: 'géneros',
pendiente: pendiente(n.generos_sin_ficha, 'sin ficha', 'generos'),
href: null
titulo: 'Taller y géneros',
nota: 'Lo que se escribe: los principios del oficio y la ficha de cada estilo.',
filas: [
{
titulo: 'Capítulos',
cuantas: n.capitulos,
unidad: 'capítulos',
detalle: `${n.principios} principios · ${n.fichas} fichas de género`,
pendiente: falta(n.capitulos_a_medias, 'sin publicar', 'capitulos'),
pantalla: 'capitulos'
},
{
titulo: 'Géneros',
cuantas: n.generos,
unidad: 'géneros',
detalle: null,
pendiente: falta(n.generos_sin_ficha, 'sin ficha', 'generos'),
pantalla: null
},
{
/*
* «Personas» y no «Letristas» porque el papel no está en la persona:
* está en su relación con cada género —letrista, compositor,
* intérprete, fundador—, y la misma persona tiene papeles distintos
* en géneros distintos. La página pública de letristas es una vista
* sobre esto, no otra tabla.
*/
titulo: 'Personas',
cuantas: n.personas,
unidad: 'fichas',
detalle: [
cuentaDe(n.letristas, 'letrista', 'letristas'),
cuentaDe(n.compositores, 'compositor', 'compositores'),
cuentaDe(n.interpretes, 'intérprete', 'intérpretes'),
cuentaDe(n.fundadores, 'fundador', 'fundadores')
].join(' · '),
pendiente: falta(n.personas_sin_revisar, 'importadas sin revisar', 'personas'),
pantalla: null
},
{
titulo: 'Obras de referencia',
cuantas: n.obras,
unidad: 'obras',
detalle: null,
pendiente: falta(n.obras_sin_enlace, 'sin dónde escucharlas', 'obras'),
pantalla: null
},
{
titulo: 'Versos de ejemplo',
cuantas: n.versos,
unidad: 'versos',
detalle: null,
pendiente: vacia(n.versos),
pantalla: null
},
{
titulo: 'Instrumentos',
cuantas: n.instrumentos,
unidad: 'instrumentos',
detalle: null,
pendiente: vacia(n.instrumentos),
pantalla: null
},
{
titulo: 'Metros y compases',
cuantas: n.metros + n.compases,
unidad: 'entradas',
detalle: `${n.metros} metros · ${n.compases} compases`,
pendiente: vacia(n.metros + n.compases),
pantalla: null
},
{
titulo: 'Países',
cuantas: n.paises,
unidad: 'países',
detalle: 'De los 22 del mapa de Iberoamérica',
pendiente: null,
pantalla: null
}
]
},
{
titulo: 'Glosario',
cuantas: n.terminos,
unidad: 'términos',
// Sin nombres: lo pendiente son marcas dentro de un texto, y el nombre
// del término no dice en qué capítulo hay que confirmarlo.
pendiente: pendiente(n.marcas_sin_confirmar, 'marcas por confirmar'),
href: null
nota: 'Las palabras del oficio y los globos que salen al leerlas en un capítulo.',
filas: [
{
titulo: 'Términos',
cuantas: n.terminos,
unidad: 'términos',
detalle: `${n.acepciones} acepciones · ${n.variantes} formas escritas`,
pendiente: falta(n.terminos_sin_variante, 'sin ninguna variante'),
pantalla: null
},
{
titulo: 'Marcas en los capítulos',
cuantas: n.marcas_sin_confirmar,
unidad: 'marcas',
detalle: 'Cada aparición señalada apunta a una acepción concreta',
pendiente: falta(n.marcas_sin_confirmar, 'por confirmar'),
pantalla: null
}
]
},
{
titulo: 'Catálogo',
cuantas: n.canciones,
unidad: 'canciones',
pendiente: pendiente(n.canciones_a_medias, 'sin publicar', 'canciones'),
href: null
},
{
titulo: 'Blog',
cuantas: n.entradas,
unidad: 'entradas',
pendiente: pendiente(n.entradas_a_medias, 'sin publicar', 'entradas'),
href: null
nota: 'La música: estilos, discos, temas y todo lo que cuelga de ellos.',
filas: [
{
titulo: 'Canciones',
cuantas: n.canciones,
unidad: 'canciones',
detalle: `${n.versiones} versiones aparte`,
pendiente: falta(n.canciones_a_medias, 'sin publicar', 'canciones'),
pantalla: null
},
{
titulo: 'Autoría y registro',
cuantas: n.registros,
unidad: 'obras registradas',
detalle: 'ISWC, ISRC y el reparto de cada tema',
pendiente: falta(n.canciones_sin_autoria, 'canciones sin reparto', 'autoria'),
pantalla: null
},
{
titulo: 'Discos y estilos',
cuantas: n.albumes,
unidad: 'discos',
detalle: `${n.estilos} estilos`,
pendiente: null,
pantalla: null
},
{
titulo: 'Etiquetas',
cuantas: n.etiquetas,
unidad: 'etiquetas',
detalle: 'Las mismas que usan los temas del blog',
pendiente: null,
pantalla: null
},
{
titulo: 'Medios',
cuantas: n.medios,
unidad: 'archivos',
detalle: `${n.videos} vídeos enlazados`,
pendiente: null,
pantalla: null
}
]
},
{
titulo: 'Personas',
cuantas: n.personas,
unidad: 'fichas',
pendiente: pendiente(n.personas_sin_revisar, 'importadas sin revisar', 'personas'),
href: null
titulo: 'Editorial',
nota: 'El blog, las páginas sueltas y los textos legales.',
filas: [
{
titulo: 'Entradas del blog',
cuantas: n.entradas,
unidad: 'entradas',
detalle: null,
pendiente: falta(n.entradas_a_medias, 'sin publicar', 'entradas'),
pantalla: null
},
{
titulo: 'Páginas',
cuantas: n.paginas,
unidad: 'páginas',
detalle: 'La bio, y lo que venga',
pendiente: null,
pantalla: null
},
{
titulo: 'Textos legales',
cuantas: n.legales,
unidad: 'documentos',
detalle: 'Con su historial de versiones en vigor',
pendiente: null,
pantalla: null
},
{
titulo: 'Redirecciones',
cuantas: n.redirecciones,
unidad: 'direcciones',
detalle: 'Lo que se mudó y no puede empezar a dar 404',
pendiente: null,
pantalla: null
}
]
}
];
}

@ -10,9 +10,29 @@
* publica, saber con qué cuenta estás no es un adorno.
*/
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { site } from '$lib/site';
let { data, children } = $props();
/*
* El menú solo lleva lo que ya tiene pantalla. Un menú con la mitad de las
* entradas muertas es peor que un menú corto: se pulsa una, no pasa nada, y
* a partir de ahí no te fías de ninguna. Lo que falta se ve en la portada,
* que dice exactamente qué hay de cada cosa.
*/
/*
* Los identificadores llevan el grupo dentro —`/(dentro)/capitulos`— aunque
* la dirección que sale sea `/capitulos`: el grupo organiza los archivos y la
* guarda, no la URL. `resolve` quiere el identificador.
*/
const menu = [
{ href: resolve('/(dentro)'), texto: 'El contenido' },
{ href: resolve('/(dentro)/capitulos'), texto: 'Capítulos' }
];
const aqui = (href: string) =>
href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
</script>
<div class="panel">
@ -36,6 +56,18 @@
</div>
</header>
<nav aria-label="Secciones del panel">
<ul>
{#each menu as entrada (entrada.href)}
<li>
<a href={entrada.href} aria-current={aqui(entrada.href) ? 'page' : undefined}>
{entrada.texto}
</a>
</li>
{/each}
</ul>
</nav>
<main>
{@render children()}
</main>
@ -114,6 +146,47 @@
color: var(--c-accent);
}
/*
* El menú, en horizontal y debajo de la barra. En columna a la izquierda
* ocuparía doce caracteres de ancho para dos entradas y dejaría el resto en
* blanco; aquí ocupa una línea y crece por donde tiene sitio.
*/
nav {
border-bottom: 1px solid var(--c-border);
background: var(--c-bg-raised);
}
nav ul {
display: flex;
gap: var(--sp-5);
list-style: none;
width: 100%;
max-width: 72rem;
margin: 0 auto;
padding: 0 var(--sp-6);
overflow-x: auto;
}
nav a {
display: block;
padding: var(--sp-3) 0;
border-bottom: 2px solid transparent;
color: var(--c-text-muted);
font-size: var(--fs-sm);
text-decoration: none;
white-space: nowrap;
}
nav a:hover {
color: var(--c-text);
}
/* La sección en la que estás, marcada con el dorado del sitio. */
nav a[aria-current='page'] {
border-bottom-color: var(--c-accent);
color: var(--c-text);
}
main {
flex: 1;
width: 100%;
@ -127,6 +200,10 @@
padding: var(--sp-4);
}
nav ul {
padding: 0 var(--sp-4);
}
main {
padding: var(--sp-4);
}

@ -2,5 +2,5 @@ import { estadoDelContenido } from '$lib/server/panel';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return { secciones: await estadoDelContenido() };
return { grupos: await estadoDelContenido() };
};

@ -1,89 +1,96 @@
<script lang="ts">
/**
* La portada del panel: qué hay y qué falta.
* La portada del panel: todo el contenido, y qué falta en cada cosa.
*
* Una tabla y no tarjetas. Son siete filas con dos números cada una y la
* pregunta que contestan es comparativa —«¿dónde queda más por hacer?»—; en
* tarjetas, esos números quedan a distinta altura y hay que ir buscándolos.
* Alineados en columna se leen de arriba abajo de una pasada.
* Tablas y no tarjetas. Son cuarenta números y la pregunta que contestan es
* comparativa —«¿dónde queda más por hacer?»—; en tarjetas, esos números
* quedan a distinta altura y hay que ir buscándolos. En columna se leen de
* arriba abajo de una pasada.
*/
import { resolve } from '$app/paths';
let { data } = $props();
const pendientes = $derived(data.secciones.filter((s) => s.pendiente).length);
const pendientes = $derived(
data.grupos.flatMap((g) => g.filas).filter((f) => f.pendiente).length
);
/*
* De nombre de pantalla a dirección. Se resuelve aquí, en el panel, y no en
* 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;
</script>
<!--
Sin el componente `Meta` del sitio: compone canónica y Open Graph contra
`senzapaura.es`, que aquí es otro dominio, y el panel no se comparte en
ninguna parte. El `noindex` lo pone el layout raíz, para todas las páginas.
-->
<svelte:head>
<title>Panel · Senza Paura</title>
</svelte:head>
<header>
<h1>Panel</h1>
<h1>El contenido</h1>
<p class="entradilla">
{#if pendientes === 0}
No hay nada a medias.
{:else}
Hay algo a medias en {pendientes}
{pendientes === 1 ? 'sección' : 'secciones'}.
{pendientes === 1 ? 'sitio' : 'sitios'}.
{/if}
</p>
</header>
<table>
<caption class="visually-hidden">El contenido del sitio, y lo que queda por terminar</caption>
<thead>
<tr>
<th scope="col">Sección</th>
<th scope="col" class="numero">Hay</th>
<th scope="col">Falta</th>
</tr>
</thead>
<tbody>
{#each data.secciones as seccion (seccion.titulo)}
<tr>
<th scope="row">
{#if seccion.href}
<a href={seccion.href}>{seccion.titulo}</a>
{:else}
{seccion.titulo}
{/if}
</th>
<td class="numero">
{seccion.cuantas}
<span class="unidad">{seccion.unidad}</span>
</td>
<td>
{#if seccion.pendiente}
{@const p = seccion.pendiente}
<span class="pendiente">{p.cuantas} {p.texto}</span>
{#if p.cuales.length > 0}
<span class="cuales">
{p.cuales.join(' · ')}{#if p.cuantas > p.cuales.length}, y {p.cuantas -
p.cuales.length} más{/if}
</span>
{/if}
{:else}
<span class="nada" aria-label="nada">—</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<p class="nota muted">
Las pantallas para editar cada sección todavía no están. Mientras tanto, el contenido se carga con
los scripts de <code>scripts/db</code>.
</p>
{#each data.grupos as grupo (grupo.titulo)}
<section>
<h2>{grupo.titulo}</h2>
<p class="nota muted">{grupo.nota}</p>
<table>
<tbody>
{#each grupo.filas as fila (fila.titulo)}
<tr>
<th scope="row">
{#if fila.pantalla}
<a href={PANTALLAS[fila.pantalla]}>{fila.titulo}</a>
{:else}
{fila.titulo}
{/if}
{#if fila.detalle}
<span class="detalle muted">{fila.detalle}</span>
{/if}
</th>
<td class="numero">
{fila.cuantas}
<span class="unidad">{fila.unidad}</span>
</td>
<td>
{#if fila.pendiente}
{@const p = fila.pendiente}
<span class="pendiente">
{p.texto === 'sin cargar' ? 'sin cargar' : `${p.cuantas} ${p.texto}`}
</span>
{#if p.cuales.length > 0}
<span class="cuales muted">
{p.cuales.join(' · ')}{#if p.cuantas > p.cuales.length}, y {p.cuantas -
p.cuales.length} más{/if}
</span>
{/if}
{:else}
<span class="nada muted" aria-label="nada">—</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</section>
{/each}
<style>
header {
max-width: 62ch;
padding-bottom: var(--sp-5);
padding-bottom: var(--sp-6);
}
h1 {
@ -96,15 +103,26 @@
color: var(--c-text-muted);
}
section + section {
padding-top: var(--sp-7);
}
h2 {
margin: 0;
font-size: var(--fs-xl);
}
.nota {
margin: var(--sp-1) 0 var(--sp-4);
font-size: var(--fs-sm);
max-width: 62ch;
}
table {
width: 100%;
border-collapse: collapse;
}
caption {
text-align: left;
}
th,
td {
padding: var(--sp-3) var(--sp-4);
@ -113,23 +131,25 @@
vertical-align: baseline;
}
thead th {
font-size: var(--fs-xs);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--c-text-muted);
}
tbody th {
font-weight: 500;
width: 40%;
}
/* El desglose, detrás del nombre: es lo que explica el número de al lado. */
.detalle {
display: block;
margin-top: 0.15rem;
font-size: var(--fs-xs);
font-weight: 400;
}
/*
* Los números a la derecha y con cifras de ancho fijo: así el 6 y el 18 se
* Los números a la derecha y con cifras de ancho fijo: así el 4 y el 55 se
* comparan por su sitio, sin leerlos.
*/
.numero {
width: 1%;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
@ -145,30 +165,19 @@
.pendiente {
color: var(--c-dato);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/*
* Los nombres de lo que falta, detrás del recuento. Son la diferencia entre
* saber que hay trabajo y saber cuál: «1 sin ficha» obliga a ir a buscar
* cuál, y esa búsqueda ya la ha hecho la consulta.
*/
.cuales {
margin-left: 0.6rem;
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
.nada {
color: var(--c-text-muted);
}
.nota {
padding-top: var(--sp-5);
font-size: var(--fs-sm);
max-width: 62ch;
}
@media (max-width: 44rem) {
tbody th {
width: auto;
}
@media (max-width: 34rem) {
th,
td {
padding: var(--sp-3) var(--sp-2);

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

@ -0,0 +1,225 @@
<script lang="ts">
/**
* Los capítulos: los principios del taller y las fichas de género.
*
* Son la misma tabla con un `tipo` distinto y se editan igual, así que van en
* una lista sola, separada en dos bloques. El largo del texto sale a la vista
* porque es lo que dice de un vistazo cuál está empezado y cuál escrito: un
* estado dice «borrador» igual con dos líneas que con dos folios.
*/
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.capitulos;
return data.capitulos.filter((c) => plano(`${c.titulo} ${c.slug}`).includes(q));
});
const principios = $derived(encontrados.filter((c) => c.tipo === 'principio'));
const fichas = $derived(encontrados.filter((c) => c.tipo === 'ficha'));
const ESTADO: Record<string, string> = {
borrador: 'Borrador',
publicado: 'Publicado',
retirado: 'Retirado'
};
/** «2.400 caracteres» pesa menos de leer que el número pelado. */
const largo = (n: number) => (n === 0 ? 'vacío' : `${n.toLocaleString('es')} caracteres`);
</script>
<svelte:head>
<title>Capítulos · Panel</title>
</svelte:head>
<header>
<h1>Capítulos</h1>
<p class="entradilla">
Los principios del taller y la ficha de cada género. Se escriben en Markdown y salen publicados
en cuanto se guardan.
</p>
</header>
<div class="buscador">
<label class="visually-hidden" for="buscar">Buscar un capítulo</label>
<input
id="buscar"
type="search"
bind:value={consulta}
placeholder="Buscar por título"
autocomplete="off"
/>
<p class="cuenta muted" role="status">{encontrados.length} de {data.capitulos.length}</p>
</div>
{#if encontrados.length === 0}
<p class="vacio muted">Nada con «{consulta.trim()}».</p>
{:else}
{#if principios.length > 0}
{@render bloque('Los principios', principios)}
{/if}
{#if fichas.length > 0}
{@render bloque('Fichas de género', fichas)}
{/if}
{/if}
{#snippet bloque(titulo: string, cuales: typeof data.capitulos)}
<section>
<h2>{titulo}</h2>
<table>
<tbody>
{#each cuales as c (c.slug)}
<tr>
<th scope="row">
<a href={resolve('/(dentro)/capitulos/[slug]', { slug: c.slug })}>{c.titulo}</a>
<span class="slug muted">{c.slug}</span>
</th>
<td class="estado">
<span class="pastilla" data-estado={c.estado}>{ESTADO[c.estado] ?? c.estado}</span>
</td>
<td class="largo muted">{largo(c.largo)}</td>
</tr>
{/each}
</tbody>
</table>
</section>
{/snippet}
<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;
}
section + section {
padding-top: var(--sp-6);
}
h2 {
margin: 0 0 var(--sp-3);
font-size: var(--fs-xl);
}
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;
}
.slug {
display: block;
margin-top: 0.15rem;
font-family: var(--font-mono);
font-size: var(--fs-xs);
font-weight: 400;
}
.estado {
width: 1%;
white-space: nowrap;
}
/*
* El estado, en pastilla y con color solo cuando dice algo. Publicado es lo
* normal y va en gris: 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);
color: var(--c-text-muted);
}
.pastilla[data-estado='borrador'] {
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);
}
.largo {
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);
}
.largo {
display: none;
}
}
</style>

@ -0,0 +1,50 @@
import { error, fail } from '@sveltejs/kit';
import {
capituloParaEditar,
guardarCapitulo,
revisarCapitulo,
type Estado
} from '$lib/server/panel-capitulos';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const capitulo = await capituloParaEditar(params.slug);
if (!capitulo) error(404, 'No hay ningún capítulo con ese slug.');
return { capitulo };
};
export const actions: Actions = {
default: async ({ request, params }) => {
const datos = await request.formData();
const cambios = {
titulo: String(datos.get('titulo') ?? ''),
resumen: String(datos.get('resumen') ?? ''),
cuerpo: String(datos.get('cuerpo') ?? ''),
estado: String(datos.get('estado') ?? '') as Estado,
orden: Number(datos.get('orden') ?? 0)
};
const errores = revisarCapitulo(cambios);
/*
* Al fallar se devuelve lo que se escribió, no lo que hay en la base. Un
* formulario que se recarga con el texto de antes es un texto perdido, y
* aquí lo que se pierde puede ser una tarde de escribir.
*/
if (Object.keys(errores).length > 0) {
return fail(400, { errores, cambios });
}
const guardado = await guardarCapitulo(params.slug, cambios);
if (!guardado) error(404, 'Ese capítulo ha dejado de existir mientras lo editabas.');
/*
* No se redirige: se contesta al mismo formulario. Guardar en mitad de
* escribir es lo normal, y una redirección te saca del sitio donde estabas
* y te devuelve al principio del texto.
*/
return { guardado: true, cuando: new Date().toISOString() };
}
};

@ -0,0 +1,354 @@
<script lang="ts">
/**
* Editar un capítulo.
*
* Un formulario y ya. Sin editor enriquecido: lo que se guarda es Markdown,
* y un editor que enseña negritas mientras escribe otra cosa por debajo
* acaba peleándose con quien sabe Markdown, que es quien escribe esto.
*
* El cuerpo se lleva toda la altura que haya. Es el campo donde se pasa el
* tiempo; los demás son cuatro datos.
*/
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { site } from '$lib/site';
import type { CambiosDelCapitulo } from '$lib/server/panel-capitulos';
let { data, form } = $props();
const copiar = (c: CambiosDelCapitulo): CambiosDelCapitulo => ({
titulo: c.titulo,
resumen: c.resumen,
cuerpo: c.cuerpo,
estado: c.estado,
orden: c.orden
});
/*
* Lo que hay en pantalla arranca de la base, y si un guardado falló, de lo
* que se escribió: eso es lo que no se puede perder. `untrack` porque es una
* semilla y no una atadura; quien manda a partir de aquí es lo que se teclea.
*/
let campos = $state(untrack(() => copiar(form?.cambios ?? data.capitulo)));
/** Lo último que se sabe guardado, para poder decir si hay algo pendiente. */
let guardado = $state(untrack(() => copiar(data.capitulo)));
let guardando = $state(false);
const sinGuardar = $derived(
campos.titulo !== guardado.titulo ||
campos.resumen !== guardado.resumen ||
campos.cuerpo !== guardado.cuerpo ||
campos.estado !== guardado.estado ||
campos.orden !== guardado.orden
);
/*
* Dónde se ve publicado. Los principios viven en el taller y las fichas en
* géneros; es la misma tabla, pero no la misma dirección.
*/
const enLaWeb = $derived(
`${site.url}/${data.capitulo.tipo === 'ficha' ? 'generos' : 'taller'}/${data.capitulo.slug}`
);
const errores = $derived(form?.errores ?? {});
let formulario: HTMLFormElement;
/**
* Ctrl+S guarda, como en cualquier editor.
*
* Se intercepta porque si no el navegador abre «guardar página», que es lo
* último que quiere quien lleva media hora escribiendo. Solo cuando hay algo
* que guardar: si no, se deja pasar y que haga lo suyo.
*/
function atajo(evento: KeyboardEvent) {
if (evento.key !== 's' || !(evento.ctrlKey || evento.metaKey)) return;
if (!sinGuardar || guardando) return;
evento.preventDefault();
formulario.requestSubmit();
}
</script>
<svelte:window onkeydown={atajo} />
<svelte:head>
<title>{data.capitulo.titulo} · Panel</title>
</svelte:head>
<p class="volver">
<a href={resolve('/(dentro)/capitulos')}>← Capítulos</a>
</p>
<form
method="POST"
bind:this={formulario}
use:enhance={() => {
guardando = true;
return async ({ update }) => {
await update({ reset: false });
guardando = false;
/*
* Si guardó, lo de la pantalla pasa a ser lo de la base. Al guardar se
* recortan los espacios de los extremos, y sin esto un espacio de más
* dejaba el aviso de «hay cambios sin guardar» puesto para siempre.
*/
if (form?.guardado) {
campos = copiar(data.capitulo);
guardado = copiar(data.capitulo);
}
};
}}
>
<header>
<div class="quees">
<h1>
<label class="visually-hidden" for="titulo">Título</label>
<input
id="titulo"
name="titulo"
bind:value={campos.titulo}
required
aria-invalid={!!errores.titulo}
/>
</h1>
<p class="donde muted">
<span class="slug">{data.capitulo.slug}</span>
{#if data.capitulo.genero}
· ficha de {data.capitulo.genero.nombre}
{/if}
{#if campos.estado === 'publicado'}
·
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={enLaWeb} target="_blank" rel="noreferrer">verlo en la web</a>
{/if}
</p>
{#if errores.titulo}<p class="fallo">{errores.titulo}</p>{/if}
</div>
<!--
Guardar arriba y no al pie. Al pie tenía que ser flotante para seguir a
la vista mientras se escribe, y flotando se pintaba encima del texto:
tapaba justo la línea que estabas escribiendo. Aquí no tapa nada, y
Ctrl+S llega igual sin mover la mano.
-->
<div class="acciones">
<span class="aviso" role="status">
{#if guardando}
Guardando…
{:else if sinGuardar}
<span class="pendiente">Sin guardar</span>
{:else if form?.guardado}
Guardado
{/if}
</span>
<button type="submit" class="btn btn--primary" disabled={guardando || !sinGuardar}>
Guardar
</button>
</div>
</header>
<div class="campos">
<p class="campo">
<label for="resumen">Resumen</label>
<span class="ayuda muted">La línea que sale en el índice y al compartir.</span>
<textarea id="resumen" name="resumen" rows="2" bind:value={campos.resumen}></textarea>
</p>
<p class="campo estrecho">
<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>
{#if errores.estado}<span class="fallo">{errores.estado}</span>{/if}
</p>
<p class="campo estrecho">
<label for="orden">Orden</label>
<input id="orden" name="orden" type="number" min="0" step="1" bind:value={campos.orden} />
{#if errores.orden}<span class="fallo">{errores.orden}</span>{/if}
</p>
</div>
<p class="campo cuerpo">
<label for="cuerpo">Texto</label>
<span class="ayuda muted">
Markdown. Los saltos de línea se respetan —en una letra el corte del verso es contenido— y los
encabezados de nivel 2 salen en el índice lateral. Ctrl+S guarda.
</span>
<textarea id="cuerpo" name="cuerpo" bind:value={campos.cuerpo} spellcheck="true"></textarea>
{#if errores.cuerpo}<span class="fallo">{errores.cuerpo}</span>{/if}
</p>
</form>
<style>
.volver {
margin: 0 0 var(--sp-4);
font-size: var(--fs-sm);
}
form {
display: grid;
gap: var(--sp-5);
}
/* El nombre a la izquierda y lo que se hace con él a la derecha. */
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;
}
h1 {
margin: 0;
font-size: var(--fs-2xl);
}
/*
* El título se edita donde se lee, sin caja: es el nombre de la página, no un
* campo más de una ficha. La caja aparece al pasar por encima o al enfocar,
* que es cuando hace falta saber que se puede escribir ahí.
*/
#titulo {
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;
}
#titulo:hover {
border-color: var(--c-border);
}
#titulo: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);
}
/* El resumen ancho y los dos datos cortos a su lado, que miden lo que miden. */
.campos {
display: grid;
grid-template-columns: minmax(0, 1fr) 10rem 7rem;
gap: var(--sp-4);
align-items: start;
}
.campo {
display: grid;
gap: 0.25rem;
margin: 0;
}
label {
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
.ayuda {
font-size: var(--fs-xs);
line-height: 1.45;
}
input:not(#titulo),
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: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;
}
/*
* El cuerpo se lleva la altura que queda de ventana. Es donde se pasa el
* rato, y un textarea de seis líneas para dos folios de texto obliga a
* escribir mirando por una rendija.
*/
.cuerpo textarea {
min-height: 60vh;
font-family: var(--font-mono);
font-size: var(--fs-sm);
line-height: 1.65;
tab-size: 2;
}
.aviso {
font-size: var(--fs-sm);
color: var(--c-text-muted);
white-space: nowrap;
}
.pendiente {
color: var(--c-dato);
}
.fallo {
margin: 0;
color: var(--c-danger);
font-size: var(--fs-sm);
}
@media (max-width: 48rem) {
.campos {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.