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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
10 KiB
Svelte
281 lines
10 KiB
Svelte
<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>
|