380 lines
20 KiB
JavaScript
380 lines
20 KiB
JavaScript
import React from 'react'
|
|
import { Head, Link, router, usePage } from '@inertiajs/react'
|
|
import NovaSelect from '../../components/ui/NovaSelect'
|
|
|
|
function getCsrfToken() {
|
|
if (typeof document === 'undefined') return ''
|
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
}
|
|
|
|
async function requestJson(url, { method = 'POST', body } = {}) {
|
|
const response = await fetch(url, {
|
|
method,
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
})
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
|
|
if (!response.ok) {
|
|
throw new Error(payload?.message || 'Request failed.')
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
if (!value) return '—'
|
|
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) return '—'
|
|
|
|
return new Intl.DateTimeFormat('en', {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
}).format(date)
|
|
}
|
|
|
|
function Badge({ children, tone = 'slate' }) {
|
|
const tones = {
|
|
slate: 'border-white/10 bg-white/[0.06] text-slate-200',
|
|
sky: 'border-sky-300/20 bg-sky-400/12 text-sky-100',
|
|
emerald: 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100',
|
|
amber: 'border-amber-300/20 bg-amber-400/12 text-amber-100',
|
|
rose: 'border-rose-300/20 bg-rose-400/12 text-rose-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>
|
|
}
|
|
|
|
function StatCard({ label, value, tone = 'sky' }) {
|
|
const tones = {
|
|
sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100',
|
|
amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100',
|
|
emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100',
|
|
rose: 'border-rose-300/15 bg-rose-400/10 text-rose-100',
|
|
slate: 'border-white/10 bg-white/10 text-slate-100',
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
|
<div className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${tones[tone] || tones.sky}`}>{label}</div>
|
|
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function selectTone(record) {
|
|
if (record.last_error_code || record.status === 'failed') return 'rose'
|
|
if (record.needs_review) return 'amber'
|
|
if (record.is_user_edited) return 'sky'
|
|
if (record.status === 'approved') return 'emerald'
|
|
return 'slate'
|
|
}
|
|
|
|
function labelForStatus(value) {
|
|
if (!value) return 'Unknown'
|
|
return String(value).replaceAll('_', ' ')
|
|
}
|
|
|
|
export default function AiBiographyAdmin() {
|
|
const { props } = usePage()
|
|
const records = props.records || { data: [] }
|
|
const stats = props.stats || {}
|
|
const endpoints = props.endpoints || {}
|
|
const filterOptions = props.filterOptions || {}
|
|
const [filters, setFilters] = React.useState(props.filters || {})
|
|
const [busyKey, setBusyKey] = React.useState('')
|
|
const [notice, setNotice] = React.useState('')
|
|
const [error, setError] = React.useState('')
|
|
|
|
React.useEffect(() => {
|
|
setFilters(props.filters || {})
|
|
}, [props.filters])
|
|
|
|
function updateFilter(key, value) {
|
|
setFilters((current) => ({ ...current, [key]: value }))
|
|
}
|
|
|
|
function applyFilters(event) {
|
|
event.preventDefault()
|
|
setError('')
|
|
setNotice('')
|
|
|
|
router.get(endpoints.index, filters, {
|
|
preserveState: true,
|
|
replace: true,
|
|
preserveScroll: true,
|
|
})
|
|
}
|
|
|
|
function resetFilters() {
|
|
setError('')
|
|
setNotice('')
|
|
|
|
router.get(endpoints.index, {
|
|
q: '',
|
|
status: 'all',
|
|
scope: 'all',
|
|
tier: 'all',
|
|
visibility: 'all',
|
|
review: 'all',
|
|
}, {
|
|
preserveState: true,
|
|
replace: true,
|
|
preserveScroll: true,
|
|
})
|
|
}
|
|
|
|
async function performAction(actionKey, url) {
|
|
setBusyKey(actionKey)
|
|
setError('')
|
|
|
|
try {
|
|
const payload = await requestJson(url)
|
|
setNotice(payload.message || 'Action completed.')
|
|
router.reload({
|
|
only: ['records', 'stats', 'filters'],
|
|
preserveScroll: true,
|
|
})
|
|
} catch (requestError) {
|
|
setError(requestError.message || 'Action failed.')
|
|
} finally {
|
|
setBusyKey('')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-8">
|
|
<Head title="AI Biography Review" />
|
|
|
|
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.2),transparent_32%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.9))] 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/80">Moderator surface</p>
|
|
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">AI biography review</h1>
|
|
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Browse active biographies and historical generations, inspect review flags and failures, and rebuild a creator biography directly from cPad.</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
|
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {records.current_page || 1} / {records.last_page || 1}</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(records.total || 0).toLocaleString()} records</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
<StatCard label="Total records" value={stats.total_records} tone="sky" />
|
|
<StatCard label="Active" value={stats.active_records} tone="emerald" />
|
|
<StatCard label="Needs review" value={stats.needs_review} tone="amber" />
|
|
<StatCard label="Hidden active" value={stats.hidden_active} tone="slate" />
|
|
<StatCard label="Failed" value={stats.failed} tone="rose" />
|
|
<StatCard label="User edited" value={stats.user_edited_active} tone="sky" />
|
|
</div>
|
|
|
|
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_repeat(5,minmax(0,1fr))]">
|
|
<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">Search creator</div>
|
|
<input
|
|
value={filters.q || ''}
|
|
onChange={(event) => updateFilter('q', event.target.value)}
|
|
placeholder="username, name, or email"
|
|
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"
|
|
/>
|
|
</label>
|
|
|
|
{['status', 'scope', 'tier', 'visibility', 'review'].map((key) => (
|
|
<div key={key} 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">{key.replace('_', ' ')}</div>
|
|
<NovaSelect
|
|
value={filters[key] || 'all'}
|
|
onChange={(value) => updateFilter(key, value)}
|
|
className="mt-2"
|
|
options={(filterOptions[key] || []).map((option) => ({ value: option.value, label: option.label }))}
|
|
searchable={false}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
<div className="lg:col-span-full flex flex-wrap gap-3">
|
|
<button type="submit" className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-400/12 px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/18">
|
|
<i className="fa-solid fa-filter text-[10px]" />
|
|
Apply filters
|
|
</button>
|
|
<button type="button" onClick={resetFilters} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
|
<i className="fa-solid fa-rotate-left text-[10px]" />
|
|
Reset
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
{notice ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{notice}</div> : null}
|
|
{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">
|
|
{(records.data || []).length === 0 ? (
|
|
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300">No AI biography records matched the current filters.</div>
|
|
) : (records.data || []).map((record) => {
|
|
const rebuildKey = `rebuild-${record.user_id}`
|
|
const approveKey = `approve-${record.id}`
|
|
const flagKey = `flag-${record.id}`
|
|
const visibilityKey = `${record.is_hidden ? 'show' : 'hide'}-${record.id}`
|
|
|
|
return (
|
|
<article key={record.id} className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
|
<div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge tone={selectTone(record)}>{labelForStatus(record.status)}</Badge>
|
|
<Badge tone={record.is_active ? 'emerald' : 'slate'}>{record.is_active ? 'active' : 'inactive'}</Badge>
|
|
<Badge tone={record.is_hidden ? 'amber' : 'sky'}>{record.is_hidden ? 'hidden' : 'visible'}</Badge>
|
|
{record.needs_review ? <Badge tone="amber">needs review</Badge> : null}
|
|
{record.is_user_edited ? <Badge tone="sky">user edited</Badge> : null}
|
|
{record.is_stale ? <Badge tone="rose">stale</Badge> : null}
|
|
{record.input_quality_tier ? <Badge tone="slate">tier: {record.input_quality_tier}</Badge> : null}
|
|
</div>
|
|
|
|
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{record.user?.display_name || 'Unknown creator'}</h2>
|
|
<p className="mt-2 text-sm text-slate-300">
|
|
@{record.user?.username || 'unknown'}
|
|
{record.user?.email ? ` • ${record.user.email}` : ''}
|
|
{record.generation_reason ? ` • reason: ${labelForStatus(record.generation_reason)}` : ''}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{record.user?.profile_url ? (
|
|
<a href={record.user.profile_url} 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]">
|
|
<i className="fa-solid fa-user text-[10px]" />
|
|
Open profile
|
|
</a>
|
|
) : null}
|
|
{record.user?.gallery_url ? (
|
|
<a href={record.user.gallery_url} 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]">
|
|
<i className="fa-solid fa-images text-[10px]" />
|
|
Open gallery
|
|
</a>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
disabled={busyKey === rebuildKey}
|
|
onClick={() => performAction(rebuildKey, String(endpoints.rebuildPattern || '').replace('__USER__', String(record.user_id)))}
|
|
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/18 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<i className="fa-solid fa-rotate text-[10px]" />
|
|
{busyKey === rebuildKey ? 'Rebuilding…' : 'Rebuild'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<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">Prompt</div>
|
|
<div className="mt-2 text-sm text-slate-200">{record.prompt_version || '—'}</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">Model</div>
|
|
<div className="mt-2 text-sm text-slate-200">{record.model || '—'}</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">Generated</div>
|
|
<div className="mt-2 text-sm text-slate-200">{formatDateTime(record.generated_at)}</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">Last attempted</div>
|
|
<div className="mt-2 text-sm text-slate-200">{formatDateTime(record.last_attempted_at)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{record.last_error_code || record.last_error_reason ? (
|
|
<div className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm leading-relaxed text-rose-100">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-rose-100/80">Last error</div>
|
|
<div className="mt-2">{record.last_error_code || 'generation_failed'}{record.last_error_reason ? ` • ${record.last_error_reason}` : ''}</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Biography text</div>
|
|
<div className="mt-3 max-h-[320px] overflow-y-auto whitespace-pre-wrap rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-4 text-sm leading-relaxed text-slate-100">
|
|
{record.text || 'No biography text stored for this record.'}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Review actions</div>
|
|
<div className="mt-4 grid gap-3">
|
|
<button
|
|
type="button"
|
|
disabled={busyKey === approveKey}
|
|
onClick={() => performAction(approveKey, String(endpoints.approvePattern || '').replace('__BIOGRAPHY__', String(record.id)))}
|
|
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-emerald-300/20 bg-emerald-400/12 px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-50 transition hover:bg-emerald-400/18 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<i className="fa-solid fa-check text-[10px]" />
|
|
{busyKey === approveKey ? 'Saving…' : 'Mark reviewed'}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={busyKey === flagKey}
|
|
onClick={() => performAction(flagKey, String(endpoints.flagPattern || '').replace('__BIOGRAPHY__', String(record.id)))}
|
|
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/12 px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-amber-50 transition hover:bg-amber-400/18 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<i className="fa-solid fa-flag text-[10px]" />
|
|
{busyKey === flagKey ? 'Saving…' : 'Flag for review'}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={!record.is_active || busyKey === visibilityKey}
|
|
onClick={() => performAction(visibilityKey, String((record.is_hidden ? endpoints.showPattern : endpoints.hidePattern) || '').replace('__BIOGRAPHY__', String(record.id)))}
|
|
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<i className={`fa-solid ${record.is_hidden ? 'fa-eye' : 'fa-eye-slash'} text-[10px]`} />
|
|
{busyKey === visibilityKey ? 'Saving…' : record.is_hidden ? 'Show publicly' : 'Hide publicly'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-2 text-xs leading-relaxed text-slate-300">
|
|
<div><span className="font-semibold text-slate-100">Approved:</span> {formatDateTime(record.approved_at)}</div>
|
|
<div><span className="font-semibold text-slate-100">Created:</span> {formatDateTime(record.created_at)}</div>
|
|
<div><span className="font-semibold text-slate-100">Updated:</span> {formatDateTime(record.updated_at)}</div>
|
|
<div><span className="font-semibold text-slate-100">Source hash:</span> {record.source_hash || '—'}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{(records.prev_page_url || records.next_page_url) ? (
|
|
<div className="mt-8 flex items-center justify-between gap-3">
|
|
<div>
|
|
{records.prev_page_url ? (
|
|
<Link href={records.prev_page_url} preserveScroll 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]">
|
|
<i className="fa-solid fa-arrow-left text-[10px]" />
|
|
Previous
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">Showing page {records.current_page || 1} of {records.last_page || 1}</div>
|
|
<div>
|
|
{records.next_page_url ? (
|
|
<Link href={records.next_page_url} preserveScroll 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]">
|
|
Next
|
|
<i className="fa-solid fa-arrow-right text-[10px]" />
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
} |