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 Disc3 from '@lucide/svelte/icons/disc-3';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte';
@@ -59,11 +60,18 @@
if (result.type === 'success') onSuccess?.();
};
}}
class="mt-4 w-full max-w-2xl space-y-4"
class="mt-5 w-full max-w-2xl space-y-5"
>
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
<Field label="Portada" optional={!requireCover}>
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} required={requireCover} />
<Field>
<ImageDropzone
name="cover"
fallbackUrl={coverFallbackUrl}
required={requireCover}
icon={Disc3}
gradient
class="h-36 w-36 rounded-[16px]"
/>
</Field>
<div class="flex flex-1 flex-col gap-4">
@@ -78,7 +86,7 @@
/>
</Field>
<Field label="Año de publicación" for="album-year" optional>
<Field label="Año de publicación" for="album-year">
<Input
id="album-year"
name="releaseYear"
@@ -92,7 +100,7 @@
</div>
</div>
<Field label="Descripción" for="album-description" optional>
<Field label="Descripción" for="album-description">
<Textarea
id="album-description"
name="description"
@@ -11,10 +11,14 @@
const tones = {
neutral: 'border-line bg-surface text-fg-2',
danger: 'border-danger/40 bg-danger/10 text-danger-fg'
danger: 'border-danger/20 bg-danger/10 text-danger-fg'
};
</script>
<p class="rounded-control border px-4 py-3 text-base {tones[tone]} {klass}">
<p
class="rounded-control border px-4 py-3 text-sm shadow-menu backdrop-blur-md {tones[
tone
]} {klass}"
>
{@render children()}
</p>
@@ -1,10 +1,11 @@
<script lang="ts">
interface Props {
name: string;
imageUrl?: string | null;
size?: number;
}
let { name, size = 108 }: Props = $props();
let { name, imageUrl, size = 108 }: Props = $props();
const initials = $derived(
name
@@ -21,11 +22,15 @@
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/artist:shadow-art-lg"
style="width:{size}px;height:{size}px"
>
<div
class="pointer-events-none absolute inset-0"
style="background:repeating-linear-gradient(135deg, rgba(255,255,255,0.12) 0 2px, transparent 2px 10px)"
></div>
<span class="relative text-lg font-semibold text-white/90">{initials}</span>
{#if imageUrl}
<img src={imageUrl} alt="" class="h-full w-full object-cover" />
{:else}
<div
class="pointer-events-none absolute inset-0"
style="background:repeating-linear-gradient(135deg, rgba(255,255,255,0.12) 0 2px, transparent 2px 10px)"
></div>
<span class="relative text-lg font-semibold text-white/90">{initials}</span>
{/if}
</div>
<div class="w-full truncate text-sm text-fg">{name}</div>
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
interface Props {
name?: string | null;
ownerUserId?: string | number | null;
class?: string;
}
let { name, ownerUserId, class: klass = '' }: Props = $props();
</script>
{#if name}
{#if ownerUserId}
<a
href="/u/{ownerUserId}"
onclick={(e) => e.stopPropagation()}
class="inline-block max-w-full truncate align-top hover:text-fg-2 hover:underline {klass}"
>
{name}
</a>
{:else}
<span class="block truncate {klass}">{name}</span>
{/if}
{/if}
+10 -8
View File
@@ -3,7 +3,7 @@
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props {
variant?: 'primary' | 'secondary' | 'subtle' | 'danger';
variant?: 'primary' | 'secondary' | 'accent' | 'danger';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit';
href?: string;
@@ -29,20 +29,22 @@
}: Props = $props();
const sizes = {
sm: 'px-4 py-2 text-base',
md: 'px-5 py-2.5 text-base',
lg: 'px-8 py-3.5 text-base'
sm: 'px-4 py-2.5 text-[12.5px]',
md: 'px-5 py-3 text-[13px]',
lg: 'px-6 py-3.5 text-sm'
};
const subtleButton = 'bg-white/[6%] text-fg-2 hover:bg-white/[9%] hover:text-fg';
const variants = {
primary: 'bg-accent-soft text-on-accent active:scale-[0.97]',
secondary: 'bg-bg text-fg ring-1 ring-line hover:bg-surface',
subtle: 'bg-white/10 text-fg hover:bg-accent/18',
primary: subtleButton,
secondary: subtleButton,
accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover',
danger: 'bg-danger text-on-accent hover:brightness-110'
};
const classes = $derived(
`inline-flex items-center justify-center gap-2 rounded-control font-medium transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
`inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
);
</script>
@@ -12,7 +12,7 @@
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
</script>
<label class="flex items-center gap-3 text-base text-fg-2 {klass}">
<label class="flex items-center gap-3 text-sm text-fg-2 {klass}">
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
<input
type="checkbox"
@@ -21,7 +21,7 @@
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
/>
<Check
class="pointer-events-none absolute h-3.5 w-3.5 text-on-accent opacity-0 transition peer-checked:opacity-100"
class="pointer-events-none absolute h-3.5 w-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/>
</span>
@@ -1,14 +0,0 @@
<script lang="ts">
interface Props {
label: string;
class?: string;
}
let { label, class: klass = '' }: Props = $props();
</script>
<span
class="pointer-events-none absolute top-2 left-2 rounded-full bg-black/45 px-2 py-0.5 text-[10px] font-semibold tracking-[0.08em] text-white uppercase backdrop-blur-sm {klass}"
>
{label}
</span>
@@ -13,24 +13,19 @@
let { icon: Icon, title, description, class: klass = '', actions }: Props = $props();
</script>
<div
class="flex flex-col items-center rounded-panel border border-dashed border-line px-6 py-16 text-center {klass}"
>
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
{#if Icon}
<span
class="grid h-16 w-16 place-items-center rounded-full text-on-accent"
style="background:radial-gradient(circle at 30% 30%, color-mix(in oklch, var(--color-accent), white 15%), var(--color-accent))"
>
<Icon class="h-7 w-7" />
</span>
<Icon class="h-9 w-9 text-fg-2" strokeWidth={1.3} />
{/if}
{#if title}
<h3 class="mt-6 font-display text-2xl font-medium tracking-tight text-fg">{title}</h3>
<h3 class="mt-6 font-display text-xl font-semibold tracking-[-0.025em] text-fg">
{title}
</h3>
{/if}
{#if description}
<p class="mt-2 max-w-sm text-fg-2">{description}</p>
<p class="mt-2.5 max-w-sm text-[13px] leading-[1.65] text-fg-2">{description}</p>
{/if}
{#if actions}
<div class="mt-6 flex items-center gap-3">{@render actions()}</div>
<div class="mt-6 flex flex-wrap items-center justify-center gap-2.5">{@render actions()}</div>
{/if}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
interface Props {
size?: number;
barWidth?: number;
paused?: boolean;
class?: string;
}
let { size = 13, barWidth = 2, paused = false, class: klass = '' }: Props = $props();
const delays = [0, 0.3, 0.6];
</script>
<div class="flex items-end justify-center gap-[2px] {klass}" style="height:{size}px">
{#each delays as delay (delay)}
<span
class="rounded-full bg-accent"
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
@@ -2,25 +2,25 @@
import type { Snippet } from 'svelte';
interface Props {
label: string;
label?: string;
for?: string;
optional?: boolean;
class?: string;
hint?: Snippet;
children: Snippet;
}
let { label, for: forId, optional = false, class: klass = '', hint, children }: Props = $props();
let { label, for: forId, class: klass = '', hint, children }: Props = $props();
</script>
<div class="flex min-w-0 flex-col {klass}">
<label for={forId} class="mb-2 block text-base font-medium text-fg-2">
{label}
{#if optional}<span class="text-muted">(Opcional)</span>{/if}
</label>
{#if label}
<label for={forId} class="mb-2 block text-sm font-medium text-fg-2">
{label}
</label>
{/if}
{@render children()}
{#if hint}
<div class="mt-2 flex items-center justify-between gap-3 text-sm text-muted">
<div class="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
{@render hint()}
</div>
{/if}
@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
style?: string;
children: Snippet;
}
let { class: klass = '', style = '', children }: Props = $props();
</script>
<div class="glass-panel animate-pop rounded-panel p-1.5 shadow-menu {klass}" {style}>
{@render children()}
</div>
@@ -1,10 +1,13 @@
<script lang="ts">
import ImageIcon from '@lucide/svelte/icons/image';
import type { LucideIcon } from '@lucide/svelte';
interface Props {
name: string;
fallbackUrl?: string;
required?: boolean;
icon?: LucideIcon;
gradient?: boolean;
class?: string;
}
@@ -12,13 +15,16 @@
name,
fallbackUrl,
required = false,
class:
klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control border border-line bg-surface hover:border-accent/50'
icon: Icon = ImageIcon,
gradient = false,
class: klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control'
}: Props = $props();
let preview = $state('');
let fallbackFailed = $state(false);
const hasImage = $derived(preview !== '' || (!!fallbackUrl && !fallbackFailed));
function onInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) preview = URL.createObjectURL(file);
@@ -26,7 +32,10 @@
</script>
<label
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {klass}"
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {gradient
? ''
: 'border border-line bg-surface hover:border-accent/50'} {klass}"
style={gradient && !hasImage ? 'background:var(--mf-cover-grad)' : undefined}
>
<input
type="file"
@@ -48,6 +57,6 @@
class="h-full w-full object-cover"
/>
{:else}
<ImageIcon class="h-12 w-12 text-muted" />
<Icon class="h-9 w-9 {gradient ? 'text-white/70' : 'text-muted'}" strokeWidth={1.3} />
{/if}
</label>
@@ -52,7 +52,7 @@
{autocomplete}
{oninput}
bind:value
class="w-full rounded-control border border-line bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/60 focus:outline-none {Icon
class="w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {Icon
? 'pr-4 pl-11'
: 'px-4'} {klass}"
/>
@@ -25,8 +25,8 @@
{oncontextmenu}
>
{@render art(mediaCardArt)}
<div class="mt-2.5 truncate text-fg transition-colors group-hover:text-accent-soft">{title}</div>
<div class="mt-2.5 truncate text-sm font-medium text-fg">{title}</div>
{#if subtitle}
<div class="mt-0.5 truncate text-sm text-fg-3">{subtitle}</div>
<div class="mt-0.5 truncate text-[12.5px] text-fg-3">{subtitle}</div>
{/if}
</a>
@@ -5,6 +5,7 @@
icon: LucideIcon;
label: string;
href?: string;
target?: string;
type?: 'button' | 'submit';
reload?: boolean;
onclick?: (event: MouseEvent) => void;
@@ -15,6 +16,7 @@
icon: Icon,
label,
href,
target,
type = 'button',
reload = false,
onclick,
@@ -22,18 +24,25 @@
}: Props = $props();
const classes = $derived(
`flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg-2 transition hover:bg-hover ${klass}`
`flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover ${klass}`
);
</script>
{#if href}
<a {href} data-sveltekit-reload={reload ? '' : undefined} {onclick} class={classes}>
<Icon class="h-6 w-6 shrink-0" strokeWidth={2} />
<a
{href}
{target}
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
data-sveltekit-reload={reload ? '' : undefined}
{onclick}
class={classes}
>
<Icon class="h-[18px] w-[18px] shrink-0" strokeWidth={1.8} />
{label}
</a>
{:else}
<button {type} {onclick} class={classes}>
<Icon class="h-6 w-6 shrink-0" strokeWidth={2} />
<Icon class="h-[18px] w-[18px] shrink-0" strokeWidth={1.8} />
{label}
</button>
{/if}
@@ -1,66 +0,0 @@
<script lang="ts">
import Cover from './Cover.svelte';
import type { Mix } from '$lib/mixes';
interface Props {
mix: Mix;
featured?: boolean;
banner?: boolean;
class?: string;
index?: number;
}
let { mix, featured = false, banner = false, class: klass = '', index = 0 }: Props = $props();
const fillCount = $derived(banner ? 40 : 12);
const tiles = $derived.by(() => {
if (mix.items.length === 0) return [];
const out: Mix['items'] = [];
while (out.length < fillCount) out.push(...mix.items);
return out.slice(0, fillCount);
});
</script>
<a
href="/mixes/{mix.id}"
class="group/tile animate-enter relative block overflow-hidden rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out [clip-path:inset(0_round_var(--radius-art))] ring-inset hover:shadow-art-lg focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {klass}"
style="animation-delay:{Math.min(index, 10) * 45}ms"
>
{#if tiles.length > 0}
<div
class="absolute inset-0 flex scale-105 transition duration-500 ease-out group-hover/tile:scale-110"
>
{#each tiles as item, i (i)}
<Cover
trackId={item.trackId ?? item.videoId ?? ''}
src={item.thumbnailUrl}
size="medium"
class="aspect-square h-full shrink-0"
/>
{/each}
</div>
{/if}
<div
class="pointer-events-none absolute inset-0 bg-linear-to-t from-scrim/85 via-scrim/65 to-scrim/40 {banner
? 'sm:bg-linear-to-r'
: ''}"
></div>
<div
class="pointer-events-none absolute inset-0 bg-scrim/25 opacity-0 transition-opacity duration-300"
></div>
<div
class="relative flex h-full flex-col justify-end p-3 sm:p-4 {banner
? 'sm:flex-row sm:items-center sm:justify-between sm:gap-4'
: ''}"
>
<div class="min-w-0">
<div class="truncate text-on-art {featured ? 'text-xl sm:text-2xl' : 'text-base'}">
{mix.title}
</div>
{#if mix.subtitle}
<div class="mt-0.5 truncate text-xs text-on-art/75 sm:text-sm">{mix.subtitle}</div>
{/if}
</div>
</div>
</a>
@@ -0,0 +1,51 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import MixArt from './MixArt.svelte';
import { player } from '$lib/player/player.svelte';
import { queueItemForTarget } from '$lib/tracks';
import { mixItemTarget, type Mix } from '$lib/mixes';
interface Props {
mix: Mix;
index?: number;
class?: string;
}
let { mix, index = 0, class: klass = '' }: Props = $props();
function play(event: MouseEvent) {
event.preventDefault();
const items = mix.items.map((item) => queueItemForTarget(mixItemTarget(item)));
player.playQueue(items, 0);
}
</script>
<a
href="/mixes/{mix.id}"
class="group/tile animate-enter block cursor-pointer {klass}"
style="animation-delay:{Math.min(index, 10) * 45}ms"
>
<div class="relative aspect-square overflow-hidden rounded-art shadow-art">
<MixArt
items={mix.items}
class="h-full w-full scale-100 transition duration-300 group-hover/tile:scale-105"
/>
<div
class="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent from-45% to-black/42"
></div>
<button
type="button"
onclick={play}
aria-label="Reproducir {mix.title}"
class="absolute right-2.5 bottom-2.5 grid h-9.5 w-9.5 place-items-center rounded-full border border-white/12 bg-ink/76 text-fg opacity-0 backdrop-blur-[8px] transition-opacity duration-150 group-hover/tile:opacity-100"
>
<Play class="h-3.5 w-3.5" fill="currentColor" />
</button>
</div>
<div class="mt-3.25 truncate text-[13px] font-semibold tracking-[-0.01em] text-fg">
{mix.title}
</div>
{#if mix.subtitle}
<div class="mt-1 truncate text-[11.5px] text-fg-3">{mix.subtitle}</div>
{/if}
</a>
+17 -5
View File
@@ -6,6 +6,7 @@
interface Props {
open: boolean;
onClose: () => void;
eyebrow?: string;
title?: string;
maxWidth?: string;
panelClass?: string;
@@ -15,9 +16,10 @@
let {
open,
onClose,
eyebrow,
title,
maxWidth = 'max-w-2xl',
panelClass = 'border border-line bg-elevated',
panelClass = '',
children
}: Props = $props();
@@ -74,16 +76,26 @@
>
<div
bind:this={panel}
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 shadow-2xl outline-none sm:p-6 {panelClass}"
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 shadow-2xl backdrop-blur-[22px] transition-[background] duration-[600ms] outline-none sm:p-6 {panelClass}"
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
{#if title}
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold tracking-tight text-fg">{title}</h2>
<IconButton label="Cerrar" onclick={onClose}>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
{#if eyebrow}
<p class="mb-2.5 text-xs font-semibold tracking-[0.1em] text-muted uppercase">
{eyebrow}
</p>
{/if}
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
{title}
</h2>
</div>
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
<X class="h-4 w-4" />
</IconButton>
</div>
@@ -5,19 +5,14 @@
let { paused = false }: Props = $props();
const bars = [
{ delay: 0, dur: 0.9 },
{ delay: 0.25, dur: 0.7 },
{ delay: 0.1, dur: 1.05 },
{ delay: 0.4, dur: 0.8 }
];
const delays = [0, 0.3, 0.6];
</script>
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-scrim/45 pb-2">
{#each bars as bar, i (i)}
<div class="absolute inset-0 flex items-end justify-center gap-[3px] bg-scrim/45 pb-2.5">
{#each delays as delay (delay)}
<span
class="w-[3px] origin-bottom rounded-full bg-on-art"
style="height:14px; animation:equalize {bar.dur}s ease-in-out {bar.delay}s infinite; animation-play-state:{paused
style="height:14px; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
+1 -1
View File
@@ -9,6 +9,6 @@
let { class: klass = '', children }: Props = $props();
</script>
<section class="page-x pt-7 pb-8 sm:pt-11 sm:pb-10 {klass}">
<section class="page-x pt-3 pb-8 sm:pt-4 sm:pb-10 {klass}">
{@render children()}
</section>
@@ -13,9 +13,11 @@
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
<div class="min-w-0">
<h1 class="font-display text-2xl font-semibold tracking-tight text-fg sm:text-4xl">{title}</h1>
<h1 class="font-display text-[28px] font-semibold tracking-[-0.03em] text-fg sm:text-[32px]">
{title}
</h1>
{#if subtitle}
<p class="mt-2 text-sm text-fg-2 sm:text-base">{subtitle}</p>
<p class="mt-2 text-[13px] text-fg-2">{subtitle}</p>
{/if}
</div>
{#if actions}
@@ -1,22 +0,0 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
interface Props {
active: boolean;
playing: boolean;
}
let { active, playing }: Props = $props();
</script>
<span
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 place-items-center rounded-full bg-accent-soft text-on-accent opacity-0 shadow-lg transition-opacity group-hover:opacity-100"
class:!opacity-100={active}
>
{#if active && playing}
<Pause class="h-5 w-5" fill="currentColor" />
{:else}
<Play class="h-5 w-5" fill="currentColor" />
{/if}
</span>
@@ -5,16 +5,18 @@
interface Props {
id: string;
name: string;
description?: string | null;
trackCount: number;
trackIds: (string | number)[];
updatedAt: string;
index?: number;
}
let { id, name, description, trackIds, updatedAt, index = 0 }: Props = $props();
let { id, name, trackCount, trackIds, updatedAt, index = 0 }: Props = $props();
const subtitle = $derived(`${trackCount} ${trackCount === 1 ? 'canción' : 'canciones'}`);
</script>
<MediaCard href="/playlists/{id}" title={name} subtitle={description} {index}>
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
{#snippet art(artClass)}
<PlaylistArt playlistId={id} {trackIds} version={updatedAt} class={artClass} />
{/snippet}
@@ -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>
@@ -3,24 +3,30 @@
interface Props {
title: string;
subtitle?: string;
class?: string;
actions?: Snippet;
}
let { title, class: klass = '', actions }: Props = $props();
let { title, subtitle, class: klass = '', actions }: Props = $props();
</script>
{#snippet heading()}
<h2 class="font-display text-[17.5px] font-semibold tracking-[-0.025em] text-fg">
{title}
</h2>
{#if subtitle}
<p class="mt-1.25 text-xs text-fg-3">{subtitle}</p>
{/if}
{/snippet}
{#if actions}
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-5 {klass}">
<h2 class="font-display text-xl font-medium tracking-tight text-fg sm:text-[1.75rem]">
{title}
</h2>
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
<div>{@render heading()}</div>
{@render actions()}
</div>
{:else}
<h2
class="mb-4 font-display text-xl font-medium tracking-tight text-fg sm:mb-5 sm:text-[1.75rem] {klass}"
>
{title}
</h2>
<div class="mb-4 sm:mb-4.5 {klass}">
{@render heading()}
</div>
{/if}
@@ -12,6 +12,6 @@
const paddings = { sm: 'p-4', md: 'p-5 sm:p-6', lg: 'p-6 sm:p-12' };
</script>
<div class="rounded-panel border border-line bg-surface transition {paddings[padding]} {klass}">
<div class="rounded-panel bg-surface transition {paddings[padding]} {klass}">
{@render children()}
</div>
@@ -24,5 +24,5 @@
{placeholder}
{maxlength}
bind:value
class="min-h-0 w-full resize-none rounded-control border border-line bg-surface px-4 py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {klass}"
class="min-h-0 w-full resize-none rounded-control bg-surface px-4 py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {klass}"
></textarea>
@@ -1,6 +1,6 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import NowPlaying from './NowPlaying.svelte';
import EqBars from './EqBars.svelte';
interface Props {
index: number;
@@ -16,10 +16,10 @@
type="button"
onclick={onToggle}
aria-label={active && playing ? 'Pausar' : 'Reproducir'}
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-muted"
class="relative grid h-8 w-8 place-items-center rounded text-xs text-muted tabular-nums"
>
{#if active}
<NowPlaying paused={!playing} />
<EqBars paused={!playing} />
{:else}
<span class="group-hover:hidden">{index + 1}</span>
<Play class="hidden h-3.5 w-3.5 text-fg group-hover:block" fill="currentColor" />
@@ -9,6 +9,6 @@
let { class: klass = '', children }: Props = $props();
</script>
<span class="truncate text-center text-sm text-muted tabular-nums {klass}">
<span class="truncate text-center text-[12.5px] text-muted tabular-nums {klass}">
{@render children()}
</span>
@@ -4,19 +4,22 @@
import { trackTableKey, type TrackTableContext } from './TrackTable.svelte';
interface Props {
active?: boolean;
class?: string;
oncontextmenu?: (event: MouseEvent) => void;
children: Snippet;
}
let { class: klass = '', oncontextmenu, children }: Props = $props();
let { active = false, class: klass = '', oncontextmenu, children }: Props = $props();
const table = getContext<TrackTableContext>(trackTableKey);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group grid track-grid items-center gap-3 rounded-control py-2.5 pr-4 pl-2 transition hover:bg-surface sm:gap-8 sm:pr-6 sm:pl-3 {klass}"
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active
? 'bg-accent-tint'
: 'hover:bg-white/[3.2%]'} {klass}"
style="--mf-track-cols:{table.columns}; --mf-track-cols-mobile:{table.columnsMobile}"
{oncontextmenu}
>
@@ -29,7 +29,7 @@
const columns = $derived(
[
index ? '32px' : '',
'1fr',
'minmax(180px,1fr)',
...meta.map((column) => column.width),
'64px',
action ? '36px' : ''
@@ -39,7 +39,7 @@
);
const columnsMobile = $derived(
[index ? '28px' : '', '1fr', '52px', action ? '32px' : '']
[index ? '28px' : '', 'minmax(120px,1fr)', '52px', action ? '32px' : '']
.filter((width) => width !== '')
.join(' ')
);
@@ -54,9 +54,9 @@
});
</script>
<div class={klass}>
<div class="overflow-x-auto {klass}">
<div
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-2 pl-2 text-sm font-medium tracking-wide text-muted uppercase sm:gap-8 sm:pr-6 sm:pl-3"
class="grid track-grid items-center gap-3 border-b border-white/5 pr-4 pb-3 pl-2 text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase sm:gap-8 sm:pr-6 sm:pl-3"
style="--mf-track-cols:{columns}; --mf-track-cols-mobile:{columnsMobile}"
>
{#if index}
@@ -71,7 +71,7 @@
<span></span>
{/if}
</div>
<div class="mt-1 flex flex-col">
<div class="mt-1 flex flex-col gap-0.5">
{@render children()}
</div>
</div>
@@ -2,14 +2,17 @@
import type { Snippet } from 'svelte';
import Cover from './Cover.svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
import ArtistLink from './ArtistLink.svelte';
interface Props {
trackId: string | number;
title: string;
artist?: string | null;
ownerUserId?: string | number | null;
coverSrc?: string | null;
coverSize?: string;
explicit?: boolean;
active?: boolean;
titleClass?: string;
onClick: () => void;
overlay?: Snippet;
@@ -20,17 +23,31 @@
trackId,
title,
artist,
ownerUserId,
coverSrc,
coverSize = 'h-[42px] w-[42px]',
explicit = false,
active = false,
titleClass = '',
onClick,
overlay,
badge
}: Props = $props();
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick();
}
</script>
<button type="button" onclick={onClick} class="flex min-w-0 items-center gap-3.5 text-left">
<div
role="button"
tabindex="0"
onclick={onClick}
onkeydown={onKeydown}
class="flex min-w-0 items-center gap-3.5 text-left"
>
<Cover
{trackId}
src={coverSrc}
@@ -45,11 +62,13 @@
{#if explicit}
<ExplicitBadge />
{/if}
<span class="min-w-0 truncate text-fg {titleClass}">{title}</span>
<span
class="min-w-0 truncate text-sm font-medium {active
? 'text-accent-soft'
: 'text-fg'} {titleClass}">{title}</span
>
{@render badge?.()}
</div>
{#if artist}
<div class="truncate text-left text-sm text-fg-3">{artist}</div>
{/if}
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-[12.5px] text-fg-3" />
</div>
</button>
</div>