release / govulncheck (push) Successful in 34s
release / web (push) Failing after 1m20s
release / go (push) Successful in 1m37s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m38s
release / android (push) Canceled after 4m39s
release / Build signed APK (releases and dev) (push) Canceled after 3m49s
Every track list (album, Liked, Search, Search → Tracks, playlist, History) now draws its rows with TrackListRow inside a TrackList box, in place of four row components that had drifted apart: - TrackListRow owns the look: lead column, title over "artist · album", like, radio, add to queue, menu, duration, then a trailing slot. It also owns the stripe, hover, the now-playing band and the unavailable state. The stripe is dropped on now-playing and selected rows, where Tailwind would otherwise let `odd:` win. - TrackRow, PlaylistTrackRow and HistoryRow are wrappers that keep only their behaviour: track number and multi-select; grip, remove and dead-entry states; cover and timestamp. Playlist and History rows gain radio, add and the menu, so every list offers the same actions. - The radio control is Lucide's Radio icon, not the 📻 emoji. - Enter on a control inside a row now belongs to that control. The old TrackRow played the track when Enter was pressed on "add to queue". - TrackList has no fill, so the stripe shows on playlists too. Drag reorder (#5393): neodrag kept the drop offset, so after the list re-rendered the dragged row sat between two slots. reorderDrag, shared by the playlist and queue rows: - snaps the row to whole rows while dragging; - resets it to its slot on drop; - on playlists, gives the touch gesture to the grip only, so the list still scrolls by touch. The playlist page reorders optimistically and rolls back on error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
87 lines
3.2 KiB
Svelte
87 lines
3.2 KiB
Svelte
<script lang="ts">
|
|
import { pageTitle } from '#lib/branding.js';
|
|
import { createHistoryQuery } from '#lib/api/history.js';
|
|
import { groupByDay } from '#lib/utils/dayGroup.js';
|
|
import HistoryRow from '#lib/components/HistoryRow.svelte';
|
|
import TrackList from '#lib/components/TrackList.svelte';
|
|
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte';
|
|
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
|
|
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
|
|
import QuickFilter from '#lib/components/QuickFilter.svelte';
|
|
import EmptyState from '#lib/components/EmptyState.svelte';
|
|
|
|
const queryStore = createHistoryQuery();
|
|
const query = $derived($queryStore);
|
|
const flatEvents = $derived(query.data?.pages?.flatMap((p) => p.events) ?? []);
|
|
|
|
let filter = $state('');
|
|
const filteredEvents = $derived.by(() => {
|
|
const q = filter.trim().toLowerCase();
|
|
if (!q) return flatEvents;
|
|
return flatEvents.filter((e) =>
|
|
e.track.title.toLowerCase().includes(q) ||
|
|
e.track.artist_name.toLowerCase().includes(q) ||
|
|
(e.track.album_title ?? '').toLowerCase().includes(q)
|
|
);
|
|
});
|
|
const grouped = $derived(groupByDay(filteredEvents, new Date()));
|
|
</script>
|
|
|
|
<svelte:head><title>{pageTitle('Library · History')}</title></svelte:head>
|
|
|
|
<div class="space-y-4">
|
|
<header class="flex flex-wrap items-end justify-between gap-3">
|
|
<h1 class="font-display text-2xl font-medium text-text-primary">History</h1>
|
|
{#if flatEvents.length > 0}
|
|
<QuickFilter bind:value={filter} placeholder="Filter history" />
|
|
{/if}
|
|
</header>
|
|
|
|
{#if query.isPending}
|
|
<LibrarySkeleton variant="list" count={12} />
|
|
{:else if query.isError}
|
|
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
|
{:else if flatEvents.length === 0}
|
|
<EmptyState
|
|
title="No listening history yet"
|
|
hint="Once you've played something, it'll show up here, grouped by day."
|
|
>
|
|
{#snippet actions()}
|
|
<a href="/" class="inline-flex items-center rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90">
|
|
Listen to something
|
|
</a>
|
|
{/snippet}
|
|
</EmptyState>
|
|
{:else if filter.trim() && filteredEvents.length === 0}
|
|
<p class="text-text-secondary">
|
|
No history in your loaded events matches
|
|
<span class="font-medium">'{filter.trim()}'</span>.
|
|
</p>
|
|
{:else}
|
|
{#each grouped as group (group.label)}
|
|
<section>
|
|
<h2 class="sticky top-0 z-10 mb-2 bg-background py-1 text-lg font-medium text-text-secondary">
|
|
{group.label}
|
|
</h2>
|
|
<TrackList>
|
|
{#each group.events as event (event.id)}
|
|
<HistoryRow {event} />
|
|
{/each}
|
|
</TrackList>
|
|
</section>
|
|
{/each}
|
|
|
|
{#if query.hasNextPage}
|
|
<InfiniteScrollSentinel
|
|
enabled={!query.isFetchingNextPage}
|
|
onIntersect={() => query.fetchNextPage()}
|
|
/>
|
|
{#if query.isFetchingNextPage}
|
|
<p class="py-2 text-center text-sm text-text-secondary">Loading more…</p>
|
|
{/if}
|
|
{:else if flatEvents.length > 0}
|
|
<p class="py-2 text-center text-sm text-text-secondary">End of history</p>
|
|
{/if}
|
|
{/if}
|
|
</div>
|