From ba99701b17f98a59bb1313f89cf3af1aaf042025 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 9 Sep 2026 20:04:32 +0200 Subject: [PATCH] =?UTF-8?q?El=20cat=C3=A1logo=20se=20edita=20desde=20el=20?= =?UTF-8?q?panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El disco entró publicado y no había forma de corregir ni un título. Ahora sí: título, letra, de dónde sale, idioma, número de pista, año, estado, y las tres casillas —instrumental, destacada, a la venta—. Agrupado por disco y no en una lista alfabética de veintiocho títulos, que es como está ordenado en la cabeza de quien lo escribió. Cada fila dice lo que no se ve desde fuera: si tiene letra, si tiene audio y si tiene portada. Con el disco recién importado eso enseña de un vistazo lo que falta, y de paso que «Dorina» lleva desde siempre sin letra. **Al guardar se tira la caché del catálogo.** Vive un minuto en memoria, así que sin esto un cambio tardaba en verse y parecía que no se había guardado. El propio `catalogo.ts` ya lo dejaba escrito: «cuando haya administración, esto tendrá que invalidarse al guardar, no por reloj». Comprobado editando una letra en el panel y viéndola en la web sin esperar. No se edita aquí lo que no se escribe: el audio y las portadas son archivos, la autoría y el registro son papeles, y las versiones son otras filas. Todo junto sería un formulario de cuarenta campos donde lo de a diario queda enterrado. Publicar un tema sin letra se rechaza, salvo que esté marcado instrumental: sin eso queda en la web una página con el título y nada debajo. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/server/panel-canciones.spec.ts | 67 ++++ src/lib/server/panel-canciones.ts | 209 ++++++++++ src/lib/server/panel.ts | 4 +- src/panel/rutas/(dentro)/+layout.svelte | 3 +- src/panel/rutas/(dentro)/+page.svelte | 1 + .../rutas/(dentro)/canciones/+page.server.ts | 6 + .../rutas/(dentro)/canciones/+page.svelte | 258 ++++++++++++ .../(dentro)/canciones/[slug]/+page.server.ts | 49 +++ .../(dentro)/canciones/[slug]/+page.svelte | 371 ++++++++++++++++++ 9 files changed, 965 insertions(+), 3 deletions(-) create mode 100644 src/lib/server/panel-canciones.spec.ts create mode 100644 src/lib/server/panel-canciones.ts create mode 100644 src/panel/rutas/(dentro)/canciones/+page.server.ts create mode 100644 src/panel/rutas/(dentro)/canciones/+page.svelte create mode 100644 src/panel/rutas/(dentro)/canciones/[slug]/+page.server.ts create mode 100644 src/panel/rutas/(dentro)/canciones/[slug]/+page.svelte diff --git a/src/lib/server/panel-canciones.spec.ts b/src/lib/server/panel-canciones.spec.ts new file mode 100644 index 0000000..6c96ee6 --- /dev/null +++ b/src/lib/server/panel-canciones.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { revisarCancion, type CambiosDeCancion } from './panel-canciones'; + +/** Un tema válido, del que cada prueba estropea una cosa. */ +const bueno = (cambios: Partial = {}): CambiosDeCancion => ({ + titulo: 'Prometiste', + letra: 'Me prometiste besos\nque apenas llegaron.', + inspiracion: '', + idioma: 'es', + numero: 7, + ano: 2026, + estado: 'publicado', + instrumental: false, + destacada: false, + alaVenta: true, + ...cambios +}); + +describe('revisarCancion', () => { + it('deja pasar lo que está bien', () => { + expect(revisarCancion(bueno())).toEqual({}); + }); + + it('no admite un título vacío', () => { + expect(revisarCancion(bueno({ titulo: ' ' })).titulo).toBeTruthy(); + }); + + describe('publicar sin letra', () => { + it('no se puede', () => { + // Deja en la web una página con el título y nada debajo. + expect(revisarCancion(bueno({ letra: ' ' })).letra).toBeTruthy(); + }); + + it('salvo que sea instrumental, que entonces es lo correcto', () => { + expect(revisarCancion(bueno({ letra: '', instrumental: true }))).toEqual({}); + }); + + it('y en borrador se puede escribir a medias', () => { + expect(revisarCancion(bueno({ letra: '', estado: 'borrador' }))).toEqual({}); + }); + }); + + it('el idioma son dos letras', () => { + expect(revisarCancion(bueno({ idioma: 'español' })).idioma).toBeTruthy(); + expect(revisarCancion(bueno({ idioma: '' })).idioma).toBeTruthy(); + expect(revisarCancion(bueno({ idioma: 'fr' })).idioma).toBeUndefined(); + }); + + it('la pista empieza en uno, y puede no haberla', () => { + // Un tema suelto no está en ningún disco y no tiene número. + expect(revisarCancion(bueno({ numero: null })).numero).toBeUndefined(); + expect(revisarCancion(bueno({ numero: 0 })).numero).toBeTruthy(); + expect(revisarCancion(bueno({ numero: 1.5 })).numero).toBeTruthy(); + }); + + it('el año no está en el futuro', () => { + expect(revisarCancion(bueno({ ano: new Date().getFullYear() + 1 })).ano).toBeTruthy(); + expect(revisarCancion(bueno({ ano: 1800 })).ano).toBeTruthy(); + expect(revisarCancion(bueno({ ano: null })).ano).toBeUndefined(); + }); + + it('rechaza un estado que no existe', () => { + expect( + revisarCancion(bueno({ estado: 'colgado' as CambiosDeCancion['estado'] })).estado + ).toBeTruthy(); + }); +}); diff --git a/src/lib/server/panel-canciones.ts b/src/lib/server/panel-canciones.ts new file mode 100644 index 0000000..6969ca0 --- /dev/null +++ b/src/lib/server/panel-canciones.ts @@ -0,0 +1,209 @@ +/** + * Las canciones, para el panel. + * + * Lo que se edita aquí es lo que se escribe: título, letra, inspiración, + * idioma, número de pista y estado. Lo que no se edita es lo que sale de otro + * sitio —el audio y las portadas son archivos, la autoría y el registro son + * papeles, las versiones son otras filas— y meterlo todo en una pantalla la + * convertiría en un formulario de cuarenta campos donde lo que se usa a diario + * queda enterrado. + * + * Al guardar se tira la caché del catálogo. Está a un minuto de vida, así que + * sin esto un cambio tardaría en verse y parecería que no se ha guardado; el + * propio `catalogo.ts` ya dejaba dicho que la administración tendría que + * invalidarla al escribir, no por reloj. + */ + +import { and, asc, eq, isNull, sql } from 'drizzle-orm'; +import { db } from './db'; +import { album, cancion, estilo } from './db/schema'; +import { olvidarCatalogo } from './catalogo'; +import { ESTADOS } from './db/schema/comun'; + +export type Estado = (typeof ESTADOS)[number]; + +export interface CancionEnLista { + slug: string; + titulo: string; + estado: Estado; + numero: number | null; + albumTitulo: string | null; + albumSlug: string | null; + estiloNombre: string; + /** Cuánta letra hay escrita. Un cero canta más que un estado. */ + versos: number; + tieneAudio: boolean; + tienePortada: boolean; +} + +/** + * Las principales, no las versiones. + * + * Una versión es una fila de `cancion` con `version_de_id`, y en una lista de + * temas se leería como un duplicado. Se editan desde su tema, cuando haya + * pantalla para eso. + */ +export async function listaDeCanciones(): Promise { + const filas = await db + .select({ + slug: cancion.slug, + titulo: cancion.titulo, + estado: cancion.estado, + numero: cancion.numero, + albumTitulo: album.titulo, + albumSlug: album.slug, + estiloNombre: estilo.nombre, + audioId: cancion.audioId, + portadaId: cancion.portadaId, + versos: sql`( + select count(*)::int from regexp_split_to_table(coalesce(${cancion.letra}, ''), '\n') l + where btrim(l) <> '' + )` + }) + .from(cancion) + .leftJoin(album, eq(cancion.albumId, album.id)) + .innerJoin(estilo, eq(cancion.estiloId, estilo.id)) + .where(isNull(cancion.versionDeId)) + .orderBy(asc(album.titulo), asc(cancion.numero), asc(cancion.titulo)); + + return filas.map(({ audioId, portadaId, ...resto }) => ({ + ...resto, + tieneAudio: audioId !== null, + tienePortada: portadaId !== null + })); +} + +export interface CancionParaEditar { + slug: string; + titulo: string; + letra: string; + inspiracion: string; + idioma: string; + numero: number | null; + ano: number | null; + estado: Estado; + instrumental: boolean; + destacada: boolean; + alaVenta: boolean; + albumTitulo: string | null; + albumSlug: string | null; + estiloNombre: string; + tieneAudio: boolean; + tienePortada: boolean; +} + +export async function cancionParaEditar(slug: string): Promise { + const [fila] = await db + .select({ + slug: cancion.slug, + titulo: cancion.titulo, + letra: cancion.letra, + inspiracion: cancion.inspiracion, + idioma: cancion.idioma, + numero: cancion.numero, + ano: cancion.ano, + estado: cancion.estado, + instrumental: cancion.instrumental, + destacada: cancion.destacada, + alaVenta: cancion.alaVenta, + albumTitulo: album.titulo, + albumSlug: album.slug, + estiloNombre: estilo.nombre, + audioId: cancion.audioId, + portadaId: cancion.portadaId + }) + .from(cancion) + .leftJoin(album, eq(cancion.albumId, album.id)) + .innerJoin(estilo, eq(cancion.estiloId, estilo.id)) + .where(eq(cancion.slug, slug)) + .limit(1); + + if (!fila) return null; + + const { audioId, portadaId, ...resto } = fila; + return { + ...resto, + letra: resto.letra ?? '', + inspiracion: resto.inspiracion ?? '', + tieneAudio: audioId !== null, + tienePortada: portadaId !== null + }; +} + +export interface CambiosDeCancion { + titulo: string; + letra: string; + inspiracion: string; + idioma: string; + numero: number | null; + ano: number | null; + estado: Estado; + instrumental: boolean; + destacada: boolean; + alaVenta: boolean; +} + +export type ErroresDeCancion = Partial< + Record<'titulo' | 'letra' | 'numero' | 'ano' | 'idioma' | 'estado', string> +>; + +const ESTE_ANO = () => new Date().getFullYear(); + +export function revisarCancion(cambios: CambiosDeCancion): ErroresDeCancion { + const errores: ErroresDeCancion = {}; + + if (cambios.titulo.trim().length < 1) errores.titulo = 'El título no puede quedar vacío.'; + if (!ESTADOS.includes(cambios.estado)) errores.estado = 'Ese estado no existe.'; + + if (!/^[a-z]{2}$/.test(cambios.idioma)) { + errores.idioma = 'El idioma son dos letras: es, fr, en.'; + } + + if (cambios.numero !== null && (!Number.isInteger(cambios.numero) || cambios.numero < 1)) { + errores.numero = 'El número de pista empieza en uno.'; + } + + if (cambios.ano !== null) { + if (!Number.isInteger(cambios.ano) || cambios.ano < 1900 || cambios.ano > ESTE_ANO()) { + errores.ano = `El año tiene que estar entre 1900 y ${ESTE_ANO()}.`; + } + } + + /* + * Publicar un tema sin letra deja en la web una página con el título y nada + * debajo. Salvo que sea instrumental, que entonces es lo correcto. + */ + if (cambios.estado === 'publicado' && !cambios.instrumental && cambios.letra.trim() === '') { + errores.letra = 'No se puede publicar un tema sin letra. Si no la tiene, márcalo instrumental.'; + } + + return errores; +} + +/** Devuelve `false` si ese slug ya no existe. */ +export async function guardarCancion(slug: string, cambios: CambiosDeCancion): Promise { + const hechas = await db + .update(cancion) + .set({ + titulo: cambios.titulo.trim(), + // La letra NO se toca por dentro: el corte del verso es contenido. + letra: cambios.letra.trim() || null, + inspiracion: cambios.inspiracion.trim() || null, + idioma: cambios.idioma, + numero: cambios.numero, + ano: cambios.ano, + estado: cambios.estado, + instrumental: cambios.instrumental, + destacada: cambios.destacada, + alaVenta: cambios.alaVenta, + actualizadoEn: new Date() + }) + .where(and(eq(cancion.slug, slug), isNull(cancion.versionDeId))) + .returning({ slug: cancion.slug }); + + if (hechas.length === 0) return false; + + // El catálogo vive en memoria un minuto. Sin esto, el cambio no se vería. + olvidarCatalogo(); + return true; +} diff --git a/src/lib/server/panel.ts b/src/lib/server/panel.ts index 5cb8281..38cdf69 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' | 'generos' | 'prompts' | 'modelos' | null; + pantalla: 'capitulos' | 'generos' | 'canciones' | 'prompts' | 'modelos' | null; } export interface GrupoDelPanel { @@ -288,7 +288,7 @@ export async function estadoDelContenido(): Promise { unidad: 'canciones', detalle: `${n.versiones} versiones aparte`, pendiente: falta(n.canciones_a_medias, 'sin publicar', 'canciones'), - pantalla: null + pantalla: 'canciones' }, { titulo: 'Autoría y registro', diff --git a/src/panel/rutas/(dentro)/+layout.svelte b/src/panel/rutas/(dentro)/+layout.svelte index f2502d8..a56950b 100644 --- a/src/panel/rutas/(dentro)/+layout.svelte +++ b/src/panel/rutas/(dentro)/+layout.svelte @@ -29,7 +29,8 @@ const menu = [ { href: resolve('/(dentro)'), texto: 'El contenido' }, { href: resolve('/(dentro)/capitulos'), texto: 'Capítulos' }, - { href: resolve('/(dentro)/generos'), texto: 'Géneros' } + { href: resolve('/(dentro)/generos'), texto: 'Géneros' }, + { href: resolve('/(dentro)/canciones'), texto: 'Catálogo' } ]; const aqui = (href: string) => diff --git a/src/panel/rutas/(dentro)/+page.svelte b/src/panel/rutas/(dentro)/+page.svelte index 7ba3de0..578612f 100644 --- a/src/panel/rutas/(dentro)/+page.svelte +++ b/src/panel/rutas/(dentro)/+page.svelte @@ -23,6 +23,7 @@ const PANTALLAS = { capitulos: resolve('/(dentro)/capitulos'), generos: resolve('/(dentro)/generos'), + canciones: resolve('/(dentro)/canciones'), prompts: resolve('/(dentro)/generos'), modelos: resolve('/(dentro)/prompts/modelos') } as const; diff --git a/src/panel/rutas/(dentro)/canciones/+page.server.ts b/src/panel/rutas/(dentro)/canciones/+page.server.ts new file mode 100644 index 0000000..8e71f1a --- /dev/null +++ b/src/panel/rutas/(dentro)/canciones/+page.server.ts @@ -0,0 +1,6 @@ +import { listaDeCanciones } from '$lib/server/panel-canciones'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + return { canciones: await listaDeCanciones() }; +}; diff --git a/src/panel/rutas/(dentro)/canciones/+page.svelte b/src/panel/rutas/(dentro)/canciones/+page.svelte new file mode 100644 index 0000000..287f29b --- /dev/null +++ b/src/panel/rutas/(dentro)/canciones/+page.svelte @@ -0,0 +1,258 @@ + + + + Catálogo · Panel + + +
+

Catálogo

+

+ Los temas y su letra. El audio y las portadas son archivos y se colocan aparte; aquí se ve si + están. +

+
+ +
+ + +

{encontradas.length} de {data.canciones.length}

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

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

+{:else} + {#each porDisco as grupo (grupo.disco)} +
+

{grupo.disco}

+ + + {#each grupo.canciones as c (c.slug)} + + + + + + + + + + {/each} + +
{c.numero ?? ''} + {c.titulo} + {c.estiloNombre} + +
+ {ESTADO[c.estado] ?? c.estado} + {#if c.versos === 0} + Sin letra + {/if} + {#if !c.tieneAudio} + Sin audio + {/if} + {#if !c.tienePortada} + Sin portada + {/if} +
+
+ {c.versos} + {c.versos === 1 ? 'verso' : 'versos'} +
+
+ {/each} +{/if} + + diff --git a/src/panel/rutas/(dentro)/canciones/[slug]/+page.server.ts b/src/panel/rutas/(dentro)/canciones/[slug]/+page.server.ts new file mode 100644 index 0000000..c568fda --- /dev/null +++ b/src/panel/rutas/(dentro)/canciones/[slug]/+page.server.ts @@ -0,0 +1,49 @@ +import { error, fail } from '@sveltejs/kit'; +import { + cancionParaEditar, + guardarCancion, + revisarCancion, + type CambiosDeCancion, + type Estado +} from '$lib/server/panel-canciones'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params }) => { + const cancion = await cancionParaEditar(params.slug); + if (!cancion) error(404, 'No hay ningún tema con ese slug.'); + + return { cancion }; +}; + +/** Un número vacío es «no se sabe», no un cero. */ +function numeroDe(valor: FormDataEntryValue | null): number | null { + const texto = String(valor ?? '').trim(); + return texto === '' ? null : Number(texto); +} + +export const actions: Actions = { + default: async ({ request, params }) => { + const datos = await request.formData(); + + const cambios: CambiosDeCancion = { + titulo: String(datos.get('titulo') ?? ''), + letra: String(datos.get('letra') ?? ''), + inspiracion: String(datos.get('inspiracion') ?? ''), + idioma: String(datos.get('idioma') ?? 'es'), + numero: numeroDe(datos.get('numero')), + ano: numeroDe(datos.get('ano')), + estado: String(datos.get('estado') ?? '') as Estado, + instrumental: datos.get('instrumental') !== null, + destacada: datos.get('destacada') !== null, + alaVenta: datos.get('alaVenta') !== null + }; + + const errores = revisarCancion(cambios); + if (Object.keys(errores).length > 0) return fail(400, { errores, cambios }); + + const guardado = await guardarCancion(params.slug, cambios); + if (!guardado) error(404, 'Ese tema ha dejado de existir mientras lo editabas.'); + + return { guardado: true }; + } +}; diff --git a/src/panel/rutas/(dentro)/canciones/[slug]/+page.svelte b/src/panel/rutas/(dentro)/canciones/[slug]/+page.svelte new file mode 100644 index 0000000..eee8435 --- /dev/null +++ b/src/panel/rutas/(dentro)/canciones/[slug]/+page.svelte @@ -0,0 +1,371 @@ + + + + + + {data.cancion.titulo} · Panel + + +

+ ← Catálogo +

+ +
{ + guardando = true; + return async ({ update }) => { + await update({ reset: false }); + guardando = false; + if (form?.guardado) { + campos = copiar(data.cancion); + guardado = copiar(data.cancion); + } + }; + }} +> +
+
+

+ + +

+ +

+ {data.cancion.slug} + {#if data.cancion.albumTitulo} + · {data.cancion.albumTitulo} + {/if} + · {data.cancion.estiloNombre} + {#if campos.estado === 'publicado'} + · + + verlo en la web + {/if} +

+ + {#if !data.cancion.tieneAudio || !data.cancion.tienePortada} +

+ {#if !data.cancion.tieneAudio && !data.cancion.tienePortada} + Sin audio ni portada: los archivos se colocan aparte. + {:else if !data.cancion.tieneAudio} + Sin audio: no se puede escuchar todavía. + {:else} + Sin portada: sale el hueco con la inicial. + {/if} +

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

{errores.titulo}

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

+ + +

+ +

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

+ +

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

+ +

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

+
+ +
+ + + +
+ +

+ + + Lo que se cuenta en la ficha antes de la letra. Markdown, y en dos o tres frases. + + +

+ +

+ + + Tal cual se canta: el corte del verso es contenido y se guarda como está. Ctrl+S guarda. + + + {#if errores.letra}{errores.letra}{/if} +

+
+ +