feat(web): every paged list loads as you scroll; Liked becomes tabs (#5416)
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

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>
This commit is contained in:
2026-10-08 16:55:33 -04:00
co-authored by Claude Opus 5.5
parent 2205b2927b
commit 0f9ddbca97
24 changed files with 593 additions and 344 deletions
@@ -12,6 +12,7 @@
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import Modal from '#lib/components/Modal.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
import type { AdminPlaybackError, PlaybackErrorResolution } from '#lib/api/types.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
@@ -175,26 +176,14 @@
<!-- Tab toggle. Resolved / unresolved are two separate query keys so
the cache holds both lists without re-fetching when you flip. -->
<div role="tablist" aria-label="Resolution status" class="flex gap-2 border-b border-border">
<button
type="button"
role="tab"
aria-selected={!resolved}
onclick={() => { resolved = false; }}
class="border-b-2 px-3 py-2 text-sm transition-colors {!resolved
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
>Unresolved</button>
<button
type="button"
role="tab"
aria-selected={resolved}
onclick={() => { resolved = true; }}
class="border-b-2 px-3 py-2 text-sm transition-colors {resolved
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
>Resolved</button>
</div>
<TabStrip
ariaLabel="Resolution status"
bind:value={() => (resolved ? 'resolved' : 'unresolved'), (v) => (resolved = v === 'resolved')}
items={[
{ id: 'unresolved', label: 'Unresolved' },
{ id: 'resolved', label: 'Resolved' }
]}
/>
{#if query.isError}
<p class="text-error-fg">Couldn't load: {errMessage(query.error)}</p>
+10 -28
View File
@@ -3,6 +3,7 @@
import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
import {
createAdminRequestsQuery,
createQualityProfilesQuery,
@@ -166,34 +167,15 @@
<p class="text-text-secondary">Approve or set aside what users have asked for.</p>
</header>
<nav aria-label="Request status filters" class="border-b border-border">
<ul class="flex gap-2">
{#each tabs as tab (tab.status)}
{@const isActive = activeStatus === tab.status}
<li>
<button
type="button"
role="tab"
aria-selected={isActive}
class="border-b-2 px-3 py-2 text-sm transition-colors {isActive
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
onclick={() => (activeStatus = tab.status)}
>
{tab.label}
{#if isActive}
<span
class="ml-1.5 inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
data-testid="active-tab-count"
>
{rows.length}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</nav>
<TabStrip
ariaLabel="Request status filters"
bind:value={activeStatus}
items={tabs.map((tab) => ({
id: tab.status,
label: tab.label,
count: activeStatus === tab.status ? rows.length : undefined
}))}
/>
{#if query.isPending}
<p class="text-text-secondary">Reading the queue…</p>
@@ -4,7 +4,7 @@
import { createSuspectSourcesQuery } from '#lib/api/admin.js';
import { coverUrl } from '#lib/media/covers.js';
import { formatDuration } from '#lib/media/duration.js';
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import type { AdminSuspectGroup, AdminSuspectTrack } from '#lib/api/types.js';
// Files whose names say they were ripped from a video (#5410): "(Official
@@ -77,7 +77,7 @@
{#if query.isPending}
<p class="text-text-secondary">Reading file names…</p>
{:else if query.isError}
{:else if query.isError && !query.isFetchNextPageError}
<p class="text-error-fg">Couldn't load the suspect-sources list.</p>
{:else if groups.length === 0}
<div class="rounded-lg border border-border bg-surface p-6 text-center">
@@ -150,14 +150,11 @@
{/each}
</ul>
{#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}
{/if}
<ListContinuation
hasMore={!!query.hasNextPage}
loading={query.isFetchingNextPage}
failed={query.isFetchNextPageError}
onMore={() => query.fetchNextPage()}
/>
{/if}
</div>