Files
SkinbaseNova/resources/js/Pages/Collection/NovaCardsAdminIndex.jsx
2026-03-28 19:15:39 +01:00

639 lines
41 KiB
JavaScript

import React from 'react'
import { Head, Link, usePage } from '@inertiajs/react'
import NovaCardCanvasPreview from '../../components/nova-cards/NovaCardCanvasPreview'
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 renderOverrideHistoryItems(items, prefix) {
return (items || []).slice(0, 3).map((entry, index) => (
<div key={`${prefix}-${index}-${entry.updated_at || entry.source || entry.moderation_status || 'override'}`} className="rounded-2xl border border-white/10 bg-black/10 px-3 py-3">
<div className="flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100/75">
<span>{entry.moderation_status || 'unknown status'}</span>
{entry.disposition_label ? <span>{entry.disposition_label}</span> : null}
{entry.actor_username ? <span>@{entry.actor_username}</span> : null}
</div>
<div className="mt-1 flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.14em] text-sky-100/55">
{entry.source ? <span>{String(entry.source).replaceAll('_', ' ')}</span> : null}
{entry.updated_at ? <span>{new Date(entry.updated_at).toLocaleString()}</span> : null}
</div>
{entry.note ? <div className="mt-2 text-sm leading-6 text-sky-50">{entry.note}</div> : null}
</div>
))
}
export default function NovaCardsAdminIndex() {
const { props } = usePage()
const [cards, setCards] = React.useState(props.cards?.data || [])
const [featuredCreators, setFeaturedCreators] = React.useState(props.featuredCreators || [])
const [categories, setCategories] = React.useState(props.categories || [])
const [reportStatus, setReportStatus] = React.useState('open')
const [reports, setReports] = React.useState([])
const [reportsMeta, setReportsMeta] = React.useState({ total: 0 })
const [reportCounts, setReportCounts] = React.useState(props.reportingQueue?.statuses || { open: 0, reviewing: 0, closed: 0 })
const [reportNotes, setReportNotes] = React.useState({})
const [reportBusy, setReportBusy] = React.useState({})
const [reportsLoading, setReportsLoading] = React.useState(false)
const [reportsError, setReportsError] = React.useState('')
const [cardDispositions, setCardDispositions] = React.useState(() => Object.fromEntries((props.cards?.data || []).map((card) => [card.id, card.moderation_override?.disposition || ''])))
const [reportDispositions, setReportDispositions] = React.useState({})
const [newCategory, setNewCategory] = React.useState({ slug: '', name: '', description: '', active: true, order_num: categories.length })
const endpoints = props.endpoints || {}
const stats = props.stats || {}
const reportingQueue = props.reportingQueue || {}
const moderationDispositionOptions = props.moderationDispositionOptions || {}
function dispositionOptionsForStatus(status) {
return moderationDispositionOptions?.[status] || []
}
function preferredDisposition(status, currentValue) {
const options = dispositionOptionsForStatus(status)
if (currentValue && options.some((option) => option.value === currentValue)) {
return currentValue
}
return options[0]?.value || ''
}
React.useEffect(() => {
let active = true
async function loadReports() {
if (!endpoints.reportsQueue) {
return
}
setReportsLoading(true)
setReportsError('')
try {
const separator = String(endpoints.reportsQueue).includes('?') ? '&' : '?'
const response = await requestJson(`${endpoints.reportsQueue}${separator}status=${reportStatus}`)
if (!active) return
setReports(response.data || [])
setReportsMeta(response.meta || { total: 0 })
setReportDispositions((current) => {
const next = { ...current }
;(response.data || []).forEach((report) => {
const target = report?.target?.moderation_target
if (target?.card_id && !(report.id in next)) {
next[report.id] = preferredDisposition(target.moderation_status, target.moderation_override?.disposition)
}
})
return next
})
setReportNotes((current) => {
const next = { ...current }
;(response.data || []).forEach((report) => {
if (!(report.id in next)) {
next[report.id] = report.moderator_note || ''
}
})
return next
})
} catch (error) {
if (!active) return
setReportsError(error.message)
setReports([])
} finally {
if (active) {
setReportsLoading(false)
}
}
}
loadReports()
return () => {
active = false
}
}, [endpoints.reportsQueue, reportStatus])
async function updateCard(cardId, patch) {
const response = await requestJson(String(endpoints.updateCardPattern || '').replace('__CARD__', String(cardId)), {
method: 'PATCH',
body: patch,
})
setCardDispositions((current) => ({
...current,
[cardId]: preferredDisposition(response.card?.moderation_status, response.card?.moderation_override?.disposition),
}))
setCards((current) => current.map((card) => (card.id === cardId ? response.card : card)))
}
async function updateCreator(creatorId, patch) {
const response = await requestJson(String(endpoints.updateCreatorPattern || '').replace('__CREATOR__', String(creatorId)), {
method: 'PATCH',
body: patch,
})
setFeaturedCreators((current) => current.map((creator) => (creator.id === creatorId ? response.creator : creator)))
}
function syncReportCounts(previousStatus, nextStatus) {
if (!previousStatus || !nextStatus || previousStatus === nextStatus) {
return
}
setReportCounts((current) => ({
...current,
[previousStatus]: Math.max(0, Number(current?.[previousStatus] || 0) - 1),
[nextStatus]: Number(current?.[nextStatus] || 0) + 1,
}))
}
function mergeUpdatedReport(updatedReport, previousStatus) {
setReportNotes((current) => ({ ...current, [updatedReport.id]: updatedReport.moderator_note || '' }))
setReportDispositions((current) => ({
...current,
[updatedReport.id]: preferredDisposition(updatedReport?.target?.moderation_target?.moderation_status, updatedReport?.target?.moderation_target?.moderation_override?.disposition),
}))
if (previousStatus && previousStatus !== updatedReport.status) {
syncReportCounts(previousStatus, updatedReport.status)
}
setReports((current) => {
if (previousStatus && previousStatus !== updatedReport.status) {
return current.filter((report) => report.id !== updatedReport.id)
}
return current.map((report) => (report.id === updatedReport.id ? updatedReport : report))
})
}
async function saveCategory(category) {
const isExisting = Boolean(category.id)
const url = isExisting
? String(endpoints.updateCategoryPattern || '').replace('__CATEGORY__', String(category.id))
: endpoints.storeCategory
const response = await requestJson(url, {
method: isExisting ? 'PATCH' : 'POST',
body: category,
})
setCategories((current) => {
if (isExisting) {
return current.map((item) => (item.id === category.id ? { ...item, ...response.category } : item))
}
return [...current, { ...response.category, cards_count: 0 }]
})
if (!isExisting) {
setNewCategory({ slug: '', name: '', description: '', active: true, order_num: categories.length + 1 })
}
}
async function updateReport(reportId, patch) {
const currentReport = reports.find((report) => report.id === reportId)
if (!currentReport) {
return
}
setReportBusy((current) => ({ ...current, [reportId]: true }))
try {
const response = await requestJson(String(endpoints.updateReportPattern || '').replace('__REPORT__', String(reportId)), {
method: 'PATCH',
body: patch,
})
mergeUpdatedReport(response.report, currentReport.status)
} finally {
setReportBusy((current) => ({ ...current, [reportId]: false }))
}
}
async function moderateReportTarget(reportId, action) {
const currentReport = reports.find((report) => report.id === reportId)
if (!currentReport) {
return
}
setReportBusy((current) => ({ ...current, [reportId]: true }))
try {
const response = await requestJson(String(endpoints.moderateReportTargetPattern || '').replace('__REPORT__', String(reportId)), {
method: 'POST',
body: { action, disposition: reportDispositions[reportId] || null },
})
mergeUpdatedReport(response.report, currentReport.status)
setCards((current) => current.map((card) => (card.id === response.report?.target?.moderation_target?.card_id
? {
...card,
moderation_status: response.report.target.moderation_target.moderation_status,
moderation_override: response.report.target.moderation_target.moderation_override,
moderation_override_history: response.report.target.moderation_target.moderation_override_history,
}
: card)))
} finally {
setReportBusy((current) => ({ ...current, [reportId]: false }))
}
}
return (
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
<Head title="Nova Cards Moderation" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_38%),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-sky-200/75">Moderation surface</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Nova Cards control panel</h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Review pending cards, feature standout work, and keep the starter category taxonomy healthy as Nova Cards launches.</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href={endpoints.templates || '/cp/cards/templates'} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i className="fa-solid fa-swatchbook" />
Manage templates
</Link>
<Link href={endpoints.assetPacks || '/cp/cards/asset-packs'} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i className="fa-solid fa-shapes" />
Asset packs
</Link>
<Link href={endpoints.challenges || '/cp/cards/challenges'} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<i className="fa-solid fa-trophy" />
Challenges
</Link>
</div>
</div>
</section>
<section className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{[
['Pending', stats.pending || 0, 'fa-clock'],
['Flagged', stats.flagged || 0, 'fa-flag'],
['Featured', stats.featured || 0, 'fa-star'],
['Published', stats.published || 0, 'fa-earth-americas'],
['Remixable', stats.remixable || 0, 'fa-code-branch'],
['Challenges', stats.challenges || 0, 'fa-trophy'],
].map(([label, value, icon]) => (
<div key={label} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{label}</div>
<div className="mt-3 flex items-center gap-3">
<span className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100"><i className={`fa-solid ${icon}`} /></span>
<span className="text-3xl font-semibold tracking-[-0.04em] text-white">{value}</span>
</div>
</div>
))}
</section>
<section className="mt-6 rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Reporting queue</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">{reportingQueue.label || 'Nova Cards report queue'}</h2>
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">{reportingQueue.description || 'Review reports targeting Nova Cards surfaces.'}</p>
</div>
<div className="rounded-[22px] border border-amber-300/20 bg-amber-400/10 px-5 py-4 text-right">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/75">Pending reports</div>
<div className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-amber-50">{reportCounts.open || 0}</div>
<div className="mt-1 text-xs text-amber-100/70">{reportingQueue.enabled ? 'Connected to moderation pipeline' : 'Reporting disabled'}</div>
</div>
</div>
<div className="mt-5 flex flex-wrap gap-3">
{[
['open', reportCounts.open || 0],
['reviewing', reportCounts.reviewing || 0],
['closed', reportCounts.closed || 0],
].map(([status, count]) => (
<button
key={status}
type="button"
onClick={() => setReportStatus(status)}
className={`rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition ${reportStatus === status ? 'border-sky-300/30 bg-sky-400/12 text-sky-100' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:bg-white/[0.06]'}`}
>
{status} {count}
</button>
))}
</div>
<div className="mt-5 rounded-[24px] border border-white/10 bg-[#08111f]/70 p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">{reportStatus.charAt(0).toUpperCase() + reportStatus.slice(1)} reports</div>
<div className="text-xs uppercase tracking-[0.16em] text-slate-500">{reportsMeta.total || reports.length} total</div>
</div>
{reportsLoading ? <div className="mt-4 text-sm text-slate-400">Loading report queue</div> : null}
{reportsError ? <div className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{reportsError}</div> : null}
{!reportsLoading && !reportsError && !reports.length ? <div className="mt-4 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400">No reports in this state.</div> : null}
<div className="mt-4 space-y-3">
{reports.map((report) => (
<div key={report.id} className="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">{String(report.target?.type || report.target_type).replaceAll('_', ' ')}</span>
<span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{report.status}</span>
</div>
<div className="mt-3 text-lg font-semibold text-white">{report.target?.label || `Target #${report.target_id}`}</div>
<div className="mt-1 text-sm text-slate-400">{report.target?.subtitle || 'No target details available.'}</div>
<div className="mt-3 text-sm font-semibold text-slate-200">{report.reason}</div>
{report.details ? <p className="mt-2 text-sm leading-6 text-slate-300">{report.details}</p> : null}
<div className="mt-3 text-xs uppercase tracking-[0.16em] text-slate-500">Reported by {report.reporter?.username ? `@${report.reporter.username}` : 'unknown'}{report.created_at ? `${new Date(report.created_at).toLocaleString()}` : ''}</div>
{report.last_moderated_by?.username || report.last_moderated_at ? (
<div className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-500">
Last touched {report.last_moderated_by?.username ? `by @${report.last_moderated_by.username}` : 'by staff'}{report.last_moderated_at ? `${new Date(report.last_moderated_at).toLocaleString()}` : ''}
</div>
) : null}
{report.target?.moderation_target ? (
<div className="mt-4 rounded-2xl border border-amber-300/15 bg-amber-400/10 px-4 py-3 text-sm text-amber-50">
<div className="font-semibold uppercase tracking-[0.16em] text-amber-100/80">Card moderation target</div>
<div className="mt-2">{report.target.moderation_target.title}</div>
{report.target.moderation_target.context ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-amber-100/70">{report.target.moderation_target.context}</div> : null}
<div className="mt-2 flex flex-wrap gap-2 text-xs uppercase tracking-[0.14em] text-amber-100/80">
<span>Status {report.target.moderation_target.status}</span>
<span>Moderation {report.target.moderation_target.moderation_status}</span>
</div>
{report.target.moderation_target.moderation_reason_labels?.length ? (
<div className="mt-3 rounded-2xl border border-amber-200/15 bg-black/10 px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/75">Heuristic flags</div>
<div className="mt-2 flex flex-wrap gap-2">
{report.target.moderation_target.moderation_reason_labels.map((label) => (
<span key={`${report.id}-${label}`} className="rounded-full border border-amber-200/20 bg-amber-50/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-50">
{label}
</span>
))}
</div>
{report.target.moderation_target.moderation_source ? <div className="mt-2 text-[11px] uppercase tracking-[0.14em] text-amber-100/60">Source {String(report.target.moderation_target.moderation_source).replaceAll('_', ' ')}</div> : null}
</div>
) : null}
{report.target.moderation_target.moderation_override ? (
<div className="mt-3 rounded-2xl border border-sky-200/15 bg-sky-400/10 px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/80">Latest staff override</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs uppercase tracking-[0.14em] text-sky-100/80">
<span>Status {report.target.moderation_target.moderation_override.moderation_status}</span>
{report.target.moderation_target.moderation_override.disposition_label ? <span>{report.target.moderation_target.moderation_override.disposition_label}</span> : null}
{report.target.moderation_target.moderation_override.actor_username ? <span>@{report.target.moderation_target.moderation_override.actor_username}</span> : null}
{report.target.moderation_target.moderation_override.source ? <span>{String(report.target.moderation_target.moderation_override.source).replaceAll('_', ' ')}</span> : null}
</div>
{report.target.moderation_target.moderation_override.note ? <div className="mt-2 text-sm leading-6 text-sky-50">{report.target.moderation_target.moderation_override.note}</div> : null}
</div>
) : null}
{report.target.moderation_target.moderation_override_history?.length > 1 ? (
<div className="mt-3 rounded-2xl border border-sky-200/10 bg-sky-400/[0.08] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/75">Recent override history</div>
<div className="mt-3 space-y-2">
{renderOverrideHistoryItems(report.target.moderation_target.moderation_override_history, `report-${report.id}`)}
</div>
</div>
) : null}
<div className="mt-3 max-w-xs">
<label className="text-sm text-amber-50">
<span className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/75">Disposition</span>
<select
value={preferredDisposition(report.target.moderation_target.moderation_status, reportDispositions[report.id])}
onChange={(event) => setReportDispositions((current) => ({ ...current, [report.id]: event.target.value }))}
className="w-full rounded-2xl border border-amber-200/20 bg-[#0d1726] px-4 py-3 text-white"
>
{dispositionOptionsForStatus(report.target.moderation_target.moderation_status).map((option) => <option key={`${report.id}-disp-${option.value}`} value={option.value}>{option.label}</option>)}
</select>
</label>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{(report.target.moderation_target.available_actions || []).map((actionItem) => (
<button
key={`${report.id}-${actionItem.action}`}
type="button"
onClick={() => moderateReportTarget(report.id, actionItem.action)}
disabled={Boolean(reportBusy[report.id])}
className="inline-flex items-center gap-2 rounded-full border border-amber-200/20 bg-amber-50/10 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-50 transition hover:bg-amber-50/15 disabled:cursor-not-allowed disabled:opacity-60"
>
{actionItem.label}
</button>
))}
</div>
</div>
) : null}
<div className="mt-4 rounded-2xl border border-white/10 bg-[#08111f]/70 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Moderator note</div>
<textarea
value={reportNotes[report.id] ?? ''}
onChange={(event) => setReportNotes((current) => ({ ...current, [report.id]: event.target.value }))}
rows={3}
className="mt-3 w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white"
placeholder="Capture reviewer context, outcome, or escalation notes."
/>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => updateReport(report.id, { moderator_note: (reportNotes[report.id] || '').trim() || null })}
disabled={Boolean(reportBusy[report.id])}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-60"
>
Save note
</button>
{['open', 'reviewing', 'closed'].filter((status) => status !== report.status).map((status) => (
<button
key={`${report.id}-${status}`}
type="button"
onClick={() => updateReport(report.id, { status, moderator_note: (reportNotes[report.id] || '').trim() || null })}
disabled={Boolean(reportBusy[report.id])}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100 transition hover:bg-sky-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
Mark {status}
</button>
))}
</div>
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-[#08111f]/70 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Audit trail</div>
{!report.history?.length ? <div className="mt-3 text-sm text-slate-400">No moderator actions recorded yet.</div> : null}
<div className="mt-3 space-y-3">
{(report.history || []).map((entry) => (
<div key={entry.id} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.16em] text-slate-500">
<span>{entry.summary || entry.action_type}</span>
<span>{entry.actor?.username ? `@${entry.actor.username}` : 'system'}</span>
<span>{entry.created_at ? new Date(entry.created_at).toLocaleString() : ''}</span>
</div>
{entry.note ? <div className="mt-2 text-sm leading-6 text-slate-300">{entry.note}</div> : null}
</div>
))}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2 lg:max-w-[260px] lg:justify-end">
{report.target?.public_url ? <a href={report.target.public_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">Open target</a> : null}
{report.target?.moderation_url ? <a href={report.target.moderation_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">Moderate</a> : null}
</div>
</div>
</div>
))}
</div>
</div>
</section>
<section className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<div className="space-y-5">
{cards.map((card) => (
<div key={card.id} className="rounded-[28px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
<NovaCardCanvasPreview card={card} />
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-xl font-semibold tracking-[-0.03em] text-white">{card.title}</div>
<div className="mt-1 text-sm text-slate-400">@{card.creator?.username} {card.category?.name || 'Uncategorized'}</div>
</div>
<a href={card.public_url} className="text-sm text-sky-300 transition hover:text-sky-200">Open public page</a>
</div>
<p className="mt-3 line-clamp-3 text-sm leading-7 text-slate-300">{card.quote_text}</p>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<label className="text-sm text-slate-300">
<span className="mb-2 block">Status</span>
<select value={card.status} onChange={(event) => updateCard(card.id, { status: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
{['draft', 'processing', 'published', 'hidden', 'rejected'].map((item) => <option key={`${card.id}-${item}`} value={item}>{item}</option>)}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Moderation</span>
<select value={card.moderation_status} onChange={(event) => updateCard(card.id, { moderation_status: event.target.value, disposition: preferredDisposition(event.target.value, cardDispositions[card.id]) })} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
{['pending', 'approved', 'flagged', 'rejected'].map((item) => <option key={`${card.id}-mod-${item}`} value={item}>{item}</option>)}
</select>
</label>
<label className="text-sm text-slate-300">
<span className="mb-2 block">Disposition</span>
<select
value={preferredDisposition(card.moderation_status, cardDispositions[card.id])}
onChange={(event) => {
const disposition = event.target.value
setCardDispositions((current) => ({ ...current, [card.id]: disposition }))
updateCard(card.id, { moderation_status: card.moderation_status, disposition })
}}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white"
>
{dispositionOptionsForStatus(card.moderation_status).map((option) => <option key={`${card.id}-disp-${option.value}`} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<span>Featured</span>
<input type="checkbox" checked={Boolean(card.featured)} onChange={(event) => updateCard(card.id, { featured: event.target.checked })} className="h-4 w-4" />
</label>
<label className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<span>Allow remix</span>
<input type="checkbox" checked={Boolean(card.allow_remix)} onChange={(event) => updateCard(card.id, { allow_remix: event.target.checked })} className="h-4 w-4" />
</label>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-4 text-xs text-slate-400">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.likes_count || 0} likes</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.saves_count || 0} saves</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.remixes_count || 0} remixes</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">{card.challenge_entries_count || 0} challenge entries</div>
</div>
{card.moderation_reason_labels?.length ? (
<div className="mt-4 rounded-2xl border border-amber-300/15 bg-amber-400/10 px-4 py-3 text-sm text-amber-50">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Heuristic moderation flags</div>
<div className="mt-2 flex flex-wrap gap-2">
{card.moderation_reason_labels.map((label) => (
<span key={`${card.id}-${label}`} className="rounded-full border border-amber-200/20 bg-amber-50/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-50">
{label}
</span>
))}
</div>
{card.moderation_source ? <div className="mt-2 text-[11px] uppercase tracking-[0.14em] text-amber-100/70">Source {String(card.moderation_source).replaceAll('_', ' ')}</div> : null}
</div>
) : null}
{card.moderation_override ? (
<div className="mt-4 rounded-2xl border border-sky-300/15 bg-sky-400/10 px-4 py-3 text-sm text-sky-50">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/80">Latest staff override</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs uppercase tracking-[0.14em] text-sky-100/80">
<span>Status {card.moderation_override.moderation_status}</span>
{card.moderation_override.disposition_label ? <span>{card.moderation_override.disposition_label}</span> : null}
{card.moderation_override.actor_username ? <span>@{card.moderation_override.actor_username}</span> : null}
{card.moderation_override.source ? <span>{String(card.moderation_override.source).replaceAll('_', ' ')}</span> : null}
</div>
{card.moderation_override.note ? <div className="mt-2 text-sm leading-6 text-sky-50">{card.moderation_override.note}</div> : null}
</div>
) : null}
{card.moderation_override_history?.length > 1 ? (
<div className="mt-4 rounded-2xl border border-sky-300/10 bg-sky-400/[0.08] px-4 py-3 text-sm text-sky-50">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/75">Recent override history</div>
<div className="mt-3 space-y-2">
{renderOverrideHistoryItems(card.moderation_override_history, `card-${card.id}`)}
</div>
</div>
) : null}
</div>
</div>
</div>
))}
</div>
<div className="space-y-6">
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Creator curation</div>
{!featuredCreators.length ? <div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400">No public Nova creators are available for curation yet.</div> : null}
<div className="space-y-3">
{featuredCreators.map((creator) => (
<div key={creator.id} className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-semibold text-white">{creator.display_name}</div>
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">@{creator.username}</div>
</div>
{creator.public_url ? <a href={creator.public_url} className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-300 transition hover:text-sky-200">Open profile</a> : null}
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-slate-300">
<div className="rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3">{creator.public_cards_count || 0} public cards</div>
<div className="rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3">{creator.featured_cards_count || 0} featured</div>
<div className="rounded-2xl border border-white/10 bg-[#08111f]/70 px-3 py-3">{creator.total_views_count || 0} views</div>
</div>
<label className="mt-3 flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
<span>Feature on editorial page</span>
<input type="checkbox" checked={Boolean(creator.nova_featured_creator)} onChange={(event) => updateCreator(creator.id, { nova_featured_creator: event.target.checked })} className="h-4 w-4" />
</label>
</div>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Categories</div>
<div className="space-y-3">
{categories.map((category) => (
<div key={category.id} className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-white">{category.name}</div>
<div className="text-xs uppercase tracking-[0.18em] text-slate-500">{category.slug} {category.cards_count} cards</div>
</div>
<button type="button" onClick={() => saveCategory(category)} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.08]">Save</button>
</div>
</div>
))}
</div>
</section>
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_20px_50px_rgba(2,6,23,0.18)]">
<div className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Add category</div>
<div className="space-y-3">
<input value={newCategory.name} onChange={(event) => setNewCategory((current) => ({ ...current, name: event.target.value }))} placeholder="Name" className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<input value={newCategory.slug} onChange={(event) => setNewCategory((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<textarea value={newCategory.description} onChange={(event) => setNewCategory((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
<button type="button" onClick={() => saveCategory(newCategory)} className="w-full rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">Create category</button>
</div>
</section>
</div>
</section>
</div>
)
}