Files
minstrel/web/src/routes/admin/quarantine/+page.svelte
T
bvandeusenandClaude Opus 5.5 743b6f5eac
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m6s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m30s
release / android (push) Successful in 5m18s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(web): error text uses error-fg on every surface, not only on tints (#3150)
Raw error red fails AA as text even with no tint behind it: in dark mode
it measures 3.63:1 on obsidian, 3.23 on iron and 2.64 on slate, against
4.5. error-fg (the house formula, 50% toward parchment) measures 5.30 at
worst across both modes.

- All 19 text-error uses become text-error-fg: the "Couldn't load"
  messages on the admin pages, the integrations form errors, the flag
  popover, and the error toast's text. The toast keeps its error border,
  since a border is a graphic with a 3:1 floor.
- check-tint-contrast flags raw error text anywhere (text-error,
  class:text-error, color: var(--fs-error)) and leaves borders and
  outlines alone. Run against the files before the swap, it finds all 19.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:00:28 -04:00

398 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { pageTitle } from '$lib/branding';
import { Music2, RotateCcw, Trash2, Cloud, Play, ChevronRight } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import RowActionsMenu, { type RowAction } from '$lib/components/RowActionsMenu.svelte';
import {
createAdminQuarantineQuery,
resolveQuarantine,
deleteQuarantineFile,
deleteQuarantineViaLidarr
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
import { playRadio } from '$lib/player/store.svelte';
import { coverUrl } from '$lib/media/covers';
import Modal from '$lib/components/Modal.svelte';
import type { AdminQuarantineRow, LidarrQuarantineReason } from '$lib/api/types';
import { relativeTime } from '$lib/utils/relativeTime';
// 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'
};
// 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);
async function invalidate() {
await client.invalidateQueries({ queryKey: qk.adminQuarantine() });
}
async function onResolve(r: AdminQuarantineRow) {
try {
await resolveQuarantine(r.track_id);
await invalidate();
} catch (e) {
pushToast(errMessage(e), 'error');
}
}
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) {
pushToast(errMessage(e), 'error');
}
}
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 msg = errMessage(e);
// Inline error keeps the modal open so the operator sees the failure
// alongside the album they were about to remove.
deleteLidarrError = msg;
// Also surface as toast so it's visible after dismissing the modal.
pushToast(msg, 'error');
}
}
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>
<svelte:head><title>{pageTitle('Admin · Quarantine')}</title></svelte:head>
<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-fg"
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-fg">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}
{@const primary: RowAction = {
icon: RotateCcw,
label: 'Resolve',
ariaLabel: `Resolve ${r.track_title}`,
onclick: () => onResolve(r)
}}
{@const secondary: RowAction[] = [
{
icon: Play,
label: 'Play',
ariaLabel: `Play ${r.track_title}`,
onclick: () => onPlay(r)
},
{
icon: Trash2,
label: 'Delete file',
ariaLabel: `Delete file for ${r.track_title}`,
onclick: () => openDeleteFile(r),
danger: true
}
]}
<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={coverUrl(r.album_id)}
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">
<RowActionsMenu {primary} {secondary} />
{#if lidarrDisabled}
<span
aria-label={`Delete via Lidarr disabled for ${r.track_title}`}
title="Local-only track — no Lidarr album to remove."
class="hidden md: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="hidden md: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>
<Modal
title="Delete file?"
open={deleteFileRow !== null}
onClose={cancelDeleteFile}
>
{#if deleteFileRow}
<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>
{/if}
</Modal>
<Modal
title="Delete via Lidarr?"
open={deleteLidarrRow !== null}
onClose={cancelDeleteLidarr}
>
{#if deleteLidarrRow}
<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-fg" 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>
{/if}
</Modal>
<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-fg);
}
</style>