diff --git a/src/lib/server/panel-generos.spec.ts b/src/lib/server/panel-generos.spec.ts new file mode 100644 index 0000000..fafbef2 --- /dev/null +++ b/src/lib/server/panel-generos.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { revisarGenero, type CambiosDelGenero } from './panel-generos'; + +/** Un género válido, del que cada prueba estropea una cosa. */ +const bueno = (cambios: Partial = {}): CambiosDelGenero => ({ + nombre: 'Bolero', + resumen: 'La escuela del desamor cantado despacio.', + origenLugar: 'Santiago de Cuba', + origenAno: 1883, + tempo: 'Lento: 60–90 pulsos por minuto', + rima: 'Consonante', + metricaNota: 'Octosílabo y endecasílabo', + compasNota: '2/4', + rasgos: ['Tratamiento de usted o de tú'], + estado: 'publicado', + paises: [ + { codigo: 'CU', relacion: 'cuna' }, + { codigo: 'MX', relacion: 'adoptado' } + ], + secciones: [], + nueva: null, + revisar: false, + ...cambios +}); + +describe('revisarGenero', () => { + it('deja pasar lo que está bien', () => { + expect(revisarGenero(bueno())).toEqual({}); + }); + + it('no admite un nombre vacío', () => { + expect(revisarGenero(bueno({ nombre: ' ' })).nombre).toBeTruthy(); + }); + + describe('el año de origen', () => { + it('puede no saberse', () => { + expect(revisarGenero(bueno({ origenAno: null })).origenAno).toBeUndefined(); + }); + + it('no puede estar en el futuro ni antes de 1500', () => { + expect( + revisarGenero(bueno({ origenAno: new Date().getFullYear() + 1 })).origenAno + ).toBeTruthy(); + expect(revisarGenero(bueno({ origenAno: 1200 })).origenAno).toBeTruthy(); + }); + + it('rechaza lo que no es un entero', () => { + // `Number('mil ochocientos')` es NaN, y llega del formulario. + expect(revisarGenero(bueno({ origenAno: Number.NaN })).origenAno).toBeTruthy(); + expect(revisarGenero(bueno({ origenAno: 1883.5 })).origenAno).toBeTruthy(); + }); + }); + + describe('los países', () => { + it('solo admite una cuna', () => { + // Un género nace en un sitio. Si nació en dos orillas —el tango—, la + // cuna es la región y el otro país va como adoptado. + const dos = bueno({ + paises: [ + { codigo: 'AR', relacion: 'cuna' }, + { codigo: 'UY', relacion: 'cuna' } + ] + }); + expect(revisarGenero(dos).paises).toBeTruthy(); + }); + + it('no deja publicar un género que no está en ninguno', () => { + // Sin país no sale en el mapa, y el mapa es por donde se llega. + expect(revisarGenero(bueno({ paises: [] })).paises).toBeTruthy(); + }); + + it('pero en borrador puede no tener ninguno todavía', () => { + expect(revisarGenero(bueno({ paises: [], estado: 'borrador' }))).toEqual({}); + }); + }); + + describe('la sección nueva', () => { + const con = (nueva: CambiosDelGenero['nueva']) => revisarGenero(bueno({ nueva })).nueva; + + it('pide una clave en minúsculas y sin espacios', () => { + expect(con({ clave: 'Historia del género', titulo: 'Historia', cuerpo: '' })).toBeTruthy(); + expect(con({ clave: 'histórico', titulo: 'Historia', cuerpo: '' })).toBeTruthy(); + expect(con({ clave: '', titulo: 'Historia', cuerpo: '' })).toBeTruthy(); + }); + + it('y un título', () => { + expect(con({ clave: 'historia', titulo: ' ', cuerpo: 'algo' })).toBeTruthy(); + }); + + it('con las dos cosas, pasa', () => { + expect(con({ clave: 'estructura', titulo: 'Estructura', cuerpo: '' })).toBeUndefined(); + }); + }); + + it('una sección que se queda no puede perder el título', () => { + const sinTitulo = bueno({ + secciones: [{ id: 's1', titulo: '', cuerpo: 'algo', orden: 0, borrar: false }] + }); + expect(revisarGenero(sinTitulo).secciones).toBeTruthy(); + }); + + it('pero si se va a borrar, da igual cómo se llame', () => { + const borrada = bueno({ + secciones: [{ id: 's1', titulo: '', cuerpo: '', orden: 0, borrar: true }] + }); + expect(revisarGenero(borrada)).toEqual({}); + }); +}); diff --git a/src/lib/server/panel-generos.ts b/src/lib/server/panel-generos.ts new file mode 100644 index 0000000..9a5ee96 --- /dev/null +++ b/src/lib/server/panel-generos.ts @@ -0,0 +1,309 @@ +/** + * Los géneros, para el panel. + * + * Una ficha de género no es una fila: es la fila más los países donde vive y la + * prosa repartida en secciones. Se edita todo en la misma pantalla porque es + * una sola cosa —la ficha—, y repartirla en tres formularios obligaría a + * guardar tres veces para arreglar un párrafo. + * + * Lo que todavía no se edita aquí —alias, temática, instrumentación, linaje, + * personas, obras y versos— se carga por script. Se irá trayendo; lo de aquí es + * lo que tiene texto que escribir. + */ + +import { and, asc, eq, inArray, sql } from 'drizzle-orm'; +import { db } from './db'; +import { capitulo, genero, generoPais, generoSeccion, pais } from './db/schema'; +import { ESTADOS } from './db/schema/comun'; + +export type Estado = (typeof ESTADOS)[number]; +export type Relacion = 'cuna' | 'adoptado'; + +export interface GeneroEnLista { + slug: string; + nombre: string; + estado: Estado; + origenLugar: string | null; + origenAno: number | null; + /** Cuántas secciones de prosa tiene escritas. */ + secciones: number; + /** Si existe el capítulo que lo publica. Sin él, la ficha no sale en la web. */ + tieneFicha: boolean; + /** Importado por un modelo y sin repasar por una persona. */ + sinRevisar: boolean; +} + +export async function listaDeGeneros(): Promise { + const filas = await db + .select({ + slug: genero.slug, + nombre: genero.nombre, + estado: genero.estado, + origenLugar: genero.origenLugar, + origenAno: genero.origenAno, + origen: genero.origen, + revisadoEn: genero.revisadoEn, + capituloId: capitulo.id, + secciones: sql`( + select count(*)::int from genero_seccion s where s.genero_id = ${genero.id} + )` + }) + .from(genero) + .leftJoin(capitulo, eq(capitulo.generoId, genero.id)) + .orderBy(asc(genero.nombre)); + + return filas.map(({ origen, revisadoEn, capituloId, ...resto }) => ({ + ...resto, + tieneFicha: capituloId !== null, + sinRevisar: origen === 'importado' && revisadoEn === null + })); +} + +export interface SeccionDelGenero { + id: string; + clave: string; + titulo: string; + cuerpo: string; + orden: number; +} + +export interface PaisDelGenero { + codigo: string; + nombre: string; + relacion: Relacion | null; +} + +export interface GeneroParaEditar { + slug: string; + nombre: string; + resumen: string; + origenLugar: string; + origenAno: number | null; + tempo: string; + rima: string; + metricaNota: string; + compasNota: string; + /** Frases sueltas: «Tratamiento de usted, nunca coloquial». */ + rasgos: string[]; + estado: Estado; + origen: string; + modeloOrigen: string | null; + revisadoEn: Date | null; + secciones: SeccionDelGenero[]; + /** Los 22 del mapa, con su relación si la tiene. */ + paises: PaisDelGenero[]; + /** El capítulo que lo publica, si existe. */ + fichaSlug: string | null; +} + +export async function generoParaEditar(slug: string): Promise { + const [fila] = await db.select().from(genero).where(eq(genero.slug, slug)).limit(1); + if (!fila) return null; + + const [secciones, relaciones, todosLosPaises, ficha] = await Promise.all([ + db + .select() + .from(generoSeccion) + .where(eq(generoSeccion.generoId, fila.id)) + .orderBy(asc(generoSeccion.orden)), + db.select().from(generoPais).where(eq(generoPais.generoId, fila.id)), + db.select().from(pais).orderBy(asc(pais.nombre)), + db.select({ slug: capitulo.slug }).from(capitulo).where(eq(capitulo.generoId, fila.id)).limit(1) + ]); + + const porCodigo = new Map(relaciones.map((r) => [r.paisCodigo, r.relacion])); + + return { + slug: fila.slug, + nombre: fila.nombre, + resumen: fila.resumen, + origenLugar: fila.origenLugar ?? '', + origenAno: fila.origenAno, + tempo: fila.tempo ?? '', + rima: fila.rima ?? '', + metricaNota: fila.metricaNota ?? '', + compasNota: fila.compasNota ?? '', + rasgos: fila.rasgos ?? [], + estado: fila.estado, + origen: fila.origen, + modeloOrigen: fila.modeloOrigen, + revisadoEn: fila.revisadoEn, + secciones: secciones.map((s) => ({ + id: s.id, + clave: s.clave, + titulo: s.titulo, + cuerpo: s.cuerpo, + orden: s.orden + })), + paises: todosLosPaises.map((p) => ({ + codigo: p.codigo, + nombre: p.nombre, + relacion: porCodigo.get(p.codigo) ?? null + })), + fichaSlug: ficha[0]?.slug ?? null + }; +} + +export interface CambiosDelGenero { + nombre: string; + resumen: string; + origenLugar: string; + origenAno: number | null; + tempo: string; + rima: string; + metricaNota: string; + compasNota: string; + rasgos: string[]; + estado: Estado; + /** Qué países y con qué relación. Reemplaza lo que hubiera. */ + paises: { codigo: string; relacion: Relacion }[]; + /** Secciones existentes, con lo que se haya cambiado o marcado para borrar. */ + secciones: { id: string; titulo: string; cuerpo: string; orden: number; borrar: boolean }[]; + /** Una sección nueva, si se ha escrito algo en ella. */ + nueva: { clave: string; titulo: string; cuerpo: string } | null; + /** Marcar la ficha como repasada por una persona. */ + revisar: boolean; +} + +export type ErroresDelGenero = Partial< + Record<'nombre' | 'origenAno' | 'paises' | 'nueva' | 'secciones', string> +>; + +/** El año actual, para no aceptar un origen en el futuro. */ +const ESTE_ANO = () => new Date().getFullYear(); + +export function revisarGenero(cambios: CambiosDelGenero): ErroresDelGenero { + const errores: ErroresDelGenero = {}; + + if (cambios.nombre.trim().length < 2) errores.nombre = 'El nombre no puede quedar vacío.'; + + if (cambios.origenAno !== null) { + if (!Number.isInteger(cambios.origenAno)) { + errores.origenAno = 'El año es un número entero.'; + } else if (cambios.origenAno < 1500 || cambios.origenAno > ESTE_ANO()) { + errores.origenAno = `El año tiene que estar entre 1500 y ${ESTE_ANO()}.`; + } + } + + /* + * Dos cunas es un dato falso, no un matiz. Un género nace en un sitio y se + * adopta en varios; si de verdad nació en dos orillas —el tango— la cuna es + * la región y el otro país va como adoptado, con su nota. + */ + if (cambios.paises.filter((p) => p.relacion === 'cuna').length > 1) { + errores.paises = 'Solo puede haber un país como cuna. Los demás van como adoptado.'; + } + + /* + * Publicar un género que no está en ningún país lo deja fuera del mapa, que + * es por donde se llega a las fichas. + */ + if (cambios.estado === 'publicado' && cambios.paises.length === 0) { + errores.paises = + 'Un género publicado tiene que estar en algún país: si no, no sale en el mapa.'; + } + + if (cambios.nueva) { + if (!/^[a-z][a-z0-9-]{1,40}$/.test(cambios.nueva.clave)) { + errores.nueva = + 'La clave va en minúsculas, sin espacios ni acentos: «historia», «estructura».'; + } else if (cambios.nueva.titulo.trim() === '') { + errores.nueva = 'La sección nueva necesita un título.'; + } + } + + if (cambios.secciones.some((s) => !s.borrar && s.titulo.trim() === '')) { + errores.secciones = 'Una sección no puede quedarse sin título.'; + } + + return errores; +} + +/** + * Guarda la ficha entera en una transacción. + * + * Todo junto porque es una sola cosa: si al reemplazar los países falla algo, + * no puede quedar el texto guardado y el mapa a medias. Devuelve `false` si el + * género ha dejado de existir. + */ +export async function guardarGenero(slug: string, cambios: CambiosDelGenero): Promise { + const [fila] = await db + .select({ id: genero.id }) + .from(genero) + .where(eq(genero.slug, slug)) + .limit(1); + if (!fila) return false; + + const ahora = new Date(); + + await db.transaction(async (tx) => { + await tx + .update(genero) + .set({ + nombre: cambios.nombre.trim(), + resumen: cambios.resumen.trim(), + // Los opcionales vacíos se guardan como nulo y no como cadena vacía: + // «no se sabe» y «es una cadena de cero letras» no son lo mismo, y la + // web pregunta por nulo para decidir si pinta el dato. + origenLugar: cambios.origenLugar.trim() || null, + origenAno: cambios.origenAno, + tempo: cambios.tempo.trim() || null, + rima: cambios.rima.trim() || null, + metricaNota: cambios.metricaNota.trim() || null, + compasNota: cambios.compasNota.trim() || null, + rasgos: cambios.rasgos.length > 0 ? cambios.rasgos : null, + estado: cambios.estado, + actualizadoEn: ahora, + /* + * Repasar es una firma, no una casilla que se pueda desmarcar sola: si + * ya estaba revisada se deja la fecha que tenía. + */ + ...(cambios.revisar ? { revisadoEn: ahora } : {}) + }) + .where(eq(genero.id, fila.id)); + + /* + * Los países se reemplazan enteros en vez de ir comparando: son cuatro + * filas y el formulario manda el conjunto completo, así que calcular + * altas y bajas sería trabajo para llegar al mismo sitio con más formas de + * equivocarse. Se pierde la nota de cada país, que hoy no se edita aquí. + */ + await tx.delete(generoPais).where(eq(generoPais.generoId, fila.id)); + if (cambios.paises.length > 0) { + await tx.insert(generoPais).values( + cambios.paises.map((p) => ({ + generoId: fila.id, + paisCodigo: p.codigo, + relacion: p.relacion + })) + ); + } + + const aBorrar = cambios.secciones.filter((s) => s.borrar).map((s) => s.id); + if (aBorrar.length > 0) { + await tx + .delete(generoSeccion) + .where(and(eq(generoSeccion.generoId, fila.id), inArray(generoSeccion.id, aBorrar))); + } + + for (const s of cambios.secciones) { + if (s.borrar) continue; + await tx + .update(generoSeccion) + .set({ titulo: s.titulo.trim(), cuerpo: s.cuerpo.trim(), orden: s.orden }) + .where(and(eq(generoSeccion.id, s.id), eq(generoSeccion.generoId, fila.id))); + } + + if (cambios.nueva) { + await tx.insert(generoSeccion).values({ + generoId: fila.id, + clave: cambios.nueva.clave, + titulo: cambios.nueva.titulo.trim(), + cuerpo: cambios.nueva.cuerpo.trim(), + orden: cambios.secciones.filter((s) => !s.borrar).length + }); + } + }); + + return true; +} diff --git a/src/lib/server/panel.ts b/src/lib/server/panel.ts index c21d918..dd5319d 100644 --- a/src/lib/server/panel.ts +++ b/src/lib/server/panel.ts @@ -33,7 +33,7 @@ export interface FilaDelPanel { * `resolve()`, contra su propio mapa de rutas, y así una que se escriba mal * deja de compilar en vez de dar un 404 al pulsarla. */ - pantalla: 'capitulos' | null; + pantalla: 'capitulos' | 'generos' | null; } export interface GrupoDelPanel { @@ -185,7 +185,7 @@ export async function estadoDelContenido(): Promise { unidad: 'géneros', detalle: null, pendiente: falta(n.generos_sin_ficha, 'sin ficha', 'generos'), - pantalla: null + pantalla: 'generos' }, { /* diff --git a/src/panel/rutas/(dentro)/+layout.svelte b/src/panel/rutas/(dentro)/+layout.svelte index 72ba35c..f2502d8 100644 --- a/src/panel/rutas/(dentro)/+layout.svelte +++ b/src/panel/rutas/(dentro)/+layout.svelte @@ -28,7 +28,8 @@ */ const menu = [ { href: resolve('/(dentro)'), texto: 'El contenido' }, - { href: resolve('/(dentro)/capitulos'), texto: 'Capítulos' } + { href: resolve('/(dentro)/capitulos'), texto: 'Capítulos' }, + { href: resolve('/(dentro)/generos'), texto: 'Géneros' } ]; const aqui = (href: string) => diff --git a/src/panel/rutas/(dentro)/+page.svelte b/src/panel/rutas/(dentro)/+page.svelte index e4cc09d..404eea7 100644 --- a/src/panel/rutas/(dentro)/+page.svelte +++ b/src/panel/rutas/(dentro)/+page.svelte @@ -20,7 +20,10 @@ * la consulta: así una ruta que se escriba mal deja de compilar en vez de dar * un 404 al pulsarla. */ - const PANTALLAS = { capitulos: resolve('/(dentro)/capitulos') } as const; + const PANTALLAS = { + capitulos: resolve('/(dentro)/capitulos'), + generos: resolve('/(dentro)/generos') + } as const; diff --git a/src/panel/rutas/(dentro)/generos/+page.server.ts b/src/panel/rutas/(dentro)/generos/+page.server.ts new file mode 100644 index 0000000..7bf3952 --- /dev/null +++ b/src/panel/rutas/(dentro)/generos/+page.server.ts @@ -0,0 +1,6 @@ +import { listaDeGeneros } from '$lib/server/panel-generos'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + return { generos: await listaDeGeneros() }; +}; diff --git a/src/panel/rutas/(dentro)/generos/+page.svelte b/src/panel/rutas/(dentro)/generos/+page.svelte new file mode 100644 index 0000000..0be6bc9 --- /dev/null +++ b/src/panel/rutas/(dentro)/generos/+page.svelte @@ -0,0 +1,233 @@ + + + + Géneros · Panel + + +
+

Géneros

+

+ De dónde viene cada estilo, en qué países se canta y toda la prosa de su ficha. +

+
+ +
+ + +

{encontrados.length} de {data.generos.length}

+
+ +{#if encontrados.length === 0} +

Nada con «{consulta.trim()}».

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

+ ← Géneros +

+ +
{ + guardando = true; + return async ({ update }) => { + await update({ reset: false }); + guardando = false; + // Si guardó, la pantalla vuelve a salir de la base: las secciones + // borradas ya no están y la nueva tiene su identificador. + if (form?.guardado) campos = copia(); + }; + }} +> +
+
+

+ + +

+ +

+ {data.genero.slug} + {#if data.genero.fichaSlug} + · + + verlo en la web + {:else} + · sin capítulo: no sale en la web + {/if} + {#if data.genero.origen === 'importado'} + · importado{#if data.genero.modeloOrigen} + de {data.genero.modeloOrigen}{/if}{#if !data.genero.revisadoEn}, sin revisar{/if} + {/if} +

+ + {#if errores.nombre}

{errores.nombre}

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

Qué es y de dónde viene

+ +

+ + Una línea. Es lo que sale en el buscador y en el mapa. + +

+ +
+

+ + +

+ +

+ + +

+ +

+ + +

+
+ + {#if errores.origenAno}

{errores.origenAno}

{/if} + +
+

+ + +

+ +

+ + +

+
+ +
+

+ + El matiz que no cabe en el vocabulario. + +

+ +

+ + «4/4 con clave 2-3», y lo que haga falta. + +

+
+ +

+ + + Una frase por línea: «Tratamiento de usted o de tú, nunca coloquial». + + +

+
+ +
+

Dónde se canta

+

+ Esto es lo que pinta el género en el mapa. La cuna es una sola: donde nació. Los demás países + van como adoptados. +

+ + {#if errores.paises}

{errores.paises}

{/if} + +
    + {#each data.genero.paises as p (p.codigo)} +
  • + + + +
  • + {/each} +
+
+ +
+

La ficha

+

+ La prosa, en secciones. Cada una es un apartado con su encabezado en la página del género. + Markdown, y Ctrl+S guarda. +

+ + {#if errores.secciones}

{errores.secciones}

{/if} + + {#each campos.secciones as s, i (s.id)} +
+ + +
+

+ + +

+ +

+ + +

+ + +
+ +

+ + +

+ +

+ {s.clave} + · sección {i + 1} de {campos.secciones.length} +

+
+ {/each} + +
+

Añadir una sección

+ + {#if errores.nueva}

{errores.nueva}

{/if} + +
+

+ + +

+ +

+ + +

+
+ +

+ + +

+
+
+ + {#if data.genero.origen === 'importado' && !data.genero.revisadoEn} +

+ + + Esta ficha la propuso un modelo. Marcarlo deja constancia de que la ha leído una persona; es + lo que distingue lo importado de lo comprobado. + +

+ {/if} +
+ +