parent
3708c84e90
commit
46fe708bc2
@ -0,0 +1,84 @@
|
||||
-- Publica una versión nueva de las condiciones: las versiones legales ya
|
||||
-- vigentes no se modifican porque documentan lo aceptado en compras anteriores.
|
||||
INSERT INTO version_legal (
|
||||
id, documento_id, cuerpo, vigente_desde, resumen_cambios
|
||||
)
|
||||
SELECT
|
||||
'legal-condiciones-uso-2026-09-10',
|
||||
d.id,
|
||||
$condiciones$
|
||||
> **Pendiente de revisión legal.** Es un borrador razonable, no un documento
|
||||
> validado por un abogado.
|
||||
|
||||
Usar esta web supone aceptar estas condiciones. Si no estás de acuerdo con
|
||||
alguna, no la uses.
|
||||
|
||||
## La cuenta
|
||||
|
||||
Para comprar hace falta una cuenta, y para crearla basta un correo electrónico
|
||||
al que llega un código. **No hay contraseña**: cada vez que entras se envía un
|
||||
código nuevo. Eso significa que quien tenga acceso a tu correo tiene acceso a
|
||||
tu cuenta, así que protégelo.
|
||||
|
||||
Al crear la cuenta te comprometes a usar una dirección **tuya**. Escribir la de
|
||||
otra persona para que le lleguen los códigos es un uso indebido, y el sistema
|
||||
lo limita activamente.
|
||||
|
||||
Puedes pedir que se cierre tu cuenta cuando quieras escribiendo a
|
||||
hola@senzapaura.es. Las compras y los datos de facturación se conservan el
|
||||
tiempo que obliga la ley, según se explica en la
|
||||
[política de privacidad](/legal/privacidad).
|
||||
|
||||
## Lo que suena y lo que se compra
|
||||
|
||||
En la web se puede escuchar **la canción completa en una copia de calidad
|
||||
reducida**. Esa copia es solo para escucharla desde el sitio: que sea accesible
|
||||
no concede permiso para descargarla, redistribuirla ni reutilizarla.
|
||||
|
||||
Al comprar recibes el archivo de mayor calidad disponible, con todas sus
|
||||
versiones alternativas, y queda vinculado a tu cuenta: puedes volver a
|
||||
descargarlo desde ella siempre que quieras.
|
||||
|
||||
## Lo que no se puede hacer
|
||||
|
||||
Con las canciones compradas:
|
||||
|
||||
- Redistribuirlas, subirlas a ninguna plataforma ni compartirlas públicamente.
|
||||
- Usarlas en vídeos, anuncios, espectáculos o cualquier producción.
|
||||
- Incorporarlas a otra obra o hacer versiones para explotarlas.
|
||||
|
||||
Todo eso requiere una licencia aparte. Los identificadores de registro están en
|
||||
la ficha de cada tema para poder pedirla.
|
||||
|
||||
Con la web:
|
||||
|
||||
- Intentar acceder a partes que no te corresponden, o a los archivos originales
|
||||
o de descarga sin haberlos comprado.
|
||||
- Automatizar peticiones de forma que degraden el servicio.
|
||||
- Usar el formulario de acceso o el de contacto para molestar a terceros.
|
||||
|
||||
## El taller de letras
|
||||
|
||||
Los textos del taller se publican para leerlos y aprender. Puedes usarlos para
|
||||
escribir tus propias canciones —para eso están— pero no republicarlos como
|
||||
propios ni venderlos.
|
||||
|
||||
## Cambios
|
||||
|
||||
Estas condiciones pueden cambiar. La fecha de arriba dice cuándo se revisaron
|
||||
por última vez, y lo que se aplica a una compra son las condiciones vigentes en
|
||||
el momento de hacerla.
|
||||
$condiciones$,
|
||||
TIMESTAMPTZ '2026-09-10 00:00:00+02',
|
||||
'La escucha pública pasa de un fragmento a la canción completa en calidad reducida.'
|
||||
FROM documento_legal d
|
||||
WHERE d.slug = 'condiciones-de-uso'
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
|
||||
UPDATE documento_legal d
|
||||
SET version_vigente_id = v.id
|
||||
FROM version_legal v
|
||||
WHERE d.slug = 'condiciones-de-uso'
|
||||
AND v.id = 'legal-condiciones-uso-2026-09-10'
|
||||
AND v.documento_id = d.id;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,7 @@
|
||||
/** Perfil único de derivados públicos; cambiarlo obliga a regenerarlos. */
|
||||
export const PERFIL_ESCUCHA = Object.freeze({
|
||||
id: 'mp3-96-v1',
|
||||
formato: 'mp3',
|
||||
codecFfmpeg: 'libmp3lame',
|
||||
bitrateKbps: 96
|
||||
});
|
||||
@ -0,0 +1,50 @@
|
||||
/** Informe de paridad tras migrar artistas, versiones y representaciones. */
|
||||
|
||||
import pg from 'pg';
|
||||
|
||||
const cliente = new pg.Client({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
query_timeout: 30_000
|
||||
});
|
||||
await cliente.connect();
|
||||
|
||||
const {
|
||||
rows: [n]
|
||||
} = await cliente.query(`
|
||||
select
|
||||
(select count(*) from cancion where version_de_id is null)::int as canciones,
|
||||
(select count(*) from cancion where version_de_id is not null)::int as versiones,
|
||||
(select count(*) from cancion where codigo is null)::int as canciones_sin_codigo,
|
||||
(select count(*) from artista)::int as artistas,
|
||||
(select count(*) from cancion_persona where rol = 'interprete')::int as creditos_anteriores,
|
||||
(select count(*) from cancion_interprete)::int as creditos_nuevos,
|
||||
(select count(*) from cancion_audio where uso = 'master')::int as masters,
|
||||
(select count(*) from cancion_audio where uso = 'stream')::int as streams,
|
||||
(select count(*) from cancion c where c.estado = 'publicado'
|
||||
and not exists (select 1 from cancion_audio ca where ca.cancion_id = c.id and ca.uso = 'stream'))::int
|
||||
as publicadas_sin_stream
|
||||
`);
|
||||
|
||||
const { rows: problemas } = await cliente.query(`
|
||||
select 'intérprete sin migrar' as tipo, c.slug as referencia
|
||||
from cancion_persona cp
|
||||
join cancion c on c.id = cp.cancion_id
|
||||
where cp.rol = 'interprete'
|
||||
and not exists (select 1 from cancion_interprete ci where ci.cancion_id = cp.cancion_id)
|
||||
union all
|
||||
select 'artista sin código', slug from artista where codigo is null or btrim(codigo) = ''
|
||||
union all
|
||||
select 'versión sin código', slug from cancion
|
||||
where version_de_id is not null and (version_codigo is null or btrim(version_codigo) = '')
|
||||
order by tipo, referencia
|
||||
`);
|
||||
|
||||
console.table(n);
|
||||
if (problemas.length) console.table(problemas);
|
||||
await cliente.end();
|
||||
|
||||
if (problemas.length || n.canciones_sin_codigo || n.publicadas_sin_stream) {
|
||||
console.error('La migración todavía no cumple la paridad requerida.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Dominio musical íntegro: códigos, créditos y streams completos.');
|
||||
@ -0,0 +1,42 @@
|
||||
/** Ejecuta migraciones dentro de una transacción que siempre se revierte. */
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import pg from 'pg';
|
||||
|
||||
const nombres = process.argv.slice(2);
|
||||
if (nombres.length === 0)
|
||||
throw new Error('Indica al menos una migración, por ejemplo 0014_artistas_y_audio.sql.');
|
||||
|
||||
const sentenciasDe = (texto) =>
|
||||
texto
|
||||
.split('--> statement-breakpoint')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && s.split('\n').some((l) => l.trim() && !l.trim().startsWith('--')));
|
||||
|
||||
const cliente = new pg.Client({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
query_timeout: 120_000
|
||||
});
|
||||
await cliente.connect();
|
||||
await cliente.query('BEGIN');
|
||||
|
||||
try {
|
||||
for (const nombre of nombres) {
|
||||
const ruta = path.join('drizzle', nombre);
|
||||
const sentencias = sentenciasDe(fs.readFileSync(ruta, 'utf8'));
|
||||
for (const [indice, sentencia] of sentencias.entries()) {
|
||||
try {
|
||||
await cliente.query(sentencia);
|
||||
} catch (error) {
|
||||
throw new Error(`${nombre}, sentencia ${indice + 1}: ${error.message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
console.log(` ${nombre}: ${sentencias.length} sentencias válidas`);
|
||||
}
|
||||
} finally {
|
||||
await cliente.query('ROLLBACK').catch(() => {});
|
||||
await cliente.end();
|
||||
}
|
||||
|
||||
console.log('Comprobadas y revertidas: la base no ha cambiado.');
|
||||
@ -0,0 +1,138 @@
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { db } from './db';
|
||||
import { artista, artistaIdentificador, medio, pais } from './db/schema';
|
||||
import { leerCatalogo } from './catalogo';
|
||||
import { componer } from './markdown';
|
||||
import type { Cancion, TipoArtista } from '$lib/types';
|
||||
|
||||
export interface ResumenDeArtista {
|
||||
id: string;
|
||||
codigo: string;
|
||||
slug: string;
|
||||
nombre: string;
|
||||
tipo: TipoArtista;
|
||||
descripcion: string;
|
||||
imagen?: string;
|
||||
pais?: string;
|
||||
lugarOrigen?: string;
|
||||
inicio?: number;
|
||||
fin?: number;
|
||||
totalCanciones: number;
|
||||
}
|
||||
|
||||
export interface FichaDeArtista extends ResumenDeArtista {
|
||||
biografia: string;
|
||||
sitioWeb?: string;
|
||||
identificadores: { sistema: string; valor: string }[];
|
||||
canciones: Cancion[];
|
||||
}
|
||||
|
||||
function rutaPublica(clave: string | null): string | undefined {
|
||||
return clave ? `/${clave}` : undefined;
|
||||
}
|
||||
|
||||
function participa(cancion: Cancion, artistaId: string): boolean {
|
||||
return (
|
||||
cancion.interpretes.some((i) => i.id === artistaId) ||
|
||||
cancion.versiones.some((v) => v.interpretes.some((i) => i.id === artistaId))
|
||||
);
|
||||
}
|
||||
|
||||
/** Directorio público de identidades artísticas, nunca personas en borrador. */
|
||||
export async function artistasPublicos(): Promise<ResumenDeArtista[]> {
|
||||
const imagen = alias(medio, 'imagen_artista_publica');
|
||||
const [filas, catalogo] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: artista.id,
|
||||
codigo: artista.codigo,
|
||||
slug: artista.slug,
|
||||
nombre: artista.nombre,
|
||||
tipo: artista.tipo,
|
||||
descripcion: artista.descripcionCorta,
|
||||
imagenClave: imagen.clave,
|
||||
pais: pais.nombre,
|
||||
lugarOrigen: artista.lugarOrigen,
|
||||
inicio: artista.inicio,
|
||||
fin: artista.fin
|
||||
})
|
||||
.from(artista)
|
||||
.leftJoin(imagen, eq(imagen.id, artista.imagenId))
|
||||
.leftJoin(pais, eq(pais.codigo, artista.paisCodigo))
|
||||
.where(eq(artista.estado, 'publicado'))
|
||||
.orderBy(asc(artista.nombre)),
|
||||
leerCatalogo()
|
||||
]);
|
||||
|
||||
return filas.map((fila) => ({
|
||||
id: fila.id,
|
||||
codigo: fila.codigo,
|
||||
slug: fila.slug,
|
||||
nombre: fila.nombre,
|
||||
tipo: fila.tipo,
|
||||
descripcion: fila.descripcion,
|
||||
imagen: rutaPublica(fila.imagenClave),
|
||||
pais: fila.pais ?? undefined,
|
||||
lugarOrigen: fila.lugarOrigen ?? undefined,
|
||||
inicio: fila.inicio ?? undefined,
|
||||
fin: fila.fin ?? undefined,
|
||||
totalCanciones: catalogo.canciones.filter((c) => participa(c, fila.id)).length
|
||||
}));
|
||||
}
|
||||
|
||||
/** Ficha pública y discografía de una identidad artística. */
|
||||
export async function obtenerArtistaPublico(slug: string): Promise<FichaDeArtista | undefined> {
|
||||
const imagen = alias(medio, 'imagen_artista_ficha');
|
||||
const [fila] = await db
|
||||
.select({
|
||||
id: artista.id,
|
||||
codigo: artista.codigo,
|
||||
slug: artista.slug,
|
||||
nombre: artista.nombre,
|
||||
tipo: artista.tipo,
|
||||
descripcion: artista.descripcionCorta,
|
||||
biografia: artista.biografia,
|
||||
imagenClave: imagen.clave,
|
||||
pais: pais.nombre,
|
||||
lugarOrigen: artista.lugarOrigen,
|
||||
inicio: artista.inicio,
|
||||
fin: artista.fin,
|
||||
sitioWeb: artista.sitioWeb
|
||||
})
|
||||
.from(artista)
|
||||
.leftJoin(imagen, eq(imagen.id, artista.imagenId))
|
||||
.leftJoin(pais, eq(pais.codigo, artista.paisCodigo))
|
||||
.where(and(eq(artista.slug, slug), eq(artista.estado, 'publicado')))
|
||||
.limit(1);
|
||||
|
||||
if (!fila) return undefined;
|
||||
const [catalogo, identificadores] = await Promise.all([
|
||||
leerCatalogo(),
|
||||
db
|
||||
.select({ sistema: artistaIdentificador.sistema, valor: artistaIdentificador.valor })
|
||||
.from(artistaIdentificador)
|
||||
.where(eq(artistaIdentificador.artistaId, fila.id))
|
||||
.orderBy(asc(artistaIdentificador.sistema))
|
||||
]);
|
||||
const canciones = catalogo.canciones.filter((c) => participa(c, fila.id));
|
||||
|
||||
return {
|
||||
id: fila.id,
|
||||
codigo: fila.codigo,
|
||||
slug: fila.slug,
|
||||
nombre: fila.nombre,
|
||||
tipo: fila.tipo,
|
||||
descripcion: fila.descripcion,
|
||||
biografia: componer(fila.biografia),
|
||||
imagen: rutaPublica(fila.imagenClave),
|
||||
pais: fila.pais ?? undefined,
|
||||
lugarOrigen: fila.lugarOrigen ?? undefined,
|
||||
inicio: fila.inicio ?? undefined,
|
||||
fin: fila.fin ?? undefined,
|
||||
sitioWeb: fila.sitioWeb ?? undefined,
|
||||
identificadores,
|
||||
canciones,
|
||||
totalCanciones: canciones.length
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
/** Selección servidor-a-servidor del archivo privado que incluye una compra. */
|
||||
|
||||
import { and, asc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
import { cancion, cancionAudio, medio } from './db/schema';
|
||||
|
||||
export interface ArchivoComprado {
|
||||
cancionId: string;
|
||||
cancionSlug: string;
|
||||
principalSlug: string;
|
||||
titulo: string;
|
||||
versionNombre?: string;
|
||||
clave: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve primero la entidad musical y después una representación privada.
|
||||
* La descarga explícita gana al máster; ninguno de los dos cruza el contrato
|
||||
* público del catálogo.
|
||||
*/
|
||||
export async function archivoComprado(
|
||||
principalSlug: string,
|
||||
versionCodigo?: string
|
||||
): Promise<ArchivoComprado | undefined> {
|
||||
const [principal] = await db
|
||||
.select({ id: cancion.id, slug: cancion.slug, titulo: cancion.titulo })
|
||||
.from(cancion)
|
||||
.where(and(eq(cancion.slug, principalSlug), isNull(cancion.versionDeId)))
|
||||
.limit(1);
|
||||
if (!principal) return undefined;
|
||||
|
||||
const destino = versionCodigo
|
||||
? (
|
||||
await db
|
||||
.select({
|
||||
id: cancion.id,
|
||||
slug: cancion.slug,
|
||||
titulo: cancion.titulo,
|
||||
versionNombre: cancion.versionNombre
|
||||
})
|
||||
.from(cancion)
|
||||
.where(
|
||||
and(
|
||||
eq(cancion.versionDeId, principal.id),
|
||||
eq(cancion.versionCodigo, versionCodigo),
|
||||
ne(cancion.estado, 'borrador')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)[0]
|
||||
: { ...principal, versionNombre: null };
|
||||
if (!destino) return undefined;
|
||||
|
||||
const [audio] = await db
|
||||
.select({ clave: medio.clave })
|
||||
.from(cancionAudio)
|
||||
.innerJoin(medio, eq(medio.id, cancionAudio.medioId))
|
||||
.where(
|
||||
and(eq(cancionAudio.cancionId, destino.id), inArray(cancionAudio.uso, ['descarga', 'master']))
|
||||
)
|
||||
.orderBy(
|
||||
sql`case when ${cancionAudio.uso} = 'descarga' then 0 else 1 end`,
|
||||
asc(cancionAudio.orden)
|
||||
)
|
||||
.limit(1);
|
||||
if (!audio) return undefined;
|
||||
|
||||
return {
|
||||
cancionId: destino.id,
|
||||
cancionSlug: destino.slug,
|
||||
principalSlug: principal.slug,
|
||||
titulo: principal.titulo,
|
||||
versionNombre: destino.versionNombre ?? undefined,
|
||||
clave: audio.clave.replace(/^audio\//, '')
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { componer } from './markdown';
|
||||
|
||||
describe('componer', () => {
|
||||
it('conserva Markdown editorial', () => {
|
||||
expect(componer('## Historia\n\nUna **voz**.')).toContain('<strong>voz</strong>');
|
||||
expect(componer('## Historia')).toContain('id="historia"');
|
||||
});
|
||||
|
||||
it('no deja ejecutar HTML ni protocolos activos', () => {
|
||||
const html = componer('<script>alert(1)</script>\n\n[abre](javascript:alert(1))');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).not.toContain('href="javascript:');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,141 @@
|
||||
/** Edición de identidades artísticas y sus identificadores externos. */
|
||||
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
import { artista, artistaIdentificador, medio, pais } from './db/schema';
|
||||
import type { TipoArtista } from '$lib/types';
|
||||
export type Estado = 'borrador' | 'publicado' | 'retirado';
|
||||
|
||||
export interface CambiosDeArtista {
|
||||
nombre: string;
|
||||
tipo: TipoArtista;
|
||||
descripcion: string;
|
||||
biografia: string;
|
||||
imagenId: string | null;
|
||||
paisCodigo: string | null;
|
||||
lugarOrigen: string;
|
||||
inicio: number | null;
|
||||
fin: number | null;
|
||||
sitioWeb: string;
|
||||
estado: Estado;
|
||||
identificadores: { sistema: string; valor: string }[];
|
||||
}
|
||||
|
||||
export type ErroresDeArtista = Partial<
|
||||
Record<'nombre' | 'periodo' | 'sitioWeb' | 'identificadores', string>
|
||||
>;
|
||||
|
||||
export function revisarArtista(cambios: CambiosDeArtista): ErroresDeArtista {
|
||||
const errores: ErroresDeArtista = {};
|
||||
if (cambios.nombre.trim().length < 2)
|
||||
errores.nombre = 'Escribe un nombre de al menos dos caracteres.';
|
||||
if (cambios.inicio !== null && (!Number.isInteger(cambios.inicio) || cambios.inicio < 1800)) {
|
||||
errores.periodo = 'El año de inicio no es válido.';
|
||||
}
|
||||
if (cambios.fin !== null && (!Number.isInteger(cambios.fin) || cambios.fin < 1800)) {
|
||||
errores.periodo = 'El año final no es válido.';
|
||||
}
|
||||
if (cambios.inicio !== null && cambios.fin !== null && cambios.fin < cambios.inicio) {
|
||||
errores.periodo = 'El final no puede ser anterior al inicio.';
|
||||
}
|
||||
if (cambios.sitioWeb && !/^https?:\/\//i.test(cambios.sitioWeb)) {
|
||||
errores.sitioWeb = 'La dirección debe empezar por https:// o http://.';
|
||||
}
|
||||
const claves = cambios.identificadores.map((i) => i.sistema.toLowerCase());
|
||||
if (new Set(claves).size !== claves.length) {
|
||||
errores.identificadores = 'Cada sistema de identificación puede aparecer una sola vez.';
|
||||
}
|
||||
return errores;
|
||||
}
|
||||
|
||||
export async function listaDeArtistas() {
|
||||
return db
|
||||
.select({
|
||||
id: artista.id,
|
||||
codigo: artista.codigo,
|
||||
slug: artista.slug,
|
||||
nombre: artista.nombre,
|
||||
tipo: artista.tipo,
|
||||
estado: artista.estado,
|
||||
descripcion: artista.descripcionCorta,
|
||||
grabaciones: sql<number>`(
|
||||
select count(*)::int from cancion_interprete ci where ci.artista_id = ${artista.id}
|
||||
)`
|
||||
})
|
||||
.from(artista)
|
||||
.orderBy(asc(artista.nombre));
|
||||
}
|
||||
|
||||
export async function opcionesDeArtista() {
|
||||
const [paises, imagenes] = await Promise.all([
|
||||
db.select({ codigo: pais.codigo, nombre: pais.nombre }).from(pais).orderBy(asc(pais.nombre)),
|
||||
db
|
||||
.select({ id: medio.id, clave: medio.clave })
|
||||
.from(medio)
|
||||
.where(eq(medio.clase, 'imagen'))
|
||||
.orderBy(asc(medio.clave))
|
||||
]);
|
||||
return { paises, imagenes };
|
||||
}
|
||||
|
||||
export async function artistaParaEditar(slug: string) {
|
||||
const [fila] = await db.select().from(artista).where(eq(artista.slug, slug)).limit(1);
|
||||
if (!fila) return null;
|
||||
const identificadores = await db
|
||||
.select({ sistema: artistaIdentificador.sistema, valor: artistaIdentificador.valor })
|
||||
.from(artistaIdentificador)
|
||||
.where(eq(artistaIdentificador.artistaId, fila.id))
|
||||
.orderBy(asc(artistaIdentificador.sistema));
|
||||
return { ...fila, identificadores };
|
||||
}
|
||||
|
||||
export async function crearArtista(
|
||||
nombre: string,
|
||||
slug: string,
|
||||
tipo: TipoArtista
|
||||
): Promise<string> {
|
||||
const [creado] = await db
|
||||
.insert(artista)
|
||||
.values({ nombre: nombre.trim(), slug, tipo })
|
||||
.returning({ slug: artista.slug });
|
||||
return creado.slug;
|
||||
}
|
||||
|
||||
export async function guardarArtista(slug: string, cambios: CambiosDeArtista): Promise<boolean> {
|
||||
const [fila] = await db
|
||||
.select({ id: artista.id })
|
||||
.from(artista)
|
||||
.where(eq(artista.slug, slug))
|
||||
.limit(1);
|
||||
if (!fila) return false;
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(artista)
|
||||
.set({
|
||||
nombre: cambios.nombre.trim(),
|
||||
tipo: cambios.tipo,
|
||||
descripcionCorta: cambios.descripcion.trim(),
|
||||
biografia: cambios.biografia.trim(),
|
||||
imagenId: cambios.imagenId,
|
||||
paisCodigo: cambios.paisCodigo,
|
||||
lugarOrigen: cambios.lugarOrigen.trim() || null,
|
||||
inicio: cambios.inicio,
|
||||
fin: cambios.fin,
|
||||
sitioWeb: cambios.sitioWeb.trim() || null,
|
||||
estado: cambios.estado,
|
||||
actualizadoEn: new Date()
|
||||
})
|
||||
.where(eq(artista.id, fila.id));
|
||||
await tx.delete(artistaIdentificador).where(eq(artistaIdentificador.artistaId, fila.id));
|
||||
if (cambios.identificadores.length > 0) {
|
||||
await tx.insert(artistaIdentificador).values(
|
||||
cambios.identificadores.map((i) => ({
|
||||
artistaId: fila.id,
|
||||
sistema: i.sistema.toLowerCase(),
|
||||
valor: i.valor
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { crearArtista, listaDeArtistas } from '$lib/server/panel-artistas';
|
||||
import type { TipoArtista } from '$lib/types';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => ({ artistas: await listaDeArtistas() });
|
||||
|
||||
function aSlug(texto: string): string {
|
||||
return texto
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
crear: async ({ request }) => {
|
||||
const datos = await request.formData();
|
||||
const nombre = String(datos.get('nombre') ?? '').trim();
|
||||
const slug = aSlug(String(datos.get('slug') ?? '') || nombre);
|
||||
const tipo = String(datos.get('tipo') ?? 'persona') as TipoArtista;
|
||||
if (nombre.length < 2 || !slug)
|
||||
return fail(400, { error: 'Escribe un nombre y un slug válidos.' });
|
||||
try {
|
||||
await crearArtista(nombre, slug, tipo);
|
||||
} catch {
|
||||
return fail(409, { error: 'Ya existe una identidad con ese nombre o slug.' });
|
||||
}
|
||||
redirect(303, `/artistas/${encodeURIComponent(slug)}`);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
let { data, form } = $props();
|
||||
let consulta = $state('');
|
||||
const encontrados = $derived(
|
||||
data.artistas.filter((a) =>
|
||||
`${a.nombre} ${a.codigo} ${a.slug}`.toLowerCase().includes(consulta.toLowerCase().trim())
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Artistas · Panel</title></svelte:head>
|
||||
|
||||
<header>
|
||||
<h1>Artistas</h1>
|
||||
<p class="muted">Personas, grupos y proyectos acreditados en las grabaciones.</p>
|
||||
</header>
|
||||
|
||||
<form class="nuevo" method="POST" action="?/crear">
|
||||
<label>Nombre <input name="nombre" required /></label>
|
||||
<label>Slug <input name="slug" placeholder="se deriva del nombre" /></label>
|
||||
<label
|
||||
>Tipo <select name="tipo"
|
||||
><option value="persona">Persona</option><option value="grupo">Grupo</option><option
|
||||
value="proyecto">Proyecto</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<button class="btn btn--primary">Crear ficha</button>
|
||||
</form>
|
||||
{#if form?.error}<p class="fallo">{form.error}</p>{/if}
|
||||
|
||||
<label class="buscar">Buscar <input type="search" bind:value={consulta} /></label>
|
||||
<table>
|
||||
<thead><tr><th>Identidad</th><th>Tipo</th><th>Estado</th><th>Grabaciones</th></tr></thead><tbody>
|
||||
{#each encontrados as artista (artista.id)}
|
||||
<tr
|
||||
><th
|
||||
><a href={resolve('/(dentro)/artistas/[slug]', { slug: artista.slug })}
|
||||
>{artista.nombre}</a
|
||||
><small>{artista.codigo} · {artista.slug}</small></th
|
||||
><td>{artista.tipo}</td><td>{artista.estado}</td><td>{artista.grabaciones}</td></tr
|
||||
>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<style>
|
||||
header {
|
||||
padding-bottom: var(--sp-5);
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-2xl);
|
||||
}
|
||||
.nuevo {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 2fr 1fr auto;
|
||||
align-items: end;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--c-border);
|
||||
background: var(--c-bg-raised);
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: var(--sp-1);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: var(--sp-2);
|
||||
border: 1px solid var(--c-border);
|
||||
background: var(--c-bg-sunken);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.buscar {
|
||||
max-width: 30rem;
|
||||
margin-block: var(--sp-6) var(--sp-3);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--sp-3);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
text-align: left;
|
||||
}
|
||||
tbody th {
|
||||
font-weight: 500;
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
color: var(--c-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
@media (max-width: 48rem) {
|
||||
.nuevo {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,58 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import {
|
||||
artistaParaEditar,
|
||||
guardarArtista,
|
||||
opcionesDeArtista,
|
||||
revisarArtista,
|
||||
type CambiosDeArtista,
|
||||
type Estado
|
||||
} from '$lib/server/panel-artistas';
|
||||
import type { TipoArtista } from '$lib/types';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const [artista, opciones] = await Promise.all([
|
||||
artistaParaEditar(params.slug),
|
||||
opcionesDeArtista()
|
||||
]);
|
||||
if (!artista) error(404, 'No existe esa identidad artística.');
|
||||
return { artista, ...opciones };
|
||||
};
|
||||
|
||||
const numero = (valor: FormDataEntryValue | null) =>
|
||||
String(valor ?? '').trim() ? Number(valor) : null;
|
||||
|
||||
export const actions: Actions = {
|
||||
guardar: async ({ request, params }) => {
|
||||
const datos = await request.formData();
|
||||
const identificadores = String(datos.get('identificadores') ?? '')
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((linea) => {
|
||||
const corte = linea.indexOf(':');
|
||||
return {
|
||||
sistema: corte < 0 ? '' : linea.slice(0, corte).trim(),
|
||||
valor: corte < 0 ? linea : linea.slice(corte + 1).trim()
|
||||
};
|
||||
});
|
||||
const cambios: CambiosDeArtista = {
|
||||
nombre: String(datos.get('nombre') ?? ''),
|
||||
tipo: String(datos.get('tipo') ?? 'persona') as TipoArtista,
|
||||
descripcion: String(datos.get('descripcion') ?? ''),
|
||||
biografia: String(datos.get('biografia') ?? ''),
|
||||
imagenId: String(datos.get('imagenId') ?? '') || null,
|
||||
paisCodigo: String(datos.get('paisCodigo') ?? '') || null,
|
||||
lugarOrigen: String(datos.get('lugarOrigen') ?? ''),
|
||||
inicio: numero(datos.get('inicio')),
|
||||
fin: numero(datos.get('fin')),
|
||||
sitioWeb: String(datos.get('sitioWeb') ?? ''),
|
||||
estado: String(datos.get('estado') ?? 'borrador') as Estado,
|
||||
identificadores
|
||||
};
|
||||
const errores = revisarArtista(cambios);
|
||||
if (Object.keys(errores).length) return fail(400, { errores });
|
||||
if (!(await guardarArtista(params.slug, cambios))) error(404, 'La ficha ya no existe.');
|
||||
return { guardado: true };
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
let { data, form } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{data.artista.nombre} · Panel</title></svelte:head>
|
||||
<p><a href={resolve('/(dentro)/artistas')}>← Artistas</a></p>
|
||||
<form method="POST" action="?/guardar" use:enhance>
|
||||
<header>
|
||||
<div>
|
||||
<p class="codigo">{data.artista.codigo} · {data.artista.slug}</p>
|
||||
<h1>
|
||||
<label class="visually-hidden" for="nombre">Nombre</label><input
|
||||
id="nombre"
|
||||
name="nombre"
|
||||
value={data.artista.nombre}
|
||||
required
|
||||
/>
|
||||
</h1>
|
||||
</div>
|
||||
<button class="btn btn--primary">Guardar</button>
|
||||
</header>
|
||||
{#if form?.guardado}<p class="guardado">Guardado</p>{/if}
|
||||
<section>
|
||||
<h2>Ficha pública</h2>
|
||||
<div class="rejilla">
|
||||
<label
|
||||
>Tipo <select name="tipo" value={data.artista.tipo}
|
||||
><option value="persona">Persona</option><option value="grupo">Grupo</option><option
|
||||
value="proyecto">Proyecto</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Estado <select name="estado" value={data.artista.estado}
|
||||
><option value="borrador">Borrador</option><option value="publicado">Publicado</option
|
||||
><option value="retirado">Retirado</option></select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>País <select name="paisCodigo" value={data.artista.paisCodigo ?? ''}
|
||||
><option value="">Sin indicar</option>{#each data.paises as p (p.codigo)}<option
|
||||
value={p.codigo}>{p.nombre}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Imagen <select name="imagenId" value={data.artista.imagenId ?? ''}
|
||||
><option value="">Sin imagen</option>{#each data.imagenes as i (i.id)}<option value={i.id}
|
||||
>{i.clave}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Lugar de origen <input name="lugarOrigen" value={data.artista.lugarOrigen ?? ''} /></label
|
||||
>
|
||||
<label
|
||||
>Año de inicio <input
|
||||
type="number"
|
||||
name="inicio"
|
||||
value={data.artista.inicio ?? ''}
|
||||
/></label
|
||||
>
|
||||
<label>Año final <input type="number" name="fin" value={data.artista.fin ?? ''} /></label>
|
||||
<label
|
||||
>Sitio web <input type="url" name="sitioWeb" value={data.artista.sitioWeb ?? ''} /></label
|
||||
>
|
||||
</div>
|
||||
{#if form?.errores}<p class="fallo">{Object.values(form.errores).join(' ')}</p>{/if}
|
||||
<label
|
||||
>Descripción corta <textarea name="descripcion" rows="2"
|
||||
>{data.artista.descripcionCorta}</textarea
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Biografía <span class="muted">Markdown</span><textarea name="biografia" rows="14"
|
||||
>{data.artista.biografia}</textarea
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Identificadores <span class="muted">Uno por línea: sistema: valor</span><textarea
|
||||
name="identificadores"
|
||||
rows="5"
|
||||
>{data.artista.identificadores.map((i) => `${i.sistema}: ${i.valor}`).join('\n')}</textarea
|
||||
></label
|
||||
>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
form header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
h1 input {
|
||||
font: inherit;
|
||||
font-size: var(--fs-2xl);
|
||||
}
|
||||
.codigo {
|
||||
color: var(--c-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
section {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
padding-top: var(--sp-6);
|
||||
}
|
||||
.rejilla {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: var(--sp-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: var(--sp-2);
|
||||
border: 1px solid var(--c-border);
|
||||
background: var(--c-bg-sunken);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
.guardado {
|
||||
color: var(--c-dato);
|
||||
}
|
||||
@media (max-width: 40rem) {
|
||||
.rejilla {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,5 @@
|
||||
import { artistasPublicos } from '$lib/server/artistas';
|
||||
|
||||
export async function load() {
|
||||
return { artistas: await artistasPublicos() };
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import Meta from '$lib/components/Meta.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<Meta
|
||||
titulo="Intérpretes"
|
||||
descripcion="Las voces, grupos y proyectos acreditados en las grabaciones del catálogo."
|
||||
/>
|
||||
|
||||
<header class="portada">
|
||||
<p class="ceja">Créditos de grabación</p>
|
||||
<h1>Intérpretes</h1>
|
||||
<p class="entradilla">
|
||||
Las identidades que dan voz y sonido a cada versión, con su ficha y la discografía en la que
|
||||
participan.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="artistas">
|
||||
{#each data.artistas as artista (artista.id)}
|
||||
<li>
|
||||
<a href={resolve('/interpretes/[slug]', { slug: artista.slug })}>
|
||||
{#if artista.imagen}
|
||||
<img src={artista.imagen} alt="" loading="lazy" />
|
||||
{/if}
|
||||
<span class="texto">
|
||||
<span class="tipo">{artista.tipo}</span>
|
||||
<strong>{artista.nombre}</strong>
|
||||
{#if artista.descripcion}<span>{artista.descripcion}</span>{/if}
|
||||
<small>{artista.totalCanciones} {artista.totalCanciones === 1 ? 'tema' : 'temas'}</small>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if data.artistas.length === 0}
|
||||
<p class="vacio muted">Aún no hay intérpretes publicados.</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.portada {
|
||||
max-width: 62ch;
|
||||
padding-block: var(--sp-6);
|
||||
}
|
||||
.ceja,
|
||||
.tipo {
|
||||
color: var(--c-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 var(--sp-2);
|
||||
font-size: var(--fs-3xl);
|
||||
}
|
||||
.entradilla {
|
||||
color: var(--c-text-muted);
|
||||
font-size: var(--fs-lg);
|
||||
}
|
||||
.artistas {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
|
||||
gap: var(--sp-4);
|
||||
margin: 0;
|
||||
padding: 0 0 var(--sp-8);
|
||||
list-style: none;
|
||||
}
|
||||
.artistas a {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem 1fr;
|
||||
gap: var(--sp-4);
|
||||
height: 100%;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-tarjeta);
|
||||
background: var(--c-bg-raised);
|
||||
text-decoration: none;
|
||||
}
|
||||
.artistas img {
|
||||
width: 5rem;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.texto {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.texto strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--fs-xl);
|
||||
}
|
||||
.texto > span:not(.tipo),
|
||||
small {
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
.vacio {
|
||||
padding-bottom: var(--sp-8);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,8 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { obtenerArtistaPublico } from '$lib/server/artistas';
|
||||
|
||||
export async function load({ params }) {
|
||||
const artista = await obtenerArtistaPublico(params.slug);
|
||||
if (!artista) error(404, 'Ese intérprete no existe.');
|
||||
return { artista };
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in new issue