Fase 6. Está lo que todo lo demás necesita: quién entra y por dónde. El rol vive en `usuario.rol`, con un CHECK que impide inventarse uno, y se da con `npm run db:admin`. En una variable de entorno con correos habría sido más rápido, pero entonces revocar a alguien pide un despliegue y a la pregunta «quién administra esto» solo sabe contestar quien pueda leer la configuración del servidor. La guarda está en el `+layout.server.ts` de la carpeta y no en cada ruta: una pantalla nueva queda protegida por el hecho de estar dentro. Sin sesión, a entrar con la vuelta puesta; con sesión y sin el rol, 404 y no 403, porque un 403 confirma que el panel existe y dónde vive. Los tres casos, comprobados contra la aplicación de verdad. La portada contesta «qué falta» y no «cuánto hay». Cada sección trae lo que tiene, lo que está a medias y los nombres de lo que está a medias: decir «1 género sin ficha» obliga a ir a buscar cuál, y esa búsqueda ya la ha hecho la consulta. Dos viajes a la base para las siete filas. Fuera Sveltia CMS y `static/admin/`. Editaba los Markdown de `src/content/`, que desde la fase 4 no lee nadie: guardaba cambios que no salían en pantalla, y además tapaba la ruta nueva. Y de paso, la prueba de contacto que fallaba en la tanda completa y pasaba a solas. No era aislamiento: `page.goto` vuelve al cargar, no al hidratar, y al hidratar Svelte reescribe el valor de cada `input` con el del componente. Lo tecleado antes de ese instante se perdía, y solo le pasaba al primer campo y solo con la máquina cargada. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>master
parent
174cf828e7
commit
3981f33867
@ -0,0 +1,17 @@
|
||||
-- Quién administra el sitio.
|
||||
--
|
||||
-- El rol va en la base y no en una variable de entorno con una lista de
|
||||
-- correos. Con la variable, revocarle el acceso a alguien pide un despliegue, y
|
||||
-- a la pregunta «quién administra esto» solo sabe contestar quien pueda leer la
|
||||
-- configuración del servidor.
|
||||
--
|
||||
-- El `CHECK` no es adorno. La columna es texto, así que sin él un `UPDATE`
|
||||
-- escrito a mano con «adminn» dejaría una cuenta en un rol que no existe: ni
|
||||
-- administra ni queda registrada como que no. Un rol desconocido tiene que ser
|
||||
-- imposible, no improbable.
|
||||
--
|
||||
-- `persona` por defecto, y las cuentas que ya existen se quedan ahí: encender
|
||||
-- esta columna no puede dar permisos a nadie.
|
||||
|
||||
ALTER TABLE "usuario" ADD COLUMN "rol" text DEFAULT 'persona' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "usuario" ADD CONSTRAINT "usuario_rol_conocido" CHECK ("rol" IN ('persona', 'admin'));
|
||||
@ -0,0 +1,40 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/*
|
||||
* El panel, desde fuera.
|
||||
*
|
||||
* Aquí solo se comprueba la puerta: que sin sesión no se ve nada y que la
|
||||
* dirección de vuelta se conserva. Quién puede pasar una vez dentro lo deciden
|
||||
* `esAdmin` y `exigirAdmin`, y eso se prueba en `administracion.spec.ts`, donde
|
||||
* se pueden montar las tres situaciones sin necesitar un buzón de correo.
|
||||
*/
|
||||
test.describe('El panel', () => {
|
||||
test('sin sesión no se ve, se manda a entrar', async ({ page }) => {
|
||||
await page.goto('/admin');
|
||||
|
||||
await expect(page).toHaveURL(/\/entrar/);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
});
|
||||
|
||||
test('y se vuelve a donde se iba, no a la portada', async ({ page }) => {
|
||||
await page.goto('/admin?ver=pendientes');
|
||||
|
||||
const url = new URL(page.url());
|
||||
expect(url.pathname).toBe('/entrar');
|
||||
expect(url.searchParams.get('volverA')).toBe('/admin?ver=pendientes');
|
||||
});
|
||||
|
||||
test('el panel no es contenido del sitio, y robots lo dice', async ({ request }) => {
|
||||
const robots = await request.get('/robots.txt');
|
||||
expect(await robots.text()).toContain('Disallow: /admin');
|
||||
});
|
||||
|
||||
test('no hay ningún enlace al panel en la web pública', async ({ page }) => {
|
||||
// No es seguridad —la guarda es el servidor—, es que el panel no forma
|
||||
// parte de la navegación de nadie que venga a leer o a comprar.
|
||||
for (const ruta of ['/', '/musica', '/generos', '/blog']) {
|
||||
await page.goto(ruta);
|
||||
await expect(page.locator('a[href^="/admin"]')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Da o quita el rol de administrador.
|
||||
*
|
||||
* npm run db:admin # quién administra ahora
|
||||
* npm run db:admin -- hola@senzapaura.es # se lo da
|
||||
* npm run db:admin -- hola@… --quitar # se lo quita
|
||||
*
|
||||
* Es un script y no una pantalla del panel a propósito: el primer administrador
|
||||
* no puede nombrarse desde un panel al que todavía no puede entrar nadie. Y
|
||||
* mientras administre una sola persona, una pantalla para gestionar permisos
|
||||
* sería mantener un sistema de permisos para un usuario.
|
||||
*
|
||||
* No crea la cuenta. Se da el rol a alguien que ya ha entrado alguna vez, para
|
||||
* que el rol no pueda quedarse esperando en una dirección mal escrita.
|
||||
*/
|
||||
|
||||
import pg from 'pg';
|
||||
|
||||
const argumentos = process.argv.slice(2);
|
||||
const quitar = argumentos.includes('--quitar');
|
||||
const email = argumentos
|
||||
.find((a) => !a.startsWith('--'))
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 15_000 });
|
||||
await c.connect();
|
||||
|
||||
if (!email) {
|
||||
const { rows } = await c.query(
|
||||
`select email, ultimo_acceso_en from usuario where rol = 'admin' order by email`
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log('No administra nadie.');
|
||||
console.log('Para dárselo a alguien: npm run db:admin -- correo@ejemplo.es');
|
||||
} else {
|
||||
console.log(`Administran ${rows.length}:`);
|
||||
for (const f of rows) {
|
||||
const visto = f.ultimo_acceso_en
|
||||
? f.ultimo_acceso_en.toISOString().slice(0, 10)
|
||||
: 'no ha entrado';
|
||||
console.log(` ${f.email.padEnd(34)} último acceso ${visto}`);
|
||||
}
|
||||
}
|
||||
|
||||
await c.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { rows } = await c.query(
|
||||
`update usuario set rol = $2 where email = $1 returning email, rol`,
|
||||
[email, quitar ? 'persona' : 'admin']
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log(`No hay ninguna cuenta con «${email}».`);
|
||||
console.log('Tiene que haber entrado al menos una vez para poder darle el rol.');
|
||||
await c.end();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
quitar ? `${rows[0].email} ya no administra.` : `${rows[0].email} administra: /admin ya le abre.`
|
||||
);
|
||||
|
||||
/*
|
||||
* Las sesiones abiertas siguen valiendo, y es lo correcto: el rol se lee de la
|
||||
* base en cada petición —la sesión solo guarda a quién pertenece—, así que dar
|
||||
* o quitar el permiso surte efecto en la siguiente página que se pida, sin
|
||||
* tener que echar a nadie fuera.
|
||||
*/
|
||||
|
||||
await c.end();
|
||||
@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isHttpError, isRedirect } from '@sveltejs/kit';
|
||||
import { esAdmin, exigirAdmin } from './administracion';
|
||||
import type { Usuario } from './db/schema';
|
||||
|
||||
/** Una cuenta cualquiera, con el rol que se le diga. */
|
||||
const cuenta = (rol: 'persona' | 'admin'): Usuario => ({
|
||||
id: 'u1',
|
||||
email: 'quien@ejemplo.es',
|
||||
nombre: null,
|
||||
rol,
|
||||
creadoEn: new Date(),
|
||||
ultimoAccesoEn: null
|
||||
});
|
||||
|
||||
const en = (ruta: string) => new URL(ruta, 'https://senzapaura.es');
|
||||
|
||||
describe('esAdmin', () => {
|
||||
it('solo el rol admin administra', () => {
|
||||
expect(esAdmin(cuenta('admin'))).toBe(true);
|
||||
expect(esAdmin(cuenta('persona'))).toBe(false);
|
||||
});
|
||||
|
||||
it('sin sesión, no', () => {
|
||||
expect(esAdmin(null)).toBe(false);
|
||||
expect(esAdmin(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exigirAdmin', () => {
|
||||
it('deja pasar a quien administra', () => {
|
||||
const admin = cuenta('admin');
|
||||
expect(exigirAdmin({ usuario: admin }, en('/admin'))).toBe(admin);
|
||||
});
|
||||
|
||||
it('sin sesión manda a entrar, y con la vuelta puesta', () => {
|
||||
// Quien administra también se queda sin sesión de vez en cuando. Volver a
|
||||
// la portada le obligaría a buscar otra vez dónde estaba.
|
||||
try {
|
||||
exigirAdmin({ usuario: null }, en('/admin/glosario?sin=confirmar'));
|
||||
expect.unreachable('tenía que redirigir');
|
||||
} catch (salida) {
|
||||
expect(isRedirect(salida)).toBe(true);
|
||||
if (!isRedirect(salida)) return;
|
||||
expect(salida.status).toBe(303);
|
||||
expect(salida.location).toBe('/entrar?volverA=%2Fadmin%2Fglosario%3Fsin%3Dconfirmar');
|
||||
}
|
||||
});
|
||||
|
||||
it('con sesión pero sin el rol, 404 y no 403', () => {
|
||||
// Un 403 confirma que el panel existe y en qué dirección vive. Quien entró
|
||||
// a comprar una canción no tiene por qué enterarse de eso.
|
||||
try {
|
||||
exigirAdmin({ usuario: cuenta('persona') }, en('/admin'));
|
||||
expect.unreachable('tenía que cortar');
|
||||
} catch (salida) {
|
||||
expect(isHttpError(salida)).toBe(true);
|
||||
if (!isHttpError(salida)) return;
|
||||
expect(salida.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* La puerta del panel.
|
||||
*
|
||||
* Una sola función, y se llama desde el `+layout.server.ts` de `/admin`: en
|
||||
* SvelteKit el layout de una carpeta corre antes que cualquier página de dentro,
|
||||
* así que la comprobación no se puede olvidar al añadir una pantalla nueva. Una
|
||||
* guarda que hay que acordarse de copiar en cada ruta acaba faltando en una.
|
||||
*/
|
||||
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { Usuario } from './db/schema';
|
||||
|
||||
export function esAdmin(quien: Usuario | null | undefined): boolean {
|
||||
return quien?.rol === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve la cuenta si administra; si no, no vuelve.
|
||||
*
|
||||
* Sin sesión se manda a entrar, con la dirección de vuelta puesta: quien
|
||||
* administra también se queda sin sesión de vez en cuando, y mandarlo a la
|
||||
* portada le obligaría a volver a buscar dónde estaba.
|
||||
*
|
||||
* Con sesión pero sin el rol, **404 y no 403**. Un 403 confirma que el panel
|
||||
* existe y en qué dirección vive, que es justo lo que no tiene por qué saber
|
||||
* quien entró a comprar una canción. La diferencia entre «no puedes» y «no hay
|
||||
* nada» no le cambia la vida a nadie legítimo y sí a quien va probando.
|
||||
*/
|
||||
export function exigirAdmin(locals: App.Locals, url: URL): Usuario {
|
||||
if (!locals.usuario) {
|
||||
const volverA = encodeURIComponent(`${url.pathname}${url.search}`);
|
||||
redirect(303, `/entrar?volverA=${volverA}`);
|
||||
}
|
||||
|
||||
if (!esAdmin(locals.usuario)) error(404, 'No hay nada en esa dirección.');
|
||||
|
||||
return locals.usuario;
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Lo que sabe la portada del panel.
|
||||
*
|
||||
* No es un cuadro de mando: no hay visitas que enseñar ni ventas que celebrar.
|
||||
* Lo que contesta es «qué falta», que es la pregunta que se hace quien entra
|
||||
* aquí. Por eso cada sección trae dos números —lo que hay y lo que está a
|
||||
* medias— y, cuando lo hay, **los nombres**: saber que un género no tiene ficha
|
||||
* obliga a ir a buscar cuál, y esa búsqueda la puede hacer la consulta.
|
||||
*/
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
|
||||
/** Una fila de la portada: una sección del contenido y su estado. */
|
||||
export interface SeccionDelPanel {
|
||||
titulo: string;
|
||||
/** Cuántas cosas hay en total. */
|
||||
cuantas: number;
|
||||
/** Cómo se llaman: «géneros», «capítulos». */
|
||||
unidad: string;
|
||||
/** Lo que está a medias, si lo hay. Es lo que da trabajo. */
|
||||
pendiente: {
|
||||
cuantas: number;
|
||||
texto: string;
|
||||
/** Los primeros por nombre; si son más, la fila dice cuántos quedan. */
|
||||
cuales: string[];
|
||||
} | null;
|
||||
/** A dónde lleva, o `null` mientras esa pantalla no exista. */
|
||||
href: string | null;
|
||||
}
|
||||
|
||||
/** Cuántos nombres se enseñan antes de resumir en «y N más». */
|
||||
const NOMBRES_A_LA_VISTA = 4;
|
||||
|
||||
/**
|
||||
* Todos los recuentos en una consulta, y todos los nombres en otra.
|
||||
*
|
||||
* Dos viajes y no catorce. Ninguna de las subconsultas depende del resultado de
|
||||
* otra, así que pedirlas por separado solo añadiría idas y venidas para pintar
|
||||
* una tabla de siete filas.
|
||||
*/
|
||||
export async function estadoDelContenido(): Promise<SeccionDelPanel[]> {
|
||||
const [recuentos, nombres] = await Promise.all([
|
||||
db.execute<Record<string, number>>(sql`
|
||||
select
|
||||
(select count(*) from capitulo where tipo = 'principio')::int as principios,
|
||||
(select count(*) from capitulo
|
||||
where tipo = 'principio' and estado <> 'publicado')::int as principios_a_medias,
|
||||
(select count(*) from capitulo where tipo = 'ficha')::int as fichas,
|
||||
(select count(*) from capitulo
|
||||
where tipo = 'ficha' and estado <> 'publicado')::int as fichas_a_medias,
|
||||
(select count(*) from genero)::int as generos,
|
||||
(select count(*) from genero g
|
||||
where not exists (select 1 from capitulo c where c.genero_id = g.id))::int
|
||||
as generos_sin_ficha,
|
||||
(select count(*) from termino)::int as terminos,
|
||||
(select count(*) from capitulo_acepcion where not confirmado)::int as marcas_sin_confirmar,
|
||||
(select count(*) from cancion where version_de_id is null)::int as canciones,
|
||||
(select count(*) from cancion
|
||||
where version_de_id is null and estado <> 'publicado')::int as canciones_a_medias,
|
||||
(select count(*) from entrada)::int as entradas,
|
||||
(select count(*) from entrada where estado <> 'publicado')::int as entradas_a_medias,
|
||||
(select count(*) from persona)::int as personas,
|
||||
(select count(*) from persona where revisado_en is null and origen = 'importado')::int
|
||||
as personas_sin_revisar
|
||||
`),
|
||||
|
||||
/*
|
||||
* Cada rama trae solo las primeras. El `limit` va dentro de cada
|
||||
* paréntesis y no fuera: puesto al final, cortaría el total y una sección
|
||||
* con muchas pendientes dejaría a las demás sin ninguna.
|
||||
*/
|
||||
db.execute<{ que: string; nombre: string }>(sql`
|
||||
(select 'principios' as que, titulo as nombre from capitulo
|
||||
where tipo = 'principio' and estado <> 'publicado'
|
||||
order by orden limit ${NOMBRES_A_LA_VISTA})
|
||||
union all
|
||||
(select 'fichas', titulo from capitulo
|
||||
where tipo = 'ficha' and estado <> 'publicado'
|
||||
order by orden limit ${NOMBRES_A_LA_VISTA})
|
||||
union all
|
||||
(select 'generos', g.nombre from genero g
|
||||
where not exists (select 1 from capitulo c where c.genero_id = g.id)
|
||||
order by g.nombre limit ${NOMBRES_A_LA_VISTA})
|
||||
union all
|
||||
(select 'canciones', titulo from cancion
|
||||
where version_de_id is null and estado <> 'publicado'
|
||||
order by titulo limit ${NOMBRES_A_LA_VISTA})
|
||||
union all
|
||||
(select 'entradas', titulo from entrada
|
||||
where estado <> 'publicado' order by titulo limit ${NOMBRES_A_LA_VISTA})
|
||||
union all
|
||||
(select 'personas', nombre from persona
|
||||
where revisado_en is null and origen = 'importado'
|
||||
order by nombre limit ${NOMBRES_A_LA_VISTA})
|
||||
`)
|
||||
]);
|
||||
|
||||
const n = recuentos.rows[0];
|
||||
|
||||
const cualesDe = (que: string) => nombres.rows.filter((f) => f.que === que).map((f) => f.nombre);
|
||||
|
||||
/** Lo pendiente solo se cuenta cuando lo hay: un cero no es una tarea. */
|
||||
const pendiente = (cuantas: number, texto: string, que?: string) =>
|
||||
cuantas > 0 ? { cuantas, texto, cuales: que ? cualesDe(que) : [] } : null;
|
||||
|
||||
return [
|
||||
{
|
||||
titulo: 'Los principios',
|
||||
cuantas: n.principios,
|
||||
unidad: 'capítulos',
|
||||
pendiente: pendiente(n.principios_a_medias, 'sin publicar', 'principios'),
|
||||
href: null
|
||||
},
|
||||
{
|
||||
titulo: 'Fichas de género',
|
||||
cuantas: n.fichas,
|
||||
unidad: 'fichas',
|
||||
pendiente: pendiente(n.fichas_a_medias, 'sin publicar', 'fichas'),
|
||||
href: null
|
||||
},
|
||||
{
|
||||
titulo: 'Géneros',
|
||||
cuantas: n.generos,
|
||||
unidad: 'géneros',
|
||||
pendiente: pendiente(n.generos_sin_ficha, 'sin ficha', 'generos'),
|
||||
href: null
|
||||
},
|
||||
{
|
||||
titulo: 'Glosario',
|
||||
cuantas: n.terminos,
|
||||
unidad: 'términos',
|
||||
// Sin nombres: lo pendiente son marcas dentro de un texto, y el nombre
|
||||
// del término no dice en qué capítulo hay que confirmarlo.
|
||||
pendiente: pendiente(n.marcas_sin_confirmar, 'marcas por confirmar'),
|
||||
href: null
|
||||
},
|
||||
{
|
||||
titulo: 'Catálogo',
|
||||
cuantas: n.canciones,
|
||||
unidad: 'canciones',
|
||||
pendiente: pendiente(n.canciones_a_medias, 'sin publicar', 'canciones'),
|
||||
href: null
|
||||
},
|
||||
{
|
||||
titulo: 'Blog',
|
||||
cuantas: n.entradas,
|
||||
unidad: 'entradas',
|
||||
pendiente: pendiente(n.entradas_a_medias, 'sin publicar', 'entradas'),
|
||||
href: null
|
||||
},
|
||||
{
|
||||
titulo: 'Personas',
|
||||
cuantas: n.personas,
|
||||
unidad: 'fichas',
|
||||
pendiente: pendiente(n.personas_sin_revisar, 'importadas sin revisar', 'personas'),
|
||||
href: null
|
||||
}
|
||||
];
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { exigirAdmin } from '$lib/server/administracion';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
/**
|
||||
* La única comprobación de permiso de todo el panel.
|
||||
*
|
||||
* En SvelteKit el `load` del layout corre antes que el de cualquier página de
|
||||
* dentro, así que una pantalla nueva queda guardada por el hecho de estar en
|
||||
* esta carpeta. Repetir la comprobación en cada ruta sería copiarla, y la copia
|
||||
* que falta es la que se descubre tarde.
|
||||
*/
|
||||
export const load: LayoutServerLoad = ({ locals, url }) => {
|
||||
const admin = exigirAdmin(locals, url);
|
||||
|
||||
return { admin: { email: admin.email } };
|
||||
};
|
||||
@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* El panel.
|
||||
*
|
||||
* Deliberadamente sobrio y distinto de la web: aquí no se viene a mirar, se
|
||||
* viene a arreglar algo concreto. Lleva la misma paleta —es el mismo sitio—
|
||||
* pero sin la cabecera de navegación, sin el reproductor y sin el pie: nada
|
||||
* de eso ayuda a corregir una ficha, y todo ello ocupa la pantalla.
|
||||
*
|
||||
* La barra dice siempre quién eres. En una herramienta que edita lo que se
|
||||
* publica, saber con qué cuenta estás no es un adorno.
|
||||
*/
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let { data, children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<header class="barra">
|
||||
<a class="marca" href={resolve('/admin')}>
|
||||
Panel<span class="sitio">Senza Paura</span>
|
||||
</a>
|
||||
|
||||
<div class="quien">
|
||||
<span class="correo">{data.admin.email}</span>
|
||||
<a href={resolve('/')}>Ver el sitio</a>
|
||||
<form method="POST" action={resolve('/salir')}>
|
||||
<button type="submit">Salir</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--c-bg);
|
||||
}
|
||||
|
||||
.barra {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-5);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
background: var(--c-bg-raised);
|
||||
}
|
||||
|
||||
/*
|
||||
* «Panel» primero y el nombre del sitio detrás y en pequeño, y no al revés:
|
||||
* quien está aquí ya sabe de qué web es, lo que necesita saber de un vistazo
|
||||
* es que está en la herramienta y no en la página.
|
||||
*/
|
||||
.marca {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--fs-lg);
|
||||
text-decoration: none;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.sitio {
|
||||
margin-left: var(--sp-3);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--fs-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.quien {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-4);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.correo {
|
||||
color: var(--c-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.quien form {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.quien button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.quien button:hover,
|
||||
.quien a:hover {
|
||||
color: var(--c-accent);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.barra {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
main {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,6 @@
|
||||
import { estadoDelContenido } from '$lib/server/panel';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return { secciones: await estadoDelContenido() };
|
||||
};
|
||||
@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* La portada del panel: qué hay y qué falta.
|
||||
*
|
||||
* Una tabla y no tarjetas. Son siete filas con dos números cada una y la
|
||||
* pregunta que contestan es comparativa —«¿dónde queda más por hacer?»—; en
|
||||
* tarjetas, esos números quedan a distinta altura y hay que ir buscándolos.
|
||||
* Alineados en columna se leen de arriba abajo de una pasada.
|
||||
*/
|
||||
import Meta from '$lib/components/Meta.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const pendientes = $derived(data.secciones.filter((s) => s.pendiente).length);
|
||||
</script>
|
||||
|
||||
<Meta titulo="Panel" descripcion="Administración del contenido." sinIndexar />
|
||||
|
||||
<header>
|
||||
<h1>Panel</h1>
|
||||
<p class="entradilla">
|
||||
{#if pendientes === 0}
|
||||
No hay nada a medias.
|
||||
{:else}
|
||||
Hay algo a medias en {pendientes}
|
||||
{pendientes === 1 ? 'sección' : 'secciones'}.
|
||||
{/if}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<table>
|
||||
<caption class="visually-hidden">El contenido del sitio, y lo que queda por terminar</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Sección</th>
|
||||
<th scope="col" class="numero">Hay</th>
|
||||
<th scope="col">Falta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.secciones as seccion (seccion.titulo)}
|
||||
<tr>
|
||||
<th scope="row">
|
||||
{#if seccion.href}
|
||||
<a href={seccion.href}>{seccion.titulo}</a>
|
||||
{:else}
|
||||
{seccion.titulo}
|
||||
{/if}
|
||||
</th>
|
||||
<td class="numero">
|
||||
{seccion.cuantas}
|
||||
<span class="unidad">{seccion.unidad}</span>
|
||||
</td>
|
||||
<td>
|
||||
{#if seccion.pendiente}
|
||||
{@const p = seccion.pendiente}
|
||||
<span class="pendiente">{p.cuantas} {p.texto}</span>
|
||||
{#if p.cuales.length > 0}
|
||||
<span class="cuales">
|
||||
{p.cuales.join(' · ')}{#if p.cuantas > p.cuales.length}, y {p.cuantas -
|
||||
p.cuales.length} más{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="nada" aria-label="nada">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="nota muted">
|
||||
Las pantallas para editar cada sección todavía no están. Mientras tanto, el contenido se carga con
|
||||
los scripts de <code>scripts/db</code>.
|
||||
</p>
|
||||
|
||||
<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);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
text-align: left;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
tbody th {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/*
|
||||
* Los números a la derecha y con cifras de ancho fijo: así el 6 y el 18 se
|
||||
* comparan por su sitio, sin leerlos.
|
||||
*/
|
||||
.numero {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unidad {
|
||||
margin-left: 0.4rem;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
/* Lo que falta va en el color del dato, que es el del mapa: es un recuento. */
|
||||
.pendiente {
|
||||
color: var(--c-dato);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/*
|
||||
* Los nombres de lo que falta, detrás del recuento. Son la diferencia entre
|
||||
* saber que hay trabajo y saber cuál: «1 sin ficha» obliga a ir a buscar
|
||||
* cuál, y esa búsqueda ya la ha hecho la consulta.
|
||||
*/
|
||||
.cuales {
|
||||
margin-left: 0.6rem;
|
||||
color: var(--c-text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.nada {
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.nota {
|
||||
padding-top: var(--sp-5);
|
||||
font-size: var(--fs-sm);
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
th,
|
||||
td {
|
||||
padding: var(--sp-3) var(--sp-2);
|
||||
}
|
||||
|
||||
.unidad {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,426 +0,0 @@
|
||||
# Panel de contenido (Sveltia CMS).
|
||||
#
|
||||
# Escribe los mismos archivos Markdown de `src/content/`, así que nada de lo
|
||||
# que ya funciona cambia: el esquema los sigue validando al compilar, git sigue
|
||||
# guardando el historial de cada letra y el catálogo sigue sin hacer consultas.
|
||||
#
|
||||
# Los campos de aquí replican el esquema de `src/lib/content/schema.ts`. Cuando
|
||||
# se añada un campo allí, hay que añadirlo también aquí, o el panel lo borrará
|
||||
# al guardar una entrada.
|
||||
|
||||
backend:
|
||||
name: github
|
||||
# Cambiar por el repositorio real cuando exista. Trabajando en local no se
|
||||
# usa: el flujo local abre la carpeta del proyecto desde el propio navegador.
|
||||
repo: senzapaura/senzapaura
|
||||
branch: main
|
||||
|
||||
# Dónde aterrizan las imágenes que se suban desde el panel. Si prefieres
|
||||
# agruparlas por disco (static/images/<album>/), se mueven a mano después: al
|
||||
# esquema le vale cualquier ruta que empiece por "/".
|
||||
media_folder: static/images
|
||||
public_folder: /images
|
||||
|
||||
collections:
|
||||
# ---------------------------------------------------------------- estilos
|
||||
- name: estilos
|
||||
label: Estilos
|
||||
label_singular: Estilo
|
||||
description: Cada estilo es un segmento de la página de música.
|
||||
folder: src/content/estilos
|
||||
identifier_field: nombre
|
||||
create: true
|
||||
slug: '{{slug}}'
|
||||
summary: '{{nombre}}'
|
||||
fields:
|
||||
- { name: nombre, label: Nombre, widget: string }
|
||||
- name: resumen
|
||||
label: Resumen
|
||||
widget: string
|
||||
required: false
|
||||
hint: Una frase corta; acompaña al título del segmento.
|
||||
- name: orden
|
||||
label: Orden
|
||||
widget: number
|
||||
value_type: int
|
||||
min: 0
|
||||
default: 10
|
||||
hint: Menor número, más arriba en la página de música.
|
||||
- name: principal
|
||||
label: Estilo principal
|
||||
widget: boolean
|
||||
default: false
|
||||
required: false
|
||||
hint: Solo uno puede serlo. Es el que abre la portada.
|
||||
- { name: imagen, label: Imagen del segmento, widget: image, required: false }
|
||||
- name: body
|
||||
label: Texto introductorio
|
||||
widget: markdown
|
||||
required: false
|
||||
|
||||
# ---------------------------------------------------------------- álbumes
|
||||
- name: albumes
|
||||
label: Álbumes
|
||||
label_singular: Álbum
|
||||
description: Discos, EPs, singles y colecciones.
|
||||
folder: src/content/albumes
|
||||
identifier_field: titulo
|
||||
create: true
|
||||
slug: '{{slug}}'
|
||||
summary: '{{titulo}} · {{fecha}}'
|
||||
fields:
|
||||
- { name: titulo, label: Título, widget: string }
|
||||
- name: estilo
|
||||
label: Estilo
|
||||
widget: relation
|
||||
collection: estilos
|
||||
value_field: '{{slug}}'
|
||||
display_fields: [nombre]
|
||||
search_fields: [nombre]
|
||||
- { name: fecha, label: Fecha de publicación, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, picker_utc: true }
|
||||
- name: tipo
|
||||
label: Tipo
|
||||
widget: select
|
||||
default: album
|
||||
options: [album, ep, single, coleccion]
|
||||
- { name: portada, label: Portada, widget: image, required: false }
|
||||
- { name: sello, label: Sello, widget: string, required: false }
|
||||
- { name: resumen, label: Resumen, widget: string, required: false }
|
||||
- { name: destacado, label: Destacado, widget: boolean, default: false, required: false }
|
||||
- name: edicion
|
||||
label: Datos de edición
|
||||
widget: object
|
||||
required: false
|
||||
collapsed: true
|
||||
fields:
|
||||
- { name: numeroCatalogo, label: Nº de catálogo, widget: string, required: false }
|
||||
- name: formatos
|
||||
label: Formatos
|
||||
widget: select
|
||||
multiple: true
|
||||
required: false
|
||||
options: [digital, cd, vinilo, casete]
|
||||
- { name: pais, label: País, widget: string, required: false }
|
||||
- name: creditos
|
||||
label: Créditos del disco
|
||||
widget: list
|
||||
required: false
|
||||
label_singular: Crédito
|
||||
summary: '{{rol}}: {{nombre}}'
|
||||
fields:
|
||||
- { name: rol, label: Cometido, widget: string }
|
||||
- { name: nombre, label: Nombre, widget: string }
|
||||
- name: enlaces
|
||||
label: Enlaces de escucha
|
||||
widget: object
|
||||
required: false
|
||||
collapsed: true
|
||||
fields:
|
||||
- { name: spotify, label: Spotify, widget: string, required: false }
|
||||
- { name: appleMusic, label: Apple Music, widget: string, required: false }
|
||||
- { name: youtube, label: YouTube, widget: string, required: false }
|
||||
- { name: bandcamp, label: Bandcamp, widget: string, required: false }
|
||||
- name: body
|
||||
label: Notas del disco
|
||||
widget: markdown
|
||||
required: false
|
||||
|
||||
# -------------------------------------------------------------- canciones
|
||||
- name: canciones
|
||||
label: Canciones
|
||||
label_singular: Canción
|
||||
description: El cuerpo del texto es la letra. Los saltos de línea se respetan.
|
||||
folder: src/content/canciones
|
||||
identifier_field: titulo
|
||||
create: true
|
||||
slug: '{{slug}}'
|
||||
summary: '{{titulo}}'
|
||||
fields:
|
||||
- { name: titulo, label: Título, widget: string }
|
||||
- name: estilo
|
||||
label: Estilo
|
||||
widget: relation
|
||||
collection: estilos
|
||||
value_field: '{{slug}}'
|
||||
display_fields: [nombre]
|
||||
search_fields: [nombre]
|
||||
- name: album
|
||||
label: Álbum
|
||||
widget: relation
|
||||
collection: albumes
|
||||
value_field: '{{slug}}'
|
||||
display_fields: [titulo]
|
||||
search_fields: [titulo]
|
||||
required: false
|
||||
hint: Déjalo vacío si es un single suelto; irá al álbum «Singles» de su estilo.
|
||||
- name: numero
|
||||
label: Nº de pista
|
||||
widget: number
|
||||
value_type: int
|
||||
min: 1
|
||||
required: false
|
||||
hint: Obligatorio si hay álbum. Se ignora en los singles.
|
||||
- name: duracion
|
||||
label: Duración
|
||||
widget: string
|
||||
required: false
|
||||
pattern: ['^\d{1,2}:[0-5]\d$', 'Escríbela como m:ss, por ejemplo 4:12']
|
||||
- { name: ano, label: Año, widget: number, value_type: int, min: 1900, max: 2200, required: false }
|
||||
- name: audio
|
||||
label: Archivo de audio
|
||||
widget: file
|
||||
required: false
|
||||
media_folder: /media/audio/subidas
|
||||
public_folder: subidas
|
||||
hint: >-
|
||||
Sube aquí el archivo completo, con el nombre que sea. Al arrancar
|
||||
(`npm run dev`) se coloca en la carpeta de su disco, se genera el
|
||||
fragmento de muestra y se rellena la duración si está vacía.
|
||||
- { name: portada, label: Portada propia, widget: image, required: false, hint: 'Si la dejas vacía se usa la del álbum.' }
|
||||
- name: interpretes
|
||||
label: Intérpretes
|
||||
widget: list
|
||||
required: false
|
||||
field: { name: nombre, label: Nombre, widget: string }
|
||||
- name: letra
|
||||
label: Firma de la letra
|
||||
widget: list
|
||||
required: false
|
||||
hint: Déjalo vacío si la firmas tú.
|
||||
field: { name: nombre, label: Nombre, widget: string }
|
||||
- name: musica
|
||||
label: Firma de la música
|
||||
widget: list
|
||||
required: false
|
||||
hint: Déjalo vacío si la firmas tú.
|
||||
field: { name: nombre, label: Nombre, widget: string }
|
||||
- { name: destacada, label: Destacada, widget: boolean, default: false, required: false }
|
||||
- name: instrumental
|
||||
label: Instrumental
|
||||
widget: boolean
|
||||
default: false
|
||||
required: false
|
||||
hint: Sin letra. El cuerpo pasa a leerse como notas de la pieza.
|
||||
- { name: alaVenta, label: A la venta, widget: boolean, required: false, hint: 'Por defecto se vende si tiene audio.' }
|
||||
- name: idioma
|
||||
label: Idioma de la letra
|
||||
widget: select
|
||||
default: es
|
||||
options:
|
||||
- { label: 'Español', value: es }
|
||||
- { label: 'Inglés', value: en }
|
||||
- { label: 'Portugués', value: pt }
|
||||
- { label: 'Francés', value: fr }
|
||||
- { label: 'Italiano', value: it }
|
||||
- { label: 'Catalán', value: ca }
|
||||
- { label: 'Gallego', value: gl }
|
||||
- { label: 'Euskera', value: eu }
|
||||
required: false
|
||||
- name: traduccion
|
||||
label: Traducción al español
|
||||
widget: text
|
||||
required: false
|
||||
hint: >-
|
||||
Solo si la letra no está en español. Se enseña en una segunda columna
|
||||
al lado de la original, así que conviene respetar el corte de los
|
||||
versos para que se puedan seguir en paralelo.
|
||||
- name: inspiracion
|
||||
label: De dónde sale
|
||||
widget: text
|
||||
required: false
|
||||
hint: Separa los párrafos con una línea en blanco.
|
||||
- name: etiquetas
|
||||
label: Etiquetas
|
||||
widget: list
|
||||
required: false
|
||||
hint: >-
|
||||
De qué habla, cómo suena, con quién se canta. Cruzan el catálogo por
|
||||
donde no lo hacen el estilo ni el disco.
|
||||
- name: versiones
|
||||
label: Alternativas musicales
|
||||
widget: list
|
||||
required: false
|
||||
label_singular: Versión
|
||||
summary: '{{nombre}}'
|
||||
collapsed: true
|
||||
fields:
|
||||
- { name: nombre, label: Nombre del arreglo, widget: string }
|
||||
- name: audio
|
||||
label: Archivo de audio
|
||||
widget: file
|
||||
media_folder: /media/audio/subidas
|
||||
public_folder: subidas
|
||||
hint: El arreglo completo. Se coloca solo al arrancar, como el principal.
|
||||
- name: duracion
|
||||
label: Duración
|
||||
widget: string
|
||||
required: false
|
||||
pattern: ['^\d{1,2}:[0-5]\d$', 'Escríbela como m:ss']
|
||||
- { name: nota, label: Nota, widget: string, required: false }
|
||||
- name: videos
|
||||
label: Vídeos
|
||||
widget: list
|
||||
required: false
|
||||
label_singular: Vídeo
|
||||
summary: '{{titulo}}'
|
||||
collapsed: true
|
||||
fields:
|
||||
- name: youtube
|
||||
label: Enlace o id de YouTube
|
||||
widget: string
|
||||
hint: Pega el enlace tal cual; el esquema saca el id.
|
||||
- { name: titulo, label: Qué es, widget: string, required: false }
|
||||
- name: registro
|
||||
label: Registro de la obra
|
||||
widget: object
|
||||
required: false
|
||||
collapsed: true
|
||||
fields:
|
||||
- name: iswc
|
||||
label: ISWC
|
||||
widget: string
|
||||
required: false
|
||||
pattern: ['^[Tt][-\s.]?\d{3}[.\s]?\d{3}[.\s]?\d{3}[-\s]?\d$', 'Formato T-DDD.DDD.DDD-C']
|
||||
hint: Identifica la OBRA. Es el mismo en todas sus versiones.
|
||||
- name: isrc
|
||||
label: ISRC
|
||||
widget: string
|
||||
required: false
|
||||
pattern: ['^[A-Za-z]{2}[-\s]?[A-Za-z0-9]{3}[-\s]?\d{2}[-\s]?\d{5}$', 'Formato CC-XXX-YY-NNNNN']
|
||||
hint: Identifica ESTA grabación. Cada versión tiene el suyo.
|
||||
- { name: entidad, label: Entidad de gestión, widget: string, required: false }
|
||||
- { name: numero, label: Nº de registro, widget: string, required: false }
|
||||
- { name: editorial, label: Editorial musical, widget: string, required: false }
|
||||
- name: reparto
|
||||
label: Reparto de autoría
|
||||
widget: list
|
||||
required: false
|
||||
label_singular: Parte
|
||||
summary: '{{nombre}} — {{porcentaje}} %'
|
||||
hint: Si lo rellenas, los porcentajes tienen que sumar exactamente 100.
|
||||
fields:
|
||||
- { name: nombre, label: Nombre, widget: string }
|
||||
- name: rol
|
||||
label: Qué firma
|
||||
widget: select
|
||||
default: letra y musica
|
||||
options: ['letra', 'musica', 'letra y musica']
|
||||
- { name: porcentaje, label: Porcentaje, widget: number, value_type: float, min: 0.01, max: 100 }
|
||||
- name: enlaces
|
||||
label: Enlaces de escucha
|
||||
widget: object
|
||||
required: false
|
||||
collapsed: true
|
||||
fields:
|
||||
- { name: spotify, label: Spotify, widget: string, required: false }
|
||||
- { name: appleMusic, label: Apple Music, widget: string, required: false }
|
||||
- { name: youtube, label: YouTube, widget: string, required: false }
|
||||
- name: body
|
||||
label: Letra
|
||||
widget: markdown
|
||||
required: false
|
||||
hint: Los saltos de línea se respetan; cada verso en su línea.
|
||||
|
||||
# ----------------------------------------------------------------- taller
|
||||
- name: taller
|
||||
label: Taller de letras
|
||||
label_singular: Capítulo
|
||||
description: Los principios del oficio y una ficha de creación por estilo.
|
||||
folder: src/content/taller
|
||||
identifier_field: titulo
|
||||
create: true
|
||||
slug: '{{slug}}'
|
||||
summary: '{{titulo}}'
|
||||
fields:
|
||||
- { name: titulo, label: Título, widget: string }
|
||||
- name: tipo
|
||||
label: Tipo
|
||||
widget: select
|
||||
default: principio
|
||||
options:
|
||||
- { label: 'Principio (el curso)', value: principio }
|
||||
- { label: 'Ficha de estilo', value: ficha }
|
||||
- { name: orden, label: Orden dentro de su bloque, widget: number, value_type: int, min: 0, default: 10 }
|
||||
- { name: resumen, label: Resumen, widget: string, required: false }
|
||||
- name: ficha
|
||||
label: Datos del estilo
|
||||
widget: object
|
||||
required: false
|
||||
collapsed: true
|
||||
hint: Solo para las fichas de estilo; en los principios se deja vacío.
|
||||
fields:
|
||||
- { name: origen, label: Origen, widget: string, required: false }
|
||||
- { name: compas, label: Compás, widget: string, required: false }
|
||||
- { name: tempo, label: Tempo, widget: string, required: false }
|
||||
- { name: metrica, label: Métrica, widget: string, required: false }
|
||||
- { name: estructura, label: Estructura, widget: string, required: false }
|
||||
- { name: rima, label: Rima, widget: string, required: false }
|
||||
- name: temas
|
||||
label: De qué se habla
|
||||
widget: list
|
||||
required: false
|
||||
field: { name: tema, label: Tema, widget: string }
|
||||
- name: rasgos
|
||||
label: Rasgos de escritura
|
||||
widget: list
|
||||
required: false
|
||||
field: { name: rasgo, label: Rasgo, widget: string }
|
||||
- { name: body, label: Texto del capítulo, widget: markdown }
|
||||
|
||||
# ---------------------------------------------------------------- páginas
|
||||
# ------------------------------------------------------------------- blog
|
||||
- name: blog
|
||||
label: Blog
|
||||
label_singular: Entrada
|
||||
description: Apuntes fechados. El cuerpo del texto es el artículo.
|
||||
folder: src/content/blog
|
||||
identifier_field: titulo
|
||||
create: true
|
||||
slug: '{{slug}}'
|
||||
summary: '{{titulo}} · {{fecha}}'
|
||||
fields:
|
||||
- { name: titulo, label: Título, widget: string }
|
||||
- { name: fecha, label: Fecha, widget: datetime, date_format: 'YYYY-MM-DD', time_format: false, picker_utc: true }
|
||||
- name: resumen
|
||||
label: Resumen
|
||||
widget: string
|
||||
required: false
|
||||
hint: Una frase; es lo que se lee en el listado y en los buscadores.
|
||||
- { name: imagen, label: Imagen de cabecera, widget: image, required: false }
|
||||
- name: etiquetas
|
||||
label: Etiquetas
|
||||
widget: list
|
||||
required: false
|
||||
hint: Las mismas que las canciones; así el blog cruza con el catálogo.
|
||||
- name: canciones
|
||||
label: Temas de los que habla
|
||||
widget: relation
|
||||
collection: canciones
|
||||
value_field: '{{slug}}'
|
||||
display_fields: [titulo]
|
||||
search_fields: [titulo]
|
||||
multiple: true
|
||||
required: false
|
||||
- name: borrador
|
||||
label: Borrador
|
||||
widget: boolean
|
||||
default: false
|
||||
required: false
|
||||
hint: Mientras esté marcado no aparece en la web.
|
||||
- name: body
|
||||
label: Texto
|
||||
widget: markdown
|
||||
|
||||
- name: paginas
|
||||
label: Páginas
|
||||
label_singular: Página
|
||||
description: Textos sueltos, como la biografía.
|
||||
folder: src/content/paginas
|
||||
identifier_field: titulo
|
||||
create: true
|
||||
slug: '{{slug}}'
|
||||
summary: '{{titulo}}'
|
||||
fields:
|
||||
- { name: titulo, label: Título, widget: string }
|
||||
- { name: descripcion, label: Descripción, widget: string, required: false }
|
||||
- { name: body, label: Texto, widget: markdown }
|
||||
@ -1,20 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- El panel no es contenido del sitio: que no acabe en los buscadores. -->
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Panel de contenido · Senza Paura</title>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
Sveltia CMS. Es un archivo suelto que se carga aquí y lee `config.yml`;
|
||||
no entra en el bundle de la aplicación ni la toca en absoluto. Escribe
|
||||
los mismos Markdown de `src/content/`, así que todo lo que ya hay sigue
|
||||
en pie: la validación al compilar, el historial en git y que el catálogo
|
||||
no haga ni una consulta.
|
||||
-->
|
||||
<script src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in new issue