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
parent
34668a80e3
commit
4e53ba64f5
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@ -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…
Reference in new issue