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) <noreply@anthropic.com>master
parent
9cefeb879a
commit
ba99701b17
@ -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> = {}): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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<CancionEnLista[]> {
|
||||||
|
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<number>`(
|
||||||
|
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<CancionParaEditar | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -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() };
|
||||||
|
};
|
||||||
@ -0,0 +1,258 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* El catálogo.
|
||||||
|
*
|
||||||
|
* Agrupado por disco, que es como está ordenado en la cabeza de quien lo
|
||||||
|
* escribió, y no en una lista alfabética de cuarenta títulos. Cada fila dice
|
||||||
|
* lo que no se ve desde fuera: si tiene letra, si tiene audio y si tiene
|
||||||
|
* portada. Un tema publicado sin audio existe —el disco entra por un lado y
|
||||||
|
* los archivos por otro—, pero conviene saberlo.
|
||||||
|
*/
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let consulta = $state('');
|
||||||
|
|
||||||
|
const plano = (t: string) => t.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||||
|
|
||||||
|
const encontradas = $derived.by(() => {
|
||||||
|
const q = plano(consulta.trim());
|
||||||
|
if (!q) return data.canciones;
|
||||||
|
return data.canciones.filter((c) =>
|
||||||
|
plano(`${c.titulo} ${c.slug} ${c.albumTitulo ?? ''}`).includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Por disco.
|
||||||
|
*
|
||||||
|
* Se recorre y se corta cuando cambia el título, sin agrupar en un mapa: la
|
||||||
|
* consulta ya viene ordenada por disco y por número de pista, así que el
|
||||||
|
* orden lo pone la base y aquí solo hay que respetarlo.
|
||||||
|
*/
|
||||||
|
const porDisco = $derived.by(() => {
|
||||||
|
const grupos: { disco: string; canciones: typeof data.canciones }[] = [];
|
||||||
|
for (const c of encontradas) {
|
||||||
|
const disco = c.albumTitulo ?? 'Sin disco';
|
||||||
|
if (grupos.at(-1)?.disco !== disco) grupos.push({ disco, canciones: [] });
|
||||||
|
grupos.at(-1)!.canciones.push(c);
|
||||||
|
}
|
||||||
|
return grupos;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ESTADO: Record<string, string> = {
|
||||||
|
borrador: 'Borrador',
|
||||||
|
publicado: 'Publicado',
|
||||||
|
retirado: 'Retirado'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Catálogo · Panel</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>Catálogo</h1>
|
||||||
|
<p class="entradilla">
|
||||||
|
Los temas y su letra. El audio y las portadas son archivos y se colocan aparte; aquí se ve si
|
||||||
|
están.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="buscador">
|
||||||
|
<label class="visually-hidden" for="buscar">Buscar un tema</label>
|
||||||
|
<input
|
||||||
|
id="buscar"
|
||||||
|
type="search"
|
||||||
|
bind:value={consulta}
|
||||||
|
placeholder="Buscar por título o por disco"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<p class="cuenta muted" role="status">{encontradas.length} de {data.canciones.length}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if encontradas.length === 0}
|
||||||
|
<p class="vacio muted">Nada con «{consulta.trim()}».</p>
|
||||||
|
{:else}
|
||||||
|
{#each porDisco as grupo (grupo.disco)}
|
||||||
|
<section>
|
||||||
|
<h2>{grupo.disco}</h2>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each grupo.canciones as c (c.slug)}
|
||||||
|
<tr>
|
||||||
|
<td class="numero muted">{c.numero ?? ''}</td>
|
||||||
|
|
||||||
|
<th scope="row">
|
||||||
|
<a href={resolve('/(dentro)/canciones/[slug]', { slug: c.slug })}>{c.titulo}</a>
|
||||||
|
<span class="donde muted">{c.estiloNombre}</span>
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div class="marcas">
|
||||||
|
<span class="pastilla" data-estado={c.estado}>{ESTADO[c.estado] ?? c.estado}</span
|
||||||
|
>
|
||||||
|
{#if c.versos === 0}
|
||||||
|
<span class="pastilla" data-aviso="si">Sin letra</span>
|
||||||
|
{/if}
|
||||||
|
{#if !c.tieneAudio}
|
||||||
|
<span class="pastilla" data-aviso="si">Sin audio</span>
|
||||||
|
{/if}
|
||||||
|
{#if !c.tienePortada}
|
||||||
|
<span class="pastilla" data-aviso="si">Sin portada</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="versos muted">
|
||||||
|
{c.versos}
|
||||||
|
{c.versos === 1 ? 'verso' : 'versos'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* El número de pista, estrecho y con cifras de ancho fijo. */
|
||||||
|
.numero {
|
||||||
|
width: 1%;
|
||||||
|
padding-right: 0;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donde {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marcas {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pastilla {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pastilla[data-estado='borrador'],
|
||||||
|
.pastilla[data-aviso='si'] {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.versos {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.versos {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,371 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Editar un tema.
|
||||||
|
*
|
||||||
|
* La letra se lleva la pantalla porque es lo que se escribe; lo demás son
|
||||||
|
* cuatro datos. Sin editor enriquecido y sin justificar nada: en una letra el
|
||||||
|
* corte del verso es contenido, y lo que se guarda tiene que ser exactamente
|
||||||
|
* lo que se ve aquí.
|
||||||
|
*/
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { site } from '$lib/site';
|
||||||
|
import type { CambiosDeCancion } from '$lib/server/panel-canciones';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
const copiar = (c: CambiosDeCancion): CambiosDeCancion => ({
|
||||||
|
titulo: c.titulo,
|
||||||
|
letra: c.letra,
|
||||||
|
inspiracion: c.inspiracion,
|
||||||
|
idioma: c.idioma,
|
||||||
|
numero: c.numero,
|
||||||
|
ano: c.ano,
|
||||||
|
estado: c.estado,
|
||||||
|
instrumental: c.instrumental,
|
||||||
|
destacada: c.destacada,
|
||||||
|
alaVenta: c.alaVenta
|
||||||
|
});
|
||||||
|
|
||||||
|
let campos = $state(untrack(() => copiar(form?.cambios ?? data.cancion)));
|
||||||
|
let guardado = $state(untrack(() => copiar(data.cancion)));
|
||||||
|
let guardando = $state(false);
|
||||||
|
|
||||||
|
const sinGuardar = $derived(
|
||||||
|
(Object.keys(guardado) as (keyof CambiosDeCancion)[]).some((k) => campos[k] !== guardado[k])
|
||||||
|
);
|
||||||
|
|
||||||
|
const enLaWeb = $derived(`${site.url}/canciones/${data.cancion.slug}`);
|
||||||
|
const errores = $derived(form?.errores ?? {});
|
||||||
|
|
||||||
|
let formulario: HTMLFormElement;
|
||||||
|
|
||||||
|
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.cancion.titulo} · Panel</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<p class="volver">
|
||||||
|
<a href={resolve('/(dentro)/canciones')}>← Catálogo</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
bind:this={formulario}
|
||||||
|
use:enhance={() => {
|
||||||
|
guardando = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
guardando = false;
|
||||||
|
if (form?.guardado) {
|
||||||
|
campos = copiar(data.cancion);
|
||||||
|
guardado = copiar(data.cancion);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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.cancion.slug}</span>
|
||||||
|
{#if data.cancion.albumTitulo}
|
||||||
|
· {data.cancion.albumTitulo}
|
||||||
|
{/if}
|
||||||
|
· {data.cancion.estiloNombre}
|
||||||
|
{#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 !data.cancion.tieneAudio || !data.cancion.tienePortada}
|
||||||
|
<p class="aviso">
|
||||||
|
{#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}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if errores.titulo}<p class="fallo">{errores.titulo}</p>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="acciones">
|
||||||
|
<span class="estado-guardado" 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="rejilla">
|
||||||
|
<p class="campo">
|
||||||
|
<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>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="campo">
|
||||||
|
<label for="numero">Pista</label>
|
||||||
|
<input id="numero" name="numero" type="number" min="1" step="1" bind:value={campos.numero} />
|
||||||
|
{#if errores.numero}<span class="fallo">{errores.numero}</span>{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="campo">
|
||||||
|
<label for="ano">Año</label>
|
||||||
|
<input id="ano" name="ano" type="number" min="1900" step="1" bind:value={campos.ano} />
|
||||||
|
{#if errores.ano}<span class="fallo">{errores.ano}</span>{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="campo">
|
||||||
|
<label for="idioma">Idioma</label>
|
||||||
|
<input id="idioma" name="idioma" maxlength="2" size="2" bind:value={campos.idioma} />
|
||||||
|
{#if errores.idioma}<span class="fallo">{errores.idioma}</span>{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="casillas">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="instrumental" bind:checked={campos.instrumental} />
|
||||||
|
Instrumental
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="destacada" bind:checked={campos.destacada} />
|
||||||
|
Destacada
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="alaVenta" bind:checked={campos.alaVenta} />
|
||||||
|
A la venta
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="campo">
|
||||||
|
<label for="inspiracion">De dónde sale</label>
|
||||||
|
<span class="ayuda muted">
|
||||||
|
Lo que se cuenta en la ficha antes de la letra. Markdown, y en dos o tres frases.
|
||||||
|
</span>
|
||||||
|
<textarea id="inspiracion" name="inspiracion" rows="3" bind:value={campos.inspiracion}
|
||||||
|
></textarea>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="campo letra">
|
||||||
|
<label for="letra">Letra</label>
|
||||||
|
<span class="ayuda muted">
|
||||||
|
Tal cual se canta: el corte del verso es contenido y se guarda como está. Ctrl+S guarda.
|
||||||
|
</span>
|
||||||
|
<textarea id="letra" name="letra" bind:value={campos.letra} spellcheck="true"></textarea>
|
||||||
|
{#if errores.letra}<span class="fallo">{errores.letra}</span>{/if}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.volver {
|
||||||
|
margin: 0 0 var(--sp-4);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--sp-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estado-guardado {
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* El título se edita donde se lee; la caja sale al pasar por encima. */
|
||||||
|
#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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lo que falta, en el color del dato: es un aviso, no un error. */
|
||||||
|
.aviso {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--c-dato);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rejilla {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(9rem, 100%), 1fr));
|
||||||
|
gap: var(--sp-4);
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campo {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ayuda {
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
line-height: 1.45;
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[type='checkbox'] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.casillas {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.casillas label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--c-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* La letra se lleva la altura que queda de ventana. Es donde se pasa el
|
||||||
|
* rato, y en una caja de seis líneas se escribe mirando por una rendija.
|
||||||
|
*/
|
||||||
|
.letra textarea {
|
||||||
|
min-height: 55vh;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pendiente {
|
||||||
|
color: var(--c-dato);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fallo {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--c-danger);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in new issue