639 lines
41 KiB
JavaScript
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>
|
|
)
|
|
}
|