release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m26s
release / integration (push) Successful in 4m18s
release / android (push) Successful in 4m44s
release / Build signed APK (releases and dev) (push) Successful in 4m53s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
The raw accent fails AA as text on every dark surface, not only on its own tint: 3.04:1 on the page, 2.70 on iron, 2.21 on slate, against 4.5. accent-fg (the house formula, 45% toward parchment) measures 5.62 at worst across both modes. The operator chose the readable colour over the signature teal for text, on 2026-10-08. - 36 sites swap. They are 35 Tailwind uses: links, "Now playing", the ingest progress line, active shuffle/repeat, the liked heart, the app download icon and its hover. The last is the alphabet rail's pending spinner in CSS. Icons follow the text: as graphics they need only 3:1, and the raw accent misses even that on iron. - check-tint-contrast adds accent to TEXT_NEVER_RAW, so a new raw text-accent or color: var(--fs-accent) fails the web lane. Run against the files before the swap, it finds all 36. Borders, rings and fills keep the raw accent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
384 lines
14 KiB
Svelte
384 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { pageTitle } from '$lib/branding';
|
|
import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte';
|
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
import RowActionsMenu, { type RowAction } from '$lib/components/RowActionsMenu.svelte';
|
|
import {
|
|
createAdminRequestsQuery,
|
|
createQualityProfilesQuery,
|
|
createRootFoldersQuery,
|
|
approveRequest,
|
|
rejectRequest
|
|
} from '$lib/api/admin';
|
|
import { errMessage } from '$lib/api/errors';
|
|
import { pushToast } from '$lib/stores/toast.svelte';
|
|
import StatusPill from '$lib/components/StatusPill.svelte';
|
|
import Modal from '$lib/components/Modal.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>('');
|
|
|
|
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) {
|
|
pushToast(errMessage(e), 'error');
|
|
}
|
|
}
|
|
|
|
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) {
|
|
pushToast(errMessage(e), 'error');
|
|
}
|
|
}
|
|
|
|
// 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>
|
|
|
|
<svelte:head><title>{pageTitle('Admin · Requests')}</title></svelte:head>
|
|
|
|
<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-fg"
|
|
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-fg">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-fg" 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'}
|
|
{@const primary: RowAction = {
|
|
icon: Check,
|
|
label: 'Approve',
|
|
ariaLabel: `Approve ${rowAccessibleName(r)}`,
|
|
onclick: () => onApprove(r)
|
|
}}
|
|
{@const secondary: RowAction[] = [
|
|
{
|
|
icon: SlidersHorizontal,
|
|
label: 'Override',
|
|
ariaLabel: `Override ${rowAccessibleName(r)}`,
|
|
onclick: () => openOverride(r)
|
|
},
|
|
{
|
|
icon: X,
|
|
label: 'Reject',
|
|
ariaLabel: `Reject ${rowAccessibleName(r)}`,
|
|
onclick: () => openReject(r),
|
|
danger: true
|
|
}
|
|
]}
|
|
<div class="shrink-0">
|
|
<RowActionsMenu {primary} {secondary} />
|
|
</div>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
|
|
<Modal
|
|
title="Approve with override"
|
|
open={overrideRow !== null}
|
|
onClose={cancelOverride}
|
|
>
|
|
<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-action-fg"
|
|
onclick={() => overrideRow && confirmOverride(overrideRow)}
|
|
>
|
|
<Check size={14} strokeWidth={2} />
|
|
Approve with override
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title="Reject request?"
|
|
open={rejectRow !== null}
|
|
onClose={cancelReject}
|
|
>
|
|
<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-action-fg"
|
|
onclick={() => rejectRow && confirmReject(rejectRow)}
|
|
>
|
|
<X size={14} strokeWidth={2} />
|
|
Confirm reject
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
<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-fg);
|
|
text-transform: capitalize;
|
|
}
|
|
</style>
|