Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps, plus the migration they need. The mechanical part is `sv migrate sveltekit-3`, run one task at a time and reviewed: - svelte.config.js is gone. Its options move into sveltekit() in vite.config.ts, exported as kitOptions so vitest.config.ts runs the same kit setup, including the $test-utils alias the tests import. - $lib becomes #lib through package.json "imports". There is no src/lib/index, so only the "#lib/*" entry is kept. - tsconfig extends $app/tsconfig. - Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite ^8.0.12, svelte-check ^4.7.5. By hand, from the codemod's list of non-automated tasks: - goto's replaceState option is now replace; keepFocus becomes reset: false. For the search typeahead, reset: false also stops the scroll-to-top, which is wanted while typing. - The test setup mocks drop pushState/replaceState and $app/paths base/assets, which kit 3 removed, and mock refreshAll in place of invalidateAll. - The other flagged files only read page.url or goto internal routes, so they needed no change. TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to 7 once both accept it. With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0, so the web lane now audits every dependency rather than only what ships to browsers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
6.3 KiB
Svelte
167 lines
6.3 KiB
Svelte
<script lang="ts">
|
|
import { pageTitle } from '#lib/branding.js';
|
|
import { Disc3, Album, Music2, X } from 'lucide-svelte';
|
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
import { createMyRequestsQuery, cancelRequest } from '#lib/api/requests.js';
|
|
import { qk } from '#lib/api/queries.js';
|
|
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.js';
|
|
|
|
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>
|