Files
minstrel/web/src/routes/library/history/+page.svelte
T
bvandeusenandClaude Opus 5.5 0f9ddbca97
release / go (push) Successful in 1m53s
release / govulncheck (push) Successful in 49s
release / web (push) Successful in 1m50s
release / android (push) Successful in 6m44s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
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 21m41s
feat(web): every paged list loads as you scroll; Liked becomes tabs (#5416)
Preference 172: no "Load more" buttons. Eight remained, on Liked (three
sections), the three Search overflow pages, Genres and Years.

- ListContinuation: the one bottom-of-list block. It loads the next page
  ahead of the reader, announces "Loading more…" through aria-live, shows an
  optional end line, and on a failed page shows the error with Try again,
  dropping the sentinel so a dead endpoint isn't re-hit on every scroll.
  Used on all ten paged lists, including the four that already autoloaded.
- A failed next page no longer replaces the list. TanStack sets isError for
  it, so the page-level error branches now apply only to a failed first
  load. Genres and Years did the same with their own loader; a later
  page's failure now keeps the grid.
- Liked: Artists | Albums | Tracks tabs (operator's choice). Stacked, a
  long Artists list loading as you scroll would bury the other two. It
  opens on the first tab that has likes.
- TabStrip: the in-page tab strip, now shared by Liked, Playback errors
  and Requests.
- test-utils/intersectionObserver: a stand-in so tests can scroll to the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:55:33 -04:00

84 lines
3.1 KiB
Svelte

<script lang="ts">
import PageColumn from '#lib/components/PageColumn.svelte';
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 ListContinuation from '#lib/components/ListContinuation.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>
<PageColumn>
<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 && !query.isFetchNextPageError}
<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}
<ListContinuation
hasMore={!!query.hasNextPage}
loading={query.isFetchingNextPage}
failed={query.isFetchNextPageError}
onMore={() => query.fetchNextPage()}
endLabel="End of history"
/>
{/if}
</PageColumn>