|
|
# Senza Paura
|
|
|
|
|
|
Web de un letrista y compositor, con tienda. La obra se agrupa en **estilos
|
|
|
por estilo** —las baladas románticas como estilos principal, y junto a ellas
|
|
|
clásica, rock y pop melódico—, se escucha desde un **reproductor persistente**
|
|
|
que no se corta al navegar, y se puede **comprar en packs de diez canciones**
|
|
|
que se entregan como descarga en MP3.
|
|
|
|
|
|
El catálogo vive en Markdown dentro del repositorio: publicar una canción es
|
|
|
añadir un archivo. La base de datos guarda solo lo que generan las personas que
|
|
|
visitan la web —cuentas, carritos, pedidos y compras—.
|
|
|
|
|
|
## Estado
|
|
|
|
|
|
La estructura, la funcionalidad y la **dirección visual** están hechas. Todo el
|
|
|
color, la tipografía y el ritmo salen de las variables de `:root` en
|
|
|
[`src/app.css`](src/app.css): cambiar la estética sigue siendo cambiar esos
|
|
|
tokens, no recorrer los componentes.
|
|
|
|
|
|
Los colores son los de la marca: **azul marino oscuro aterciopelado y dorado**.
|
|
|
El marino lleva croma de verdad (0,04 en oklch) en lugar de ser un gris
|
|
|
azulado, que es lo que le da el aspecto de terciopelo en vez del de pizarra;
|
|
|
todos los fondos comparten tono y solo cambian de claridad. El dorado es el
|
|
|
único acento y lo llevan las cosas accionables, nada más.
|
|
|
|
|
|
**Cuidado con el atajo `background` en cualquier regla que pueda alcanzar a un
|
|
|
botón dorado.** El atajo reinicia también `background-image`, así que
|
|
|
`.btn:hover { background: … }` dejaba el botón en un rectángulo oscuro con el
|
|
|
borde de oro. Por eso las reglas base usan `background-color`. Y todo control
|
|
|
que responda al puntero está cubierto por una prueba: eso no se ve en una
|
|
|
captura en reposo, que es justo como se coló.
|
|
|
|
|
|
**En los botones el dorado no es plano.** Un metal refleja, y lo que lo hace
|
|
|
parecer metal es la banda de luz que lo cruza: el degradado `--oro` va de la
|
|
|
sombra del canto al brillo y vuelve, con un filo de luz arriba que lo bisela. El
|
|
|
fondo se pinta más alto que el botón a propósito, así que al acercar el puntero
|
|
|
la luz **recorre** la pieza en vez de limitarse a aclararla; es la diferencia
|
|
|
entre algo que se ilumina y algo que gira. El color plano se queda debajo como
|
|
|
reserva: si el degradado no llegara a pintarse, el botón sigue siendo dorado y
|
|
|
legible.
|
|
|
|
|
|
Hay **dos modos, y el claro es una inversión de verdad**: el texto pasa a ser
|
|
|
el propio marino y los fondos, blancos apenas teñidos de él. El dorado baja de
|
|
|
claridad en el modo claro —el que se lee bien sobre marino desaparece sobre
|
|
|
blanco—, así que ahí es un dorado más hondo, casi bronce.
|
|
|
|
|
|
Funciona en tres estados: mientras no se elija nada se sigue al sistema, sin
|
|
|
JavaScript, y el botón de la cabecera escribe `data-tema` en la raíz y manda
|
|
|
sobre él. Lo aplica un script en línea de `app.html` **antes de pintar**: si se
|
|
|
hiciera al hidratar, quien tenga el claro guardado vería un destello oscuro en
|
|
|
cada carga.
|
|
|
|
|
|
Dos familias: **Newsreader** (serif) para titulares y para las letras, que son
|
|
|
texto para leer despacio, y **Archivo** (sans) para interfaz, datos y
|
|
|
navegación.
|
|
|
|
|
|
Lo que sigue siendo provisional son los **materiales**: las portadas son SVG
|
|
|
generados y el clip de la cabecera es un degradado con grano, no metraje real.
|
|
|
|
|
|
**Antes de vender de verdad** hacen falta dos cosas que no son código: las
|
|
|
claves de Stripe (abajo se explica dónde van) y decidir el tratamiento del IVA.
|
|
|
Vender bienes digitales a particulares en la UE obliga a repercutir el IVA del
|
|
|
país del comprador; Stripe Tax lo resuelve activándolo en la cuenta y añadiendo
|
|
|
`automatic_tax` a la sesión de pago, en
|
|
|
[`src/lib/server/stripe.ts`](src/lib/server/stripe.ts). También faltan las
|
|
|
páginas de condiciones de venta y privacidad.
|
|
|
|
|
|
## Empezar
|
|
|
|
|
|
```bash
|
|
|
npm install
|
|
|
cp .env.example .env # basta con dejarlo como está para desarrollo
|
|
|
npm run db:migrate # crea local.db
|
|
|
npm run dev
|
|
|
```
|
|
|
|
|
|
La web queda en `http://localhost:5173`.
|
|
|
|
|
|
Sin claves de Stripe la tienda funciona salvo el cobro: el carrito calcula, pero
|
|
|
el botón de pagar avisa de que no hay pasarela. Sin clave de correo, en
|
|
|
desarrollo **el código de acceso se imprime por consola**, así que se puede
|
|
|
entrar en una cuenta sin configurar nada.
|
|
|
|
|
|
| Comando | Qué hace |
|
|
|
| --------------------- | --------------------------------------------- |
|
|
|
| `npm run dev` | Servidor de desarrollo (panel en `/admin`) |
|
|
|
| `npm run build` | Compila a `build/` (adapter-node) |
|
|
|
| `npm run preview` | Sirve la compilación de producción en `:4173` |
|
|
|
| `npm run check` | Comprobación de tipos |
|
|
|
| `npm run lint` | Prettier + ESLint |
|
|
|
| `npm run format` | Formatea el proyecto |
|
|
|
| `npm run test:unit` | Tests unitarios y de componente (Vitest) |
|
|
|
| `npm run test:e2e` | Compila y ejecuta los end-to-end (Playwright) |
|
|
|
| `npm test` | Todo lo anterior |
|
|
|
| `npm run db:generate` | Genera una migración a partir del esquema |
|
|
|
| `npm run db:migrate` | Aplica las migraciones pendientes |
|
|
|
| `npm run db:studio` | Abre el visor de la base de datos |
|
|
|
|
|
|
## El panel de contenido
|
|
|
|
|
|
En `/admin` hay un panel ([Sveltia CMS](https://sveltiacms.app)) para editar el
|
|
|
catálogo sin tocar archivos: formularios por tipo de contenido, con desplegables
|
|
|
para elegir el estilo o el disco de una canción en vez de escribir su slug.
|
|
|
|
|
|
Sube también el **audio** y las **portadas**: el máster va a una bandeja y
|
|
|
`npm run medios` lo coloca al arrancar (más abajo, en «Dónde va el audio»).
|
|
|
|
|
|
**Escribe los mismos Markdown de `src/content/`.** Esa es la razón de haberlo
|
|
|
elegido en lugar de mover el catálogo a una base de datos: se gana el panel sin
|
|
|
perder nada de lo que ya funcionaba. El esquema los sigue validando al
|
|
|
compilar, git sigue guardando el historial de cada letra —que en una web de un
|
|
|
letrista es el patrimonio— y el catálogo sigue sin hacer ni una consulta,
|
|
|
porque va compilado en el bundle.
|
|
|
|
|
|
### Editar en local
|
|
|
|
|
|
El flujo local de Sveltia usa la File System Access API del navegador, sin
|
|
|
servidor intermedio. Requiere dos cosas:
|
|
|
|
|
|
1. Un **navegador basado en Chromium** (Chrome, Edge, Brave). Firefox y Safari
|
|
|
todavía no traen esa API.
|
|
|
2. Que el proyecto sea un **repositorio git**. Ya lo es.
|
|
|
|
|
|
Con `npm run dev` en marcha, se abre `http://localhost:5173/admin`, se pulsa
|
|
|
«Trabajar con un repositorio local» y se elige la carpeta del proyecto. A partir
|
|
|
de ahí, guardar en el panel escribe el archivo, y el sitio se recarga solo.
|
|
|
|
|
|
### Editar desde cualquier sitio
|
|
|
|
|
|
Para eso hace falta que el repositorio esté en GitHub y cambiar `repo` en
|
|
|
[`static/admin/config.yml`](static/admin/config.yml) por el nombre real. Con eso,
|
|
|
el panel funciona desde el navegador de cualquier equipo y cada guardado es un
|
|
|
commit.
|
|
|
|
|
|
### Al añadir un campo
|
|
|
|
|
|
`config.yml` replica el esquema de
|
|
|
[`schema.ts`](src/lib/content/schema.ts). **Si añades un campo allí, añádelo
|
|
|
también aquí**: el panel guarda lo que conoce, así que un campo que no esté
|
|
|
declarado se pierde al editar esa entrada desde el panel.
|
|
|
|
|
|
Donde el panel puede, comprueba lo mismo que el esquema: el formato del ISWC y
|
|
|
el ISRC, la duración como `m:ss`, la ruta relativa del audio y el rango de los
|
|
|
porcentajes. Lo que no puede comprobar —que un reparto sume exactamente 100— lo
|
|
|
sigue cazando la compilación.
|
|
|
|
|
|
## Cómo se organiza el contenido
|
|
|
|
|
|
```
|
|
|
src/content/
|
|
|
estilos/ un archivo por estilo → un estilos de /musica
|
|
|
albumes/ discos, EPs, singles y colecciones
|
|
|
canciones/ un archivo por tema; el cuerpo del Markdown es la letra
|
|
|
taller/ los capítulos del taller de letras
|
|
|
paginas/ textos sueltos (bio)
|
|
|
```
|
|
|
|
|
|
El **slug** de cualquier pieza de contenido es el nombre de su archivo sin
|
|
|
extensión, y es lo que aparece en la URL. `src/content/albumes/como-si-nada.md`
|
|
|
se publica en `/albumes/como-si-nada`.
|
|
|
|
|
|
Las relaciones se declaran por slug. **Si una referencia no existe, la
|
|
|
compilación falla** con un mensaje que dice el archivo y el campo exactos; no
|
|
|
hay contenido que desaparezca en silencio de los listados.
|
|
|
|
|
|
Cada estilo, cada álbum y cada canción tienen su propia página.
|
|
|
|
|
|
### Canciones sueltas: el álbum «Singles»
|
|
|
|
|
|
Una canción sin campo `album` es un single suelto. No se queda fuera de la web:
|
|
|
los singles de cada estilo se agrupan en un álbum **«Singles»** que se genera
|
|
|
solo, con su propia página en `/albumes/singles-<estilo>`, y que aparece el
|
|
|
último dentro de su estilos. No existe como archivo y no hay que mantenerlo.
|
|
|
|
|
|
### Añadir un estilo (un estilos nuevo)
|
|
|
|
|
|
`src/content/estilos/bolero.md`
|
|
|
|
|
|
```markdown
|
|
|
---
|
|
|
nombre: 'Bolero'
|
|
|
resumen: 'Frase corta que acompaña al título del estilos.'
|
|
|
orden: 4 # menor = más arriba en /musica
|
|
|
---
|
|
|
|
|
|
Texto introductorio del estilos. Es opcional y aparece en /musica/bolero.
|
|
|
```
|
|
|
|
|
|
Solo un estilo puede llevar `principal: true`: es el que abre la portada y
|
|
|
encabeza los estilos.
|
|
|
|
|
|
### Añadir un álbum
|
|
|
|
|
|
`src/content/albumes/nombre-del-disco.md`
|
|
|
|
|
|
```markdown
|
|
|
---
|
|
|
titulo: 'Nombre del disco'
|
|
|
estilo: baladas-romanticas # slug de un archivo de estilos/
|
|
|
fecha: '2026-03-15'
|
|
|
tipo: album # album | ep | single | coleccion
|
|
|
portada: /images/albumes/nombre-del-disco.jpg
|
|
|
sello: 'Autoeditado' # opcional
|
|
|
resumen: 'Una frase sobre el disco.'
|
|
|
destacado: true # opcional
|
|
|
edicion: # opcional: lo que iría en la contraportada
|
|
|
numeroCatalogo: 'SP-004'
|
|
|
formatos: [digital, cd] # digital | cd | vinilo | casete
|
|
|
pais: España
|
|
|
creditos:
|
|
|
- rol: 'Producción'
|
|
|
nombre: 'Senza Paura'
|
|
|
- rol: 'Diseño de portada'
|
|
|
nombre: 'Estudio Bermejo'
|
|
|
enlaces: # opcional
|
|
|
spotify: https://open.spotify.com/...
|
|
|
---
|
|
|
|
|
|
Notas del disco. Aparecen bajo el tracklist.
|
|
|
```
|
|
|
|
|
|
### Añadir una canción
|
|
|
|
|
|
`src/content/canciones/nombre-del-tema.md`
|
|
|
|
|
|
```markdown
|
|
|
---
|
|
|
titulo: 'Nombre del tema'
|
|
|
estilo: baladas-romanticas
|
|
|
album: nombre-del-disco # quítalo y el tema pasa a ser un single suelto
|
|
|
numero: 1 # obligatorio si hay álbum; se ignora en los singles
|
|
|
duracion: '4:12'
|
|
|
ano: 2026
|
|
|
audio: nombre-del-disco/01-nombre-del-tema.mp3 # clave privada, sin barra inicial
|
|
|
portada: /images/canciones/nombre-del-tema.jpg # opcional: si falta, se usa la del álbum
|
|
|
interpretes:
|
|
|
- 'Quien lo canta'
|
|
|
destacada: true # opcional: aparece en portada y en su estilos
|
|
|
versiones: # opcional: otros arreglos del mismo tema
|
|
|
- nombre: 'Versión orquestal'
|
|
|
audio: nombre-del-disco/01-nombre-del-tema-orquestal.mp3
|
|
|
duracion: '4:48'
|
|
|
nota: 'Con cuerda y arpa, para el concierto de presentación.'
|
|
|
registro: # opcional: la obra como propiedad registrada
|
|
|
iswc: 'T-034.524.680-1' # identifica la OBRA, igual en todas sus versiones
|
|
|
isrc: 'ES-A2B-24-00001' # identifica ESTA grabación
|
|
|
entidad: SGAE
|
|
|
numero: '9400685'
|
|
|
editorial: 'Ediciones Senza Paura' # distinta del sello del disco
|
|
|
reparto: # tiene que sumar 100; si no, el build falla
|
|
|
- nombre: 'Senza Paura'
|
|
|
rol: letra y musica # letra | musica | letra y musica
|
|
|
porcentaje: 50
|
|
|
- nombre: 'Coral Vidal'
|
|
|
rol: letra y musica
|
|
|
porcentaje: 50
|
|
|
inspiracion: |
|
|
|
De dónde sale el tema. Se escribe como bloque y se separa en párrafos por
|
|
|
líneas en blanco.
|
|
|
|
|
|
Este es el segundo párrafo.
|
|
|
---
|
|
|
|
|
|
Aquí va la letra completa.
|
|
|
Los saltos de línea se respetan,
|
|
|
porque en una canción el verso es contenido.
|
|
|
```
|
|
|
|
|
|
La ficha del tema abre con la portada y el reproductor, y reparte el contenido
|
|
|
en **pestañas**: «Letra» (abierta por defecto, porque es lo que se viene a
|
|
|
leer), «De dónde sale» y «Alternativas musicales». Las dos últimas solo
|
|
|
aparecen si el tema las tiene. Los créditos van al lado, siempre visibles.
|
|
|
|
|
|
Las pestañas siguen el patrón de WAI-ARIA —flechas, `Inicio` y `Fin` mueven
|
|
|
entre ellas— y **el contenido de todas está siempre en el HTML**, aunque su
|
|
|
pestaña esté cerrada: es lo que indexan los buscadores. Sin JavaScript la fila
|
|
|
de pestañas desaparece y se ven los tres bloques seguidos, cada uno con su
|
|
|
encabezado; la letra de una canción no puede depender de que el navegador
|
|
|
ejecute nada.
|
|
|
|
|
|
Detalles que ahorran trabajo:
|
|
|
|
|
|
- **La autoría se presupone.** Sin `letra:` ni `musica:`, ambas se atribuyen al
|
|
|
artista. Solo hay que escribirlas cuando firma alguien más.
|
|
|
- **`instrumental: true`** marca las piezas sin letra (las de clásica, por
|
|
|
ejemplo). El cuerpo del Markdown pasa a leerse como notas de la pieza.
|
|
|
- **Las versiones no se venden aparte.** Quien compra un tema se lleva todos sus
|
|
|
arreglos.
|
|
|
- **`audio` es opcional.** Sin él, la ficha y la letra se publican igual, el
|
|
|
botón de reproducción sale desactivado y el tema no está a la venta. Para
|
|
|
retirar de la venta un tema que sí tiene audio, `alaVenta: false`.
|
|
|
- **El extracto para las tarjetas no se escribe.** Los primeros versos salen
|
|
|
solos del cuerpo del Markdown, así que nunca se desincronizan de la letra. Si
|
|
|
esos versos concretos no son los que mejor presentan el tema, un campo
|
|
|
`extracto:` con una lista de líneas manda sobre el automático.
|
|
|
|
|
|
### La obra como propiedad registrada
|
|
|
|
|
|
El bloque `registro` es lo que separa un catálogo de una web. Los códigos se
|
|
|
validan y se normalizan al compilar —da igual si escribes `T0345246801` o
|
|
|
`T-034.524.680-1`—, y **un reparto que no suma 100 rompe el build**: un reparto
|
|
|
descuadrado solo se descubre cuando la entidad de gestión rechaza el registro, y
|
|
|
para entonces ya nadie recuerda de dónde salió el número.
|
|
|
|
|
|
La distinción que más se confunde: el **ISWC identifica la obra** y es el mismo
|
|
|
en todas sus versiones; el **ISRC identifica una grabación**, así que la versión
|
|
|
orquestal de un tema comparte ISWC con la original y tiene ISRC propio.
|
|
|
|
|
|
Estos datos se publican en la ficha del tema, bajo «Registro». Es deliberado: el
|
|
|
ISRC ya aparece en cada plataforma, el ISWC está en los repertorios públicos, y
|
|
|
quien quiera licenciar una obra necesita localizarla y saber con quién negociar.
|
|
|
Si prefieres no enseñar el reparto de autoría, no lo rellenes: lo que falta no
|
|
|
se pinta.
|
|
|
|
|
|
## Letras en otro idioma
|
|
|
|
|
|
Una canción declara su `idioma` (ISO 639, español por defecto). Cuando no está
|
|
|
en español y trae `traduccion`, la pestaña de la letra se parte en **dos
|
|
|
columnas**: la original a la izquierda y el castellano a la derecha, verso a
|
|
|
verso. Con una sola de las dos cosas se queda en una columna, porque una columna
|
|
|
vacía sería peor que ninguna.
|
|
|
|
|
|
```yaml
|
|
|
idioma: en
|
|
|
traduccion: |
|
|
|
Dejé las luces encendidas al salir,
|
|
|
no porque fuera a volver
|
|
|
```
|
|
|
|
|
|
Dos detalles que importan. Cada columna lleva su `lang`, que es lo que impide
|
|
|
que un lector de pantalla lea el inglés con voz española. Y la traducción se
|
|
|
guarda tal cual y se pinta con `white-space: pre-line`: es verso, no prosa, y
|
|
|
una traducción que pierde los cortes deja de servir para seguir la original.
|
|
|
|
|
|
El nombre del idioma se saca de `Intl.DisplayNames`, no de una tabla que
|
|
|
mantener.
|
|
|
|
|
|
## Etiquetas
|
|
|
|
|
|
Cada tema y cada entrada del blog pueden llevar **etiquetas** libres: de qué
|
|
|
habla, cómo suena, con quién se canta. Existen porque el estilo y el disco no
|
|
|
cruzan el catálogo por donde a veces hace falta: un desamor puede ser una balada
|
|
|
o una ranchera, y el año no ayuda a encontrarlo.
|
|
|
|
|
|
Se escriben tal cual en el frontmatter y **agrupan por slug**, así que «Desamor»
|
|
|
y «desamor» son la misma; el nombre que se lee es el primero que apareció. En
|
|
|
`/etiquetas` están todas, de más usada a menos, y cada una tiene su página.
|
|
|
|
|
|
También son lo que hace útil el buscador de `/musica`: quien recuerda «el dueto»
|
|
|
o «desamor» llega igual que quien recuerda el título.
|
|
|
|
|
|
## Blog
|
|
|
|
|
|
Apuntes fechados, en `src/content/blog/`. Es lo único del sitio que se fecha: el
|
|
|
catálogo no caduca, pero un apunte sobre cómo salió un tema pertenece al día en
|
|
|
que se escribió.
|
|
|
|
|
|
```markdown
|
|
|
---
|
|
|
titulo: 'Lo que no cabe en una canción'
|
|
|
fecha: 2026-02-14
|
|
|
resumen: 'Una frase; es lo que se lee en el listado y en los buscadores.'
|
|
|
etiquetas:
|
|
|
- 'Oficio'
|
|
|
canciones:
|
|
|
- como-si-nada
|
|
|
borrador: false
|
|
|
---
|
|
|
|
|
|
El cuerpo del Markdown es el artículo.
|
|
|
```
|
|
|
|
|
|
Dos cosas que conviene saber. El campo `canciones` enlaza temas del catálogo, y
|
|
|
la entrada los enseña al final con su reproductor, así que se puede escuchar lo
|
|
|
que se está leyendo. Y `borrador: true` deja la entrada fuera **en el catálogo**,
|
|
|
no en cada página: no hay forma de escribir un listado nuevo y olvidarse de
|
|
|
filtrarlos.
|
|
|
|
|
|
## Taller de letras
|
|
|
|
|
|
Un apartado propio (`/taller`) con el oficio puesto por escrito, en
|
|
|
`src/content/taller/`. Son dos bloques y se distinguen por el campo `tipo`:
|
|
|
|
|
|
- **`principio`** — el curso: qué hace que una letra funcione, la imagen
|
|
|
concreta, métrica y acento, la rima, la estructura y el estribillo. Se leen
|
|
|
en orden y cada capítulo enlaza con el siguiente.
|
|
|
- **`ficha`** — una por estilo hispanoamericano (bolero, son cubano, ranchera,
|
|
|
tango, vals criollo, bachata, vallenato y cumbia). No son historia de la
|
|
|
música: son lo que hay que tener delante para escribir en ese estilo, con
|
|
|
compás, tempo, métrica, estructura, rima, de qué se habla y qué rasgos de
|
|
|
escritura lo distinguen.
|
|
|
|
|
|
Añadir un estilo es un archivo:
|
|
|
|
|
|
```markdown
|
|
|
---
|
|
|
titulo: 'Milonga'
|
|
|
tipo: ficha
|
|
|
orden: 9
|
|
|
resumen: 'Una frase sobre qué aporta el estilo a quien escribe.'
|
|
|
ficha:
|
|
|
origen: 'Río de la Plata, siglo XIX'
|
|
|
compas: '2/4'
|
|
|
tempo: 'Vivo'
|
|
|
metrica: 'Octosílabo'
|
|
|
estructura: 'Estrofas encadenadas'
|
|
|
rima: 'Consonante en los pares'
|
|
|
temas:
|
|
|
- 'El desafío'
|
|
|
rasgos:
|
|
|
- 'Se canta casi hablando'
|
|
|
---
|
|
|
|
|
|
El cuerpo del Markdown: cómo se escribe una, el error más común y un ejercicio.
|
|
|
```
|
|
|
|
|
|
**Todas** las páginas del taller llevan el índice en una barra lateral a la
|
|
|
izquierda —la portada y cada capítulo—, con el capítulo abierto marcado: en un
|
|
|
curso, saber dónde estás y qué queda es parte de poder seguirlo. Vive en
|
|
|
[`+layout.svelte`](src/routes/taller/+layout.svelte) de la sección, no en cada
|
|
|
página, así que no se vuelve a pintar al cambiar de capítulo —no parpadea ni
|
|
|
pierde el desplazamiento— y no hay forma de añadir una página y olvidarse de
|
|
|
ponerlo. En pantallas estrechas no se oculta: baja debajo del texto.
|
|
|
|
|
|
Los rótulos de sus dos bloques **no son encabezados**. En la portada repetirían
|
|
|
palabra por palabra los títulos de la página y el esquema diría dos veces «Los
|
|
|
principios»; nombran su lista con `aria-labelledby`, que es lo que hacía falta.
|
|
|
|
|
|
Los capítulos anterior y siguiente se calculan **dentro de su bloque**: pasar de
|
|
|
los principios a las fichas es cambiar de cosa, no seguir leyendo.
|
|
|
|
|
|
## Dónde va el audio
|
|
|
|
|
|
Como el catálogo se vende, **el archivo completo no es público**:
|
|
|
|
|
|
Todo va agrupado **por disco**, con el mismo nombre de carpeta en los tres
|
|
|
árboles:
|
|
|
|
|
|
```
|
|
|
media/audio/<album>/NN-<cancion>.mp3 archivo completo — privado, nunca se sirve
|
|
|
static/muestras/<album>/NN-<cancion>.mp3 fragmento — público, es lo que suena en la web
|
|
|
static/images/<album>/portada.jpg portada del disco
|
|
|
static/images/<album>/<cancion>.jpg portada propia de un tema
|
|
|
static/images/sin-portada.svg reserva cuando no hay ninguna
|
|
|
```
|
|
|
|
|
|
Los temas sueltos usan `singles/` como carpeta, y ahí vive también la portada
|
|
|
del álbum «Singles» que se genera solo.
|
|
|
|
|
|
La `<clave>` del audio es lo que declara el campo `audio` de la canción, sin
|
|
|
barra inicial: `como-si-nada/01-como-si-nada.mp3`. De ahí salen la ruta privada
|
|
|
y la de la muestra, así que no hay que escribirlas por duplicado.
|
|
|
|
|
|
El directorio privado se configura con `MEDIA_DIR`. Los detalles de formato, la
|
|
|
convención de nombres y cómo sustituir los archivos de marcador de posición
|
|
|
están en [`media/LEEME.md`](media/LEEME.md).
|
|
|
|
|
|
### Subir un tema sin tocar el disco
|
|
|
|
|
|
El campo **Archivo de audio** del panel sube el máster a `media/audio/subidas/`.
|
|
|
Lo que el navegador no puede hacer —recortar la muestra y medir la duración— lo
|
|
|
hace [`scripts/medios.mjs`](scripts/medios.mjs), que corre solo antes de
|
|
|
`npm run dev` y de `npm run build`, y también a mano con `npm run medios`:
|
|
|
coloca el archivo en la carpeta de su disco con el nombre de la convención,
|
|
|
reescribe la clave en el Markdown, genera el fragmento y rellena `duracion` si
|
|
|
estaba vacía.
|
|
|
|
|
|
Necesita **ffmpeg** en el equipo de quien edita. Si falta, avisa de lo que no ha
|
|
|
podido generar y sigue; no rompe el arranque ni el build. El servidor de
|
|
|
despliegue no lo necesita: las muestras están en el repositorio.
|
|
|
|
|
|
Un archivo que se quede en `subidas/` sin ficha que lo reclame se avisa en cada
|
|
|
arranque, para que no se pierda ahí.
|
|
|
|
|
|
Los `.wav` y `.svg` del repositorio son **provisionales**, para poder probar el
|
|
|
reproductor y las descargas: seis segundos como archivo completo y dos como
|
|
|
muestra, con una nota distinta por pista.
|
|
|
|
|
|
## La portada
|
|
|
|
|
|
Un carrusel de tres láminas: el disco en el tocadiscos, una invitación al taller
|
|
|
y otra a la tienda. Un carrusel mal hecho es de las piezas más hostiles que hay,
|
|
|
así que este:
|
|
|
|
|
|
- **se para solo** al pasar el puntero o al entrar el foco, y sigue al salir;
|
|
|
- tiene un **botón de pausa** de verdad, cuyo estado manda sobre lo demás;
|
|
|
- **no gira solo** con `prefers-reduced-motion`;
|
|
|
- deja las láminas ocultas **fuera del orden de tabulación** (`inert`), así que
|
|
|
el teclado no cae en botones invisibles;
|
|
|
- se mueve con las flechas del teclado desde sus controles.
|
|
|
|
|
|
Las tres se pintan a la vez y se cruzan con opacidad, no se desmontan: así el
|
|
|
fondo de la primera no se recarga al volver a ella. Las dos láminas de llamada
|
|
|
reutilizan el mismo fondo, de modo que no añaden peso.
|
|
|
|
|
|
Debajo del carrusel, el catálogo en tres pasos:
|
|
|
|
|
|
el **estilo principal a fila completa**, sus discos debajo, y el resto de
|
|
|
estilos en una rejilla donde cada tarjeta dice cuánto hay —discos y temas— y de
|
|
|
qué se habla, con las etiquetas más usadas de sus canciones.
|
|
|
|
|
|
El estilo principal no compite en la rejilla a propósito: es el grueso de la
|
|
|
obra, y ponerlo del mismo tamaño que los demás diría lo contrario de lo que es.
|
|
|
Cuál es lo marca `principal: true` en su archivo.
|
|
|
|
|
|
Las etiquetas de un estilo **no se declaran**: salen de sus temas
|
|
|
(`etiquetasDeEstilo`). Así no hay dos sitios que mantener ni forma de que la
|
|
|
etiqueta de un estilo contradiga a las de sus canciones.
|
|
|
|
|
|
## La página de música
|
|
|
|
|
|
Tres formas de recorrer lo mismo, porque no todo el mundo llega buscando igual:
|
|
|
|
|
|
- **Los estilos, siempre a la izquierda.** Están en el layout de la sección, así
|
|
|
que los llevan igual la portada de música y la página de cada estilo, con el
|
|
|
abierto marcado. Son la navegación de la sección; en pantalla estrecha suben
|
|
|
arriba en horizontal, no se esconden.
|
|
|
- **Un buscador** que mira el título, el estilo, el disco, el año, las etiquetas
|
|
|
y los intérpretes, sin tildes y sin mayúsculas. Con una búsqueda en marcha los
|
|
|
estilos no dicen nada, así que se enseñan las coincidencias sueltas.
|
|
|
- **Dos vistas, «Tarjetas» y «Tabla».** Las tarjetas sirven para descubrir; la
|
|
|
tabla, ordenable por cualquier columna, para comparar: cuál dura menos, qué
|
|
|
salió en 2022. Ordenar es cosa del cliente porque el catálogo va compilado en
|
|
|
el bundle: no hay consulta que hacer ni espera que enseñar.
|
|
|
|
|
|
En la página de **un estilo** se listan **todos** sus temas, no solo los
|
|
|
destacados. En la de música, donde conviven todos los estilos, solo caben los
|
|
|
destacados; ahí no, porque si no un tema recién añadido no se ve por ninguna
|
|
|
parte.
|
|
|
|
|
|
## Las tarjetas de canción
|
|
|
|
|
|
La tarjeta **identifica** un tema, no lo cuenta: portada, título, disco, año y
|
|
|
estilo. Nada más. La letra se lee en la ficha, que es donde tiene sitio para
|
|
|
respirar; en una rejilla, un fragmento de estrofa por tarjeta convierte el
|
|
|
listado en un muro de texto y estorba justo lo que el listado sirve para hacer,
|
|
|
que es recorrer y elegir.
|
|
|
|
|
|
Sigue existiendo el **extracto** —los primeros versos, que saca del cuerpo del
|
|
|
Markdown el preprocesador [`extracto.ts`](src/lib/content/extracto.ts) antes de
|
|
|
que mdsvex lo compile, cortando en la primera línea en blanco—. Ya no se usa en
|
|
|
las tarjetas, pero sí en la lista numerada de un disco, donde cada tema es una
|
|
|
fila y una línea de letra distingue mejor que unos créditos idénticos en todo el
|
|
|
álbum.
|
|
|
|
|
|
La animación es doble: aparición escalonada al entrar en pantalla, y respuesta
|
|
|
al puntero (la tarjeta se eleva, la portada se acerca y el botón de reproducir
|
|
|
sube desde la esquina). Tres cosas que conviene no romper al rediseñar:
|
|
|
|
|
|
- **Sin JavaScript se ve todo.** El estado oculto lo pone la acción
|
|
|
[`revelar`](src/lib/acciones/revelar.ts), nunca el CSS: si no hay JS no hay
|
|
|
atributo, y la tarjeta se pinta directamente.
|
|
|
- **`prefers-reduced-motion: reduce` desactiva el movimiento**, y entonces el
|
|
|
botón de reproducir se queda siempre visible.
|
|
|
- **En pantallas táctiles** el botón también se queda visible: sin hover que lo
|
|
|
revele, un botón invisible pero pulsable sería peor que ninguno.
|
|
|
|
|
|
Dónde se usa cada cosa: **tarjetas** donde se descubre música (portada, temas
|
|
|
destacados de cada estilos, colecciones de singles) y **lista numerada** en la
|
|
|
página de un disco, donde el orden de las pistas es la información.
|
|
|
|
|
|
## Las láminas de llamada
|
|
|
|
|
|
Las dos que no son la cabecera llevan **foto propia**, no la textura de marca:
|
|
|
un café con un cuaderno para la tienda, y alguien escribiendo en la calle para
|
|
|
el taller. En las dos, lo que se ve está a la derecha y el texto va a la
|
|
|
izquierda, que es como está calibrado el velo de tipo `foto`: sobre una imagen
|
|
|
clara el texto blanco no se lee, así que ahí el velo no atenúa, **oscurece**.
|
|
|
|
|
|
Llegan en JPEG de más de medio mega y se sirven en tres anchos WebP, como el
|
|
|
fondo de marca.
|
|
|
|
|
|
## El fondo de la portada
|
|
|
|
|
|
Encima de todo, en la cabecera, van **ondas animadas**
|
|
|
([`OndasWebgl`](src/lib/components/OndasWebgl.svelte)): WebGL a pelo, sin
|
|
|
biblioteca. Un shader de fondo es un triángulo y un programa de fragmentos;
|
|
|
traerse three.js para eso sería medio mega de dependencia en la primera pantalla
|
|
|
para dibujar dos triángulos.
|
|
|
|
|
|
El dibujo es _domain warping_: se calcula ruido, se usa para desplazar las
|
|
|
coordenadas y se vuelve a calcular. Dos vueltas bastan para que las formas dejen
|
|
|
de parecer nubes y empiecen a parecer agua. La escala horizontal es mayor que la
|
|
|
vertical, que es lo que las tumba en bandas.
|
|
|
|
|
|
La paleta sale de los colores de marca convertidos a **RGB lineal**, que es en
|
|
|
lo que mezcla el shader. El primer intento usó los cuatro fondos de `app.css`,
|
|
|
que caben en un pelo de claridad, y salió una plancha lisa: por bien que calcule
|
|
|
el ruido, sin recorrido de color no se ve nada.
|
|
|
|
|
|
Cuatro cosas que no son negociables, y por las que no vale con soltar un canvas:
|
|
|
|
|
|
- **Con `prefers-reduced-motion` no se monta.**
|
|
|
- **Se para cuando no se ve**, al salir de pantalla y al cambiar de pestaña. Un
|
|
|
shader a pantalla completa detrás de una página por la que ya has bajado es
|
|
|
batería tirada.
|
|
|
- **Si falla, no pasa nada**: sin WebGL o sin compilar, debajo queda la imagen.
|
|
|
Un fondo decorativo no puede tumbar una portada.
|
|
|
- **Solo lo lleva la cabecera.** En las tres láminas serían tres shaders
|
|
|
peleándose por la tarjeta gráfica para pintar lo mismo.
|
|
|
|
|
|
Las ondas resuelven además el **tema claro**, que no tiene imagen propia: las
|
|
|
pinta en azules pálidos, y el clip de reserva dejó de hacer falta. Donde no
|
|
|
pueden correr, el tema claro se queda con el color de fondo a secas hasta que
|
|
|
exista una imagen suya.
|
|
|
|
|
|
Las pruebas arrancan Chromium con SwiftShader —su rasterizador por software—,
|
|
|
porque si no solo se podría comprobar la ausencia del efecto.
|
|
|
|
|
|
## La imagen del fondo
|
|
|
|
|
|
Una imagen a sangre detrás del tocadiscos y del texto, en
|
|
|
`static/background/`. Se sirve en **tres anchos WebP** y el navegador coge el
|
|
|
que le toca: 10 kB en un móvil, 57 kB en un panel grande. El PNG original de
|
|
|
2,1 MB se queda en el repositorio como fuente, pero no lo pide nadie.
|
|
|
|
|
|
El velo que va encima existe solo para que el texto de la izquierda se lea, y va
|
|
|
lo justo: el fondo es material de marca y taparlo entero sería no haberlo
|
|
|
puesto.
|
|
|
|
|
|
**El tema claro todavía no tiene imagen** —esta es marino oscuro, y sobre blanco
|
|
|
no valdría—, así que ahí sigue el clip de reserva. Mientras sea así, quien tenga
|
|
|
el claro se descarga la imagen oscura una vez: el servidor pinta el tema de
|
|
|
marca, que es el que no puede adivinar. Se acaba en cuanto exista la versión
|
|
|
clara.
|
|
|
|
|
|
## Vídeo
|
|
|
|
|
|
Dos sitios llevan vídeo, y ninguno carga nada de terceros por su cuenta:
|
|
|
|
|
|
- **La cabecera de la portada** ([`Heroe.svelte`](src/lib/components/Heroe.svelte))
|
|
|
pone un disco en un [tocadiscos](src/lib/components/Tocadiscos.svelte) sobre
|
|
|
un clip propio, mudo y en bucle desde `static/video/` —hay uno por tema, sobre
|
|
|
marino y sobre blanco—. Es un archivo nuestro y
|
|
|
no un incrustado porque en la primera pantalla un iframe de YouTube pesa más
|
|
|
que todo lo demás junto. No se arranca con el atributo `autoplay` sino desde
|
|
|
código, para poder respetar `prefers-reduced-motion`. El disco que se pone se
|
|
|
elige en `heroe` (`src/lib/site.ts`).
|
|
|
|
|
|
El tocadiscos no es un adorno: está atado al reproductor de verdad, gira
|
|
|
mientras suena algo de ese disco y se para al pausar, y el propio disco es un
|
|
|
botón enfocable con teclado. El giro es una animación CSS que se pausa con
|
|
|
`animation-play-state`, no un bucle en JavaScript.
|
|
|
|
|
|
- **Los vídeos de una canción** ([`VideoYoutube.svelte`](src/lib/components/VideoYoutube.svelte))
|
|
|
se declaran en su frontmatter y aparecen en una pestaña más de la ficha.
|
|
|
Hasta que alguien pulsa, la página **no pide nada a YouTube**: se ve la
|
|
|
portada propia del tema con un botón encima, y solo al pulsar se inserta el
|
|
|
reproductor, contra `youtube-nocookie.com`. Evita también la miniatura
|
|
|
oficial, que se serviría desde los servidores de Google. Hay un test end-to-end
|
|
|
que comprueba que no sale ninguna petición antes de pulsar.
|
|
|
|
|
|
En el frontmatter se acepta el enlace tal cual se copia del navegador o el id
|
|
|
suelto; el esquema extrae el id y lo valida:
|
|
|
|
|
|
```yaml
|
|
|
videos:
|
|
|
- youtube: https://www.youtube.com/watch?v=XXXXXXXXXXX
|
|
|
titulo: 'Videoclip oficial'
|
|
|
- youtube: XXXXXXXXXXX
|
|
|
titulo: 'En directo en el Teatro Circo'
|
|
|
```
|
|
|
|
|
|
## La cabecera y las anclas
|
|
|
|
|
|
La portada **empieza en el borde de la ventana** y la cabecera flota encima:
|
|
|
por eso es translúcida con desenfoque, para apoyarse sobre la imagen en vez de
|
|
|
tapar una franja. Se consigue tirando de la portada hacia arriba con un margen
|
|
|
negativo del alto de la cabecera, que cada lámina devuelve en relleno para que
|
|
|
su contenido no se mueva.
|
|
|
|
|
|
Ese alto **lo mide la cabecera** y lo publica en `--altura-cabecera`, igual que
|
|
|
hace la barra del reproductor con el suyo. Estaba escrito a mano en 4,75rem
|
|
|
cuando la cabecera mide 68px, así que todo lo que dependía de él —el salto a un
|
|
|
ancla, las barras laterales pegajosas— quedaba desplazado.
|
|
|
|
|
|
La cabecera **acompaña al desplazamiento** (`position: sticky`) y deja ver lo
|
|
|
que pasa por debajo. La transparencia va **con desenfoque, no sola**: sobre la
|
|
|
portada, un fondo solo translúcido dejaría el contraste del menú a merced de la
|
|
|
zona que quedara detrás; el desenfoque la promedia y la vuelve previsible. Donde
|
|
|
no hay `backdrop-filter` la cabecera se vuelve casi opaca, porque es preferible
|
|
|
perder el efecto a perder la legibilidad. El botón de claro/oscuro es el
|
|
|
**último elemento del menú**, después del carrito y de la cuenta.
|
|
|
|
|
|
Una cabecera fija tapa aquello a lo que se salta. Lo evita
|
|
|
`--altura-cabecera` en [`app.css`](src/app.css), que alimenta el
|
|
|
`scroll-padding-top` de la raíz y el `top` de las barras laterales que
|
|
|
también se quedan fijas —el índice y la ficha del taller—. Si cambia el
|
|
|
espaciado de la cabecera, ese número la sigue; nadie la mide en JavaScript.
|
|
|
|
|
|
Las **pestañas se pueden enlazar**: `#panel-videos` en la ficha de una canción
|
|
|
abre esa pestaña y baja hasta ella. Sin esto el enlace no llevaría a ninguna
|
|
|
parte, porque el panel cerrado está oculto. Lo hace
|
|
|
[`Pestanas.svelte`](src/lib/components/Pestanas.svelte) leyendo el hash de la
|
|
|
URL; un ancla que no corresponde a ninguna pestaña se ignora y se queda la de
|
|
|
siempre.
|
|
|
|
|
|
Entre secciones **no hay líneas**: la separación la hace el aire. Los bordes
|
|
|
que quedan son de pieza, no de sección —el pie, la barra del reproductor, las
|
|
|
filas de un listado, la tabla—, y ahí sí dibujan algo.
|
|
|
|
|
|
## Piezas compartidas
|
|
|
|
|
|
Tres cosas que estaban copiadas en varios sitios y ahora se escriben una vez:
|
|
|
|
|
|
- **El fondo de la portada** ([`FondoDePortada`](src/lib/components/FondoDePortada.svelte)),
|
|
|
que usan la cabecera del disco y las dos láminas de llamada. Estaba duplicado
|
|
|
y **ya había divergido**: el degradado era distinto por descuido, no por
|
|
|
decisión. El clip de vídeo solo lo monta la cabecera; con él compartido, el
|
|
|
tema claro llegó a reproducir tres copias del mismo vídeo en la portada.
|
|
|
- **Las migas de pan** ([`Migas`](src/lib/components/Migas.svelte)), en seis
|
|
|
páginas. Son una lista ordenada, no una fila de enlaces: un lector de pantalla
|
|
|
anuncia cuántos escalones hay y en cuál va. El último no es enlace, porque no
|
|
|
se enlaza a donde ya estás.
|
|
|
- **La pastilla de una etiqueta** (`.chip` en `app.css`), en cinco. A veces es
|
|
|
enlace y a veces solo texto —dentro de una tarjeta que ya es clicable, meter
|
|
|
un enlace dentro de otro deja una zona donde el clic hace una cosa distinta
|
|
|
según el píxel—, así que es una clase y no un componente.
|
|
|
|
|
|
## Cantos y radios
|
|
|
|
|
|
Dos valores y no más, para que nada se vea de otra familia:
|
|
|
`--radius-boton` (6px) para lo que se pulsa o se escribe —botones, campos,
|
|
|
pastillas—, y `--radius-tarjeta` (8px) para las superficies —tarjetas, paneles,
|
|
|
portadas—. Antes había tres radios sueltos y una píldora de 999px repetida en
|
|
|
quince archivos.
|
|
|
|
|
|
Los botones de **icono suelto** siguen siendo redondos: el de reproducir sobre
|
|
|
una portada, el del tema, los puntos del carrusel. Un icono en un círculo tiene
|
|
|
sentido; un botón con texto en forma de cápsula es otra cosa.
|
|
|
|
|
|
## Tarjetas
|
|
|
|
|
|
Las tres clases —estilo, álbum y canción— comparten `.tarjeta` en `app.css` y
|
|
|
se mueven igual. Al acercarse: la tarjeta sube, se le enciende el borde en
|
|
|
dorado, la portada recupera el color que tenía apagado en reposo y un reflejo
|
|
|
**nace donde está el puntero** —lo escribe la acción
|
|
|
[`brillo`](src/lib/acciones/brillo.ts) en `--mx` y `--my`—. El subrayado del
|
|
|
título crece desde la izquierda y al pulsar la tarjeta cede.
|
|
|
|
|
|
Nada usa la curva `ease`, que es la de por defecto y hace que todo se parezca:
|
|
|
se sale rápido y se frena largo (`--sal`), que es como se mueve algo que pesa.
|
|
|
|
|
|
**Cuidado con los filtros de varias funciones.** El minificador de CSS los junta
|
|
|
sin el espacio que las separa —deja `saturate(.85)brightness(.92)`, que no es
|
|
|
válido— y el navegador descarta la declaración entera. Por eso esos valores van
|
|
|
dentro de una variable, donde se copian tal cual. Y no se declara nunca
|
|
|
`-webkit-backdrop-filter` a mano: si está, el minificador borra la versión sin
|
|
|
prefijo y deja la prefijada, que no llega a aplicarse. El prefijo lo pone él
|
|
|
solo.
|
|
|
|
|
|
Hay tres clases —estilo, álbum y canción— y se comportan igual porque comparten
|
|
|
`.tarjeta` en `app.css`: aparecen escalonadas al entrar en pantalla, se elevan
|
|
|
al acercar el puntero y descubren el botón de reproducir sobre la imagen. Tres
|
|
|
cosas que conviene no romper: sin JavaScript se ven todas (el estado oculto lo
|
|
|
pone la acción [`revelar`](src/lib/acciones/revelar.ts), nunca el CSS),
|
|
|
`prefers-reduced-motion` desactiva el movimiento, y en pantallas táctiles el
|
|
|
botón de reproducir se queda siempre visible, porque sin puntero no hay nada
|
|
|
que lo revele.
|
|
|
|
|
|
## El reproductor
|
|
|
|
|
|
Vive en el layout raíz, fuera de la parte que cambia al navegar, así que la
|
|
|
música sigue sonando mientras se recorre la web. Su estado es una única
|
|
|
instancia de módulo ([`src/lib/player/player.svelte.ts`](src/lib/player/player.svelte.ts)),
|
|
|
no un contexto de Svelte, precisamente por eso.
|
|
|
|
|
|
**Lo que suena siempre es el fragmento de muestra**, y la barra lo dice. El
|
|
|
archivo completo solo se obtiene comprando.
|
|
|
|
|
|
Qué hace: cola con orden propio, aleatorio (respetando el tema en curso),
|
|
|
repetición de cola o de tema, búsqueda dentro del tema, volumen recordado entre
|
|
|
visitas, panel de cola e integración con la Media Session del sistema operativo.
|
|
|
|
|
|
Atajos, mientras no se esté escribiendo en un campo:
|
|
|
|
|
|
| Tecla | Acción |
|
|
|
| ------------------- | ------------------------- |
|
|
|
| `Espacio` | Reproducir / pausar |
|
|
|
| `←` / `→` | Retroceder / avanzar 5 s |
|
|
|
| `Mayús` + `←` / `→` | Tema anterior / siguiente |
|
|
|
| `M` | Silenciar |
|
|
|
|
|
|
Reproducir un tema desde cualquier listado carga **todo el listado** en la cola.
|
|
|
En la ficha de un tema, las alternativas musicales forman su propia cola, para
|
|
|
poder comparar arreglos seguidos.
|
|
|
|
|
|
## La tienda
|
|
|
|
|
|
### Precio
|
|
|
|
|
|
Las canciones se venden en **packs cerrados de diez, a 1,50 € el pack**: diez
|
|
|
canciones por lo que cuesta un café, que es la propuesta y por eso el número y
|
|
|
el importe se leen siempre juntos. El carrito no deja pagar hasta completar un
|
|
|
múltiplo de diez, dice cuántas faltan
|
|
|
y propone temas para cerrar el pack. Toda esa aritmética está en
|
|
|
[`src/lib/tienda.ts`](src/lib/tienda.ts), en funciones puras que usan por igual
|
|
|
la página y el servidor: no hay dos versiones del cálculo.
|
|
|
|
|
|
Para cambiar el precio o el tamaño del pack, esas dos constantes.
|
|
|
|
|
|
### Cuentas
|
|
|
|
|
|
Alta y entrada con un **código de seis dígitos enviado por correo**. No hay
|
|
|
contraseñas que guardar ni recuperar, y la verificación del correo va incluida
|
|
|
en el propio registro. Si es la primera vez, la cuenta se crea sola.
|
|
|
|
|
|
Ni el código ni el token de sesión se guardan en claro: en la base de datos hay
|
|
|
hashes. El código caduca a los quince minutos, solo sirve una vez, y pedir uno
|
|
|
nuevo invalida el anterior. Los envíos están limitados por dirección IP.
|
|
|
|
|
|
`/entrar?paso=codigo&email=…` vuelve al paso del código sin gastar otro, para
|
|
|
quien recarga la página o vuelve desde el correo.
|
|
|
|
|
|
### Carrito
|
|
|
|
|
|
Se puede llenar **sin haber entrado**: mientras no hay cuenta se identifica con
|
|
|
una cookie, y al iniciar sesión se fusiona con el carrito de la cuenta. Nadie
|
|
|
pierde lo que estaba eligiendo por tener que registrarse a mitad.
|
|
|
|
|
|
Los botones son formularios de verdad, así que **funcionan sin JavaScript**: la
|
|
|
acción redirige de vuelta a la página de origen. Con JavaScript, `enhance` sigue
|
|
|
esa misma redirección sin recargar.
|
|
|
|
|
|
### Pago
|
|
|
|
|
|
Se usa **Stripe Checkout alojado**: la web no ve nunca los datos de la tarjeta.
|
|
|
El importe no viaja como total, sino como precio del pack por cantidad, para que
|
|
|
la factura de Stripe diga lo mismo que el carrito.
|
|
|
|
|
|
El pedido se guarda como pendiente antes de mandar a la pasarela y se cierra
|
|
|
cuando Stripe lo confirma. La confirmación llega por dos caminos —el webhook y
|
|
|
la vuelta a `/compra/completada`, que consulta el estado real—, así que la
|
|
|
compra queda registrada aunque se cierre la pestaña o el webhook tarde. Cerrar
|
|
|
un pedido es idempotente, de modo que llegar por los dos no molesta.
|
|
|
|
|
|
En local, para recibir el webhook:
|
|
|
|
|
|
```bash
|
|
|
stripe listen --forward-to localhost:5173/api/stripe/webhook
|
|
|
```
|
|
|
|
|
|
y el secreto que imprime va en `STRIPE_WEBHOOK_SECRET`.
|
|
|
|
|
|
### Descargas
|
|
|
|
|
|
`/descargas/<cancion>` y `/descargas/<cancion>/<version>` comprueban sesión y
|
|
|
compra en cada petición y transmiten el archivo desde el directorio privado. No
|
|
|
hay URLs firmadas a propósito: el servidor ya tiene la sesión delante, y una URL
|
|
|
firmada solo añadiría una copia del permiso que puede quedarse obsoleta.
|
|
|
|
|
|
Si algún día el catálogo se mueve a almacenamiento de objetos (S3, R2), lo que
|
|
|
cambia es [`src/lib/server/medios.ts`](src/lib/server/medios.ts): en vez de leer
|
|
|
del disco, firmaría una URL temporal. El resto de la aplicación no se entera.
|
|
|
|
|
|
## Favoritos
|
|
|
|
|
|
Quien tiene cuenta puede guardar temas en una lista personal, con el corazón que
|
|
|
hay en la ficha, en las tarjetas y en la tabla. La lista está en `/favoritos`.
|
|
|
|
|
|
Se puede marcar **cualquier** tema, esté a la venta o no, y marcarlo no dice
|
|
|
nada público sobre él: no hay contadores a la vista. Es lo que lo distingue de
|
|
|
una compra —que da derecho a la descarga— y de una valoración, que es un dato
|
|
|
agregado y visible.
|
|
|
|
|
|
Los slugs viajan enteros en `page.data.favoritos`, igual que el carrito: son
|
|
|
unas decenas y cualquier tarjeta necesita saber si el suyo está dentro, así que
|
|
|
resolverlo una vez en el layout raíz sale más barato que consultarlo por
|
|
|
componente.
|
|
|
|
|
|
Quien no ha entrado **ve el corazón igualmente**, pero lleva a entrar. Esconderlo
|
|
|
dejaría la interfaz cambiando de forma según quién mira, sin explicar por qué
|
|
|
falta algo.
|
|
|
|
|
|
## Valoraciones
|
|
|
|
|
|
Quien tiene cuenta puede puntuar un tema de 1 a 5 desde su ficha. La media y el
|
|
|
número de votos se ven siempre, también sin entrar: saber cómo está valorado un
|
|
|
tema no exige cuenta; lo que la exige es votar, para que un voto sea de alguien.
|
|
|
|
|
|
**No hace falta haber comprado el tema.** Lo que suena en la web es un fragmento,
|
|
|
y opinar sobre lo que se ha escuchado es el uso normal; exigir la compra
|
|
|
convertiría la valoración en una reseña de producto.
|
|
|
|
|
|
Un voto por cuenta y tema, y volver a votar reemplaza el anterior. Eso lo impone
|
|
|
la **clave primaria compuesta** de la tabla `valoracion`, no el código de la
|
|
|
acción: si mañana hubiera otra vía de escritura, seguiría sin poder duplicar.
|
|
|
|
|
|
El control son cinco radios dentro de un formulario, no cinco botones con
|
|
|
`onclick`. De ahí salen gratis tres cosas: funciona sin JavaScript, las flechas
|
|
|
del teclado se mueven entre las estrellas sin programar nada, y un lector de
|
|
|
pantalla anuncia «3 de 5, seleccionado» en vez de cinco botones sueltos.
|
|
|
|
|
|
## Sindicación y buscadores
|
|
|
|
|
|
Tres cosas generadas del catálogo, no escritas a mano: añadir un tema o un
|
|
|
capítulo no puede exigir acordarse de tocar un archivo aparte.
|
|
|
|
|
|
- **`/blog/rss.xml`** — el feed. Un blog sin feed obliga a volver a mirar si hay
|
|
|
algo nuevo, y eso no lo hace nadie. Es lo único que se sindica: el catálogo no
|
|
|
es una corriente de novedades, es una obra. Va anunciado con
|
|
|
`<link rel="alternate">` en toda la web, que es como lo encuentran los
|
|
|
lectores.
|
|
|
- **`/sitemap.xml`** — todas las páginas de contenido, con `robots.txt`
|
|
|
apuntando a él. Quedan fuera las que dependen de quién mira —cuenta, carrito,
|
|
|
favoritos, entrar— y las de descarga: no son contenido y no hay nada que
|
|
|
indexar. Un test lo comprueba, porque colar ahí una ruta privada es fácil.
|
|
|
- **`/og.jpg`** — la imagen de reserva al compartir. Sin ella, una página sin
|
|
|
portada propia se pega en un chat como un enlace pelado. Es **provisional**,
|
|
|
como el resto del material visual: sale del fotograma de la portada.
|
|
|
|
|
|
## Volver a donde estabas
|
|
|
|
|
|
Los formularios llevan un campo `volverA` para devolver a quien pulsa
|
|
|
exactamente a donde estaba. Ese campo lo escribe cualquiera, así que pasa por
|
|
|
[`destinoSeguro`](src/lib/server/destino.ts) antes de convertirse en una
|
|
|
redirección.
|
|
|
|
|
|
No compara prefijos a mano. Se intentó, y se le escapaba `/otro-dominio.com`:
|
|
|
el navegador convierte la barra invertida en barra normal al resolver, así que
|
|
|
eso es `//otro-dominio.com` disfrazado, y comprobar `//` no basta. Ahora decide
|
|
|
la propia resolución de URL —la misma que hará el navegador— y solo se acepta lo
|
|
|
que sigue en el origen del sitio.
|
|
|
|
|
|
Importa más de lo que parece en la página de entrar: sin filtrar, basta un
|
|
|
enlace a nuestra propia pantalla de acceso con `?volverA=` apuntando fuera para
|
|
|
que alguien escriba su correo aquí y acabe en una página ajena creyendo que
|
|
|
sigue en esta.
|
|
|
|
|
|
## La base de datos
|
|
|
|
|
|
**PostgreSQL 16**, con Drizzle por delante. Aquí no vive el catálogo —estilos,
|
|
|
discos, temas y capítulos siguen siendo Markdown del repositorio—: la base
|
|
|
guarda solo lo que generan las personas que visitan la web, que es lo que no
|
|
|
puede estar en el código. Cuentas, códigos de acceso, sesiones, límites de
|
|
|
envío, carritos, pedidos, compras, favoritos y valoraciones.
|
|
|
|
|
|
Dos diferencias con SQLite que costaron un fallo cada una, por si aparecen otra
|
|
|
vez:
|
|
|
|
|
|
- **`integer` en Postgres son cuatro bytes**, no ocho. El limitador guarda su
|
|
|
vencimiento en milisegundos desde 1970 —trece dígitos— y en `integer` no
|
|
|
cabe: va en `bigint`.
|
|
|
- **`avg()` y `count()` llegan como cadena.** El driver no las convierte para
|
|
|
no perder precisión, así que `cuantas === 0` nunca se cumpliría y «1 voto»
|
|
|
saldría siempre en plural. Se convierten en la propia consulta con
|
|
|
`::float8` y `::int`.
|
|
|
|
|
|
## Variables de entorno
|
|
|
|
|
|
Copia `.env.example` a `.env`. Todas son opcionales en desarrollo:
|
|
|
|
|
|
| Variable | Para qué |
|
|
|
| ----------------------- | ----------------------------------------------------- |
|
|
|
| `DATABASE_URL` | SQLite local (`file:local.db`) o Turso (`libsql://…`) |
|
|
|
| `MEDIA_DIR` | Directorio privado del audio completo |
|
|
|
| `STRIPE_SECRET_KEY` | Cobros |
|
|
|
| `STRIPE_WEBHOOK_SECRET` | Firma del webhook de Stripe |
|
|
|
| `RESEND_API_KEY` | Códigos de acceso y formulario de contacto |
|
|
|
| `CONTACTO_DESTINO` | Buzón que recibe los mensajes |
|
|
|
| `CONTACTO_REMITENTE` | Remitente verificado en el dominio |
|
|
|
| `ORIGIN` | Origen público, que adapter-node necesita conocer |
|
|
|
|
|
|
## Responsive
|
|
|
|
|
|
Se comprueba en pruebas, no de vista: [`responsive.e2e.ts`](e2e/responsive.e2e.ts)
|
|
|
recorre diez páginas a 320, 375, 768 y 1440 px y exige que **ninguna desborde a
|
|
|
lo ancho**, que ningún control suelto baje de 24×24 —el mínimo para acertarle
|
|
|
con el dedo; los enlaces dentro de un texto quedan fuera, los limita la línea—,
|
|
|
que la navegación móvil se despliegue entera y que las barras laterales **bajen
|
|
|
en vez de desaparecer**: son la navegación de su sección.
|
|
|
|
|
|
Dos cosas que salieron de ahí y no se veían a simple vista. Los puntos del
|
|
|
carrusel medían 8×8; ahora el punto se ve igual pero el botón que lo rodea mide
|
|
|
24×24. Y el hueco que se reserva bajo el contenido para la barra de
|
|
|
reproducción estaba escrito a mano —88 px— cuando la barra mide 97 en
|
|
|
escritorio y **145 apilada en un móvil**: se comía el pie de página. Ahora la
|
|
|
barra mide su propio alto con un `ResizeObserver` y lo publica en
|
|
|
`--player-height`; el valor de `app.css` se queda solo como reserva para el
|
|
|
primer pintado.
|
|
|
|
|
|
## Tests
|
|
|
|
|
|
- **Unitarios** (Vitest, Node): validación del frontmatter, esquemas de
|
|
|
contenido, reglas de la tienda, fechas y duraciones, lógica de cola del
|
|
|
reproductor, validación del formulario y limitador de envíos.
|
|
|
- **De componente** (Vitest en Chromium): renderizado de listados de temas.
|
|
|
- **End-to-end** (Playwright): navegación entre estilos, reproducción y su
|
|
|
persistencia al cambiar de página, cola, letras, pestañas de la ficha, taller
|
|
|
de letras, cambio de tema, formulario de contacto, carrito anónimo, reglas del
|
|
|
pack, alta por código y control de acceso a las descargas.
|
|
|
|
|
|
```bash
|
|
|
npm run test:unit -- --run
|
|
|
npm run test:e2e
|
|
|
```
|
|
|
|
|
|
## Despliegue
|
|
|
|
|
|
El proyecto usa `@sveltejs/adapter-node`: `npm run build` deja un servidor en
|
|
|
`build/`, que se arranca con `node build`. **No son archivos estáticos.** Cada
|
|
|
página se genera sabiendo quién la pide —si ha entrado, qué lleva en el carrito,
|
|
|
qué ha comprado—, así que hace falta un proceso vivo. Un servidor web por sí
|
|
|
solo no puede servir esto.
|
|
|
|
|
|
El montaje es el de siempre: **Nginx delante, Node detrás**. Nginx recibe en el
|
|
|
443, habla HTTPS y pasa al 3000; y de paso sirve él los estáticos, que es lo que
|
|
|
hace que abrir la web no despierte al proceso de Node para devolver un CSS. En
|
|
|
[`despliegue/`](despliegue/) están los tres archivos, comentados:
|
|
|
|
|
|
| Archivo | Qué es |
|
|
|
| ----------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
|
| [`senzapaura.service`](despliegue/senzapaura.service) | Servicio de systemd: mantiene Node en pie y lo levanta al reiniciar |
|
|
|
| [`senzapaura.nginx.conf`](despliegue/senzapaura.nginx.conf) | El proxy inverso, con los estáticos servidos aparte |
|
|
|
| [`desplegar.sh`](despliegue/desplegar.sh) | Construye aquí y sube el resultado por SSH |
|
|
|
| [`instalar.sh`](despliegue/instalar.sh) | Lo que en el servidor necesita root, una sola vez |
|
|
|
| [`con-proxy-manager.md`](despliegue/con-proxy-manager.md) | El montaje real: DNS, router, Nginx Proxy Manager y sus variables |
|
|
|
|
|
|
Lo que hace falta en el servidor: **Node 22**, Nginx y PostgreSQL. Nada más; ni
|
|
|
Vite ni el compilador, porque el build se hace en la máquina de desarrollo.
|
|
|
|
|
|
El script sube con `tar` por SSH y no con `rsync` —la máquina de desarrollo es
|
|
|
Windows y Git Bash no lo trae—, y **sube las dependencias ya instaladas** en vez
|
|
|
de resolverlas allí: el servidor tiene salida a internet pero su DNS no resuelve
|
|
|
`registry.npmjs.org`, así que `npm ci` falla. Los tres paquetes de producción
|
|
|
son JavaScript puro y viajan tal cual; si algún día entra uno con binarios de
|
|
|
plataforma, el script se para y avisa en vez de dejar el servicio caído.
|
|
|
|
|
|
Cinco cosas que muerden si se olvidan:
|
|
|
|
|
|
1. **`ORIGIN` con el dominio público.** Sin él, SvelteKit rechaza todos los
|
|
|
formularios por CSRF con un 403 y parece que la web está rota. Es el error
|
|
|
más fácil de cometer y el más desconcertante.
|
|
|
2. **`ADDRESS_HEADER` y `XFF_DEPTH`, si hay un proxy delante.** Sin ellas,
|
|
|
`getClientAddress()` devuelve la IP del proxy, y el limitador del formulario
|
|
|
de acceso —que cuenta por dirección— salta con una sola persona insistiendo.
|
|
|
3. **Copiar `media/audio/`.** No va en git ni lo genera el build, y sin él no
|
|
|
hay descargas. Lo sube `desplegar.sh`.
|
|
|
4. **HTTPS de verdad.** Aquí se escriben correos y se paga.
|
|
|
5. **Las descargas no las puede servir Nginx.** `/descargas/…` comprueba la
|
|
|
compra antes de entregar el archivo; el audio completo vive fuera de
|
|
|
`build/` justamente para que no haya forma de pedirlo por URL.
|
|
|
|
|
|
Al desplegar por primera vez:
|
|
|
|
|
|
1. `npm run db:migrate` con la `DATABASE_URL` de producción.
|
|
|
2. Crear `/var/www/senzapaura/.env` con las claves, en modo 600.
|
|
|
3. `ORIGIN` con el dominio público.
|
|
|
4. Configurar el webhook de Stripe apuntando a `/api/stripe/webhook`.
|
|
|
|
|
|
Todas las páginas se renderizan en el servidor. El sitio dejó de prerenderizarse
|
|
|
al añadir las cuentas: la cabecera y los listados dependen de quién pide la
|
|
|
página.
|
|
|
|
|
|
## Un fallo conocido del entorno
|
|
|
|
|
|
En Windows, el empaquetador nativo de Vite 8 (rolldown) termina con SIGSEGV **al
|
|
|
salir del proceso** de forma intermitente —unas dos de cada tres veces en la
|
|
|
máquina donde se escribió esto—. El build se completa y los artefactos son
|
|
|
correctos; lo que falla es el cierre.
|
|
|
|
|
|
`npm run build` no lo disimula: devuelve el código de error tal cual, que es lo
|
|
|
que debe llegar a una plataforma de despliegue. Lo que sí lo tolera es
|
|
|
[`scripts/construir.mjs`](scripts/construir.mjs), que usa solo
|
|
|
`npm run test:e2e`: comprueba que los artefactos existan y se hayan escrito en
|
|
|
esa ejecución, y si es así continúa avisando por consola. Un build que falla de
|
|
|
verdad no deja artefactos nuevos y sigue fallando ahí.
|
|
|
|
|
|
Si el fallo no aparece en el servidor de despliegue (es habitual: en Linux no se
|
|
|
ha visto), no hay nada más que hacer.
|
|
|
|
|
|
## Decisiones que conviene conocer
|
|
|
|
|
|
- **El catálogo no está en la base de datos.** Los pedidos referencian las
|
|
|
canciones por slug, y guardan el título del momento de la compra: una factura
|
|
|
no debe cambiar porque se renombre un tema.
|
|
|
- **Markdown con validación estricta.** El frontmatter se convierte a datos
|
|
|
tipados en un único punto ([`src/lib/content/fields.ts`](src/lib/content/fields.ts)),
|
|
|
y los errores dicen archivo y campo.
|
|
|
- **Fechas siempre en UTC.** Las del contenido son días sueltos. Formateadas en
|
|
|
la zona local del navegador, un disco publicado el 1 de marzo se mostraría
|
|
|
como 28 de febrero al oeste de Greenwich.
|
|
|
- **`remark-breaks` está activo.** Sin él, Markdown juntaría los versos de una
|
|
|
letra en un único párrafo.
|
|
|
- **El formulario de acceso está limitado en varias capas**
|
|
|
([`src/lib/server/limite.ts`](src/lib/server/limite.ts)), y no por costumbre:
|
|
|
manda un correo a la dirección que le escriban, sea de quien sea, así que sin
|
|
|
frenos es un botón para bombardear el buzón de cualquiera desde nuestro
|
|
|
dominio. Hay enfriamiento por dirección, tope por hora y por día, tope por IP
|
|
|
y un techo global que salva la cuota de envío. Los contadores viven en la base
|
|
|
de datos, no en memoria: uno que se borra al reiniciar no protege a nadie. Y
|
|
|
el tope por dirección usa la forma canónica del correo
|
|
|
([`correo-clave.ts`](src/lib/server/correo-clave.ts)), porque si contara la
|
|
|
cadena literal bastaría con ir cambiando `+1`, `+2` para saltárselo.
|
|
|
- **Una cuenta solo existe tras verificar el código.** Pedir un código para una
|
|
|
dirección ajena no crea nada ni da acceso a nada.
|
|
|
- **El contenido va entero en el bundle.** Los listados necesitan el frontmatter
|
|
|
de forma síncrona, lo que obliga a importar los `.md` de forma estática, y eso
|
|
|
impide separar el cuerpo (las letras) en trozos aparte. Con un catálogo
|
|
|
pequeño el coste es despreciable. Si crece hasta cientos de temas, la salida
|
|
|
es un plugin de Vite que exponga el frontmatter como módulo virtual; está
|
|
|
anotado en [`src/lib/content/index.ts`](src/lib/content/index.ts).
|