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
This commit is contained in:
2026-09-14 23:42:45 +02:00
parent 389b3b9244
commit dc1ba89a6d
166 changed files with 14271 additions and 1982 deletions
@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Music from '@lucide/svelte/icons/music';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte';
@@ -8,15 +9,20 @@
import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte';
const NAME_MAX_LENGTH = 60;
const DESCRIPTION_MAX_LENGTH = 300;
interface Props {
action: string;
initialName?: string;
initialDescription?: string;
initialVisibility?: 'private' | 'public';
namePlaceholder?: string;
coverFallbackUrl?: string;
formMessage?: string;
submitLabel: string;
submittingLabel: string;
helperText?: string;
onCancel: () => void;
onSuccess?: () => void;
}
@@ -25,11 +31,13 @@
action,
initialName = '',
initialDescription = '',
namePlaceholder = '',
initialVisibility = 'private',
namePlaceholder = 'Playlist sin título',
coverFallbackUrl,
formMessage,
submitLabel,
submittingLabel,
helperText,
onCancel,
onSuccess
}: Props = $props();
@@ -37,6 +45,7 @@
let name = $state(untrack(() => initialName));
let description = $state(untrack(() => initialDescription));
let submitting = $state(false);
let visibility = $state(untrack(() => initialVisibility));
</script>
<form
@@ -48,49 +57,105 @@
return async ({ update, result }) => {
await update({ reset: false });
submitting = false;
if (result.type === 'success') onSuccess?.();
if (result.type === 'success' || result.type === 'redirect') onSuccess?.();
};
}}
class="mt-4 w-full max-w-4xl space-y-4"
class="mt-5 w-full max-w-4xl space-y-5"
>
<input type="hidden" name="visibility" value={visibility} />
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
<Field label="Portada" optional>
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} />
<Field>
<ImageDropzone
name="cover"
fallbackUrl={coverFallbackUrl}
icon={Music}
gradient
class="h-36 w-36 rounded-[16px]"
/>
</Field>
<div class="flex flex-1 flex-col gap-4">
<Field label="Nombre" for="playlist-name">
{#snippet hint()}
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
{/snippet}
<Input
id="playlist-name"
name="name"
maxlength={100}
maxlength={NAME_MAX_LENGTH}
required
bind:value={name}
placeholder={namePlaceholder}
/>
</Field>
<Field label="Descripción" for="playlist-description" optional class="min-h-0 flex-1">
<Field label="Descripción" for="playlist-description" class="min-h-0 flex-1">
<Textarea
id="playlist-description"
name="description"
maxlength={300}
maxlength={DESCRIPTION_MAX_LENGTH}
bind:value={description}
placeholder="¿De qué va esta lista?"
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
class="flex-1"
/>
</Field>
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-[14px] bg-surface px-4 py-3.5">
<div class="min-w-0">
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
<div class="mt-0.5 text-xs text-muted">
{visibility === 'private'
? 'Solo tú la ves en tu biblioteca'
: 'Cualquiera con el enlace puede verla'}
</div>
</div>
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
<div
class="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-fg transition-transform duration-300 ease-out {visibility ===
'public'
? 'translate-x-full'
: ''}"
></div>
<button
type="button"
onclick={() => (visibility = 'private')}
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-semibold transition-colors {visibility ===
'private'
? 'text-ink'
: 'text-muted hover:text-fg-2'}"
>
Privada
</button>
<button
type="button"
onclick={() => (visibility = 'public')}
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-semibold transition-colors {visibility ===
'public'
? 'text-ink'
: 'text-muted hover:text-fg-2'}"
>
Pública
</button>
</div>
</div>
{#if formMessage}
<Alert tone="danger">{formMessage}</Alert>
{/if}
<div class="flex items-center justify-end gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || name.trim() === ''}>
{submitting ? submittingLabel : submitLabel}
</Button>
<div class="flex items-center justify-between gap-4">
{#if helperText}
<p class="text-sm text-muted">{helperText}</p>
{:else}
<span></span>
{/if}
<div class="flex shrink-0 items-center gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || name.trim() === ''}>
{submitting ? submittingLabel : submitLabel}
</Button>
</div>
</div>
</form>