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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user