c9afd4f584
test-web / test (push) Failing after 33s
First wave of Home visual polish (UI tasks 78/79/81/85): - CardActionCluster like/queue/menu cluster is now opacity-0 and fades in on group-hover or focus-within. The group class moves from the inner anchor to the outer card wrapper so the cluster (positioned outside the anchor) participates in the same hover scope. PlaylistCard refresh kebab gets the same treatment. - AlbumCard drops the year line. Title plus artist is enough on Home tiles; the album detail page still shows the year. - HorizontalScrollRow h2 picks up a flex-baseline layout with a trailing 12-wide accent rule (after:bg-accent/60), so section headers read as chapter breaks instead of identical plain text. - AlbumCard art-wrap is shadow-sm by default and lifts to shadow-lg plus ring-accent/40 on hover. Pairs with the existing group-hover:scale-1.03 for a clean lift-on-mouseover feel.
189 lines
6.9 KiB
Svelte
189 lines
6.9 KiB
Svelte
<script lang="ts">
|
|
import { Play, MoreVertical, RefreshCcw } from 'lucide-svelte';
|
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
import type { Playlist, PlaylistTrack, TrackRef } from '$lib/api/types';
|
|
import { user } from '$lib/auth/store.svelte';
|
|
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
|
|
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
|
|
import { errCode } from '$lib/api/errors';
|
|
import { qk } from '$lib/api/queries';
|
|
import { playQueue } from '$lib/player/store.svelte';
|
|
import { pushToast } from '$lib/stores/toast.svelte';
|
|
|
|
let { playlist }: { playlist: Playlist } = $props();
|
|
|
|
const isOwn = $derived(user.value?.id === playlist.user_id);
|
|
// Server tells us which system playlists support by-kind refresh
|
|
// (#411 R2) — no hardcoded kind list here.
|
|
const refreshable = $derived(playlist.refreshable === true);
|
|
|
|
// #417: system playlists atomic-replace on rebuild, so created_at
|
|
// is the last-rotated time. Surface it so users know how fresh the
|
|
// mix is. Friendly wording, not the HistoryRow "m/h ago" style.
|
|
function refreshedLabel(iso: string): string {
|
|
const t = new Date(iso);
|
|
if (Number.isNaN(t.getTime())) return '';
|
|
const now = new Date();
|
|
const mins = Math.floor((now.getTime() - t.getTime()) / 60000);
|
|
if (mins < 5) return 'Refreshed just now';
|
|
const startOfToday = new Date(now);
|
|
startOfToday.setHours(0, 0, 0, 0);
|
|
const days = Math.floor((startOfToday.getTime() - t.getTime()) / 86400000) + 1;
|
|
if (t.getTime() >= startOfToday.getTime()) return 'Refreshed today';
|
|
if (days <= 1) return 'Refreshed yesterday';
|
|
if (days < 7) return `Refreshed ${days} days ago`;
|
|
return `Refreshed ${t.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`;
|
|
}
|
|
const refreshed = $derived(
|
|
playlist.system_variant != null ? refreshedLabel(playlist.created_at) : ''
|
|
);
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
let starting = $state(false);
|
|
let menuOpen = $state(false);
|
|
|
|
function toggleMenu(e: MouseEvent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
menuOpen = !menuOpen;
|
|
}
|
|
|
|
// Map PlaylistTrack rows to TrackRef shape and drop tracks whose
|
|
// upstream file has been removed (track_id null).
|
|
function toTrackRefs(rows: PlaylistTrack[]): TrackRef[] {
|
|
return rows
|
|
.map((r) => playlistTrackToRef(r))
|
|
.filter((t): t is TrackRef => t !== null);
|
|
}
|
|
|
|
async function onPlayClick(e: MouseEvent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (starting) return;
|
|
starting = true;
|
|
try {
|
|
const variant = playlist.system_variant;
|
|
if (variant != null) {
|
|
// #415: server returns the rotation-aware order (unplayed
|
|
// this rotation first). Play it as-is — no client shuffle —
|
|
// and tag the queue so play_started carries `source` and
|
|
// the server advances the rotation.
|
|
const detail = await systemShuffle(variant);
|
|
const refs = toTrackRefs(detail.tracks);
|
|
if (refs.length > 0) {
|
|
playQueue(refs, 0, { source: variant });
|
|
}
|
|
} else {
|
|
const detail = await getPlaylist(playlist.id);
|
|
const refs = toTrackRefs(detail.tracks);
|
|
if (refs.length > 0) {
|
|
playQueue(refs, 0);
|
|
}
|
|
}
|
|
} finally {
|
|
starting = false;
|
|
}
|
|
}
|
|
|
|
async function onRefresh(e: MouseEvent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
menuOpen = false;
|
|
if (!refreshable || playlist.system_variant == null) return;
|
|
try {
|
|
await refreshSystem(playlist.system_variant);
|
|
pushToast(`${playlist.name} refreshed.`);
|
|
await queryClient.invalidateQueries({ queryKey: qk.playlist(playlist.id) });
|
|
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
|
} catch (err: unknown) {
|
|
pushToast(`Refresh failed: ${errCode(err)}`, 'error');
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onclick={() => { menuOpen = false; }} />
|
|
|
|
<div class="card group relative">
|
|
{#if refreshable}
|
|
<!-- Kebab is hover/focus-revealed so the default tile state is
|
|
just the cover. Same pattern as CardActionCluster. -->
|
|
<div
|
|
class="kebab-wrap absolute right-1 top-1 z-10 transition-opacity duration-150
|
|
{menuOpen ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-within:opacity-100'}"
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label="Playlist actions"
|
|
aria-haspopup="menu"
|
|
aria-expanded={menuOpen}
|
|
onclick={toggleMenu}
|
|
class="rounded p-1 text-text-muted hover:bg-surface-hover hover:text-text-primary"
|
|
>
|
|
<MoreVertical size={16} strokeWidth={1} />
|
|
</button>
|
|
{#if menuOpen}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- onclick is a stopPropagation guard against the window-level
|
|
click-closes-menu pattern; not user-facing interaction. -->
|
|
<div
|
|
role="menu"
|
|
tabindex="-1"
|
|
class="absolute right-0 top-full z-20 mt-1 w-44 rounded-md border border-border bg-surface p-1 shadow-lg"
|
|
onclick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
onclick={onRefresh}
|
|
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm text-text-primary hover:bg-surface-hover"
|
|
>
|
|
<RefreshCcw size={14} strokeWidth={1} />
|
|
Refresh {playlist.name}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
<a
|
|
href={`/playlists/${playlist.id}`}
|
|
class="group flex w-full flex-col items-start gap-2 rounded-md p-2 text-left hover:bg-surface-hover"
|
|
>
|
|
<div class="art-wrap relative aspect-square w-full overflow-hidden rounded-md bg-surface-hover">
|
|
{#if playlist.cover_url}
|
|
<img
|
|
src={playlist.cover_url}
|
|
alt=""
|
|
class="h-full w-full object-cover transition-transform group-hover:scale-105"
|
|
/>
|
|
{:else}
|
|
<div class="flex h-full w-full items-center justify-center text-text-muted">
|
|
<span class="text-xs">No tracks yet</span>
|
|
</div>
|
|
{/if}
|
|
<button
|
|
type="button"
|
|
aria-label={`Play ${playlist.name}`}
|
|
onclick={onPlayClick}
|
|
disabled={starting || playlist.track_count === 0}
|
|
class="play-overlay absolute inset-0 m-auto flex h-12 w-12 items-center justify-center rounded-full disabled:opacity-50"
|
|
>
|
|
<Play size={24} strokeWidth={1.5} fill="currentColor" class="ml-0.5" />
|
|
</button>
|
|
</div>
|
|
<div class="w-full min-w-0">
|
|
<div class="truncate text-sm font-medium text-text-primary">{playlist.name}</div>
|
|
<div class="truncate text-xs text-text-muted">
|
|
{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}
|
|
{#if !isOwn}
|
|
· by {playlist.owner_username}
|
|
{/if}
|
|
</div>
|
|
{#if refreshed}
|
|
<div class="truncate text-xs text-text-muted">{refreshed}</div>
|
|
{/if}
|
|
</div>
|
|
</a>
|
|
</div>
|
|
|