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 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,220 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { SessionUser, YouTubeSong } from '$lib/types';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import {
|
||||
queueItemForTarget,
|
||||
targetArtist,
|
||||
targetCoverSrc,
|
||||
targetExplicit,
|
||||
targetId,
|
||||
targetTitle,
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import User from '@lucide/svelte/icons/user';
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import MenuItem from './ui/MenuItem.svelte';
|
||||
import Cover from './ui/Cover.svelte';
|
||||
import ExplicitBadge from './ui/ExplicitBadge.svelte';
|
||||
import GlassMenu from './ui/GlassMenu.svelte';
|
||||
import SearchBox from './SearchBox.svelte';
|
||||
import { themeMode } from '$lib/theme/mode.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
accountUrl?: string | null;
|
||||
}
|
||||
|
||||
const QUICK_RESULTS_LIMIT = 5;
|
||||
let { user, accountUrl }: Props = $props();
|
||||
|
||||
interface QuickSearchTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
artist?: string | null;
|
||||
isExplicit?: boolean;
|
||||
}
|
||||
|
||||
interface QuickSearchResponse {
|
||||
items: { track?: QuickSearchTrack | null; youTubeSong?: YouTubeSong | null }[];
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<TrackTarget[]>([]);
|
||||
let resultsOpen = $state(false);
|
||||
let searching = $state(false);
|
||||
let menuOpen = $state(false);
|
||||
let menuRef: HTMLDivElement | undefined = $state();
|
||||
let searchRef: HTMLDivElement | undefined = $state();
|
||||
let searchInput: HTMLInputElement | undefined = $state();
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let searchToken = 0;
|
||||
|
||||
function clearQuery() {
|
||||
query = '';
|
||||
results = [];
|
||||
resultsOpen = false;
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
const term = query.trim();
|
||||
if (term.length < SEARCH_MIN_LENGTH) {
|
||||
results = [];
|
||||
resultsOpen = false;
|
||||
return;
|
||||
}
|
||||
resultsOpen = true;
|
||||
searchTimeout = setTimeout(() => runQuickSearch(term), SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function runQuickSearch(term: string) {
|
||||
const token = ++searchToken;
|
||||
searching = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ name: term, pageSize: String(QUICK_RESULTS_LIMIT) });
|
||||
const res = await fetch(`/api/tracks?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = (await res.json()) as QuickSearchResponse;
|
||||
if (token !== searchToken) return;
|
||||
results = data.items
|
||||
.flatMap((item): TrackTarget[] => {
|
||||
if (item.track) return [{ kind: 'local', track: item.track }];
|
||||
if (item.youTubeSong) return [{ kind: 'youtube', song: item.youTubeSong }];
|
||||
return [];
|
||||
})
|
||||
.slice(0, QUICK_RESULTS_LIMIT);
|
||||
} catch {
|
||||
if (token === searchToken) results = [];
|
||||
} finally {
|
||||
if (token === searchToken) searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function playResult(index: number) {
|
||||
player.playOrToggle(results.map(queueItemForTarget), index);
|
||||
resultsOpen = false;
|
||||
}
|
||||
|
||||
function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
clearTimeout(searchTimeout);
|
||||
resultsOpen = false;
|
||||
const q = query.trim();
|
||||
goto(q ? `/explore?q=${encodeURIComponent(q)}` : '/explore');
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
|
||||
if (searchRef && !searchRef.contains(event.target as Node)) resultsOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-30 hidden grid-cols-[1fr_minmax(0,3fr)_1fr] items-center gap-4 border-b border-line bg-bg/70 px-8 py-4 backdrop-blur-lg md:grid"
|
||||
>
|
||||
<div></div>
|
||||
|
||||
<div class="relative mx-auto w-full max-w-2xl" bind:this={searchRef}>
|
||||
<form onsubmit={onSubmit}>
|
||||
<label
|
||||
class="flex w-full items-center gap-2.5 rounded-full border border-line bg-surface px-4 py-2.5 transition focus-within:border-accent-soft/60"
|
||||
>
|
||||
<Search class="h-4 w-4 shrink-0 text-fg-2" />
|
||||
<input
|
||||
type="text"
|
||||
bind:this={searchInput}
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
onfocus={() => (resultsOpen = results.length > 0)}
|
||||
placeholder="Buscar"
|
||||
class="w-full bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
|
||||
/>
|
||||
{#if query}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearQuery}
|
||||
aria-label="Borrar búsqueda"
|
||||
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{#if resultsOpen && (results.length > 0 || searching)}
|
||||
<div
|
||||
class="animate-pop absolute top-full right-0 left-0 z-20 mt-2 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
>
|
||||
{#if searching && results.length === 0}
|
||||
<p class="px-3 py-2.5 text-sm text-fg-2">Buscando…</p>
|
||||
{:else}
|
||||
{#each results as target, i (targetId(target))}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playResult(i)}
|
||||
class="group/result flex w-full min-w-0 items-center gap-3 rounded-control p-2 text-left transition hover:bg-hover"
|
||||
>
|
||||
<Cover
|
||||
trackId={targetId(target)}
|
||||
src={targetCoverSrc(target)}
|
||||
size="small"
|
||||
alt={targetTitle(target)}
|
||||
class="h-9 w-9 shrink-0 rounded-control shadow-art"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(target)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-sm text-fg">{targetTitle(target)}</span>
|
||||
</div>
|
||||
{#if targetArtist(target)}
|
||||
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
|
||||
<div class="flex shrink-0 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => history.back()}
|
||||
aria-label="Atrás"
|
||||
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-white/[3.5%] text-fg-2 transition-colors hover:bg-white/7 hover:text-fg"
|
||||
>
|
||||
<ChevronLeft class="h-[15px] w-[15px]" strokeWidth={1.9} />
|
||||
</button>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-white/[3.5%] text-muted"
|
||||
>
|
||||
<ChevronRight class="h-[15px] w-[15px]" strokeWidth={1.9} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-6">
|
||||
<SearchBox class="max-w-[480px] flex-1" />
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<div class="relative shrink-0" bind:this={menuRef}>
|
||||
{#if user}
|
||||
<div class="relative" bind:this={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
class="block shrink-0 rounded-full transition active:scale-95"
|
||||
>
|
||||
{#if user.picture}
|
||||
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
|
||||
{:else}
|
||||
<span
|
||||
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm font-semibold text-fg uppercase"
|
||||
>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="animate-pop absolute top-full right-0 z-20 mt-2 w-40 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
class="block h-8.25 w-8.25 shrink-0 overflow-hidden rounded-full border border-white/10 transition active:scale-95"
|
||||
>
|
||||
{#if user.picture}
|
||||
<img src={user.picture} alt="" class="h-full w-full object-cover opacity-90" />
|
||||
{:else}
|
||||
<span
|
||||
class="grid h-full w-full place-items-center bg-surface-2 text-xs font-semibold text-fg uppercase"
|
||||
>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Salir" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<GlassMenu class="absolute top-full right-0 z-20 mt-2 w-52 overflow-hidden">
|
||||
<MenuItem
|
||||
icon={User}
|
||||
label="Ver perfil"
|
||||
href="/u/{user.sub}"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{#if accountUrl}
|
||||
<MenuItem
|
||||
icon={Settings}
|
||||
label="Ajustes"
|
||||
href={accountUrl}
|
||||
target="_blank"
|
||||
onclick={() => (menuOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
<MenuItem
|
||||
icon={themeMode.current === 'dark' ? Sun : Moon}
|
||||
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
|
||||
onclick={() => themeMode.toggle()}
|
||||
/>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
|
||||
</form>
|
||||
</GlassMenu>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/login"
|
||||
data-sveltekit-reload
|
||||
class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-on-accent transition hover:brightness-110"
|
||||
class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-ink transition hover:brightness-110"
|
||||
>
|
||||
Iniciar sesión
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user