Files
Musify/web-player/src/routes/(app)/upload/+page.svelte
T
Admin dc1ba89a6d Add likes, follows and playlist visibility; redesign web-player UI
Backend:
- New Likes feature (toggle/get liked tracks) and endpoints
- User follow/unfollow, listening stats, public user profile
- Playlist visibility (public/private) support
- Various auth and DTO/response adjustments to support the above

Frontend (web-player):
- New design system pass: theme tokens/mode (light/dark), glass menu,
  tabs bar, player dock/extras/queue components, mix tiles, artist link
- New routes: Liked, public user profile (/u/[id]), likes API proxy
- Search refactor: shared SearchBox component used by TopBar and
  MobileHeader (search now reachable on mobile), live results as you
  type instead of requiring Enter, dropped the "Buscando…" quick-result
  preview
- Removed now-unused components (MobileNav, CoverBadge, MixBentoTile,
  PlayPauseOverlay, SuggestionsTable, userDirectory)
- Design handoff reference docs added under web-player/design_handoff_musify_player
  and web-player/docs/design-system-refactor-plan.md
2026-09-14 23:42:45 +02:00

289 lines
8.9 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 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 coverPreview = $state('');
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 !== '' && coverPreview !== '' && 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 onCoverInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) coverPreview = URL.createObjectURL(file);
}
function reset() {
title = '';
audioName = '';
audioSize = 0;
coverPreview = '';
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"
subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
/>
<ol class="mt-7 grid gap-3 sm:mt-8 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 h-7 w-7 shrink-0 place-items-center rounded-full text-xs font-bold {stepStates[
i
] === 'idle'
? 'bg-white/[3.5%] text-fg-2'
: 'bg-accent-btn text-accent-soft'}"
>
{#if stepStates[i] === 'done'}
<Check class="h-3.5 w-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 h-14 w-14 place-items-center rounded-full bg-accent-soft text-ink">
<Check class="h-7 w-7" strokeWidth={2.5} />
</span>
<h2 class="mt-6 font-display text-2xl font-semibold tracking-[-0.02em] 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="h-9 w-9 text-muted sm:h-10 sm:w-10" strokeWidth={1.4} />
{:else}
<Music class="h-9 w-9 text-accent-soft sm:h-10 sm:w-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">
<label
class="relative grid h-36 w-36 cursor-pointer place-items-center overflow-hidden rounded-control transition sm:h-44 sm:w-44"
style={coverPreview === '' ? 'background:var(--mf-cover-grad)' : undefined}
>
<input
type="file"
name="cover"
accept="image/*"
onchange={onCoverInput}
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada"
/>
{#if coverPreview !== ''}
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
{:else}
<ImageIcon class="h-11 w-11 text-white/70" strokeWidth={1.3} />
{/if}
</label>
</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>