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,8 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { fetchAlbumQueueItems } from '$lib/albums';
|
||||
import { fmtTime } from '$lib/format';
|
||||
@@ -10,17 +7,16 @@
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
|
||||
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte';
|
||||
import Rail from '$lib/components/ui/Rail.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config';
|
||||
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
@@ -42,10 +38,24 @@
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
|
||||
let { data, form } = $props();
|
||||
const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
|
||||
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
|
||||
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
|
||||
reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' },
|
||||
'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' },
|
||||
indie: { label: 'Indie', tagline: 'Voces fuera del radar' },
|
||||
salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' },
|
||||
'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' },
|
||||
electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' },
|
||||
jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' },
|
||||
baladas: { label: 'Baladas', tagline: 'Para sentir con calma' },
|
||||
trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' },
|
||||
cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' },
|
||||
'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' },
|
||||
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' }
|
||||
};
|
||||
|
||||
let searchValue = $state(untrack(() => data.query));
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let { data, form } = $props();
|
||||
|
||||
let ytItems = $state<YouTubeSong[]>([]);
|
||||
let ytUnavailable = $state(false);
|
||||
@@ -71,26 +81,26 @@
|
||||
if (data.query) {
|
||||
ytItems = appendUnique(
|
||||
[],
|
||||
data.tracks.items.flatMap((item) => (item.youTubeSong ? [item.youTubeSong as YouTubeSong] : [])),
|
||||
data.tracks.items.flatMap((item) =>
|
||||
item.youTubeSong ? [item.youTubeSong as YouTubeSong] : []
|
||||
),
|
||||
(i) => i.videoId
|
||||
);
|
||||
ytUnavailable = data.tracks.youtubeUnavailable;
|
||||
return;
|
||||
} else {
|
||||
ytItems = [];
|
||||
ytUnavailable = false;
|
||||
}
|
||||
|
||||
ytItems = [];
|
||||
ytUnavailable = false;
|
||||
|
||||
let cancelled = false;
|
||||
data.youtubeFiller?.then((filler) => {
|
||||
if (cancelled || !filler) return;
|
||||
ytItems = appendUnique([], filler.items, (i) => i.videoId);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const genreTiles = $derived(
|
||||
data.genres.map((query, i) => ({
|
||||
query,
|
||||
hue: Math.round((i * 360) / data.genres.length),
|
||||
...(GENRE_INFO[query] ?? { label: query, tagline: 'Explora este estilo' })
|
||||
}))
|
||||
);
|
||||
|
||||
const songRows = $derived([
|
||||
...localItems.map((track) => ({
|
||||
target: { kind: 'local' as const, track },
|
||||
@@ -126,35 +136,10 @@
|
||||
!!data.query && !ytUnavailable && items.length === 0 && !hasAlbums && !hasUsers
|
||||
);
|
||||
|
||||
function localAlbumCaption(album: LocalAlbum) {
|
||||
return [
|
||||
album.releaseYear === null ? undefined : String(album.releaseYear),
|
||||
`${Number(album.trackCount)} ${Number(album.trackCount) === 1 ? 'canción' : 'canciones'}`
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function youtubeAlbumCaption(album: YouTubeAlbum) {
|
||||
return [album.artist, album.releaseYear === null ? undefined : String(album.releaseYear)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function buildHref(query: string) {
|
||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||
}
|
||||
|
||||
function onSearchInput(event: Event) {
|
||||
const value = (event.currentTarget as HTMLInputElement).value;
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const term = value.trim();
|
||||
if (term === data.query) return;
|
||||
goto(buildHref(term), { keepFocus: true, replaceState: true, noScroll: true });
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function togglePlay(index: number) {
|
||||
player.playOrToggle(items.map(queueItemForTarget), index);
|
||||
}
|
||||
@@ -224,87 +209,99 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Buscar</title>
|
||||
<meta name="description" content="Busca canciones, álbumes y usuarios en Musify." />
|
||||
<title>{data.query ? `Buscar «${data.query}»` : 'Descubrir'}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Busca canciones, álbumes y personas, o descubre por género en Musify."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PageHeader title="Buscar" subtitle="Encuentra canciones, álbumes y personas." />
|
||||
|
||||
<div class="mt-6">
|
||||
<Input
|
||||
type="search"
|
||||
icon={Search}
|
||||
bind:value={searchValue}
|
||||
oninput={onSearchInput}
|
||||
placeholder="¿Qué quieres reproducir?"
|
||||
{#if !data.query}
|
||||
<PageHeader
|
||||
title="Descubrir"
|
||||
subtitle="Encuentra tu próxima canción favorita explorando por género."
|
||||
/>
|
||||
</div>
|
||||
{:else if !nothingFound}
|
||||
<PageHeader title="Resultados para «{data.query}»" />
|
||||
{/if}
|
||||
|
||||
{#if form?.message}
|
||||
<Alert class="mt-4">{form.message}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if nothingFound}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
title="Sin resultados"
|
||||
description="No hay resultados para «{data.query}»."
|
||||
/>
|
||||
<div class="mx-auto max-w-115 py-[8vh] text-center">
|
||||
<h1 class="font-display text-xl font-semibold tracking-[-0.025em] text-fg">
|
||||
Sin resultados para «{data.query}»
|
||||
</h1>
|
||||
<p class="mt-2.5 text-[13px] leading-[1.65] text-fg-2">
|
||||
Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o
|
||||
por playlist.
|
||||
</p>
|
||||
<div class="mt-5.5 flex flex-wrap justify-center gap-2">
|
||||
{#each genreTiles.slice(0, 4) as genre (genre.query)}
|
||||
<a
|
||||
href={buildHref(genre.query)}
|
||||
class="rounded-full border border-white/8 px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-white/5 hover:text-fg"
|
||||
>
|
||||
{genre.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !data.query}
|
||||
<!-- GÉNEROS -->
|
||||
<div class="mt-7 grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
|
||||
{#each genreTiles as genre, i (genre.query)}
|
||||
<a
|
||||
href={buildHref(genre.query)}
|
||||
class="animate-enter group relative block h-28 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-125"
|
||||
style="animation-delay:{Math.min(i, 10) *
|
||||
40}ms; background:linear-gradient(140deg, oklch(0.24 0.07 {genre.hue}), rgba(9,9,12,.95))"
|
||||
>
|
||||
<div class="font-display text-[15.5px] font-semibold tracking-[-0.02em] text-fg">
|
||||
{genre.label}
|
||||
</div>
|
||||
<div class="mt-1.5 text-[11.5px] text-fg-3">{genre.tagline}</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasAlbums}
|
||||
<div class="mt-8">
|
||||
<div class="mt-9">
|
||||
<SectionHeading title="Álbumes" />
|
||||
<MediaGrid as="ul" min="168px" minMobile="150px">
|
||||
{#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
|
||||
<li>
|
||||
{#if entry.kind === 'local'}
|
||||
<a
|
||||
href="/albums/{entry.album.id}"
|
||||
class="group/card animate-enter block min-w-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
oncontextmenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
|
||||
>
|
||||
<div class="relative">
|
||||
<PlaylistArt
|
||||
trackIds={entry.album.coverTrackIds}
|
||||
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||
>
|
||||
{entry.album.title}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">
|
||||
{localAlbumCaption(entry.album)}
|
||||
</div>
|
||||
</a>
|
||||
<AlbumCard
|
||||
id={entry.album.id}
|
||||
title={entry.album.title}
|
||||
releaseYear={entry.album.releaseYear === null
|
||||
? undefined
|
||||
: Number(entry.album.releaseYear)}
|
||||
trackCount={Number(entry.album.trackCount)}
|
||||
trackIds={entry.album.coverTrackIds}
|
||||
index={i}
|
||||
onContextMenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
|
||||
/>
|
||||
{:else}
|
||||
<a
|
||||
<AlbumCard
|
||||
id={entry.album.albumId}
|
||||
href="/albums/external/youtube/{entry.album.albumId}"
|
||||
class="group/card animate-enter block min-w-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
oncontextmenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
|
||||
>
|
||||
<div class="relative">
|
||||
<Cover
|
||||
trackId={entry.album.albumId}
|
||||
src={entry.album.thumbnailUrl}
|
||||
size="large"
|
||||
alt={entry.album.title}
|
||||
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||
>
|
||||
{entry.album.title}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">
|
||||
{youtubeAlbumCaption(entry.album)}
|
||||
</div>
|
||||
</a>
|
||||
title={entry.album.title}
|
||||
subtitle={entry.album.artist}
|
||||
releaseYear={entry.album.releaseYear === null
|
||||
? undefined
|
||||
: Number(entry.album.releaseYear)}
|
||||
coverSrc={entry.album.thumbnailUrl}
|
||||
index={i}
|
||||
onContextMenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
|
||||
/>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
@@ -313,71 +310,78 @@
|
||||
{/if}
|
||||
|
||||
{#if hasUsers}
|
||||
<div class="mt-8">
|
||||
<div class="mt-9">
|
||||
<SectionHeading title="Usuarios" />
|
||||
<Rail>
|
||||
{#each users as u, i (u.id)}
|
||||
<div
|
||||
<a
|
||||
href="/u/{u.id}"
|
||||
class="group/artist animate-enter shrink-0"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
>
|
||||
<ArtistAvatar name={u.name} size={96} />
|
||||
<div class="mt-1 w-24 truncate text-center text-xs text-fg-3">@{u.handle}</div>
|
||||
</div>
|
||||
<ArtistAvatar name={u.name} imageUrl={u.profilePictureUrl} size={96} />
|
||||
</a>
|
||||
{/each}
|
||||
</Rail>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6">
|
||||
{#if songRows.length > 0}
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each songRows as { target: item, seconds }, i (targetId(item))}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => togglePlay(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, item)}
|
||||
aria-label="Reproducir {targetTitle(item)}"
|
||||
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
|
||||
>
|
||||
<Cover
|
||||
trackId={targetId(item)}
|
||||
src={targetCoverSrc(item)}
|
||||
size="small"
|
||||
alt={targetTitle(item)}
|
||||
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
|
||||
{#if data.query}
|
||||
<div class="mt-9">
|
||||
{#if songRows.length > 0}
|
||||
<SectionHeading title="Canciones" />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each songRows as { target: item, seconds }, i (targetId(item))}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => togglePlay(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, item)}
|
||||
aria-label="Reproducir {targetTitle(item)}"
|
||||
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
|
||||
>
|
||||
{#if isTargetCurrent(item)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(item)}
|
||||
<ExplicitBadge />
|
||||
<Cover
|
||||
trackId={targetId(item)}
|
||||
src={targetCoverSrc(item)}
|
||||
size="small"
|
||||
alt={targetTitle(item)}
|
||||
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
|
||||
>
|
||||
{#if isTargetCurrent(item)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(item)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-[13px] font-medium text-fg">
|
||||
{targetTitle(item)}
|
||||
</span>
|
||||
</div>
|
||||
{#if targetArtist(item)}
|
||||
<div class="truncate text-[11.5px] text-fg-3">
|
||||
Canción · {targetArtist(item)}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="truncate text-fg transition-colors group-hover/row:text-accent-soft">
|
||||
{targetTitle(item)}
|
||||
</span>
|
||||
</div>
|
||||
{#if targetArtist(item)}
|
||||
<div class="truncate text-sm text-fg-3">Canción · {targetArtist(item)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="shrink-0 text-sm text-muted tabular-nums">{fmtTime(seconds)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<span class="shrink-0 text-[11.5px] text-muted tabular-nums"
|
||||
>{fmtTime(seconds)}</span
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if hasMore}
|
||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||
{#if hasMore}
|
||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||
{/if}
|
||||
{:else if !ytUnavailable && !nothingFound}
|
||||
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." />
|
||||
{/if}
|
||||
{:else if data.query && !ytUnavailable}
|
||||
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ytUnavailable}
|
||||
<EmptyState
|
||||
|
||||
Reference in New Issue
Block a user