7426f6c718
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
557 lines
22 KiB
Svelte
557 lines
22 KiB
Svelte
<script lang="ts">
|
|
import { pageTitle } from '$lib/branding';
|
|
import { Disc3, Album, Music2, Check, X, RotateCcw, Trash2, Cloud, ChevronRight } from 'lucide-svelte';
|
|
import StageBadge from '$lib/components/StageBadge.svelte';
|
|
import { stageState } from './stage-state';
|
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
import {
|
|
createAdminRequestsQuery,
|
|
createLidarrConfigQuery,
|
|
createAdminQuarantineQuery,
|
|
createScanStatusQuery,
|
|
createCoverageQuery,
|
|
approveRequest,
|
|
rejectRequest,
|
|
resolveQuarantine,
|
|
deleteQuarantineFile,
|
|
deleteQuarantineViaLidarr,
|
|
refetchMissingCovers,
|
|
triggerScan,
|
|
researchMissingArt
|
|
} from '$lib/api/admin';
|
|
import { qk } from '$lib/api/queries';
|
|
import { errCode } from '$lib/api/errors';
|
|
import { pushToast } from '$lib/stores/toast.svelte';
|
|
import { runMutation } from '$lib/util/runMutation';
|
|
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)
|
|
);
|
|
|
|
// ---- 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) {
|
|
await runMutation(async () => {
|
|
await approveRequest(r.id);
|
|
await invalidateRequests();
|
|
});
|
|
}
|
|
async function onReject(r: LidarrRequest) {
|
|
await runMutation(async () => {
|
|
await rejectRequest(r.id);
|
|
await invalidateRequests();
|
|
});
|
|
}
|
|
|
|
async function invalidateQuarantine() {
|
|
await Promise.all([
|
|
client.invalidateQueries({ queryKey: qk.adminQuarantine() }),
|
|
client.invalidateQueries({ queryKey: qk.adminQuarantineActions() })
|
|
]);
|
|
}
|
|
|
|
async function onResolve(row: AdminQuarantineRow) {
|
|
await runMutation(async () => {
|
|
await resolveQuarantine(row.track_id);
|
|
await invalidateQuarantine();
|
|
});
|
|
}
|
|
async function onDeleteFile(row: AdminQuarantineRow) {
|
|
const key = confirmKey(row.track_id, 'delete-file');
|
|
if (confirmingKey !== key) {
|
|
armConfirm(row.track_id, 'delete-file');
|
|
return;
|
|
}
|
|
clearConfirm();
|
|
await runMutation(async () => {
|
|
await deleteQuarantineFile(row.track_id);
|
|
await invalidateQuarantine();
|
|
});
|
|
}
|
|
async function onDeleteLidarr(row: AdminQuarantineRow) {
|
|
const key = confirmKey(row.track_id, 'delete-lidarr');
|
|
if (confirmingKey !== key) {
|
|
armConfirm(row.track_id, 'delete-lidarr');
|
|
return;
|
|
}
|
|
clearConfirm();
|
|
await runMutation(async () => {
|
|
await deleteQuarantineViaLidarr(row.track_id);
|
|
await invalidateQuarantine();
|
|
});
|
|
}
|
|
|
|
// ---- Library scan ----
|
|
const scanStatusStore = $derived(createScanStatusQuery());
|
|
const scanStatusQ = $derived($scanStatusStore);
|
|
const scan = $derived(scanStatusQ.data);
|
|
const scanInFlight = $derived(scan?.in_flight === true);
|
|
|
|
// ---- Cover-art coverage gauge ----
|
|
const coverageStore = $derived(createCoverageQuery());
|
|
const coverageQ = $derived($coverageStore);
|
|
const coverage = $derived(coverageQ.data);
|
|
|
|
let triggering = $state(false);
|
|
let triggerResult = $state<string | null>(null);
|
|
|
|
async function onTriggerScan() {
|
|
if (triggering || scanInFlight) return;
|
|
triggering = true;
|
|
triggerResult = null;
|
|
try {
|
|
await triggerScan();
|
|
triggerResult = 'Scan started.';
|
|
await client.invalidateQueries({ queryKey: qk.scanStatus() });
|
|
await client.invalidateQueries({ queryKey: qk.coverage() });
|
|
} catch (e) {
|
|
const code = errCode(e);
|
|
const status = (e as { status?: number })?.status;
|
|
if (status === 409) {
|
|
triggerResult = 'A scan is already running.';
|
|
} else {
|
|
triggerResult = `Failed: ${code}`;
|
|
}
|
|
} finally {
|
|
triggering = false;
|
|
}
|
|
}
|
|
|
|
function formatTime(iso: string | null | undefined): string {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleString();
|
|
}
|
|
|
|
// ---- Cover art bulk refetch ----
|
|
let bulkBusy = $state(false);
|
|
let bulkResult = $state<string | null>(null);
|
|
|
|
// ---- Re-search missing art ----
|
|
let researchSaving = $state(false);
|
|
|
|
async function onResearchMissingArt() {
|
|
researchSaving = true;
|
|
try {
|
|
await researchMissingArt();
|
|
pushToast('All previously-failed art will be re-attempted on the next scan.');
|
|
} catch (e) {
|
|
pushToast(`Re-search failed: ${errCode(e)}`, 'error');
|
|
} finally {
|
|
researchSaving = false;
|
|
}
|
|
}
|
|
|
|
async function onBulkRefetch() {
|
|
if (bulkBusy) return;
|
|
bulkBusy = true;
|
|
bulkResult = null;
|
|
try {
|
|
const { started } = await refetchMissingCovers();
|
|
bulkResult = started
|
|
? 'Refetching all missing covers — local sources are fast, remote providers throttle per their limits.'
|
|
: 'Could not start the cover refetch.';
|
|
await client.invalidateQueries({ queryKey: qk.coverage() });
|
|
} catch (e) {
|
|
bulkResult = `Failed: ${errCode(e)}`;
|
|
} finally {
|
|
bulkBusy = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head>
|
|
|
|
<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-action-fg 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>
|
|
|
|
<!-- Library scan -->
|
|
<section class="rounded border border-border bg-surface p-4">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 class="font-display text-lg font-medium">Library scan</h2>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
Walks the music tree, fills missing MusicBrainz IDs from tags, and refetches album covers.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onclick={onTriggerScan}
|
|
disabled={triggering || scanInFlight}
|
|
class="rounded-md bg-action-primary px-4 py-2 text-sm font-medium text-action-fg hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{scanInFlight ? 'Scanning…' : triggering ? 'Starting…' : 'Run scan'}
|
|
</button>
|
|
</div>
|
|
|
|
{#if !scan || !scan.id}
|
|
<p class="mt-3 text-sm text-text-muted">No scan has run yet.</p>
|
|
{:else}
|
|
<div class="mt-3 grid gap-3 sm:grid-cols-4">
|
|
<!-- Library stage -->
|
|
<div class="rounded border border-border bg-bg p-3">
|
|
<div class="flex items-baseline justify-between gap-2">
|
|
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Library walk</div>
|
|
<StageBadge state={stageState(scan, 'library')} />
|
|
</div>
|
|
{#if scan.library}
|
|
<dl class="mt-2 space-y-0.5 text-sm">
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Scanned</dt><dd>{scan.library.scanned}</dd></div>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Added</dt><dd>{scan.library.added}</dd></div>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Updated</dt><dd>{scan.library.updated}</dd></div>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Skipped</dt><dd>{scan.library.skipped}</dd></div>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Errored</dt><dd>{scan.library.errored}</dd></div>
|
|
</dl>
|
|
{:else if stageState(scan, 'library') === 'pending'}
|
|
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- MBID backfill stage -->
|
|
<div class="rounded border border-border bg-bg p-3">
|
|
<div class="flex items-baseline justify-between gap-2">
|
|
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">MBID backfill</div>
|
|
<StageBadge state={stageState(scan, 'mbid_backfill')} />
|
|
</div>
|
|
{#if scan.mbid_backfill}
|
|
<dl class="mt-2 space-y-0.5 text-sm">
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.mbid_backfill.processed}</dd></div>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Healed</dt><dd>{scan.mbid_backfill.healed}</dd></div>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Skipped</dt><dd>{scan.mbid_backfill.skipped}</dd></div>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Duplicates</dt><dd>{scan.mbid_backfill.duplicates ?? 0}</dd></div>
|
|
</dl>
|
|
{:else if stageState(scan, 'mbid_backfill') === 'pending'}
|
|
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Cover enrichment stage -->
|
|
<div class="rounded border border-border bg-bg p-3">
|
|
<div class="flex items-baseline justify-between gap-2">
|
|
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Cover enrichment</div>
|
|
<StageBadge state={stageState(scan, 'cover_enrich')} />
|
|
</div>
|
|
{#if scan.cover_enrich}
|
|
<dl class="mt-2 space-y-0.5 text-sm">
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.cover_enrich.processed}</dd></div>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Succeeded</dt><dd>{scan.cover_enrich.succeeded}</dd></div>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Failed</dt><dd>{scan.cover_enrich.failed}</dd></div>
|
|
</dl>
|
|
{:else if stageState(scan, 'cover_enrich') === 'pending'}
|
|
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Artist art enrichment stage -->
|
|
<div class="rounded border border-border bg-bg p-3">
|
|
<div class="flex items-baseline justify-between gap-2">
|
|
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Artist art</div>
|
|
<StageBadge state={stageState(scan, 'artist_art_enrich')} />
|
|
</div>
|
|
{#if scan.artist_art_enrich}
|
|
<dl class="mt-2 space-y-0.5 text-sm">
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.artist_art_enrich.processed}</dd></div>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Succeeded</dt><dd>{scan.artist_art_enrich.succeeded}</dd></div>
|
|
<div class="flex justify-between"><dt class="text-text-secondary">Failed</dt><dd>{scan.artist_art_enrich.failed}</dd></div>
|
|
</dl>
|
|
{:else if stageState(scan, 'artist_art_enrich') === 'pending'}
|
|
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3 text-xs text-text-muted">
|
|
Started {formatTime(scan.started_at)}
|
|
{#if scan.finished_at}· Finished {formatTime(scan.finished_at)}{/if}
|
|
{#if scan.error_message}· <span class="text-oxblood">Error: {scan.error_message}</span>{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if triggerResult}
|
|
<p class="mt-2 text-sm">{triggerResult}</p>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Cover art bulk refetch -->
|
|
<section class="rounded border border-border bg-surface p-4">
|
|
<h2 class="font-display text-lg font-medium">Cover art</h2>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
Refetch cover art for albums that are missing one or where the previous attempt failed.
|
|
</p>
|
|
<div class="mt-3 flex flex-wrap items-center gap-4">
|
|
<button
|
|
type="button"
|
|
onclick={onBulkRefetch}
|
|
disabled={bulkBusy}
|
|
class="flex h-8 items-center gap-1 rounded-md bg-action-primary px-4 text-sm text-action-fg hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{bulkBusy ? 'Queueing…' : 'Refetch missing covers'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={onResearchMissingArt}
|
|
disabled={researchSaving}
|
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
|
>
|
|
{researchSaving ? 'Bumping…' : 'Re-search missing art'}
|
|
</button>
|
|
|
|
{#if coverage}
|
|
{#if coverage.total === 0}
|
|
<span class="text-sm text-text-muted">Library is empty.</span>
|
|
{:else}
|
|
<div class="flex items-center gap-3 text-sm">
|
|
<span>{coverage.with_art.toLocaleString()} with art</span>
|
|
<span class="text-text-muted">·</span>
|
|
<span
|
|
class={coverage.pending_no_mbid > 0 ? 'cursor-help' : ''}
|
|
title={coverage.pending_no_mbid > 0
|
|
? `${coverage.pending_no_mbid.toLocaleString()} of these have no MBID and won't be helped by another scan — fix tags or merge duplicate albums.`
|
|
: undefined}
|
|
>
|
|
{coverage.pending.toLocaleString()} pending
|
|
</span>
|
|
<span class="text-text-muted">·</span>
|
|
<span>{coverage.settled.toLocaleString()} settled</span>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{#if bulkResult}
|
|
<p class="mt-2 text-sm">{bulkResult}</p>
|
|
{/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-action-fg'
|
|
: '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-action-fg'
|
|
: '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>
|
|
|