Añade scripts/check-tokens.mjs (npm run lint:tokens, incorporado a
lint) como guardrail: falla si src/**/*.svelte reintroduce valores
arbitrarios fuera de la lista blanca de B.4, rounded-* de fábrica,
pares h-N w-N (deberían ser size-N), espaciado fraccionario en
cuartos, o colores hex/rgba() literales.
Se limpia el repo hasta dejar el script en 0 violaciones: ~40 pares
h-N w-N → size-N (sobre todo iconos, vía script puntual), ~26
espaciados fraccionarios en cuartos redondeados al entero más cercano
(Δ≤1px, p. ej. py-2.75→py-3, gap-1.75→gap-2), radios de fábrica
sueltos (rounded-sm/lg, rounded a secas) migrados a los tokens
correspondientes, y dos tracking-[-0.02em] que coincidían
exactamente con el token tracking-display ya existente pero nunca se
habían migrado. Se retiran las recetas stroke-thin/regular/bold
(creadas en P1, cero consumidores) — decisión tomada, sin bloquear:
no se migra strokeWidth={n} (13 valores, en casi todos los
componentes) a CSS porque no se puede verificar sin Docker que
lucide-svelte respete stroke-width por clase en vez del prop, y una
regresión ahí sería silenciosa en toda la app; el script documenta
este gap explícitamente en vez de fingir cobertura completa.
Detector de huérfanos: 0 componentes sin importadores. Se borra
docs/design-system-refactor-plan.md (plan anterior, superseded por
frontend-refactor-plan.md). web-player/CLAUDE.md gana una sección
"Estilos" con la lista blanca B.4 y los componentes base a reutilizar.
Se recalcula la tabla de línea base (A.4) y se anota en el Registro
(D.1): 61→47 componentes, 44→11 arbitrarios (los 11 restantes en
lista blanca), 13→8 radios distintos, 0 violaciones de lint:tokens.
Verificado con npm run check (0 errores), npm run lint:tokens (0
violaciones) y npm run build. Smoke test de /login sin errores de
consola achacables al cambio (Docker no disponible en esta sesión).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
277 lines
8.3 KiB
Svelte
277 lines
8.3 KiB
Svelte
<script lang="ts">
|
|
import { enhance } from '$app/forms';
|
|
import Headphones from '@lucide/svelte/icons/headphones';
|
|
import Music from '@lucide/svelte/icons/music';
|
|
import ImageIcon from '@lucide/svelte/icons/image';
|
|
import Check from '@lucide/svelte/icons/check';
|
|
import Page from '$lib/components/ui/Page.svelte';
|
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
|
import Surface from '$lib/components/ui/Surface.svelte';
|
|
import ImageDropzone from '$lib/components/ui/ImageDropzone.svelte';
|
|
import Alert from '$lib/components/ui/Alert.svelte';
|
|
import Field from '$lib/components/ui/Field.svelte';
|
|
import Input from '$lib/components/ui/Input.svelte';
|
|
import Checkbox from '$lib/components/ui/Checkbox.svelte';
|
|
import Button from '$lib/components/ui/Button.svelte';
|
|
|
|
type Status = 'idle' | 'uploading' | 'done' | 'error';
|
|
type StepState = 'idle' | 'active' | 'done';
|
|
|
|
const MAX_TITLE = 100;
|
|
|
|
let title = $state('');
|
|
let audioName = $state('');
|
|
let audioSize = $state(0);
|
|
let hasCover = $state(false);
|
|
let coverResetToken = $state(0);
|
|
let dragging = $state(false);
|
|
let status = $state<Status>('idle');
|
|
let acceptedTerms = $state(false);
|
|
let errorMsg = $state('');
|
|
let publishedTitle = $state('');
|
|
let audioInput = $state<HTMLInputElement>();
|
|
|
|
const steps = [
|
|
{ key: 'upload', label: 'Subir', hint: 'Portada y audio.' },
|
|
{ key: 'process', label: 'Procesar', hint: 'Transcodificado para streaming.' },
|
|
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo.' }
|
|
];
|
|
|
|
const busy = $derived(status === 'uploading');
|
|
|
|
const canPublish = $derived(
|
|
title.trim() !== '' && audioName !== '' && hasCover && acceptedTerms && !busy
|
|
);
|
|
|
|
const stepStates = $derived.by<StepState[]>(() => {
|
|
if (status === 'done') return ['done', 'done', 'done'];
|
|
if (status === 'uploading') return ['active', 'idle', 'idle'];
|
|
return ['idle', 'idle', 'idle'];
|
|
});
|
|
|
|
function stepClass(s: StepState) {
|
|
return s === 'idle' ? '' : 'bg-accent-tint';
|
|
}
|
|
|
|
function formatSize(bytes: number) {
|
|
if (bytes === 0) return '';
|
|
const mb = bytes / (1024 * 1024);
|
|
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
|
|
}
|
|
|
|
function pickAudio(file: File) {
|
|
audioName = file.name;
|
|
audioSize = file.size;
|
|
if (title.trim() === '') title = file.name.replace(/\.[^.]+$/, '');
|
|
}
|
|
|
|
function onAudioInput(event: Event) {
|
|
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
|
if (file) pickAudio(file);
|
|
}
|
|
|
|
function onDrop(event: DragEvent) {
|
|
event.preventDefault();
|
|
dragging = false;
|
|
const file = event.dataTransfer?.files?.[0];
|
|
if (!file || !audioInput) return;
|
|
const dt = new DataTransfer();
|
|
dt.items.add(file);
|
|
audioInput.files = dt.files;
|
|
pickAudio(file);
|
|
}
|
|
|
|
function reset() {
|
|
title = '';
|
|
audioName = '';
|
|
audioSize = 0;
|
|
hasCover = false;
|
|
coverResetToken += 1;
|
|
status = 'idle';
|
|
acceptedTerms = false;
|
|
errorMsg = '';
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Subir música</title>
|
|
<meta name="description" content="Sube tus canciones a Musify: portada, audio y título." />
|
|
</svelte:head>
|
|
|
|
<Page>
|
|
<PageHeader
|
|
title="Subir música"
|
|
description="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
|
|
/>
|
|
|
|
<ol class="grid gap-3 sm:grid-cols-3 sm:gap-4">
|
|
{#each steps as step, i (step.key)}
|
|
<li>
|
|
<Surface padding="sm" class={stepClass(stepStates[i])}>
|
|
<div class="flex items-center gap-3">
|
|
<span
|
|
class="grid size-7 shrink-0 place-items-center rounded-full text-sm {stepStates[i] ===
|
|
'idle'
|
|
? 'bg-surface-2 text-fg-2'
|
|
: 'bg-accent-btn text-accent-soft'}"
|
|
>
|
|
{#if stepStates[i] === 'done'}
|
|
<Check class="size-3.5" strokeWidth={2.5} />
|
|
{:else}
|
|
{i + 1}
|
|
{/if}
|
|
</span>
|
|
<span class="text-sm font-medium text-fg">{step.label}</span>
|
|
</div>
|
|
<p class="mt-2.5 text-xs text-muted">{step.hint}</p>
|
|
</Surface>
|
|
</li>
|
|
{/each}
|
|
</ol>
|
|
|
|
{#if status === 'done'}
|
|
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
|
|
<span class="mx-auto grid size-14 place-items-center rounded-full bg-accent-soft text-ink">
|
|
<Check class="size-7" strokeWidth={2.5} />
|
|
</span>
|
|
<h2 class="mt-6 font-display text-2xl font-semibold tracking-display text-fg">¡Subida!</h2>
|
|
<p class="mt-3 text-sm text-fg-2">
|
|
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
|
|
unos momentos.
|
|
</p>
|
|
<div class="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row sm:gap-4">
|
|
<Button href="/library" size="lg" class="w-full sm:w-auto">Ir a la biblioteca</Button>
|
|
<Button variant="secondary" size="lg" onclick={reset} class="w-full sm:w-auto">
|
|
Subir otra
|
|
</Button>
|
|
</div>
|
|
</Surface>
|
|
{:else}
|
|
<form
|
|
method="POST"
|
|
enctype="multipart/form-data"
|
|
use:enhance={() => {
|
|
status = 'uploading';
|
|
errorMsg = '';
|
|
return async ({ result }) => {
|
|
if (result.type === 'success') {
|
|
publishedTitle = title;
|
|
status = 'done';
|
|
} else if (result.type === 'failure') {
|
|
status = 'error';
|
|
errorMsg = (result.data?.message as string) ?? 'No se pudo subir la canción.';
|
|
} else {
|
|
status = 'error';
|
|
errorMsg = 'Error inesperado durante la subida.';
|
|
}
|
|
};
|
|
}}
|
|
class="mt-10 space-y-8"
|
|
>
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
ondragover={(e) => {
|
|
e.preventDefault();
|
|
dragging = true;
|
|
}}
|
|
ondragleave={() => (dragging = false)}
|
|
ondrop={onDrop}
|
|
class="relative flex min-h-48 flex-col items-center justify-center rounded-panel p-6 text-center transition sm:min-h-64 sm:p-10 {dragging
|
|
? 'bg-surface-2 ring-2 ring-accent/50'
|
|
: 'bg-surface'}"
|
|
>
|
|
<input
|
|
bind:this={audioInput}
|
|
type="file"
|
|
name="audio"
|
|
accept="audio/*"
|
|
onchange={onAudioInput}
|
|
class="absolute inset-0 cursor-pointer opacity-0 focus:outline-none"
|
|
aria-label="Seleccionar archivo de audio"
|
|
/>
|
|
{#if audioName === ''}
|
|
<Headphones class="size-9 text-muted sm:size-10" strokeWidth={1.4} />
|
|
{:else}
|
|
<Music class="size-9 text-accent-soft sm:size-10" strokeWidth={1.4} />
|
|
{/if}
|
|
{#if audioName === ''}
|
|
<p class="mt-4 text-sm font-medium text-fg">
|
|
Arrastra tu audio aquí o haz clic para elegir
|
|
</p>
|
|
<p class="mt-1.5 text-xs text-muted">MP3, FLAC, WAV…</p>
|
|
{:else}
|
|
<p class="mt-4 max-w-full truncate text-sm font-medium text-fg">
|
|
{audioName}
|
|
</p>
|
|
<p class="mt-1.5 text-xs text-muted">{formatSize(audioSize)} · Listo para subir</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="grid items-start gap-6 sm:grid-cols-[auto_1fr] sm:gap-8">
|
|
<Field label="Portada">
|
|
{#key coverResetToken}
|
|
<ImageDropzone
|
|
name="cover"
|
|
icon={ImageIcon}
|
|
gradient
|
|
size="hero"
|
|
onselect={() => (hasCover = true)}
|
|
/>
|
|
{/key}
|
|
</Field>
|
|
|
|
<Field label="Título" for="title">
|
|
<Input
|
|
id="title"
|
|
name="title"
|
|
bind:value={title}
|
|
maxlength={MAX_TITLE}
|
|
placeholder="Nombre de la canción"
|
|
/>
|
|
{#snippet hint()}
|
|
<p>Se usa como nombre público de la pista en el catálogo.</p>
|
|
<span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span>
|
|
{/snippet}
|
|
</Field>
|
|
</div>
|
|
|
|
<Checkbox bind:checked={acceptedTerms}>
|
|
Acepto los
|
|
<a
|
|
href="/terms"
|
|
class="text-accent-soft underline underline-offset-2 transition hover:brightness-110"
|
|
>
|
|
términos y condiciones
|
|
</a>
|
|
al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}.
|
|
</Checkbox>
|
|
|
|
{#if status === 'uploading'}
|
|
<Surface padding="sm">
|
|
<div class="flex items-center justify-between text-sm">
|
|
<span class="font-medium text-fg">Subiendo y creando la pista…</span>
|
|
<span class="text-xs text-muted">un momento</span>
|
|
</div>
|
|
<div class="mt-3 h-2 overflow-hidden rounded-full bg-surface-2">
|
|
<div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div>
|
|
</div>
|
|
</Surface>
|
|
{/if}
|
|
|
|
{#if status === 'error'}
|
|
<Alert tone="danger">{errorMsg}</Alert>
|
|
{/if}
|
|
|
|
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-end sm:gap-4">
|
|
<Button href="/explore" variant="secondary" size="lg" class="w-full sm:w-auto">
|
|
Cancelar
|
|
</Button>
|
|
<Button type="submit" size="lg" disabled={!canPublish} class="w-full sm:w-auto">
|
|
Publicar
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
</Page>
|