Require review for untrusted accounts, add admin artwork review APIs, and keep queued or auto-trusted publishes from counting as established history.
359 lines
13 KiB
React
359 lines
13 KiB
React
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 [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 {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadPending()
|
|
}, [])
|
|
|
|
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] || '') }
|
|
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-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">
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
{loading ? <p role="status" className="text-sm text-white/70">Loading…</p> : null}
|
|
{error ? <p role="alert" className="mb-3 text-sm text-rose-200">{error}</p> : null}
|
|
{!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) => {
|
|
const previewSrc = item.preview_url || item.preview_lg_url || null
|
|
|
|
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>
|
|
|
|
<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>
|
|
)
|
|
}
|