f0bfac2555
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
461 lines
16 KiB
Svelte
461 lines
16 KiB
Svelte
<script lang="ts">
|
||
import { Music2, RotateCcw, Trash2, Cloud, Play, ChevronRight } from 'lucide-svelte';
|
||
import { useQueryClient } from '@tanstack/svelte-query';
|
||
import {
|
||
createAdminQuarantineQuery,
|
||
resolveQuarantine,
|
||
deleteQuarantineFile,
|
||
deleteQuarantineViaLidarr
|
||
} from '$lib/api/admin';
|
||
import { qk } from '$lib/api/queries';
|
||
import { copyForCode } from '$lib/api/error-copy';
|
||
import { playRadio } from '$lib/player/store.svelte';
|
||
import type { AdminQuarantineRow, LidarrQuarantineReason } from '$lib/api/types';
|
||
|
||
// Aggregated triage queue. One row per track, with per-row resolution
|
||
// actions: Resolve (clears reports), Delete file (Bronze; modal-confirm),
|
||
// Delete via Lidarr (Oxblood; typed-confirm "DELETE"). Mirrors
|
||
// /admin/requests for shape — toast helper, copyForCode(), invalidateQueries.
|
||
|
||
const client = useQueryClient();
|
||
|
||
const queryStore = createAdminQuarantineQuery();
|
||
const query = $derived($queryStore);
|
||
const rows = $derived((query.data ?? []) as AdminQuarantineRow[]);
|
||
|
||
// Sum of reports across all rows; rendered as the accent-tint pill in the
|
||
// header. Count of *reports*, not unique tracks — that's the spec.
|
||
const totalReports = $derived(
|
||
rows.reduce((sum, r) => sum + r.report_count, 0)
|
||
);
|
||
|
||
const REASON_LABELS: Record<LidarrQuarantineReason, string> = {
|
||
bad_rip: 'Bad rip',
|
||
wrong_file: 'Wrong file',
|
||
wrong_tags: 'Wrong tags',
|
||
duplicate: 'Duplicate',
|
||
other: 'Other'
|
||
};
|
||
|
||
function relativeTime(iso: string): string {
|
||
const ms = Date.now() - new Date(iso).getTime();
|
||
const days = Math.floor(ms / (24 * 3_600_000));
|
||
if (days >= 1) return `${days}d ago`;
|
||
const hours = Math.floor(ms / 3_600_000);
|
||
if (hours >= 1) return `${hours}h ago`;
|
||
const minutes = Math.floor(ms / 60_000);
|
||
if (minutes >= 1) return `${minutes}m ago`;
|
||
return 'just now';
|
||
}
|
||
|
||
// Row-level expand state: tracks which rows have their per-user report
|
||
// details revealed. Keyed by track_id.
|
||
let expanded = $state<Record<string, boolean>>({});
|
||
|
||
function toggleExpanded(trackID: string) {
|
||
expanded[trackID] = !expanded[trackID];
|
||
}
|
||
|
||
// Modal state. The modal-confirm (delete file) and the typed-confirm (delete
|
||
// via Lidarr) both store the active row's track_id; null when closed.
|
||
let deleteFileOpen = $state<string | null>(null);
|
||
|
||
let deleteLidarrOpen = $state<string | null>(null);
|
||
let deleteLidarrInput = $state<string>('');
|
||
// Inline error inside the typed-confirm modal — the modal stays open with
|
||
// feedback if Lidarr can't be reached or the album lookup fails.
|
||
let deleteLidarrError = $state<string | null>(null);
|
||
|
||
// Toast surface — same pattern as /admin/requests for the lidarr-unreachable
|
||
// and other error codes from /resolve and /delete-file.
|
||
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);
|
||
}
|
||
|
||
async function invalidate() {
|
||
await client.invalidateQueries({ queryKey: qk.adminQuarantine() });
|
||
}
|
||
|
||
async function onResolve(r: AdminQuarantineRow) {
|
||
try {
|
||
await resolveQuarantine(r.track_id);
|
||
await invalidate();
|
||
} catch (e) {
|
||
const code = (e as { code?: string }).code ?? 'unknown';
|
||
showToast(copyForCode(code));
|
||
}
|
||
}
|
||
|
||
function openDeleteFile(r: AdminQuarantineRow) {
|
||
deleteFileOpen = r.track_id;
|
||
}
|
||
|
||
function cancelDeleteFile() {
|
||
deleteFileOpen = null;
|
||
}
|
||
|
||
async function confirmDeleteFile(r: AdminQuarantineRow) {
|
||
deleteFileOpen = null;
|
||
try {
|
||
await deleteQuarantineFile(r.track_id);
|
||
await invalidate();
|
||
} catch (e) {
|
||
const code = (e as { code?: string }).code ?? 'unknown';
|
||
showToast(copyForCode(code));
|
||
}
|
||
}
|
||
|
||
function openDeleteLidarr(r: AdminQuarantineRow) {
|
||
deleteLidarrOpen = r.track_id;
|
||
deleteLidarrInput = '';
|
||
deleteLidarrError = null;
|
||
}
|
||
|
||
function cancelDeleteLidarr() {
|
||
deleteLidarrOpen = null;
|
||
deleteLidarrInput = '';
|
||
deleteLidarrError = null;
|
||
}
|
||
|
||
async function confirmDeleteLidarr(r: AdminQuarantineRow) {
|
||
// Trimmed equality matches the M5a Disconnect typed-confirm pattern.
|
||
if (deleteLidarrInput.trim() !== 'DELETE') return;
|
||
try {
|
||
await deleteQuarantineViaLidarr(r.track_id);
|
||
deleteLidarrOpen = null;
|
||
deleteLidarrInput = '';
|
||
deleteLidarrError = null;
|
||
await invalidate();
|
||
} catch (e) {
|
||
const code = (e as { code?: string }).code ?? 'unknown';
|
||
// Inline error keeps the modal open so the operator sees the failure
|
||
// alongside the album they were about to remove.
|
||
deleteLidarrError = copyForCode(code);
|
||
// Also surface as toast so it's visible after dismissing the modal.
|
||
showToast(copyForCode(code));
|
||
}
|
||
}
|
||
|
||
async function onPlay(r: AdminQuarantineRow) {
|
||
try {
|
||
await playRadio(r.track_id);
|
||
} catch {
|
||
// Silent on failure — the play button is a convenience, not a primary
|
||
// action. Toast surface is reserved for the destructive action errors.
|
||
}
|
||
}
|
||
|
||
// Modal lookups guard against the row disappearing if the query refetches
|
||
// while the modal is open.
|
||
const deleteFileRow = $derived(
|
||
deleteFileOpen ? rows.find((r) => r.track_id === deleteFileOpen) ?? null : null
|
||
);
|
||
const deleteLidarrRow = $derived(
|
||
deleteLidarrOpen ? rows.find((r) => r.track_id === deleteLidarrOpen) ?? null : null
|
||
);
|
||
</script>
|
||
|
||
<div class="space-y-6">
|
||
<header class="space-y-1">
|
||
<div class="flex items-center gap-2">
|
||
<h2 class="font-display text-2xl font-medium text-text-primary">Quarantine</h2>
|
||
{#if totalReports > 0}
|
||
<span
|
||
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
|
||
data-testid="report-count-pill"
|
||
>
|
||
{totalReports}
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
<p class="text-text-secondary">User-reported broken tracks waiting for triage.</p>
|
||
</header>
|
||
|
||
{#if query.isPending}
|
||
<p class="text-text-secondary">Reading the queue…</p>
|
||
{:else if query.isError}
|
||
<p class="text-error">Couldn't load the quarantine queue.</p>
|
||
{:else if rows.length === 0}
|
||
<p class="text-text-secondary">Nothing to triage right now.</p>
|
||
{:else}
|
||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||
{#each rows as r (r.track_id)}
|
||
{@const isExpanded = !!expanded[r.track_id]}
|
||
{@const lidarrDisabled = !r.lidarr_album_mbid}
|
||
<li
|
||
class="flex items-start gap-4 p-3"
|
||
data-testid="admin-quarantine-row"
|
||
data-track-id={r.track_id}
|
||
>
|
||
<!-- 56px album art with Slate fallback. -->
|
||
<div
|
||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover"
|
||
aria-hidden="true"
|
||
>
|
||
{#if r.album_id}
|
||
<img
|
||
src={`/api/albums/${r.album_id}/cover`}
|
||
alt=""
|
||
class="h-full w-full rounded-md object-cover"
|
||
loading="lazy"
|
||
/>
|
||
{:else}
|
||
<Music2 size={24} strokeWidth={1} class="text-text-muted" />
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="min-w-0 flex-1 space-y-1">
|
||
<div class="truncate text-base font-medium text-text-primary">
|
||
{r.track_title}
|
||
</div>
|
||
<div class="truncate text-sm text-text-secondary">
|
||
{r.artist_name} · {r.album_title} · {r.report_count} reports — latest {relativeTime(r.latest_at)}
|
||
</div>
|
||
|
||
<!-- Reason distribution pills -->
|
||
<div class="flex flex-wrap gap-1.5 pt-1">
|
||
{#each Object.entries(r.reason_counts) as [reason, count] (reason)}
|
||
<span
|
||
class="reason-pill"
|
||
data-testid="reason-pill"
|
||
>
|
||
{count}× {REASON_LABELS[reason as LidarrQuarantineReason] ?? reason}
|
||
</span>
|
||
{/each}
|
||
</div>
|
||
|
||
<!-- Reports details disclosure -->
|
||
<button
|
||
type="button"
|
||
class="mt-1 inline-flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary"
|
||
aria-expanded={isExpanded}
|
||
aria-label={`${isExpanded ? 'Hide' : 'Show'} reports for ${r.track_title}`}
|
||
onclick={() => toggleExpanded(r.track_id)}
|
||
>
|
||
<ChevronRight
|
||
size={12}
|
||
strokeWidth={1.5}
|
||
class="transition-transform {isExpanded ? 'rotate-90' : ''}"
|
||
/>
|
||
{isExpanded ? 'Hide reports' : `Show ${r.reports.length} reports`}
|
||
</button>
|
||
|
||
{#if isExpanded}
|
||
<ul
|
||
class="mt-2 space-y-1 rounded-md border border-border bg-background p-2 text-xs text-text-secondary"
|
||
data-testid="reports-details"
|
||
>
|
||
{#each r.reports as rep, idx (rep.user_id + ':' + rep.created_at + ':' + idx)}
|
||
<li>
|
||
<span class="text-text-primary">{rep.username}</span>
|
||
· {REASON_LABELS[rep.reason] ?? rep.reason}
|
||
{#if rep.notes}
|
||
· <span class="italic">{rep.notes}</span>
|
||
{/if}
|
||
· {relativeTime(rep.created_at)}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Action cluster -->
|
||
<div class="flex shrink-0 items-center gap-2">
|
||
<button
|
||
type="button"
|
||
aria-label={`Play ${r.track_title}`}
|
||
class="inline-flex items-center justify-center rounded-md p-2 text-accent hover:bg-accent-tint"
|
||
onclick={() => onPlay(r)}
|
||
>
|
||
<Play size={16} strokeWidth={2} />
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
aria-label={`Resolve ${r.track_title}`}
|
||
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={() => onResolve(r)}
|
||
>
|
||
<RotateCcw size={14} strokeWidth={1.5} />
|
||
Resolve
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
aria-label={`Delete file for ${r.track_title}`}
|
||
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg"
|
||
onclick={() => openDeleteFile(r)}
|
||
>
|
||
<Trash2 size={14} strokeWidth={2} />
|
||
Delete file
|
||
</button>
|
||
|
||
{#if lidarrDisabled}
|
||
<span
|
||
aria-label={`Delete via Lidarr disabled for ${r.track_title}`}
|
||
title="Local-only track — no Lidarr album to remove."
|
||
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg opacity-50 cursor-not-allowed"
|
||
data-testid="delete-lidarr-disabled"
|
||
>
|
||
<Trash2 size={14} strokeWidth={2} />
|
||
<Cloud size={14} strokeWidth={2} />
|
||
Delete via Lidarr
|
||
</span>
|
||
{:else}
|
||
<button
|
||
type="button"
|
||
aria-label={`Delete via Lidarr for ${r.track_title}`}
|
||
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg"
|
||
onclick={() => openDeleteLidarr(r)}
|
||
>
|
||
<Trash2 size={14} strokeWidth={2} />
|
||
<Cloud size={14} strokeWidth={2} />
|
||
Delete via Lidarr
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if deleteFileRow}
|
||
<!-- 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={cancelDeleteFile}
|
||
>
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="delete-file-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="delete-file-title" class="font-display text-lg font-medium text-text-primary">
|
||
Delete file?
|
||
</h3>
|
||
<p class="mt-2 text-sm text-text-secondary">
|
||
Remove <em class="font-medium text-text-primary">{deleteFileRow.track_title}</em>
|
||
from disk and clear {deleteFileRow.report_count} reports? Lidarr may auto-redownload depending on its monitor settings.
|
||
</p>
|
||
<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={cancelDeleteFile}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Confirm delete file"
|
||
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg"
|
||
onclick={() => confirmDeleteFile(deleteFileRow!)}
|
||
>
|
||
<Trash2 size={14} strokeWidth={2} />
|
||
Delete file
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if deleteLidarrRow}
|
||
<!-- 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={cancelDeleteLidarr}
|
||
>
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="delete-lidarr-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="delete-lidarr-title" class="font-display text-lg font-medium text-text-primary">
|
||
Delete via Lidarr?
|
||
</h3>
|
||
<p class="mt-2 text-sm text-text-secondary">
|
||
This will tell Lidarr to remove <em class="font-medium text-text-primary">{deleteLidarrRow.album_title}</em>
|
||
(artist <em class="font-medium text-text-primary">{deleteLidarrRow.artist_name}</em>)
|
||
and add it to the import-list exclusion. Affects all tracks on the album. Type <strong>DELETE</strong> to confirm.
|
||
</p>
|
||
<label class="mt-3 block">
|
||
<span class="block text-sm text-text-secondary">Type DELETE to confirm</span>
|
||
<input
|
||
type="text"
|
||
bind:value={deleteLidarrInput}
|
||
placeholder="DELETE"
|
||
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
|
||
aria-label="Type DELETE to confirm"
|
||
/>
|
||
</label>
|
||
{#if deleteLidarrError}
|
||
<p class="mt-3 text-sm text-error" data-testid="delete-lidarr-error">
|
||
{deleteLidarrError}
|
||
</p>
|
||
{/if}
|
||
<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={cancelDeleteLidarr}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Confirm delete via Lidarr"
|
||
disabled={deleteLidarrInput.trim() !== 'DELETE'}
|
||
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg disabled:cursor-not-allowed disabled:opacity-50"
|
||
onclick={() => confirmDeleteLidarr(deleteLidarrRow!)}
|
||
>
|
||
<Trash2 size={14} strokeWidth={2} />
|
||
<Cloud size={14} strokeWidth={2} />
|
||
Delete via Lidarr
|
||
</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>
|
||
.reason-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);
|
||
}
|
||
</style>
|