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
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>
398 lines
14 KiB
Svelte
398 lines
14 KiB
Svelte
<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>
|