From fef07e6b8b349b1e737c50f781eee42edd2e50cb Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 9 Sep 2026 20:58:00 +0200 Subject: [PATCH] La ficha de un tema: portada mayor, etiquetas y botones abajo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La portada pasa de 17rem a 22rem y la columna del título crece hasta su altura, así que los botones se descuelgan y su base queda a la misma línea que la de la portada: 494 y 494 medidos en el navegador. El aviso de venta y las plataformas suben por encima de ellos, porque lo que se viene a hacer va abajo del todo. La separación la pone un `gap` y no un margen: ninguna de las dos cosas está siempre, y un margen sobre un hueco vacío despegaba los botones justo en las fichas que no las tienen. Las etiquetas no salían porque no había ninguna: las diez de «Cupido sin flechas» entraron sin ellas. No están en el material —el prompt de Suno dice cómo suena el tema, no de qué va—, así que van escritas a mano en `sembrar-etiquetas-cupido.mjs`, donde se leen y se discuten, reusando el vocabulario que ya existía siempre que encajaba. Y se pintan en fantasma y en color. Sin relleno: son un dato, no una acción, y un botón lleno al lado del de escuchar competiría con él. El tono sale del nombre —nueve elegidos, saltando el marino del fondo y el oro del acento—, así que «Desamor» es del mismo color en la ficha, en el blog y en la portada sin tabla que mantener. Medidos los dieciocho casos: el peor da 6,14 sobre 4,5 que pide el texto pequeño. El botón de añadir al pack faltaba por lo mismo: el importador ponía `estado = publicado` pero dejaba `ala_venta` en el false de la columna, y un tema publicado y no a la venta sale sin botón. Ahora lo pone al crear la canción y no lo toca al reimportar, que esa decisión es del panel. Las diez ya están a la venta. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 1 + scripts/album/importar.mjs | 13 ++- scripts/db/sembrar-etiquetas-cupido.mjs | 125 +++++++++++++++++++++++ src/lib/components/Etiquetas.svelte | 110 ++++++++++++++++++++ src/lib/components/FichaDeCancion.svelte | 21 +--- src/lib/components/TarjetaEstilo.svelte | 15 +-- src/lib/utils/etiquetas.spec.ts | 61 +++++++++++ src/lib/utils/etiquetas.ts | 38 +++++++ src/routes/+page.svelte | 17 +-- src/routes/blog/+page.svelte | 19 +--- src/routes/blog/[slug]/+page.svelte | 18 +--- src/routes/canciones/[slug]/+page.svelte | 97 +++++++++++++----- 12 files changed, 434 insertions(+), 101 deletions(-) create mode 100644 scripts/db/sembrar-etiquetas-cupido.mjs create mode 100644 src/lib/components/Etiquetas.svelte create mode 100644 src/lib/utils/etiquetas.spec.ts create mode 100644 src/lib/utils/etiquetas.ts diff --git a/package.json b/package.json index 75cc87a..e3bc6ab 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "db:versos": "node --env-file=.env scripts/db/sembrar-versos.mjs", "db:glosario": "node --env-file=.env scripts/db/sembrar-glosario.mjs", "db:animos": "node --env-file=.env scripts/db/sembrar-animos.mjs", + "db:etiquetas-cupido": "node --env-file=.env scripts/db/sembrar-etiquetas-cupido.mjs", "album:leer": "node scripts/album/leer.mjs", "album:importar": "node --env-file=.env scripts/album/importar.mjs", "album:medios": "node --env-file=.env scripts/album/medios.mjs", diff --git a/scripts/album/importar.mjs b/scripts/album/importar.mjs index 783866a..e6652e8 100644 --- a/scripts/album/importar.mjs +++ b/scripts/album/importar.mjs @@ -201,9 +201,18 @@ try { for (const [i, x] of album.canciones.entries()) { if (!x.letra?.limpia) continue; + /* + * `ala_venta` va a true al crearla y no se toca al reimportar. La columna + * viene en false por defecto, y un tema publicado pero no a la venta sale + * en la web sin botón de pack, que era el fallo: si un disco se importa + * como publicado, se importa para venderlo. Al reimportar se respeta lo + * que diga el panel, porque quitar un tema de la venta es una decisión + * que no puede deshacer un `.txt` corregido. + */ const { rows: canciones } = await c.query( - `insert into cancion (id, slug, titulo, letra, idioma, estilo_id, album_id, numero, estado) - values (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'publicado') + `insert into cancion (id, slug, titulo, letra, idioma, estilo_id, album_id, numero, + estado, ala_venta) + values (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'publicado', true) on conflict (slug) do update set titulo = excluded.titulo, letra = excluded.letra, diff --git a/scripts/db/sembrar-etiquetas-cupido.mjs b/scripts/db/sembrar-etiquetas-cupido.mjs new file mode 100644 index 0000000..18d4587 --- /dev/null +++ b/scripts/db/sembrar-etiquetas-cupido.mjs @@ -0,0 +1,125 @@ +/** + * Pone las etiquetas de «Cupido sin flechas». + * + * npm run db:etiquetas-cupido + * npm run db:etiquetas-cupido -- --escribir + * + * **Por defecto no toca nada.** Dice lo que pondría y se para. + * + * El importador trae el texto y el colocador trae los archivos; las etiquetas no + * las trae nadie, porque no están en el material: los `.txt` de Suno llevan el + * prompt de estilo —instrumentos, tempo, timbre de la voz—, que describe cómo + * suena y no de qué va. La etiqueta es una decisión editorial, así que va + * escrita a mano y aquí, donde se puede leer y discutir, en vez de deducida. + * + * Se reutiliza el vocabulario que ya existe siempre que encaja —«Mentiras», + * «Rabia», «Melancolía», «Despedidas»— para que la página de cada etiqueta junte + * estos temas con los de antes en lugar de abrir un vocabulario paralelo con + * sinónimos. + * + * Idempotente: vuelve a lanzarse y deja lo mismo, sin duplicar. + */ + +import pg from 'pg'; + +const ESCRIBIR = process.argv.includes('--escribir'); + +const DISCO = 'cupido-sin-flechas'; + +/** El orden dentro de cada canción es el que se pinta. De más a menos central. */ +const POR_CANCION = { + 'amame-esta-noche-nada-mas': ['Deseo', 'Una noche', 'Autoengaño'], + 'cuando-tus-manos-me-tocan': ['Deseo', 'Entrega'], + 'inventario-a-los-56': ['Paso del tiempo', 'Balance'], + 'no-me-mientas': ['Mentiras', 'Celos', 'Ruptura'], + 'pague-por-una-noche-de-amor': ['Una noche', 'Soledad', 'Compasión'], + 'para-que-recordar': ['Melancolía', 'Recuerdos'], + prometiste: ['Promesas', 'Desamor'], + quedate: ['Deseo', 'Despedidas', 'Súplica'], + 'ton-silence-me-libere': ['Silencio', 'Rabia', 'Ruptura'], + 'tus-malas-maneras': ['Rabia', 'Despecho', 'Mentiras'] +}; + +/** El mismo que `aSlug` en `$lib/content/schema`: la web resuelve por él. */ +const aSlug = (texto) => + texto + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + +const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 30_000 }); +await c.connect(); + +const { rows: canciones } = await c.query( + `select c.id, c.slug, c.titulo, c.numero + from cancion c join album a on a.id = c.album_id + where a.slug = $1 order by c.numero`, + [DISCO] +); + +if (canciones.length === 0) { + console.error(`No hay ningún disco «${DISCO}» en la base.`); + process.exit(1); +} + +const { rows: yaHay } = await c.query('select slug, nombre from etiqueta'); +const conocidas = new Set(yaHay.map((e) => e.slug)); + +console.log(`\n${canciones.length} temas de «${DISCO}»\n`); + +const sinConocer = new Set(); +for (const cancion of canciones) { + const nombres = POR_CANCION[cancion.slug]; + if (!nombres) { + console.log(` ${String(cancion.numero).padStart(2, '0')} ${cancion.titulo} — SIN ASIGNAR`); + continue; + } + const pintadas = nombres.map((n) => (conocidas.has(aSlug(n)) ? n : `${n}*`)); + for (const n of nombres) if (!conocidas.has(aSlug(n))) sinConocer.add(n); + console.log( + ` ${String(cancion.numero).padStart(2, '0')} ${cancion.titulo.padEnd(30)} ${pintadas.join(' · ')}` + ); +} + +if (sinConocer.size > 0) { + console.log(`\n* etiquetas nuevas (${sinConocer.size}): ${[...sinConocer].join(', ')}`); +} + +if (!ESCRIBIR) { + console.log('\nEnsayo. No se ha escrito nada. Añade --escribir.'); + await c.end(); + process.exit(0); +} + +let puestas = 0; +for (const cancion of canciones) { + const nombres = POR_CANCION[cancion.slug]; + if (!nombres) continue; + + /* + * Se borra primero lo que tenga: si aquí se le quita una etiqueta, tiene que + * desaparecer de la canción, no quedarse por no haberla nombrado. + */ + await c.query('delete from cancion_etiqueta where cancion_id = $1', [cancion.id]); + + for (const [orden, nombre] of nombres.entries()) { + const { rows } = await c.query( + `insert into etiqueta (id, slug, nombre) values (gen_random_uuid(), $1, $2) + on conflict (slug) do update set nombre = excluded.nombre + returning id`, + [aSlug(nombre), nombre] + ); + await c.query( + `insert into cancion_etiqueta (cancion_id, etiqueta_id, orden) values ($1, $2, $3) + on conflict (cancion_id, etiqueta_id) do update set orden = excluded.orden`, + [cancion.id, rows[0].id, orden] + ); + puestas++; + } +} + +await c.end(); +console.log(`\nHecho: ${puestas} etiquetas en ${canciones.length} temas.`); +console.log('El catálogo se cachea 60 s: si la web aún no las enseña, espera o reinicia.'); diff --git a/src/lib/components/Etiquetas.svelte b/src/lib/components/Etiquetas.svelte new file mode 100644 index 0000000..c66cbf3 --- /dev/null +++ b/src/lib/components/Etiquetas.svelte @@ -0,0 +1,110 @@ + + +{#if etiquetas.length > 0} + +{/if} + + diff --git a/src/lib/components/FichaDeCancion.svelte b/src/lib/components/FichaDeCancion.svelte index f422807..81ca38b 100644 --- a/src/lib/components/FichaDeCancion.svelte +++ b/src/lib/components/FichaDeCancion.svelte @@ -13,7 +13,6 @@ */ import { resolve } from '$app/paths'; import Valoracion from './Valoracion.svelte'; - import { aSlug as slugDeEtiqueta } from '$lib/content/schema'; import type { ResumenDeValoracion } from '$lib/server/valoraciones'; import type { AlbumConCanciones, Cancion, Estilo } from '$lib/types'; import { listaDeNombres, segundosADuracion } from '$lib/utils/format'; @@ -106,25 +105,7 @@ {/if} - {#if cancion.etiquetas.length > 0} -
Etiquetas
-
-
    - {#each cancion.etiquetas as etiqueta (etiqueta)} -
  • - - {etiqueta} - -
  • - {/each} -
-
- {/if} + {#if album?.sello}
Sello
diff --git a/src/lib/components/TarjetaEstilo.svelte b/src/lib/components/TarjetaEstilo.svelte index 6942330..de57a98 100644 --- a/src/lib/components/TarjetaEstilo.svelte +++ b/src/lib/components/TarjetaEstilo.svelte @@ -12,6 +12,7 @@ */ import { resolve } from '$app/paths'; import BotonReproducir from './BotonReproducir.svelte'; + import Etiquetas from './Etiquetas.svelte'; import { brillo } from '$lib/acciones/brillo'; import { revelar } from '$lib/acciones/revelar'; import { aPistas } from '$lib/player/pista'; @@ -66,16 +67,10 @@

{#if etiquetas.length > 0} - - + +
+ +
{/if} diff --git a/src/lib/utils/etiquetas.spec.ts b/src/lib/utils/etiquetas.spec.ts new file mode 100644 index 0000000..3da9c05 --- /dev/null +++ b/src/lib/utils/etiquetas.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { tonoDeEtiqueta } from './etiquetas'; + +/** + * Lo que se prueba no es qué número sale —eso lo decide el revoltijo y da igual— + * sino las tres cosas de las que depende que el color sirva para algo. + */ +describe('tonoDeEtiqueta', () => { + it('da siempre lo mismo para el mismo nombre', () => { + // Si esto fallara, la etiqueta cambiaría de color al hidratar. + expect(tonoDeEtiqueta('Desamor')).toBe(tonoDeEtiqueta('Desamor')); + }); + + it('no distingue mayúsculas ni tildes de más', () => { + expect(tonoDeEtiqueta('Desamor')).toBe(tonoDeEtiqueta('desamor')); + }); + + it('se queda en los tonos elegidos, fuera del marino y del oro', () => { + const nombres = [ + 'Desamor', + 'Mentiras', + 'Rabia', + 'Deseo', + 'Una noche', + 'Soledad', + 'Silencio', + 'Recuerdos', + 'Promesas', + 'Despecho', + 'Celos', + 'Ruptura' + ]; + for (const n of nombres) { + const tono = tonoDeEtiqueta(n); + expect(tono).toBeGreaterThanOrEqual(0); + expect(tono).toBeLessThan(360); + // El marino del fondo ronda los 264 y el oro del acento los 84. + expect(Math.abs(tono - 264)).toBeGreaterThan(20); + expect(Math.abs(tono - 84)).toBeGreaterThan(20); + } + }); + + it('reparte: doce etiquetas no caen todas en el mismo color', () => { + const nombres = [ + 'Desamor', + 'Mentiras', + 'Rabia', + 'Deseo', + 'Una noche', + 'Soledad', + 'Silencio', + 'Recuerdos', + 'Promesas', + 'Despecho', + 'Celos', + 'Ruptura' + ]; + const tonos = new Set(nombres.map(tonoDeEtiqueta)); + expect(tonos.size).toBeGreaterThanOrEqual(5); + }); +}); diff --git a/src/lib/utils/etiquetas.ts b/src/lib/utils/etiquetas.ts new file mode 100644 index 0000000..7390e6b --- /dev/null +++ b/src/lib/utils/etiquetas.ts @@ -0,0 +1,38 @@ +/** + * El color de una etiqueta. + * + * No hay tabla de colores porque no hay tabla de etiquetas: se crean escribiendo + * una. El tono sale del propio nombre, así que «Desamor» es del mismo color en + * la ficha, en el blog y en la página de la etiqueta sin que nadie lo mantenga, + * y una etiqueta nueva nace con el suyo. + * + * Los tonos no son los 360 del círculo sino nueve elegidos. Dos motivos: entre + * tonos vecinos no se distingue nada —un 210 y un 218 son el mismo color— y hay + * dos zonas prohibidas, el marino del fondo y el oro del acento, donde una + * etiqueta o se pierde o parece un botón. + */ + +/** + * Los nueve. Saltan el marino (~264) y el oro (~84), y están lo bastante + * separados como para no confundirse de dos en dos. + */ +const TONOS = [20, 45, 135, 165, 200, 230, 290, 320, 350]; + +/** + * FNV-1a de 32 bits. Vale cualquier función que reparta; esta se elige porque + * cabe en cinco líneas y da lo mismo en el servidor y en el navegador, que es + * lo único que importa aquí: el color no puede cambiar al hidratar. + */ +function revoltijo(texto: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < texto.length; i++) { + h ^= texto.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** El tono en grados que le toca a una etiqueta por su nombre. */ +export function tonoDeEtiqueta(nombre: string): number { + return TONOS[revoltijo(nombre.toLowerCase()) % TONOS.length]; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1a4ac20..67903e4 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3,6 +3,7 @@ import { page } from '$app/state'; import BotonReproducir from '$lib/components/BotonReproducir.svelte'; import Carrusel, { type Lamina } from '$lib/components/Carrusel.svelte'; + import Etiquetas from '$lib/components/Etiquetas.svelte'; import Heroe from '$lib/components/Heroe.svelte'; import LaminaCta from '$lib/components/LaminaCta.svelte'; import Meta from '$lib/components/Meta.svelte'; @@ -10,7 +11,6 @@ import TarjetaEstilo from '$lib/components/TarjetaEstilo.svelte'; import { revelar } from '$lib/acciones/revelar'; import { aPistas } from '$lib/player/pista'; - import { aSlug as slugDeEtiqueta } from '$lib/content/schema'; import { heroe, site } from '$lib/site'; import { CANCIONES_POR_PACK, formatearPrecio, PRECIO_PACK_CENTIMOS } from '$lib/tienda'; @@ -115,18 +115,9 @@

{#if data.etiquetasPrincipal.length > 0} - +
+ +
{/if}
diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte index fc25bd7..8716428 100644 --- a/src/routes/blog/+page.svelte +++ b/src/routes/blog/+page.svelte @@ -11,7 +11,7 @@ import Meta from '$lib/components/Meta.svelte'; import { brillo } from '$lib/acciones/brillo'; import { revelar } from '$lib/acciones/revelar'; - import { aSlug as slugDeEtiqueta } from '$lib/content/schema'; + import Etiquetas from '$lib/components/Etiquetas.svelte'; import { formatearFecha } from '$lib/utils/date'; let { data } = $props(); @@ -200,20 +200,9 @@

{entrada.resumen}

{/if} {#if entrada.etiquetas.length > 0} -
    - {#each entrada.etiquetas as etiqueta (etiqueta)} -
  • - - {etiqueta} - -
  • - {/each} -
+
+ +
{/if}
diff --git a/src/routes/blog/[slug]/+page.svelte b/src/routes/blog/[slug]/+page.svelte index 9fe90c8..2be6fb9 100644 --- a/src/routes/blog/[slug]/+page.svelte +++ b/src/routes/blog/[slug]/+page.svelte @@ -2,7 +2,7 @@ import { resolve } from '$app/paths'; import Meta from '$lib/components/Meta.svelte'; import RejillaCanciones from '$lib/components/RejillaCanciones.svelte'; - import { aSlug as slugDeEtiqueta } from '$lib/content/schema'; + import Etiquetas from '$lib/components/Etiquetas.svelte'; import { formatearFecha } from '$lib/utils/date'; import Migas from '$lib/components/Migas.svelte'; @@ -44,18 +44,7 @@ {#if entrada.etiquetas.length > 0}
-
    - {#each entrada.etiquetas as etiqueta (etiqueta)} -
  • - - {etiqueta} - -
  • - {/each} -
+
{/if} @@ -141,9 +130,6 @@ max-width: 64ch; padding-block: var(--sp-5); } - .pie .chips { - margin: 0; - } .temas { padding-block: var(--sp-5); diff --git a/src/routes/canciones/[slug]/+page.svelte b/src/routes/canciones/[slug]/+page.svelte index f84fe5f..3e5460e 100644 --- a/src/routes/canciones/[slug]/+page.svelte +++ b/src/routes/canciones/[slug]/+page.svelte @@ -12,6 +12,7 @@ import { aPista, aPistaDeVersion, aPistas } from '$lib/player/pista'; import { CANCIONES_POR_PACK, formatearPrecio, PRECIO_PACK_CENTIMOS } from '$lib/tienda'; import Migas from '$lib/components/Migas.svelte'; + import Etiquetas from '$lib/components/Etiquetas.svelte'; import { listaDeNombres, segundosADuracion } from '$lib/utils/format'; let { data } = $props(); @@ -104,11 +105,11 @@ class="portada" src={data.portada} alt="Portada de {cancion.titulo}" - width="280" - height="280" + width="352" + height="352" /> {:else} - + {/if}
@@ -137,26 +138,40 @@ {/if}

-
- - - -
- - {#if cancion.alaVenta} -

- En la web suena un fragmento. La descarga completa en MP3, con todas sus versiones, va en - packs de {CANCIONES_POR_PACK} canciones por {formatearPrecio(PRECIO_PACK_CENTIMOS)}. -

+ {#if cancion.etiquetas.length > 0} +
+ +
{/if} - + +
+ {#if cancion.alaVenta} +

+ En la web suena un fragmento. La descarga completa en MP3, con todas sus versiones, va + en packs de {CANCIONES_POR_PACK} canciones por {formatearPrecio(PRECIO_PACK_CENTIMOS)}. +

+ {/if} + + + + +
+ + + +
+
@@ -303,11 +318,15 @@ } } + /* + * `stretch` en vez de `start`: la columna del titulo crece hasta la altura + * de la portada, que es lo que le da a `.pie` un sitio al que bajar. + */ .ficha { display: grid; - grid-template-columns: minmax(0, 17rem) minmax(0, 1fr); + grid-template-columns: minmax(0, 22rem) minmax(0, 1fr); gap: var(--sp-6); - align-items: start; + align-items: stretch; padding-bottom: var(--sp-5); border-bottom: 1px solid var(--c-border); } @@ -321,20 +340,43 @@ background: var(--c-bg-sunken); } + .titulo { + display: flex; + flex-direction: column; + min-width: 0; + } + .meta { font-size: var(--fs-sm); } + .etiquetas { + margin-bottom: var(--sp-4); + } + + /* + * `gap` y no margenes: el aviso de venta y las plataformas no siempre estan, + * y un margen sobre un hueco vacio dejaria los botones despegados de la base + * de la portada justo en las fichas que no tienen ninguna de las dos cosas. + */ + .pie { + display: flex; + flex-direction: column; + gap: var(--sp-3); + margin-top: auto; + } + .acciones { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-3); - margin-bottom: var(--sp-3); } + /* Sin margen propio: dentro de `.pie` la separacion la pone el `gap`. */ .venta { max-width: 46ch; + margin: 0; font-size: var(--fs-xs); } @@ -437,8 +479,13 @@ grid-template-columns: minmax(0, 1fr); } + /* Apilada ya no hay nada debajo a lo que alinearse. */ + .pie { + margin-top: var(--sp-4); + } + .portada { - max-width: 14rem; + max-width: 18rem; } }