El catálogo se edita desde el panel

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
dev 4 weeks ago
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;
}

@ -33,7 +33,7 @@ export interface FilaDelPanel {
* `resolve()`, contra su propio mapa de rutas, y así una que se escriba mal * `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. * 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 { export interface GrupoDelPanel {
@ -288,7 +288,7 @@ export async function estadoDelContenido(): Promise<GrupoDelPanel[]> {
unidad: 'canciones', unidad: 'canciones',
detalle: `${n.versiones} versiones aparte`, detalle: `${n.versiones} versiones aparte`,
pendiente: falta(n.canciones_a_medias, 'sin publicar', 'canciones'), pendiente: falta(n.canciones_a_medias, 'sin publicar', 'canciones'),
pantalla: null pantalla: 'canciones'
}, },
{ {
titulo: 'Autoría y registro', titulo: 'Autoría y registro',

@ -29,7 +29,8 @@
const menu = [ const menu = [
{ href: resolve('/(dentro)'), texto: 'El contenido' }, { 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' } { href: resolve('/(dentro)/generos'), texto: 'Géneros' },
{ href: resolve('/(dentro)/canciones'), texto: 'Catálogo' }
]; ];
const aqui = (href: string) => const aqui = (href: string) =>

@ -23,6 +23,7 @@
const PANTALLAS = { const PANTALLAS = {
capitulos: resolve('/(dentro)/capitulos'), capitulos: resolve('/(dentro)/capitulos'),
generos: resolve('/(dentro)/generos'), generos: resolve('/(dentro)/generos'),
canciones: resolve('/(dentro)/canciones'),
prompts: resolve('/(dentro)/generos'), prompts: resolve('/(dentro)/generos'),
modelos: resolve('/(dentro)/prompts/modelos') modelos: resolve('/(dentro)/prompts/modelos')
} as const; } as const;

@ -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…
Cancel
Save

Powered by TurnKey Linux.