Files
SkinbaseNova/resources/js/components/admin/AdminUploadQueue.jsx
T
klevze 0f52803b05 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.
2026-09-20 14:49:06 +02:00

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>
)
}