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
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>
207 lines
6.8 KiB
Svelte
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}
|