diff --git a/scripts/db/hacer-admin.mjs b/scripts/db/hacer-admin.mjs index 52718eb..67c49bf 100644 --- a/scripts/db/hacer-admin.mjs +++ b/scripts/db/hacer-admin.mjs @@ -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.` ); /* diff --git a/src/lib/server/panel-capitulos.spec.ts b/src/lib/server/panel-capitulos.spec.ts new file mode 100644 index 0000000..c2b1a73 --- /dev/null +++ b/src/lib/server/panel-capitulos.spec.ts @@ -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 => ({ + 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(); + }); +}); diff --git a/src/lib/server/panel-capitulos.ts b/src/lib/server/panel-capitulos.ts new file mode 100644 index 0000000..228227e --- /dev/null +++ b/src/lib/server/panel-capitulos.ts @@ -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 { + 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 { + 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>; + +/** + * 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 { + 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; +} diff --git a/src/lib/server/panel.ts b/src/lib/server/panel.ts index b3d4426..c21d918 100644 --- a/src/lib/server/panel.ts +++ b/src/lib/server/panel.ts @@ -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 { +export async function estadoDelContenido(): Promise { const [recuentos, nombres] = await Promise.all([ db.execute>(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 + } + ] } ]; } diff --git a/src/panel/rutas/(dentro)/+layout.svelte b/src/panel/rutas/(dentro)/+layout.svelte index 16e022d..72ba35c 100644 --- a/src/panel/rutas/(dentro)/+layout.svelte +++ b/src/panel/rutas/(dentro)/+layout.svelte @@ -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);
@@ -36,6 +56,18 @@
+ +
{@render children()}
@@ -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); } diff --git a/src/panel/rutas/(dentro)/+page.server.ts b/src/panel/rutas/(dentro)/+page.server.ts index b562ff1..d2ea3b6 100644 --- a/src/panel/rutas/(dentro)/+page.server.ts +++ b/src/panel/rutas/(dentro)/+page.server.ts @@ -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() }; }; diff --git a/src/panel/rutas/(dentro)/+page.svelte b/src/panel/rutas/(dentro)/+page.svelte index c4b3ac8..e4cc09d 100644 --- a/src/panel/rutas/(dentro)/+page.svelte +++ b/src/panel/rutas/(dentro)/+page.svelte @@ -1,89 +1,96 @@ - Panel · Senza Paura
-

Panel

+

El contenido

{#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}

- - - - - - - - - - - {#each data.secciones as seccion (seccion.titulo)} - - - - - - {/each} - -
El contenido del sitio, y lo que queda por terminar
SecciónHayFalta
- {#if seccion.href} - {seccion.titulo} - {:else} - {seccion.titulo} - {/if} - - {seccion.cuantas} - {seccion.unidad} - - {#if seccion.pendiente} - {@const p = seccion.pendiente} - {p.cuantas} {p.texto} - {#if p.cuales.length > 0} - - {p.cuales.join(' · ')}{#if p.cuantas > p.cuales.length}, y {p.cuantas - - p.cuales.length} más{/if} - - {/if} - {:else} - — - {/if} -
- -

- Las pantallas para editar cada sección todavía no están. Mientras tanto, el contenido se carga con - los scripts de scripts/db. -

+{#each data.grupos as grupo (grupo.titulo)} +
+

{grupo.titulo}

+

{grupo.nota}

+ + + + {#each grupo.filas as fila (fila.titulo)} + + + + + + + + {/each} + +
+ {#if fila.pantalla} + {fila.titulo} + {:else} + {fila.titulo} + {/if} + {#if fila.detalle} + {fila.detalle} + {/if} + + {fila.cuantas} + {fila.unidad} + + {#if fila.pendiente} + {@const p = fila.pendiente} + + {p.texto === 'sin cargar' ? 'sin cargar' : `${p.cuantas} ${p.texto}`} + + {#if p.cuales.length > 0} + + {p.cuales.join(' · ')}{#if p.cuantas > p.cuales.length}, y {p.cuantas - + p.cuales.length} más{/if} + + {/if} + {:else} + — + {/if} +
+
+{/each} diff --git a/src/panel/rutas/(dentro)/capitulos/[slug]/+page.server.ts b/src/panel/rutas/(dentro)/capitulos/[slug]/+page.server.ts new file mode 100644 index 0000000..4aca286 --- /dev/null +++ b/src/panel/rutas/(dentro)/capitulos/[slug]/+page.server.ts @@ -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() }; + } +}; diff --git a/src/panel/rutas/(dentro)/capitulos/[slug]/+page.svelte b/src/panel/rutas/(dentro)/capitulos/[slug]/+page.svelte new file mode 100644 index 0000000..701bfa7 --- /dev/null +++ b/src/panel/rutas/(dentro)/capitulos/[slug]/+page.svelte @@ -0,0 +1,354 @@ + + + + + + {data.capitulo.titulo} · Panel + + +

+ ← Capítulos +

+ +
{ + 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); + } + }; + }} +> +
+
+

+ + +

+ +

+ {data.capitulo.slug} + {#if data.capitulo.genero} + · ficha de {data.capitulo.genero.nombre} + {/if} + {#if campos.estado === 'publicado'} + · + + verlo en la web + {/if} +

+ + {#if errores.titulo}

{errores.titulo}

{/if} +
+ + +
+ + {#if guardando} + Guardando… + {:else if sinGuardar} + Sin guardar + {:else if form?.guardado} + Guardado + {/if} + + + +
+
+ +
+

+ + La línea que sale en el índice y al compartir. + +

+ +

+ + + {#if errores.estado}{errores.estado}{/if} +

+ +

+ + + {#if errores.orden}{errores.orden}{/if} +

+
+ +

+ + + 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. + + + {#if errores.cuerpo}{errores.cuerpo}{/if} +

+
+ +