Files
minstrel/web/src/routes/admin/requests/+page.svelte
T
bvandeusen c3be0f3e6e refactor(web): consolidate error-copy table to JSON source of truth
Three admin pages had divergent inline error-code → user-copy switch
tables (with wording drift, including a stale "Settings → Integrations"
that should have been Admin). Consolidate into web/src/lib/styles/error-copy.json,
expose via web/src/lib/api/error-copy.ts (ERROR_COPY + copyForCode),
and refactor the three pages to import the helper.

Fixes the three-way drift; the only user-visible behavior change is
the quarantine page now correctly says "Admin → Integrations".

Sets up the JSON for the M7 Flutter client (#356) to consume the same
table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:01:46 -04:00

442 lines
16 KiB
Svelte

<script lang="ts">
import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createAdminRequestsQuery,
createQualityProfilesQuery,
createRootFoldersQuery,
approveRequest,
rejectRequest
} from '$lib/api/admin';
import { copyForCode } from '$lib/api/error-copy';
import StatusPill from '$lib/components/StatusPill.svelte';
import type { LidarrRequest, LidarrRequestKind, LidarrRequestStatus } from '$lib/api/types';
// Approval queue. Tabs filter by status; rows expose Override/Approve/Reject
// per spec. The override modal collapses by default — most requests get the
// snapshot defaults, so we don't make the operator stare at two dropdowns
// they almost never touch.
const client = useQueryClient();
// The four tab filters. "Pending" is default — that's the actionable bucket;
// the rest are review-only views.
const tabs: { status: LidarrRequestStatus; label: string }[] = [
{ status: 'pending', label: 'Pending' },
{ status: 'approved', label: 'Approved' },
{ status: 'completed', label: 'Completed' },
{ status: 'rejected', label: 'Rejected' }
];
let activeStatus = $state<LidarrRequestStatus>('pending');
// The query factory is re-created on every status flip so TanStack treats
// each status as its own cache key. We don't need a single shared store.
const queryStore = $derived(createAdminRequestsQuery(activeStatus));
const query = $derived($queryStore);
const rows = $derived((query.data ?? []) as LidarrRequest[]);
// Quality + root dropdowns for the override modal. Always enabled; if Lidarr
// is disabled the modal is unreachable from this page anyway (the queue
// would be empty), and on this page we'd rather see the dropdown options
// than block the modal on a separate gate.
const profilesStore = createQualityProfilesQuery(true);
const profiles = $derived($profilesStore);
const foldersStore = createRootFoldersQuery(true);
const folders = $derived($foldersStore);
// Modal state. `overrideOpen` and `rejectOpen` carry the request id when
// open; null otherwise. The current row is looked up by id at render time.
let overrideOpen = $state<string | null>(null);
let qualityOverride = $state<number | ''>('');
let rootOverride = $state<string>('');
let rejectOpen = $state<string | null>(null);
let rejectNotes = $state<string>('');
// Toast surface — single line, fixed bottom-right, auto-clears after 5s.
// No third-party lib for v1; this is enough to surface the lidarr-unreachable
// error per spec §7 without dragging in a toast framework.
let toast = $state<string | null>(null);
let toastTimer: ReturnType<typeof setTimeout> | null = null;
function showToast(msg: string) {
if (toastTimer) clearTimeout(toastTimer);
toast = msg;
toastTimer = setTimeout(() => {
toast = null;
}, 5000);
}
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 ?? '—';
}
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}`;
}
// The id is a UUID — first 8 chars is plenty to disambiguate without a real
// username, and matches what the polish pass will replace with the real
// username surface.
function rowMeta(r: LidarrRequest): string {
const when = new Date(r.requested_at).toLocaleDateString();
const userBit = `by user ${r.user_id.slice(0, 8)}`;
return `${userBit} · ${when}`;
}
async function invalidate() {
// Invalidate every status bucket so e.g. an approve flips a row from the
// pending list to the approved list once the operator switches tabs.
await client.invalidateQueries({ queryKey: ['adminRequests'] });
}
async function onApprove(
r: LidarrRequest,
overrides?: { quality_profile_id?: number; root_folder_path?: string }
) {
try {
if (overrides && (overrides.quality_profile_id !== undefined || overrides.root_folder_path !== undefined)) {
await approveRequest(r.id, overrides);
} else {
await approveRequest(r.id);
}
await invalidate();
} catch (e) {
const code = (e as { code?: string }).code ?? 'unknown';
showToast(copyForCode(code));
}
}
function openOverride(r: LidarrRequest) {
overrideOpen = r.id;
// Reset to "Use defaults" each time — operators should consciously opt in
// to an override, not inherit the previous row's choice.
qualityOverride = '';
rootOverride = '';
}
function cancelOverride() {
overrideOpen = null;
}
async function confirmOverride(r: LidarrRequest) {
const overrides: { quality_profile_id?: number; root_folder_path?: string } = {};
if (qualityOverride !== '') overrides.quality_profile_id = Number(qualityOverride);
if (rootOverride !== '') overrides.root_folder_path = rootOverride;
overrideOpen = null;
await onApprove(r, overrides);
}
function openReject(r: LidarrRequest) {
rejectOpen = r.id;
rejectNotes = '';
}
function cancelReject() {
rejectOpen = null;
}
async function confirmReject(r: LidarrRequest) {
const notes = rejectNotes.trim();
rejectOpen = null;
try {
await rejectRequest(r.id, notes ? notes : undefined);
await invalidate();
} catch (e) {
const code = (e as { code?: string }).code ?? 'unknown';
showToast(copyForCode(code));
}
}
// Modal lookups: if the active tab has been refetched while a modal is
// open, the row may no longer be in `rows`. Guard against that by treating
// missing-row as closed.
const overrideRow = $derived(
overrideOpen ? rows.find((r) => r.id === overrideOpen) ?? null : null
);
const rejectRow = $derived(
rejectOpen ? rows.find((r) => r.id === rejectOpen) ?? null : null
);
</script>
<div class="space-y-6">
<header class="space-y-1">
<h2 class="font-display text-2xl font-medium text-text-primary">Requests</h2>
<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"
data-testid="active-tab-count"
>
{rows.length}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</nav>
{#if query.isPending}
<p class="text-text-secondary">Reading the queue…</p>
{:else if query.isError}
<p class="text-error">Couldn't load requests.</p>
{:else if rows.length === 0}
<p class="text-text-secondary">Nothing here.</p>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each rows as r (r.id)}
{@const Icon = fallbackIcon(r.kind)}
<li class="flex items-start gap-4 p-3" data-testid="admin-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" 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.kind === 'track' && r.album_title}
<div class="text-sm text-text-secondary" data-testid="track-disclosure">
Approving will add the album <em class="font-medium text-text-primary">{r.album_title}</em>.
</div>
{/if}
{#if r.notes}
<div class="text-sm text-text-secondary">{r.notes}</div>
{/if}
</div>
{#if r.status === 'pending'}
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label={`Override ${rowAccessibleName(r)}`}
class="inline-flex items-center gap-1 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover"
onclick={() => openOverride(r)}
>
<SlidersHorizontal size={14} strokeWidth={1.5} />
Override
</button>
<button
type="button"
aria-label={`Approve ${rowAccessibleName(r)}`}
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary"
onclick={() => onApprove(r)}
>
<Check size={14} strokeWidth={2} />
Approve
</button>
<button
type="button"
aria-label={`Reject ${rowAccessibleName(r)}`}
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
onclick={() => openReject(r)}
>
<X size={14} strokeWidth={2} />
Reject
</button>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
{#if overrideRow}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.5);"
onclick={cancelOverride}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="override-title"
class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg"
onclick={(e) => e.stopPropagation()}
tabindex="-1"
>
<h3 id="override-title" class="font-display text-lg font-medium text-text-primary">
Approve with override
</h3>
<p class="mt-2 text-sm text-text-secondary">
Leave fields blank to use the saved defaults.
</p>
<label class="mt-4 block">
<span class="block text-sm text-text-secondary">Quality profile</span>
<select
bind:value={qualityOverride}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="">Use default</option>
{#each profiles.data ?? [] as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</label>
<label class="mt-3 block">
<span class="block text-sm text-text-secondary">Root folder</span>
<select
bind:value={rootOverride}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="">Use default</option>
{#each folders.data ?? [] as f (f.path)}
<option value={f.path}>{f.path}{f.accessible ? '' : ' (not accessible)'}</option>
{/each}
</select>
</label>
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
onclick={cancelOverride}
>
Cancel
</button>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary"
onclick={() => confirmOverride(overrideRow!)}
>
<Check size={14} strokeWidth={2} />
Approve with override
</button>
</div>
</div>
</div>
{/if}
{#if rejectRow}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.5);"
onclick={cancelReject}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="reject-title"
class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg"
onclick={(e) => e.stopPropagation()}
tabindex="-1"
>
<h3 id="reject-title" class="font-display text-lg font-medium text-text-primary">
Reject request?
</h3>
<p class="mt-2 text-sm text-text-secondary">
Notes are shown to the requester.
</p>
<label class="mt-3 block">
<span class="block text-sm text-text-secondary">Notes</span>
<textarea
bind:value={rejectNotes}
rows="3"
placeholder="Optional — why this is being set aside"
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
></textarea>
</label>
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
onclick={cancelReject}
>
Cancel
</button>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
onclick={() => confirmReject(rejectRow!)}
>
<X size={14} strokeWidth={2} />
Confirm reject
</button>
</div>
</div>
</div>
{/if}
{#if toast}
<div
role="status"
aria-live="polite"
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border border-border bg-surface px-4 py-3 text-sm text-text-primary shadow-lg"
data-testid="toast"
>
{toast}
</div>
{/if}
<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);
text-transform: capitalize;
}
</style>