La ficha de un tema: portada mayor, etiquetas y botones abajo

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) <noreply@anthropic.com>
master
dev 4 weeks ago
parent af40c25e1b
commit fef07e6b8b

@ -35,6 +35,7 @@
"db:versos": "node --env-file=.env scripts/db/sembrar-versos.mjs", "db:versos": "node --env-file=.env scripts/db/sembrar-versos.mjs",
"db:glosario": "node --env-file=.env scripts/db/sembrar-glosario.mjs", "db:glosario": "node --env-file=.env scripts/db/sembrar-glosario.mjs",
"db:animos": "node --env-file=.env scripts/db/sembrar-animos.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:leer": "node scripts/album/leer.mjs",
"album:importar": "node --env-file=.env scripts/album/importar.mjs", "album:importar": "node --env-file=.env scripts/album/importar.mjs",
"album:medios": "node --env-file=.env scripts/album/medios.mjs", "album:medios": "node --env-file=.env scripts/album/medios.mjs",

@ -201,9 +201,18 @@ try {
for (const [i, x] of album.canciones.entries()) { for (const [i, x] of album.canciones.entries()) {
if (!x.letra?.limpia) continue; 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( const { rows: canciones } = await c.query(
`insert into cancion (id, slug, titulo, letra, idioma, estilo_id, album_id, numero, estado) `insert into cancion (id, slug, titulo, letra, idioma, estilo_id, album_id, numero,
values (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'publicado') estado, ala_venta)
values (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'publicado', true)
on conflict (slug) do update set on conflict (slug) do update set
titulo = excluded.titulo, titulo = excluded.titulo,
letra = excluded.letra, letra = excluded.letra,

@ -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.');

@ -0,0 +1,110 @@
<script lang="ts">
/**
* Las etiquetas de un tema o de una entrada, en fantasma y con color.
*
* Fantasma: sin relleno. Son un dato, no una acción, y un botón lleno al lado
* del botón de escuchar competiría con él. El relleno aparece solo al pasar
* por encima, que es cuando sí hay una acción.
*
* Con color: el tono sale del nombre —ver `$lib/utils/etiquetas`—, así que la
* misma etiqueta es del mismo color en toda la web. El color distingue; no
* significa nada, y por eso ninguna información va solo en él: el nombre
* siempre está escrito.
*
* La claridad y el croma se fijan aquí, iguales para los nueve tonos, para que
* ninguna etiqueta grite más que otra y todas se lean sobre el fondo.
*/
import { resolve } from '$app/paths';
import { aSlug } from '$lib/content/schema';
import { tonoDeEtiqueta } from '$lib/utils/etiquetas';
interface Props {
etiquetas: string[];
/** Título accesible de la lista. */
titulo?: string;
/**
* Sin enlaces, para cuando ya van dentro de algo clicable: un enlace
* dentro de otro deja una zona donde el clic hace una cosa distinta según
* el píxel exacto.
*/
enlaces?: boolean;
}
let { etiquetas, titulo = 'Etiquetas', enlaces = true }: Props = $props();
</script>
{#if etiquetas.length > 0}
<ul class="etiquetas" aria-label={titulo}>
{#each etiquetas as etiqueta (etiqueta)}
<li>
{#if enlaces}
<a
class="etiqueta"
style="--tono: {tonoDeEtiqueta(etiqueta)}"
href={resolve('/etiquetas/[etiqueta]', { etiqueta: aSlug(etiqueta) })}
>
{etiqueta}
</a>
{:else}
<span class="etiqueta" style="--tono: {tonoDeEtiqueta(etiqueta)}">{etiqueta}</span>
{/if}
</li>
{/each}
</ul>
{/if}
<style>
.etiquetas {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
list-style: none;
margin: 0;
padding: 0;
}
/*
* En oscuro el color va claro y el borde apagado; el relleno del hover es el
* mismo tono a un 12 % de opacidad, que sobre el marino se nota sin encender
* la etiqueta.
*/
.etiqueta {
--claro: oklch(0.84 0.11 var(--tono));
--apagado: oklch(0.52 0.08 var(--tono));
--velo: oklch(0.84 0.11 var(--tono) / 0.14);
display: inline-block;
padding: 0.2em 0.7em;
border: 1px solid var(--apagado);
border-radius: var(--radius-boton);
background: transparent;
font-size: var(--fs-xs);
color: var(--claro);
text-decoration: none;
transition:
background-color 0.3s var(--suave),
border-color 0.3s var(--suave);
}
/*
* En claro se invierte: el texto tiene que bajar de claridad para leerse sobre
* el fondo casi blanco, y el borde subir para no ser un trazo negro.
*/
:global(:root[data-tema='claro']) .etiqueta {
--claro: oklch(0.45 0.13 var(--tono));
--apagado: oklch(0.78 0.08 var(--tono));
--velo: oklch(0.45 0.13 var(--tono) / 0.1);
}
a.etiqueta:hover,
a.etiqueta:focus-visible {
border-color: var(--claro);
background: var(--velo);
}
@media (prefers-reduced-motion: reduce) {
.etiqueta {
transition: none;
}
}
</style>

@ -13,7 +13,6 @@
*/ */
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import Valoracion from './Valoracion.svelte'; import Valoracion from './Valoracion.svelte';
import { aSlug as slugDeEtiqueta } from '$lib/content/schema';
import type { ResumenDeValoracion } from '$lib/server/valoraciones'; import type { ResumenDeValoracion } from '$lib/server/valoraciones';
import type { AlbumConCanciones, Cancion, Estilo } from '$lib/types'; import type { AlbumConCanciones, Cancion, Estilo } from '$lib/types';
import { listaDeNombres, segundosADuracion } from '$lib/utils/format'; import { listaDeNombres, segundosADuracion } from '$lib/utils/format';
@ -106,25 +105,7 @@
</dd> </dd>
{/if} {/if}
{#if cancion.etiquetas.length > 0} <!-- Las etiquetas van arriba, junto al titulo: ver `canciones/[slug]`. -->
<dt>Etiquetas</dt>
<dd>
<ul class="chips">
{#each cancion.etiquetas as etiqueta (etiqueta)}
<li>
<a
class="chip"
href={resolve('/etiquetas/[etiqueta]', {
etiqueta: slugDeEtiqueta(etiqueta)
})}
>
{etiqueta}
</a>
</li>
{/each}
</ul>
</dd>
{/if}
{#if album?.sello} {#if album?.sello}
<dt>Sello</dt> <dt>Sello</dt>

@ -12,6 +12,7 @@
*/ */
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import BotonReproducir from './BotonReproducir.svelte'; import BotonReproducir from './BotonReproducir.svelte';
import Etiquetas from './Etiquetas.svelte';
import { brillo } from '$lib/acciones/brillo'; import { brillo } from '$lib/acciones/brillo';
import { revelar } from '$lib/acciones/revelar'; import { revelar } from '$lib/acciones/revelar';
import { aPistas } from '$lib/player/pista'; import { aPistas } from '$lib/player/pista';
@ -66,16 +67,10 @@
</p> </p>
{#if etiquetas.length > 0} {#if etiquetas.length > 0}
<!-- <!-- Sin enlaces: la tarjeta entera ya lleva a un sitio. -->
Texto, no enlaces: la tarjeta entera ya lleva a un sitio, y meter <div class="chips">
enlaces dentro de otro enlace deja una zona donde el clic hace una <Etiquetas {etiquetas} titulo="Etiquetas de {bloque.estilo.nombre}" enlaces={false} />
cosa distinta según el píxel exacto. </div>
-->
<ul class="chips">
{#each etiquetas as etiqueta (etiqueta)}
<li class="chip">{etiqueta}</li>
{/each}
</ul>
{/if} {/if}
</div> </div>
</article> </article>

@ -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);
});
});

@ -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];
}

@ -3,6 +3,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import BotonReproducir from '$lib/components/BotonReproducir.svelte'; import BotonReproducir from '$lib/components/BotonReproducir.svelte';
import Carrusel, { type Lamina } from '$lib/components/Carrusel.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 Heroe from '$lib/components/Heroe.svelte';
import LaminaCta from '$lib/components/LaminaCta.svelte'; import LaminaCta from '$lib/components/LaminaCta.svelte';
import Meta from '$lib/components/Meta.svelte'; import Meta from '$lib/components/Meta.svelte';
@ -10,7 +11,6 @@
import TarjetaEstilo from '$lib/components/TarjetaEstilo.svelte'; import TarjetaEstilo from '$lib/components/TarjetaEstilo.svelte';
import { revelar } from '$lib/acciones/revelar'; import { revelar } from '$lib/acciones/revelar';
import { aPistas } from '$lib/player/pista'; import { aPistas } from '$lib/player/pista';
import { aSlug as slugDeEtiqueta } from '$lib/content/schema';
import { heroe, site } from '$lib/site'; import { heroe, site } from '$lib/site';
import { CANCIONES_POR_PACK, formatearPrecio, PRECIO_PACK_CENTIMOS } from '$lib/tienda'; import { CANCIONES_POR_PACK, formatearPrecio, PRECIO_PACK_CENTIMOS } from '$lib/tienda';
@ -115,18 +115,9 @@
</p> </p>
{#if data.etiquetasPrincipal.length > 0} {#if data.etiquetasPrincipal.length > 0}
<ul class="chips"> <div class="chips">
{#each data.etiquetasPrincipal as etiqueta (etiqueta)} <Etiquetas etiquetas={data.etiquetasPrincipal} titulo="Etiquetas del estilo" />
<li> </div>
<a
class="chip"
href={resolve('/etiquetas/[etiqueta]', { etiqueta: slugDeEtiqueta(etiqueta) })}
>
{etiqueta}
</a>
</li>
{/each}
</ul>
{/if} {/if}
<div class="acciones"> <div class="acciones">

@ -11,7 +11,7 @@
import Meta from '$lib/components/Meta.svelte'; import Meta from '$lib/components/Meta.svelte';
import { brillo } from '$lib/acciones/brillo'; import { brillo } from '$lib/acciones/brillo';
import { revelar } from '$lib/acciones/revelar'; 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'; import { formatearFecha } from '$lib/utils/date';
let { data } = $props(); let { data } = $props();
@ -200,20 +200,9 @@
<p class="resumen muted">{entrada.resumen}</p> <p class="resumen muted">{entrada.resumen}</p>
{/if} {/if}
{#if entrada.etiquetas.length > 0} {#if entrada.etiquetas.length > 0}
<ul class="chips"> <div class="chips">
{#each entrada.etiquetas as etiqueta (etiqueta)} <Etiquetas etiquetas={entrada.etiquetas} titulo="Etiquetas de {entrada.titulo}" />
<li> </div>
<a
class="chip"
href={resolve('/etiquetas/[etiqueta]', {
etiqueta: slugDeEtiqueta(etiqueta)
})}
>
{etiqueta}
</a>
</li>
{/each}
</ul>
{/if} {/if}
</div> </div>
</article> </article>

@ -2,7 +2,7 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte'; import Meta from '$lib/components/Meta.svelte';
import RejillaCanciones from '$lib/components/RejillaCanciones.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 { formatearFecha } from '$lib/utils/date';
import Migas from '$lib/components/Migas.svelte'; import Migas from '$lib/components/Migas.svelte';
@ -44,18 +44,7 @@
{#if entrada.etiquetas.length > 0} {#if entrada.etiquetas.length > 0}
<footer class="pie"> <footer class="pie">
<ul class="chips"> <Etiquetas etiquetas={entrada.etiquetas} titulo="Etiquetas de {entrada.titulo}" />
{#each entrada.etiquetas as etiqueta (etiqueta)}
<li>
<a
class="chip"
href={resolve('/etiquetas/[etiqueta]', { etiqueta: slugDeEtiqueta(etiqueta) })}
>
{etiqueta}
</a>
</li>
{/each}
</ul>
</footer> </footer>
{/if} {/if}
</article> </article>
@ -141,9 +130,6 @@
max-width: 64ch; max-width: 64ch;
padding-block: var(--sp-5); padding-block: var(--sp-5);
} }
.pie .chips {
margin: 0;
}
.temas { .temas {
padding-block: var(--sp-5); padding-block: var(--sp-5);

@ -12,6 +12,7 @@
import { aPista, aPistaDeVersion, aPistas } from '$lib/player/pista'; import { aPista, aPistaDeVersion, aPistas } from '$lib/player/pista';
import { CANCIONES_POR_PACK, formatearPrecio, PRECIO_PACK_CENTIMOS } from '$lib/tienda'; import { CANCIONES_POR_PACK, formatearPrecio, PRECIO_PACK_CENTIMOS } from '$lib/tienda';
import Migas from '$lib/components/Migas.svelte'; import Migas from '$lib/components/Migas.svelte';
import Etiquetas from '$lib/components/Etiquetas.svelte';
import { listaDeNombres, segundosADuracion } from '$lib/utils/format'; import { listaDeNombres, segundosADuracion } from '$lib/utils/format';
let { data } = $props(); let { data } = $props();
@ -104,11 +105,11 @@
class="portada" class="portada"
src={data.portada} src={data.portada}
alt="Portada de {cancion.titulo}" alt="Portada de {cancion.titulo}"
width="280" width="352"
height="280" height="352"
/> />
{:else} {:else}
<SinPortada titulo={cancion.titulo} lado={280} /> <SinPortada titulo={cancion.titulo} lado={352} />
{/if} {/if}
<div class="titulo"> <div class="titulo">
@ -137,6 +138,28 @@
{/if} {/if}
</p> </p>
{#if cancion.etiquetas.length > 0}
<div class="etiquetas">
<Etiquetas etiquetas={cancion.etiquetas} titulo="Etiquetas de {cancion.titulo}" />
</div>
{/if}
<!--
Lo de abajo se descuelga hasta la base de la portada: los botones son
lo que se viene a hacer, y quedan a la misma altura en todas las
fichas, tenga el titulo una linea o tres.
-->
<div class="pie">
{#if cancion.alaVenta}
<p class="venta muted">
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)}.
</p>
{/if}
<EnlacesPlataformas enlaces={cancion.enlaces} etiqueta="Escuchar {cancion.titulo} en" />
<!-- Los últimos: lo que se viene a hacer va abajo del todo. -->
<div class="acciones"> <div class="acciones">
<BotonReproducir <BotonReproducir
{pistas} {pistas}
@ -148,15 +171,7 @@
<BotonCarrito {cancion} /> <BotonCarrito {cancion} />
<BotonFavorito cancionSlug={cancion.slug} /> <BotonFavorito cancionSlug={cancion.slug} />
</div> </div>
</div>
{#if cancion.alaVenta}
<p class="venta muted">
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)}.
</p>
{/if}
<EnlacesPlataformas enlaces={cancion.enlaces} etiqueta="Escuchar {cancion.titulo} en" />
</div> </div>
</header> </header>
@ -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 { .ficha {
display: grid; display: grid;
grid-template-columns: minmax(0, 17rem) minmax(0, 1fr); grid-template-columns: minmax(0, 22rem) minmax(0, 1fr);
gap: var(--sp-6); gap: var(--sp-6);
align-items: start; align-items: stretch;
padding-bottom: var(--sp-5); padding-bottom: var(--sp-5);
border-bottom: 1px solid var(--c-border); border-bottom: 1px solid var(--c-border);
} }
@ -321,20 +340,43 @@
background: var(--c-bg-sunken); background: var(--c-bg-sunken);
} }
.titulo {
display: flex;
flex-direction: column;
min-width: 0;
}
.meta { .meta {
font-size: var(--fs-sm); 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 { .acciones {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--sp-3); gap: var(--sp-3);
margin-bottom: var(--sp-3);
} }
/* Sin margen propio: dentro de `.pie` la separacion la pone el `gap`. */
.venta { .venta {
max-width: 46ch; max-width: 46ch;
margin: 0;
font-size: var(--fs-xs); font-size: var(--fs-xs);
} }
@ -437,8 +479,13 @@
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
/* Apilada ya no hay nada debajo a lo que alinearse. */
.pie {
margin-top: var(--sp-4);
}
.portada { .portada {
max-width: 14rem; max-width: 18rem;
} }
} }
</style> </style>

Loading…
Cancel
Save

Powered by TurnKey Linux.