feat: ship creator journey v2 and profile updates
This commit is contained in:
307
resources/js/Pages/Moderation/ArtworkMaturityQueue.jsx
Normal file
307
resources/js/Pages/Moderation/ArtworkMaturityQueue.jsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import ArtworkViewer from '../../components/viewer/ArtworkViewer'
|
||||
|
||||
function requestJson(url, { method = 'GET', body } = {}) {
|
||||
return fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(async (response) => {
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Request failed')
|
||||
}
|
||||
return payload
|
||||
})
|
||||
}
|
||||
|
||||
function Badge({ children, tone = 'slate' }) {
|
||||
const tones = {
|
||||
slate: 'border-white/10 bg-white/[0.05] text-slate-200',
|
||||
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',
|
||||
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
}
|
||||
|
||||
return <span className={`inline-flex items-center rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tones[tone] || tones.slate}`}>{children}</span>
|
||||
}
|
||||
|
||||
export default function ArtworkMaturityQueue() {
|
||||
const { props } = usePage()
|
||||
const [items, setItems] = useState(props.initialItems || [])
|
||||
const [stats, setStats] = useState(props.stats || {})
|
||||
const [status, setStatus] = useState(props.initialFilters?.status || 'suspected')
|
||||
const [aiAction, setAiAction] = useState(props.initialFilters?.ai_action || 'all')
|
||||
const [aiStatus, setAiStatus] = useState(props.initialFilters?.ai_status || 'all')
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
const [noteById, setNoteById] = useState({})
|
||||
const [error, setError] = useState('')
|
||||
const [previewItem, setPreviewItem] = useState(null)
|
||||
|
||||
const endpoints = props.endpoints || {}
|
||||
const filterOptions = props.filterOptions || {}
|
||||
const reviewActions = props.reviewActions || []
|
||||
|
||||
function queueStatusKey(key) {
|
||||
return key === 'mature' ? 'reviewed' : key
|
||||
}
|
||||
|
||||
async function load(nextStatus, nextAiAction = aiAction, nextAiStatus = aiStatus) {
|
||||
setStatus(nextStatus)
|
||||
setAiAction(nextAiAction)
|
||||
setAiStatus(nextAiStatus)
|
||||
setError('')
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
status: nextStatus,
|
||||
ai_action: nextAiAction,
|
||||
ai_status: nextAiStatus,
|
||||
})
|
||||
const payload = await requestJson(`${endpoints.list}?${query.toString()}`)
|
||||
setItems(payload.data || [])
|
||||
setStats(payload.meta?.stats || {})
|
||||
} catch (loadError) {
|
||||
setError(loadError.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function review(itemId, action) {
|
||||
setBusyId(itemId)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(String(endpoints.reviewPattern || '').replace('__ARTWORK__', String(itemId)), {
|
||||
method: 'POST',
|
||||
body: {
|
||||
action,
|
||||
note: noteById[itemId] || '',
|
||||
},
|
||||
})
|
||||
|
||||
setStats(payload.stats || {})
|
||||
setItems((current) => current.filter((item) => item.id !== itemId).concat(status === 'reviewed' ? [payload.artwork] : []))
|
||||
|
||||
if (status !== 'reviewed') {
|
||||
setItems((current) => current.filter((item) => item.id !== itemId))
|
||||
}
|
||||
} catch (reviewError) {
|
||||
setError(reviewError.message)
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const statusSummary = useMemo(() => [
|
||||
{ key: 'suspected', label: 'Suspected', value: Number(stats.suspected || 0) },
|
||||
{ key: 'audit', label: 'Audit candidates', value: Number(stats.audit || 0) },
|
||||
{ key: 'reviewed', label: 'Reviewed', value: Number(stats.reviewed || 0) },
|
||||
{ key: 'mature', label: 'Marked mature', value: Number(stats.mature || 0) },
|
||||
], [stats])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-8">
|
||||
<Head title="Artwork Maturity Queue" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-amber-200/80">Moderator surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Artwork maturity review</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Review uploads where the uploader declaration and AI suspicion do not match, plus legacy artworks detected by the non-mutating thumbnail audit. Audit candidates stay read-only until a moderator confirms the final maturity state.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{statusSummary.map((entry) => (
|
||||
(() => {
|
||||
const queueKey = queueStatusKey(entry.key)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
onClick={() => load(queueKey)}
|
||||
className={`rounded-2xl border px-4 py-3 text-left transition ${status === queueKey ? 'border-amber-300/30 bg-amber-400/10 text-white' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.07]'}`}
|
||||
>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em]">{entry.label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tracking-tight">{entry.value.toLocaleString()}</div>
|
||||
</button>
|
||||
)
|
||||
})()
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-2">
|
||||
<label className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">AI action hint</div>
|
||||
<select
|
||||
value={aiAction}
|
||||
onChange={(event) => load(status, event.target.value, aiStatus)}
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
{(filterOptions.aiAction || []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">AI processing status</div>
|
||||
<select
|
||||
value={aiStatus}
|
||||
onChange={(event) => load(status, aiAction, event.target.value)}
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
{(filterOptions.aiStatus || []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300">{status === 'audit' ? 'No legacy artworks are currently flagged by the thumbnail audit.' : 'No artworks are waiting in this queue.'}</div>
|
||||
) : items.map((item) => (
|
||||
(() => {
|
||||
const evidence = item.audit || item.maturity || {}
|
||||
|
||||
return (
|
||||
<article key={item.id} className="grid gap-5 rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)] lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => item.preview_image ? setPreviewItem(item) : null}
|
||||
className="group overflow-hidden rounded-[22px] border border-white/10 bg-slate-950/85 text-left transition hover:border-sky-300/30"
|
||||
>
|
||||
{item.thumbnail ? (
|
||||
<div className="relative flex min-h-[360px] items-center justify-center p-3">
|
||||
<img src={item.thumbnail} alt={item.title} className="max-h-[480px] w-full object-contain" />
|
||||
<div className="pointer-events-none absolute inset-x-3 bottom-3 flex items-center justify-between rounded-full border border-white/10 bg-[#07101bdd] px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-100 opacity-0 transition group-hover:opacity-100">
|
||||
<span>Preview full image</span>
|
||||
<i className="fa-solid fa-expand text-[10px]" />
|
||||
</div>
|
||||
</div>
|
||||
) : <div className="min-h-[360px]" />}
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.audit ? <Badge tone="sky">audit candidate</Badge> : null}
|
||||
<Badge tone={item.maturity?.is_flagged ? 'rose' : 'amber'}>{item.maturity?.status || 'unknown'}</Badge>
|
||||
{item.maturity?.is_mature_effective ? <Badge tone="amber">effective mature</Badge> : <Badge tone="emerald">currently safe</Badge>}
|
||||
{item.maturity?.source ? <Badge tone="sky">source: {String(item.maturity.source).replaceAll('_', ' ')}</Badge> : null}
|
||||
{item.audit?.legacy_unset ? <Badge tone="slate">legacy unset</Badge> : null}
|
||||
{evidence.ai_action_hint ? <Badge tone={evidence.ai_action_hint === 'flag_high' ? 'rose' : evidence.ai_action_hint === 'review' ? 'amber' : 'emerald'}>AI: {String(evidence.ai_action_hint).replaceAll('_', ' ')}</Badge> : null}
|
||||
{evidence.ai_status ? <Badge tone="slate">status: {String(evidence.ai_status).replaceAll('_', ' ')}</Badge> : null}
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{item.title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-300">{item.publisher} {item.category ? `• ${item.category}` : ''} {item.content_type ? `• ${item.content_type}` : ''}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href={item.url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">
|
||||
<i className="fa-solid fa-arrow-up-right-from-square text-[10px]" />
|
||||
Open artwork
|
||||
</a>
|
||||
{item.admin_url ? (
|
||||
<a href={item.admin_url} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-amber-50 transition hover:bg-amber-400/15">
|
||||
<i className="fa-solid fa-screwdriver-wrench text-[10px]" />
|
||||
Open in cPad
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{Array.isArray(evidence.ai_labels) && evidence.ai_labels.length > 0 ? evidence.ai_labels.map((label) => <Badge key={`${item.id}-${label}`} tone="rose">{label}</Badge>) : <Badge tone="slate">no AI labels</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">AI score</div>
|
||||
<div className="mt-2 text-xl font-semibold text-white">{evidence.ai_score != null ? Number(evidence.ai_score).toFixed(4) : 'n/a'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">AI label</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-slate-200">{evidence.ai_label || 'n/a'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">{item.audit ? 'Audit detected' : 'Published'}</div>
|
||||
<div className="mt-2 text-sm text-slate-200">{item.audit?.detected_at ? new Date(item.audit.detected_at).toLocaleString() : item.published_at ? new Date(item.published_at).toLocaleString() : 'Draft / unavailable'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Confidence</div>
|
||||
<div className="mt-2 text-sm text-slate-200">{evidence.ai_confidence != null ? Number(evidence.ai_confidence).toFixed(4) : 'n/a'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Vision model</div>
|
||||
<div className="mt-2 text-sm text-slate-200">{evidence.ai_model || 'n/a'}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Current DB state</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-slate-200">{String(item.maturity?.source || 'legacy').replaceAll('_', ' ')} • {String(item.maturity?.status || 'clear').replaceAll('_', ' ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{evidence.ai_advisory ? (
|
||||
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm leading-relaxed text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">AI advisory</div>
|
||||
<div className="mt-2">{evidence.ai_advisory}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<label className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Moderator note</label>
|
||||
<textarea
|
||||
value={noteById[item.id] ?? item.review?.reviewer_note ?? ''}
|
||||
onChange={(event) => setNoteById((current) => ({ ...current, [item.id]: event.target.value }))}
|
||||
rows={3}
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 text-sm text-white outline-none transition focus:border-amber-300/40"
|
||||
placeholder="Explain why you are confirming or changing the maturity state."
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{reviewActions.map((action) => (
|
||||
<button
|
||||
key={`${item.id}-${action.value}`}
|
||||
type="button"
|
||||
disabled={busyId === item.id}
|
||||
onClick={() => review(item.id, action.value)}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busyId === item.id ? 'Saving…' : action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})()
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ArtworkViewer
|
||||
isOpen={Boolean(previewItem)}
|
||||
onClose={() => setPreviewItem(null)}
|
||||
artwork={previewItem ? { title: previewItem.title, thumb: previewItem.thumbnail } : null}
|
||||
presentXl={previewItem?.preview_image ? { url: previewItem.preview_image } : null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user