Send new artwork uploads through a trust-based review policy.

Require review for untrusted accounts, add admin artwork review APIs, and keep queued or auto-trusted publishes from counting as established history.
This commit is contained in:
2026-09-20 14:49:06 +02:00
parent 31c87e4977
commit 0f52803b05
14 changed files with 1175 additions and 70 deletions
@@ -1,18 +1,98 @@
import React, { useEffect, useState } from 'react'
function tagList(item) {
return Array.isArray(item?.tags) ? item.tags.filter(Boolean) : []
}
function categoryList(item) {
return Array.isArray(item?.categories) ? item.categories.filter(Boolean) : []
}
function creatorName(item) {
return item?.user?.username || item?.user?.name || 'Unknown creator'
}
function isMature(item) {
return Boolean(item?.nsfw || item?.is_mature)
}
function formatDate(value) {
if (!value) {
return null
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return null
}
return date.toLocaleString()
}
function PreviewImage({ src, alt, className }) {
const [failed, setFailed] = useState(false)
useEffect(() => {
setFailed(false)
}, [src])
if (!src || failed) {
return (
<div className={`flex items-center justify-center bg-white/5 text-xs text-white/40 ${className}`}>
No preview
</div>
)
}
return (
<img
src={src}
alt={alt}
className={className}
onError={() => setFailed(true)}
/>
)
}
function Badge({ children, tone = 'slate' }) {
const tones = {
slate: 'border-white/10 bg-white/5 text-white/70',
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
rose: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
}
return (
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${tones[tone] || tones.slate}`}>
{children}
</span>
)
}
export default function AdminUploadQueue() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [notes, setNotes] = useState({})
const [selected, setSelected] = useState(null)
const [busyId, setBusyId] = useState(null)
const loadPending = async () => {
setLoading(true)
setError('')
try {
const response = await window.axios.get('/api/admin/uploads/pending')
setItems(Array.isArray(response?.data?.data) ? response.data.data : [])
const [legacyResponse, artworkResponse] = await Promise.all([
window.axios.get('/api/admin/uploads/pending'),
window.axios.get('/api/admin/artwork-review/pending'),
])
const legacyItems = Array.isArray(legacyResponse?.data?.data)
? legacyResponse.data.data.map((item) => ({ ...item, queue: 'upload' }))
: []
const artworkItems = Array.isArray(artworkResponse?.data?.data)
? artworkResponse.data.data.map((item) => ({ ...item, queue: 'artwork' }))
: []
setItems([...artworkItems, ...legacyItems])
} catch (loadError) {
setError(loadError?.response?.data?.message || 'Failed to load moderation queue.')
} finally {
@@ -24,18 +104,130 @@ export default function AdminUploadQueue() {
loadPending()
}, [])
const moderate = async (id, action) => {
useEffect(() => {
if (!selected) {
return undefined
}
const onKeyDown = (event) => {
if (event.key === 'Escape') {
setSelected(null)
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [selected])
const moderate = async (item, action) => {
const id = item?.id
if (!id || busyId) {
return
}
setBusyId(id)
try {
const payload = { note: String(notes[id] || '') }
await window.axios.post(`/api/admin/uploads/${id}/${action}`, payload)
setItems((prev) => prev.filter((item) => item.id !== id))
const base = item.queue === 'artwork' ? '/api/admin/artwork-review' : '/api/admin/uploads'
await window.axios.post(`${base}/${id}/${action}`, payload)
setItems((prev) => prev.filter((candidate) => !(candidate.id === id && candidate.queue === item.queue)))
setSelected((current) => (current?.id === id && current?.queue === item.queue ? null : current))
} catch (moderateError) {
setError(moderateError?.response?.data?.message || `Failed to ${action} upload.`)
} finally {
setBusyId(null)
}
}
const renderMeta = (item, { compact = false } = {}) => {
const tags = tagList(item)
const categories = categoryList(item)
const createdAt = formatDate(item.created_at)
return (
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium text-white">{item.title || '(untitled upload)'}</div>
<Badge tone={item.queue === 'artwork' ? 'emerald' : 'slate'}>
{item.queue === 'artwork' ? 'Artwork' : 'Draft upload'}
</Badge>
{isMature(item) ? <Badge tone="rose">NSFW</Badge> : null}
</div>
<div className="mt-1 text-xs text-white/65">
{creatorName(item)} · {item.type} · {item.id}
</div>
{createdAt ? <div className="mt-1 text-[11px] text-white/40">{createdAt}</div> : null}
{item.description ? (
<p className={`mt-2 text-xs leading-5 text-white/70 ${compact ? 'line-clamp-3' : 'whitespace-pre-wrap'}`}>
{item.description}
</p>
) : (
<p className="mt-2 text-xs text-white/40">No description</p>
)}
{categories.length > 0 ? (
<div className="mt-2 text-[11px] text-amber-200/80">{categories.join(' · ')}</div>
) : null}
{tags.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{(compact ? tags.slice(0, 10) : tags).map((tag) => (
<span key={tag} className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-white/70">
{tag}
</span>
))}
</div>
) : (
<div className="mt-2 text-[11px] text-white/40">No tags</div>
)}
{item.moderation_note ? (
<div className="mt-2 text-[11px] text-sky-200/80">Review reasons: {item.moderation_note}</div>
) : null}
</div>
)
}
const renderActions = (item) => (
<div className="w-full max-w-sm space-y-2">
<input
type="text"
aria-label={`Moderation note for ${item.id}`}
value={notes[item.id] || ''}
onChange={(event) => setNotes((prev) => ({ ...prev, [item.id]: event.target.value }))}
placeholder="Moderation note"
className="w-full rounded-lg border border-white/15 bg-white/10 px-3 py-2 text-xs text-white"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
aria-label={`Open details for ${item.id}`}
onClick={() => setSelected(item)}
className="rounded-lg border border-white/20 px-3 py-2 text-xs font-semibold text-white"
>
Details
</button>
<button
type="button"
aria-label={`Approve upload ${item.id}`}
disabled={busyId === item.id}
onClick={() => moderate(item, 'approve')}
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-semibold text-black disabled:opacity-60"
>
{busyId === item.id ? 'Saving…' : 'Approve'}
</button>
<button
type="button"
aria-label={`Reject upload ${item.id}`}
disabled={busyId === item.id}
onClick={() => moderate(item, 'reject')}
className="rounded-lg bg-rose-500 px-3 py-2 text-xs font-semibold text-white disabled:opacity-60"
>
Reject
</button>
</div>
</div>
)
return (
<section aria-label="Moderation queue" className="mx-auto w-full max-w-5xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
<section aria-label="Moderation queue" className="mx-auto w-full max-w-6xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Pending Upload Moderation</h2>
<button type="button" onClick={loadPending} className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white">
@@ -48,47 +240,119 @@ export default function AdminUploadQueue() {
{!loading && items.length === 0 ? <p role="status" className="text-sm text-white/60">No pending uploads.</p> : null}
<ul className="space-y-3">
{items.map((item) => (
<li key={item.id} aria-label={`Pending upload ${item.id}`} className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-sm font-medium text-white">{item.title || '(untitled upload)'}</div>
<div className="mt-1 text-xs text-white/65">{item.type} · {item.id}</div>
{item.preview_path ? <div className="mt-1 text-xs text-white/55">Preview: {item.preview_path}</div> : null}
</div>
{items.map((item) => {
const previewSrc = item.preview_url || item.preview_lg_url || null
<div className="w-full max-w-sm space-y-2">
<input
type="text"
aria-label={`Moderation note for ${item.id}`}
value={notes[item.id] || ''}
onChange={(event) => setNotes((prev) => ({ ...prev, [item.id]: event.target.value }))}
placeholder="Moderation note"
className="w-full rounded-lg border border-white/15 bg-white/10 px-3 py-2 text-xs text-white"
/>
<div className="flex gap-2">
<button
type="button"
aria-label={`Approve upload ${item.id}`}
onClick={() => moderate(item.id, 'approve')}
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
>
Approve
</button>
<button
type="button"
aria-label={`Reject upload ${item.id}`}
onClick={() => moderate(item.id, 'reject')}
className="rounded-lg bg-rose-500 px-3 py-2 text-xs font-semibold text-white"
>
Reject
</button>
return (
<li key={`${item.queue}-${item.id}`} aria-label={`Pending upload ${item.id}`} className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<button
type="button"
aria-label={`Open details for ${item.title || item.id}`}
onClick={() => setSelected(item)}
className="flex min-w-0 flex-1 items-start gap-4 text-left"
>
<PreviewImage
src={previewSrc}
alt={item.title || 'Artwork preview'}
className="h-40 w-40 shrink-0 rounded-lg object-cover"
/>
{renderMeta(item, { compact: true })}
</button>
{renderActions(item)}
</div>
</li>
)
})}
</ul>
{selected ? (
<div
role="dialog"
aria-modal="true"
aria-label={`Details for ${selected.title || selected.id}`}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
onClick={() => setSelected(null)}
>
<div
className="max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-2xl border border-white/10 bg-slate-950 p-5 shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="text-lg font-semibold text-white">{selected.title || '(untitled upload)'}</div>
<div className="mt-1 text-xs text-white/60">
{creatorName(selected)} · {selected.type} · {selected.id}
</div>
</div>
<button
type="button"
aria-label="Close details"
onClick={() => setSelected(null)}
className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white"
>
Close
</button>
</div>
</li>
))}
</ul>
<PreviewImage
src={selected.preview_lg_url || selected.preview_url}
alt={selected.title || 'Artwork preview'}
className="mb-4 max-h-[62vh] w-full rounded-xl bg-black/40 object-contain"
/>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="space-y-3 text-sm text-white/80">
<div>
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Description</div>
<p className="mt-1 whitespace-pre-wrap leading-6">{selected.description || 'No description'}</p>
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Categories</div>
<p className="mt-1">{categoryList(selected).join(', ') || 'None'}</p>
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Tags</div>
<div className="mt-2 flex flex-wrap gap-1">
{tagList(selected).length > 0 ? tagList(selected).map((tag) => (
<span key={tag} className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[11px] text-white/70">
{tag}
</span>
)) : <span className="text-white/40">None</span>}
</div>
</div>
<div className="grid gap-3 text-xs text-white/70 sm:grid-cols-2">
<div>
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Visibility</div>
<p className="mt-1">{selected.visibility || 'n/a'}</p>
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Maturity</div>
<p className="mt-1">{isMature(selected) ? 'NSFW / mature' : (selected.maturity_status || 'safe')}</p>
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">License</div>
<p className="mt-1">{selected.license || 'n/a'}</p>
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Submitted</div>
<p className="mt-1">{formatDate(selected.created_at) || 'n/a'}</p>
</div>
</div>
{selected.moderation_note ? (
<div>
<div className="text-[11px] font-semibold uppercase tracking-widest text-white/40">Review reasons</div>
<p className="mt-1">{selected.moderation_note}</p>
</div>
) : null}
</div>
{renderActions(selected)}
</div>
</div>
</div>
) : null}
</section>
)
}
@@ -9,11 +9,41 @@ function makePendingUpload(overrides = {}) {
id: '11111111-1111-1111-1111-111111111111',
title: 'Neon Skyline',
type: 'image',
preview_path: 'tmp/drafts/1111/preview.webp',
description: 'A glowing city at dusk.',
preview_url: '/api/admin/uploads/11111111-1111-1111-1111-111111111111/preview',
preview_lg_url: '/api/admin/uploads/11111111-1111-1111-1111-111111111111/preview',
tags: ['neon', 'city'],
categories: ['Photography'],
user: { id: 7, name: 'Mira', username: 'mira' },
...overrides,
}
}
function makePendingArtwork(overrides = {}) {
return {
id: 88,
title: 'Forest Spirit',
type: 'artwork',
description: 'A mossy guardian in fog.',
preview_url: 'https://files.skinbase.org/artworks/md/aa/bb/aabb.webp',
preview_lg_url: 'https://files.skinbase.org/artworks/lg/aa/bb/aabb.webp',
tags: ['forest', 'spirit'],
categories: ['Illustration'],
user: { id: 9, name: 'Kai', username: 'kai' },
...overrides,
}
}
function mockQueue({ uploads = [], artworks = [] } = {}) {
window.axios.get.mockImplementation((url) => {
if (String(url).includes('/api/admin/artwork-review/pending')) {
return Promise.resolve({ data: { data: artworks } })
}
return Promise.resolve({ data: { data: uploads } })
})
}
describe('AdminUploadQueue', () => {
beforeEach(() => {
window.axios = {
@@ -28,7 +58,7 @@ describe('AdminUploadQueue', () => {
it('renders pending list with accessible controls', async () => {
const upload = makePendingUpload()
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
mockQueue({ uploads: [upload] })
render(<AdminUploadQueue />)
@@ -36,6 +66,9 @@ describe('AdminUploadQueue', () => {
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
expect(within(item).getByText('Neon Skyline')).not.toBeNull()
expect(within(item).getByText('A glowing city at dusk.')).not.toBeNull()
expect(within(item).getByText('neon')).not.toBeNull()
expect(within(item).getByRole('img', { name: 'Neon Skyline' })).not.toBeNull()
expect(within(item).getByRole('textbox', { name: `Moderation note for ${upload.id}` })).not.toBeNull()
expect(within(item).getByRole('button', { name: `Approve upload ${upload.id}` })).not.toBeNull()
expect(within(item).getByRole('button', { name: `Reject upload ${upload.id}` })).not.toBeNull()
@@ -43,7 +76,7 @@ describe('AdminUploadQueue', () => {
it('approves upload and removes it from queue', async () => {
const upload = makePendingUpload()
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
mockQueue({ uploads: [upload] })
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
render(<AdminUploadQueue />)
@@ -60,7 +93,7 @@ describe('AdminUploadQueue', () => {
it('rejects upload with note and removes it from queue', async () => {
const upload = makePendingUpload({ id: '22222222-2222-2222-2222-222222222222', title: 'Retro Pack' })
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
mockQueue({ uploads: [upload] })
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
render(<AdminUploadQueue />)
@@ -85,7 +118,7 @@ describe('AdminUploadQueue', () => {
it('shows API failure message and keeps item when moderation action fails', async () => {
const upload = makePendingUpload({ id: '33333333-3333-3333-3333-333333333333' })
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
mockQueue({ uploads: [upload] })
window.axios.post.mockRejectedValueOnce({
response: { data: { message: 'Moderation API failed.' } },
})
@@ -100,8 +133,49 @@ describe('AdminUploadQueue', () => {
expect(screen.getByRole('listitem', { name: `Pending upload ${upload.id}` })).not.toBeNull()
})
it('opens a details popup with title, description, tags and preview', async () => {
const upload = makePendingUpload()
mockQueue({ uploads: [upload] })
render(<AdminUploadQueue />)
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
await userEvent.click(within(item).getByRole('button', { name: `Open details for ${upload.id}` }))
const dialog = await screen.findByRole('dialog', { name: 'Details for Neon Skyline' })
expect(within(dialog).getByText('A glowing city at dusk.')).not.toBeNull()
expect(within(dialog).getByText('Photography')).not.toBeNull()
expect(within(dialog).getByText('neon')).not.toBeNull()
expect(within(dialog).getByRole('img', { name: 'Neon Skyline' })).not.toBeNull()
expect(within(dialog).getByRole('button', { name: `Approve upload ${upload.id}` })).not.toBeNull()
expect(within(dialog).getByRole('button', { name: `Reject upload ${upload.id}` })).not.toBeNull()
})
it('approves artwork review items from the details popup', async () => {
const artwork = makePendingArtwork()
mockQueue({ artworks: [artwork] })
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
render(<AdminUploadQueue />)
const item = await screen.findByRole('listitem', { name: `Pending upload ${artwork.id}` })
expect(within(item).getByText('Forest Spirit')).not.toBeNull()
expect(within(item).getByText('forest')).not.toBeNull()
expect(within(item).getByRole('img', { name: 'Forest Spirit' })).not.toBeNull()
await userEvent.click(within(item).getByRole('button', { name: `Open details for ${artwork.id}` }))
const dialog = await screen.findByRole('dialog', { name: 'Details for Forest Spirit' })
await userEvent.click(within(dialog).getByRole('button', { name: `Approve upload ${artwork.id}` }))
await waitFor(() => {
expect(screen.queryByRole('listitem', { name: `Pending upload ${artwork.id}` })).toBeNull()
})
expect(window.axios.post).toHaveBeenCalledWith(`/api/admin/artwork-review/${artwork.id}/approve`, { note: '' })
})
it('shows empty state when no pending uploads exist', async () => {
window.axios.get.mockResolvedValueOnce({ data: { data: [] } })
mockQueue()
render(<AdminUploadQueue />)