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
@@ -35,7 +35,7 @@
</PageHeader>
{#if items.length > 0}
<MediaGrid class="mt-9">
<MediaGrid class="mt-7 sm:mt-8">
{#each items as album, i (album.id)}
<AlbumCard
id={album.id}
@@ -55,10 +55,7 @@
description="Crea el primero y añádele las canciones que hayas subido."
>
{#snippet actions()}
<Button onclick={() => (creating = true)}>
<Plus class="h-4 w-4" strokeWidth={2} />
Crear álbum
</Button>
<Button onclick={() => (creating = true)}>Crear álbum</Button>
{/snippet}
</EmptyState>
{/if}
@@ -42,7 +42,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
(track) => track.source === 'Local' && !inAlbum.has(track.id)
);
const isOwner = user !== null && Number(album.ownerUserId) === Number(user.sub);
const isOwner = user !== null && String(album.ownerUserId) === user.sub;
return { album, tracks, library, isOwner, section: isOwner ? '/albums' : null };
};
@@ -48,17 +48,17 @@
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
/>
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
<PlaylistArt
{trackIds}
{coverUrl}
size="large"
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Álbum</p>
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Álbum</p>
<h1
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
>
{title}
</h1>
@@ -72,10 +72,10 @@
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{#if playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
@@ -31,18 +31,16 @@
<TrackTable index action={isOwner} class="mt-6 sm:mt-8">
{#each tracks as track, i (track.id)}
<TrackRow>
<TrackIndexCell
index={i}
active={player.current.id === queueIdForTrack(track)}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
{@const active = player.current.id === queueIdForTrack(track)}
<TrackRow {active}>
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
@@ -28,12 +28,15 @@
<SectionHeading title="Tus canciones subidas" />
<TrackTable action>
{#each library as track, i (track.id)}
<TrackRow>
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
>
{#snippet overlay()}
@@ -79,21 +79,21 @@
<Page>
<BackLink href="/explore" label="Volver a explorar" />
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
<Cover
trackId={album.albumId}
src={album.thumbnailUrl}
size="large"
alt={album.title}
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
oncontextmenu={(e) => (albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))}
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">
{album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'}
</p>
<h1
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
>
{album.title}
</h1>
@@ -113,10 +113,10 @@
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
@@ -132,19 +132,19 @@
<TrackTable index class="mt-6 sm:mt-8">
{#each tracks as track, i (track.videoId)}
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}>
<TrackIndexCell
index={i}
active={player.current.id === track.videoId}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
{@const active = player.current.id === track.videoId}
<TrackRow
{active}
oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}
>
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
<TrackTitleCell
trackId={track.videoId}
title={track.title}
artist={album.artist}
coverSrc={album.thumbnailUrl}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta>{fmtTime(Number(track.durationSeconds))}</TrackMeta>