c3be0f3e6e
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>
442 lines
16 KiB
Svelte
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>
|