Files
minstrel/web/src/routes/admin/requests/+page.svelte
T
bvandeusenandClaude Opus 5.5 453a977e48
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m10s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
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
refactor: the admin and user Requests pages draw a request with one RequestRow, and the kind chip is one KindPill there and on Hidden tracks (#5651)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:20:53 -04:00

281 lines
10 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.js';
import { Check, X, SlidersHorizontal } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
import { createAdminRequestsQuery, approveRequest, rejectRequest } from '#lib/api/admin.js';
import { createReleaseProfilesQuery } from '#lib/api/wanted.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import RequestRow, { requestName } from '#lib/components/RequestRow.svelte';
import Modal from '#lib/components/Modal.svelte';
import type { MusicRequest, RequestStatus } from '#lib/api/types.js';
// Approval queue. Tabs filter by status; rows expose Approve/Reject, and an
// artist request can be approved on a chosen release-type profile. Approving
// puts the request on the wanted list (M502 #5520): an album or track wants
// its album, an artist is followed.
const client = useQueryClient();
// The four tab filters. "Pending" is default — that's the actionable bucket;
// the rest are review-only views.
const tabs: { status: RequestStatus; label: string }[] = [
{ status: 'pending', label: 'Pending' },
{ status: 'approved', label: 'Approved' },
{ status: 'completed', label: 'Completed' },
{ status: 'rejected', label: 'Rejected' }
];
let activeStatus = $state<RequestStatus>('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 MusicRequest[]);
// Release-type profiles for the "Approve on a profile" modal: which kinds of
// release following the artist will want.
const profilesStore = createReleaseProfilesQuery();
const profiles = $derived($profilesStore.data?.profiles ?? []);
// Modal state. `profileOpen` and `rejectOpen` carry the request id when
// open; null otherwise. The current row is looked up by id at render time.
let profileOpen = $state<string | null>(null);
let profileChoice = $state<string>('');
let rejectOpen = $state<string | null>(null);
let rejectNotes = $state<string>('');
// 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: MusicRequest): 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'] });
await client.invalidateQueries({ queryKey: ['adminWants'] });
}
async function onApprove(r: MusicRequest, profileId?: string) {
try {
await approveRequest(r.id, profileId);
await invalidate();
} catch (e) {
pushToast(errMessage(e), 'error');
}
}
function openProfile(r: MusicRequest) {
profileOpen = r.id;
// Start from the default each time, not the previous row's choice.
profileChoice = profiles.find((p) => p.is_default)?.id ?? '';
}
function cancelProfile() {
profileOpen = null;
}
async function confirmProfile(r: MusicRequest) {
const choice = profileChoice;
profileOpen = null;
await onApprove(r, choice || undefined);
}
function openReject(r: MusicRequest) {
rejectOpen = r.id;
rejectNotes = '';
}
function cancelReject() {
rejectOpen = null;
}
async function confirmReject(r: MusicRequest) {
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 profileRow = $derived(
profileOpen ? rows.find((r) => r.id === profileOpen) ?? 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. An approved album or track goes on the
<a href="/collection/wanted" class="text-accent-fg hover:underline">wanted list</a>; an approved
artist is followed, wanting what they've released and what they release next.
</p>
</header>
<TabStrip
ariaLabel="Request status filters"
bind:value={activeStatus}
items={tabs.map((tab) => ({
id: tab.status,
label: tab.label,
count: activeStatus === tab.status ? rows.length : undefined
}))}
/>
{#if query.isPending}
<p class="text-text-secondary">Reading the queue…</p>
{:else if query.isError}
<p class="text-sm text-error-fg">
Couldn't load the requests.
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
</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)}
<RequestRow request={r} meta={rowMeta(r)} testid="admin-request-row">
{#snippet details()}
{#if r.kind === 'track' && r.album_title}
<div class="text-sm text-text-secondary" data-testid="track-disclosure">
Approving wants 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}
{/snippet}
{#snippet actions()}
{#if r.status === 'pending'}
{@const primary: RowAction = {
icon: Check,
label: 'Approve',
ariaLabel: `Approve ${requestName(r)}`,
onclick: () => onApprove(r)
}}
{@const secondary: RowAction[] = [
...(r.kind === 'artist'
? [
{
icon: SlidersHorizontal,
label: 'Approve on a profile',
ariaLabel: `Approve ${requestName(r)} on a profile`,
onclick: () => openProfile(r)
}
]
: []),
{
icon: X,
label: 'Reject',
ariaLabel: `Reject ${requestName(r)}`,
onclick: () => openReject(r),
danger: true
}
]}
<RowActionsMenu {primary} {secondary} />
{/if}
{/snippet}
</RequestRow>
{/each}
</ul>
{/if}
</div>
<Modal
title="Approve on a profile"
open={profileRow !== null}
onClose={cancelProfile}
>
<p class="-mt-2 text-sm text-text-secondary">
The profile says which kinds of release following
{profileRow?.artist_name ?? 'this artist'} wants. Profiles are edited under <a href="/collection/following" class="underline hover:no-underline">Collection › Following</a>.
</p>
<label class="mt-4 block">
<span class="block text-sm text-text-secondary">Release-type profile</span>
<select
bind:value={profileChoice}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
>
{#each profiles as p (p.id)}
<option value={p.id}>{p.name}{p.is_default ? ' (default)' : ''}</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={cancelProfile}
>
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={() => profileRow && confirmProfile(profileRow)}
>
<Check size={14} strokeWidth={2} />
Approve and follow
</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-hidden 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>