Files
minstrel/web/src/routes/library/years/+page.svelte
T
bvandeusenandClaude Opus 5.5 b46c080d19
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m26s
release / integration (push) Successful in 4m18s
release / android (push) Successful in 4m44s
release / Build signed APK (releases and dev) (push) Successful in 4m53s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(web): all accent text and icons use accent-fg (#5318)
The raw accent fails AA as text on every dark surface, not only on its
own tint: 3.04:1 on the page, 2.70 on iron, 2.21 on slate, against 4.5.
accent-fg (the house formula, 45% toward parchment) measures 5.62 at
worst across both modes. The operator chose the readable colour over the
signature teal for text, on 2026-10-08.

- 36 sites swap. They are 35 Tailwind uses: links, "Now playing", the
  ingest progress line, active shuffle/repeat, the liked heart, the app
  download icon and its hover. The last is the alphabet rail's pending
  spinner in CSS. Icons follow the text: as graphics they need only
  3:1, and the raw accent misses even that on iron.
- check-tint-contrast adds accent to TEXT_NEVER_RAW, so a new raw
  text-accent or color: var(--fs-accent) fails the web lane. Run against
  the files before the swap, it finds all 36. Borders, rings and fills
  keep the raw accent.

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

207 lines
6.8 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { pageTitle } from '$lib/branding';
import { ChevronLeft } from 'lucide-svelte';
import {
createAlbumYearsQuery,
listAlbumsByYear,
BROWSE_PAGE_SIZE,
type YearCount
} from '$lib/api/browse';
import AlbumCard from '$lib/components/AlbumCard.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import type { AlbumRef } from '$lib/api/types';
const indexStore = createAlbumYearsQuery();
const index = $derived($indexStore);
const years = $derived(index.data ?? []);
const selected = $derived.by(() => {
const raw = page.url.searchParams.get('y');
if (!raw) return null;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
});
// Grouped by decade so the index stays scannable — a flat list of every year
// in a decades-deep library is a wall of numbers, and the decade is usually
// how someone actually thinks about it.
const decades = $derived.by(() => {
const buckets = new Map<number, YearCount[]>();
for (const y of years) {
const decade = Math.floor(y.year / 10) * 10;
const list = buckets.get(decade);
if (list) list.push(y);
else buckets.set(decade, [y]);
}
return [...buckets.entries()]
.sort((a, b) => b[0] - a[0])
.map(([decade, entries]) => ({
decade,
entries: entries.slice().sort((a, b) => b.year - a.year),
albumCount: entries.reduce((sum, e) => sum + e.album_count, 0)
}));
});
let albums = $state<AlbumRef[]>([]);
let total = $state(0);
let loading = $state(false);
let failed = $state(false);
// Plain `let`, not $state — see the note in the genres page: as reactive
// state, reading it in the fetch path would make the effect below depend on
// its own writes. It exists so a late response for a previously selected
// year is discarded instead of painted over the current one.
let requestToken = 0;
$effect(() => {
const y = selected; // only tracked read
void reload(y);
});
async function reload(year: number | null) {
requestToken += 1;
albums = [];
total = 0;
failed = false;
if (year === null) return;
await fetchPage(year, 0, requestToken);
}
async function fetchPage(year: number, offset: number, token: number) {
loading = true;
try {
const p = await listAlbumsByYear(year, BROWSE_PAGE_SIZE, offset);
if (token !== requestToken) return;
albums = offset === 0 ? p.items : [...albums, ...p.items];
total = p.total;
} catch {
if (token === requestToken) failed = true;
} finally {
if (token === requestToken) loading = false;
}
}
function loadMore() {
if (selected !== null) void fetchPage(selected, albums.length, requestToken);
}
</script>
<svelte:head>
<title>{pageTitle(selected !== null ? `Library · ${selected}` : 'Library · Years')}</title>
</svelte:head>
{#if selected !== null}
<div class="space-y-4">
<header class="space-y-2">
<a
href="/library/years"
class="inline-flex items-center gap-1 text-sm text-accent-fg hover:underline"
>
<ChevronLeft size={14} aria-hidden="true" />
All years
</a>
<div>
<h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1>
{#if !loading || albums.length > 0}
<p class="text-sm text-text-secondary">
{total} {total === 1 ? 'album' : 'albums'}
</p>
{/if}
</div>
</header>
{#if failed}
<p class="text-sm text-action-destructive">
Couldn't load albums for {selected}.
<button
type="button"
class="underline hover:no-underline"
onclick={() => reload(selected)}>Try again</button
>
</p>
{:else if loading && albums.length === 0}
<p class="text-text-secondary">Loading…</p>
{:else if albums.length === 0}
<EmptyState
title="No albums from {selected}"
hint="The library may have been rescanned since this list was built."
/>
{:else}
<div
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
>
{#each albums as album (album.id)}
<AlbumCard {album} />
{/each}
</div>
{#if albums.length < total}
<div class="flex justify-center py-2">
<button
type="button"
class="rounded-md border border-border px-4 py-2 text-sm hover:bg-surface-hover
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
disabled={loading}
onclick={loadMore}
>
{loading ? 'Loading…' : `Load more (${total - albums.length} left)`}
</button>
</div>
{:else}
<p class="py-2 text-center text-sm text-text-secondary">End of year</p>
{/if}
{/if}
</div>
{:else}
<div class="space-y-4">
<header>
<h1 class="font-display text-2xl font-medium text-text-primary">Years</h1>
{#if !index.isPending && !index.isError}
<p class="text-sm text-text-secondary">
{years.length} {years.length === 1 ? 'year' : 'years'} with dated releases
</p>
{/if}
</header>
{#if index.isError}
<ApiErrorBanner error={index.error} onRetry={index.refetch} />
{:else if index.isPending}
<p class="text-text-secondary">Loading…</p>
{:else if years.length === 0}
<!-- Albums with no release date are absent by design rather than bucketed
under a fake year, so an untagged library lands here legitimately. -->
<EmptyState
title="No release years found"
hint="Years come from the release date on your albums. Albums without one don't appear on this axis."
/>
{:else}
<ul class="space-y-4">
{#each decades as d (d.decade)}
<li>
<h2 class="mb-2 text-sm font-medium text-text-secondary">
{d.decade}s
<span class="font-normal">· {d.albumCount}</span>
</h2>
<ul class="flex flex-wrap gap-2">
{#each d.entries as y (y.year)}
<li>
<a
href={`/library/years?y=${y.year}`}
class="inline-flex items-baseline gap-1.5 rounded-md border border-border
bg-surface px-3 py-1.5 hover:bg-surface-hover
focus-visible:ring-2 focus-visible:ring-accent"
>
<span class="text-text-primary">{y.year}</span>
<span class="text-xs text-text-secondary">{y.album_count}</span>
</a>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
{/if}
</div>
{/if}