Files
minstrel/web/src/routes/requests/+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

167 lines
6.2 KiB
Svelte

<script lang="ts">
import { pageTitle } from '$lib/branding';
import { Disc3, Album, Music2, X } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { createMyRequestsQuery, cancelRequest } from '$lib/api/requests';
import { qk } from '$lib/api/queries';
import StatusPill from '$lib/components/StatusPill.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import DiscoverTabs from '$lib/components/DiscoverTabs.svelte';
import type { LidarrRequest, LidarrRequestKind } from '$lib/api/types';
const queryStore = createMyRequestsQuery();
const query = $derived($queryStore);
const requests = $derived((query.data ?? []) as LidarrRequest[]);
const client = useQueryClient();
async function onCancel(id: string) {
try {
await cancelRequest(id);
await client.invalidateQueries({ queryKey: qk.myRequests() });
} catch {
// Swallow for v1; toast surface lands later. The row stays put so the
// user can retry — failed cancel doesn't lie about success.
}
}
function fallbackIcon(kind: LidarrRequestKind) {
if (kind === 'artist') return Disc3;
if (kind === 'album') return Album;
return Music2;
}
function rowTitle(r: LidarrRequest): string {
if (r.kind === 'artist') return r.artist_name;
if (r.kind === 'album') return r.album_title ?? '—';
return r.track_title ?? '—';
}
// Used in aria-labels — '—' is meaningless to screen readers, so when a
// title is missing fall back to a generic-but-intelligible phrase.
function rowAccessibleName(r: LidarrRequest): string {
if (r.kind === 'artist') return r.artist_name;
if (r.kind === 'album') return r.album_title ?? `this album by ${r.artist_name}`;
return r.track_title ?? `this track by ${r.artist_name}`;
}
// Keep the meta line short and readable. We always lead with the artist,
// then a relative-ish date — locale formatting is good enough for v1
// and keeps tests deterministic without pinning Intl.RelativeTimeFormat.
function rowMeta(r: LidarrRequest): string {
const when = new Date(r.requested_at).toLocaleDateString();
if (r.kind === 'artist') return `Requested ${when}`;
return `by ${r.artist_name} · ${when}`;
}
function listenHref(r: LidarrRequest): string | null {
if (r.matched_track_id) return `/tracks/${r.matched_track_id}`;
if (r.matched_album_id) return `/albums/${r.matched_album_id}`;
if (r.matched_artist_id) return `/artists/${r.matched_artist_id}`;
return null;
}
</script>
<svelte:head><title>{pageTitle('Requests')}</title></svelte:head>
<DiscoverTabs />
<div class="space-y-6 pt-6">
<header class="space-y-1">
<h2 class="font-display text-2xl font-medium text-text-primary">
Your requests
</h2>
<p class="text-text-secondary">
What you've asked Minstrel to add to the library.
</p>
</header>
{#if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if query.isPending}
<p class="text-text-secondary">Reading the ledger…</p>
{:else if requests.length === 0}
<p class="text-text-secondary">Nothing requested yet.</p>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each requests as r (r.id)}
{@const Icon = fallbackIcon(r.kind)}
{@const href = listenHref(r)}
<li class="flex items-center gap-4 p-3" data-testid="request-row" data-status={r.status}>
<div
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover"
aria-hidden="true"
>
<Icon size={24} strokeWidth={1} class="text-text-muted" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<span class="kind-pill">{r.kind}</span>
<StatusPill status={r.status} />
</div>
<div class="truncate text-base font-medium text-text-primary">
{rowTitle(r)}
</div>
<div class="truncate text-sm text-text-secondary">
{rowMeta(r)}
</div>
{#if r.imported_album_count > 0 || r.imported_track_count > 0}
<div class="text-sm text-accent-fg" data-testid="ingest-progress">
{#if r.kind === 'artist'}
{r.imported_album_count} {r.imported_album_count === 1 ? 'album' : 'albums'}
· {r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested
{:else if r.kind === 'album'}
{r.imported_track_count} {r.imported_track_count === 1 ? 'track' : 'tracks'} ingested
{:else}
Track ingested
{/if}
</div>
{/if}
{#if r.status === 'rejected' && r.notes}
<div class="text-sm text-text-secondary" data-testid="rejection-notes">
{r.notes}
</div>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
{#if r.status === 'pending'}
<button
type="button"
aria-label={`Cancel request for ${rowAccessibleName(r)}`}
class="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover"
onclick={() => onCancel(r.id)}
>
<X size={16} strokeWidth={1} /> Cancel
</button>
{:else if r.status === 'completed' && href}
<a
{href}
aria-label={`Listen to ${rowAccessibleName(r)}`}
class="inline-flex items-center gap-1 text-sm text-accent-fg hover:underline"
>
<Music2 size={16} strokeWidth={1} /> Listen
</a>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.kind-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 14px;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent-fg);
text-transform: capitalize;
}
</style>