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

339 lines
13 KiB
Svelte

<script lang="ts">
import { Disc3, Album, Music2, Check, X, RotateCcw, Trash2, Cloud, ChevronRight } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createAdminRequestsQuery,
createLidarrConfigQuery,
createAdminQuarantineQuery,
approveRequest,
rejectRequest,
resolveQuarantine,
deleteQuarantineFile,
deleteQuarantineViaLidarr
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import { copyForCode } from '$lib/api/error-copy';
import type {
AdminQuarantineRow,
LidarrRequest,
LidarrRequestKind,
LidarrQuarantineReason
} from '$lib/api/types';
// Top-of-admin overview. Shows the connection card + the most recent
// 5 of each actionable queue (requests, quarantine) with inline
// handlers for the common case. The dedicated pages keep the full
// modal flows (override, typed-confirm); the overview offers a faster
// path for the common-case actions, with a two-click confirm on
// destructive quarantine actions to avoid modal duplication.
const PREVIEW_LIMIT = 5;
const client = useQueryClient();
const requestsStore = createAdminRequestsQuery('pending');
const requests = $derived($requestsStore);
const configStore = createLidarrConfigQuery();
const config = $derived($configStore);
const quarantineStore = createAdminQuarantineQuery();
const quarantine = $derived($quarantineStore);
const pendingCount = $derived(requests.data?.length ?? 0);
const quarantineCount = $derived(quarantine.data?.length ?? 0);
const lidarrConnected = $derived(config.data?.enabled === true);
const requestPreview = $derived(
(requests.data ?? []).slice(0, PREVIEW_LIMIT)
);
const quarantinePreview = $derived(
(quarantine.data ?? []).slice(0, PREVIEW_LIMIT)
);
// ---- Toast (shared) ----
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);
}
// ---- Two-click destructive confirm for quarantine actions ----
// Map key is `${trackId}:${actionKey}`. First click sets a key; second
// click within 3s fires the action; otherwise the key clears.
let confirmingKey = $state<string | null>(null);
let confirmTimer: ReturnType<typeof setTimeout> | null = null;
function confirmKey(trackID: string, action: 'delete-file' | 'delete-lidarr'): string {
return `${trackID}:${action}`;
}
function armConfirm(trackID: string, action: 'delete-file' | 'delete-lidarr') {
confirmingKey = confirmKey(trackID, action);
if (confirmTimer) clearTimeout(confirmTimer);
confirmTimer = setTimeout(() => { confirmingKey = null; }, 3000);
}
function clearConfirm() {
confirmingKey = null;
if (confirmTimer) clearTimeout(confirmTimer);
}
// ---- Helpers ----
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 rowSubtitle(r: LidarrRequest): string {
if (r.kind === 'artist') return 'Artist';
if (r.kind === 'album') return `Album · ${r.artist_name}`;
return `Track · ${r.artist_name}`;
}
const REASON_LABELS: Record<LidarrQuarantineReason, string> = {
bad_rip: 'Bad rip',
wrong_file: 'Wrong file',
wrong_tags: 'Wrong tags',
duplicate: 'Duplicate',
other: 'Other'
};
// ---- Action handlers ----
async function invalidateRequests() {
await Promise.all([
client.invalidateQueries({ queryKey: qk.adminRequests('pending') }),
client.invalidateQueries({ queryKey: qk.adminRequests('approved') }),
client.invalidateQueries({ queryKey: qk.adminRequests('rejected') }),
client.invalidateQueries({ queryKey: qk.adminRequests('completed') })
]);
}
async function onApprove(r: LidarrRequest) {
try {
await approveRequest(r.id);
await invalidateRequests();
} catch (e) {
showToast(copyForCode((e as { code?: string }).code));
}
}
async function onReject(r: LidarrRequest) {
try {
await rejectRequest(r.id);
await invalidateRequests();
} catch (e) {
showToast(copyForCode((e as { code?: string }).code));
}
}
async function invalidateQuarantine() {
await Promise.all([
client.invalidateQueries({ queryKey: qk.adminQuarantine() }),
client.invalidateQueries({ queryKey: qk.adminQuarantineActions() })
]);
}
async function onResolve(row: AdminQuarantineRow) {
try {
await resolveQuarantine(row.track_id);
await invalidateQuarantine();
} catch (e) {
showToast(copyForCode((e as { code?: string }).code));
}
}
async function onDeleteFile(row: AdminQuarantineRow) {
const key = confirmKey(row.track_id, 'delete-file');
if (confirmingKey !== key) {
armConfirm(row.track_id, 'delete-file');
return;
}
clearConfirm();
try {
await deleteQuarantineFile(row.track_id);
await invalidateQuarantine();
} catch (e) {
showToast(copyForCode((e as { code?: string }).code));
}
}
async function onDeleteLidarr(row: AdminQuarantineRow) {
const key = confirmKey(row.track_id, 'delete-lidarr');
if (confirmingKey !== key) {
armConfirm(row.track_id, 'delete-lidarr');
return;
}
clearConfirm();
try {
await deleteQuarantineViaLidarr(row.track_id);
await invalidateQuarantine();
} catch (e) {
showToast(copyForCode((e as { code?: string }).code));
}
}
</script>
<div class="space-y-8">
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Overview</h2>
</header>
<!-- Top stats -->
<div class="grid gap-4 sm:grid-cols-3">
<a
href="/admin/requests"
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
>
<div class="text-sm text-text-secondary">Pending requests</div>
<div class="font-display mt-1 text-3xl font-medium text-text-primary">{pendingCount}</div>
</a>
<a
href="/admin/quarantine"
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
>
<div class="text-sm text-text-secondary">Quarantined tracks</div>
<div class="font-display mt-1 text-3xl font-medium text-text-primary">{quarantineCount}</div>
</a>
<a
href="/admin/integrations"
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
>
<div class="text-sm text-text-secondary">Lidarr</div>
<div class="font-display mt-1 text-2xl font-medium text-text-primary">
{lidarrConnected ? 'Connected' : 'Unset'}
</div>
</a>
</div>
<!-- Pending requests preview -->
<section class="space-y-3">
<header class="flex items-baseline justify-between">
<h3 class="font-display text-xl font-medium text-text-primary">Pending requests</h3>
{#if pendingCount > PREVIEW_LIMIT}
<a href="/admin/requests" class="text-sm text-accent hover:underline">View all {pendingCount}</a>
{/if}
</header>
{#if requests.isPending}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if requestPreview.length === 0}
<p class="text-sm text-text-secondary">No pending requests.</p>
{:else}
<ul class="divide-y divide-border rounded-xl border border-border bg-surface">
{#each requestPreview as r (r.id)}
{@const Icon = fallbackIcon(r.kind)}
<li class="flex items-center gap-3 p-3">
<Icon size={20} strokeWidth={1.5} class="shrink-0 text-text-muted" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-text-primary">{rowTitle(r)}</div>
<div class="truncate text-xs text-text-secondary">{rowSubtitle(r)}</div>
</div>
<button
type="button"
onclick={() => onApprove(r)}
aria-label={`Approve ${rowTitle(r)}`}
class="flex h-8 items-center gap-1 rounded-md bg-action-primary px-3 text-sm text-text-primary hover:opacity-90"
>
<Check size={14} strokeWidth={1.5} /> Approve
</button>
<button
type="button"
onclick={() => onReject(r)}
aria-label={`Reject ${rowTitle(r)}`}
class="flex h-8 items-center gap-1 rounded-md border border-border px-3 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover"
>
<X size={14} strokeWidth={1.5} /> Reject
</button>
</li>
{/each}
</ul>
{/if}
</section>
<!-- Quarantine preview -->
<section class="space-y-3">
<header class="flex items-baseline justify-between">
<h3 class="font-display text-xl font-medium text-text-primary">Quarantine</h3>
{#if quarantineCount > PREVIEW_LIMIT}
<a href="/admin/quarantine" class="text-sm text-accent hover:underline">View all {quarantineCount}</a>
{/if}
</header>
{#if quarantine.isPending}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if quarantinePreview.length === 0}
<p class="text-sm text-text-secondary">No quarantined tracks.</p>
{:else}
<ul class="divide-y divide-border rounded-xl border border-border bg-surface">
{#each quarantinePreview as row (row.track_id)}
{@const fileKey = confirmKey(row.track_id, 'delete-file')}
{@const lidarrKey = confirmKey(row.track_id, 'delete-lidarr')}
{@const fileArmed = confirmingKey === fileKey}
{@const lidarrArmed = confirmingKey === lidarrKey}
<li class="flex items-center gap-3 p-3">
<Music2 size={20} strokeWidth={1.5} class="shrink-0 text-text-muted" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-text-primary">{row.track_title}</div>
<div class="truncate text-xs text-text-secondary">
{row.artist_name} · {row.album_title}
· {row.report_count} {row.report_count === 1 ? 'report' : 'reports'}
{#if row.reports[0]}
· {REASON_LABELS[row.reports[0].reason] ?? row.reports[0].reason}
{/if}
</div>
</div>
<button
type="button"
onclick={() => onResolve(row)}
aria-label={`Resolve ${row.track_title}`}
class="flex h-8 items-center gap-1 rounded-md border border-border px-3 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover"
>
<RotateCcw size={14} strokeWidth={1.5} /> Resolve
</button>
<button
type="button"
onclick={() => onDeleteFile(row)}
aria-label={fileArmed ? `Confirm delete file ${row.track_title}` : `Delete file ${row.track_title}`}
class="flex h-8 items-center gap-1 rounded-md px-3 text-sm transition-colors {fileArmed
? 'bg-action-secondary text-text-primary'
: 'border border-border text-text-secondary hover:text-text-primary hover:bg-surface-hover'}"
>
<Trash2 size={14} strokeWidth={1.5} /> {fileArmed ? 'Confirm?' : 'Delete file'}
</button>
<button
type="button"
onclick={() => onDeleteLidarr(row)}
aria-label={lidarrArmed ? `Confirm delete via Lidarr ${row.track_title}` : `Delete via Lidarr ${row.track_title}`}
class="flex h-8 items-center gap-1 rounded-md px-3 text-sm transition-colors {lidarrArmed
? 'bg-action-destructive text-text-primary'
: 'border border-border text-text-secondary hover:text-text-primary hover:bg-surface-hover'}"
>
<Cloud size={14} strokeWidth={1.5} /> {lidarrArmed ? 'Confirm?' : 'Delete via Lidarr'}
</button>
<a
href="/admin/quarantine"
aria-label="Open in quarantine queue"
class="text-text-secondary hover:text-text-primary"
>
<ChevronRight size={16} strokeWidth={1.5} />
</a>
</li>
{/each}
</ul>
{/if}
</section>
</div>
{#if toast}
<div
role="alert"
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border border-border bg-surface px-4 py-2 text-sm text-text-primary shadow"
>
{toast}
</div>
{/if}