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

170 lines
9.8 KiB
JavaScript

import React from 'react'
import { Head, usePage } from '@inertiajs/react'
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
function formatDateTime(value) {
if (!value) return 'Unknown time'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Unknown time'
return date.toLocaleString()
}
function FieldChanges({ label, value }) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const entries = Object.entries(value).slice(0, 8)
if (!entries.length) return null
return (
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
<div className="mt-3 space-y-2 text-sm text-slate-300">
{entries.map(([key, fieldValue]) => (
<div key={key} className="flex items-start justify-between gap-4 border-b border-white/5 pb-2 last:border-b-0 last:pb-0">
<span className="font-medium text-white">{key}</span>
<span className="max-w-[60%] truncate text-right">{Array.isArray(fieldValue) ? `${fieldValue.length} items` : String(fieldValue)}</span>
</div>
))}
</div>
</div>
)
}
function buildPageUrl(pageNumber) {
if (typeof window === 'undefined') return '#'
const url = new URL(window.location.href)
url.searchParams.set('page', String(pageNumber))
return url.toString()
}
export default function CollectionHistory() {
const { props } = usePage()
const collection = props.collection || {}
const history = props.history || {}
const entries = Array.isArray(history.data) ? history.data : []
const meta = history.meta || {}
const seo = props.seo || {}
const [busyId, setBusyId] = React.useState(null)
const [notice, setNotice] = React.useState('')
async function handleRestore(entry) {
if (!props.restorePattern || !entry?.can_restore) return
const confirmed = window.confirm(`Restore this collection state from history entry #${entry.id}?`)
if (!confirmed) return
setBusyId(entry.id)
setNotice('')
try {
const response = await fetch(props.restorePattern.replace('__HISTORY__', String(entry.id)), {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || 'Unable to restore this history entry right now.')
}
window.location.reload()
} catch (error) {
setNotice(error?.message || 'Unable to restore this history entry right now.')
} finally {
setBusyId(null)
}
}
return (
<>
<Head>
<title>{seo.title || `${collection.title || 'Collection'} History — Skinbase Nova`}</title>
<meta name="description" content={seo.description || 'Collection audit history.'} />
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo.robots || 'noindex,follow'} />
</Head>
<div className="relative min-h-screen overflow-hidden pb-16">
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[32rem] opacity-95" style={{ background: 'radial-gradient(circle at 14% 14%, rgba(56,189,248,0.16), transparent 26%), radial-gradient(circle at 84% 20%, rgba(244,63,94,0.14), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 opacity-[0.05]" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }} />
<div className="mx-auto max-w-6xl px-4 pt-8 md:px-6">
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
{props.dashboardUrl ? <a href={props.dashboardUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white"><i className="fa-solid fa-arrow-left fa-fw text-[11px]" />Dashboard</a> : null}
{props.analyticsUrl ? <a href={props.analyticsUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-chart-column fa-fw text-[11px]" />Analytics</a> : null}
{collection.manage_url ? <a href={collection.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white"><i className="fa-solid fa-pen-to-square fa-fw text-[11px]" />Manage</a> : null}
</div>
<section className="mt-6 rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Audit</p>
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{collection.title || 'Collection history'}</h1>
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
A chronological log of lifecycle transitions, editorial changes, artwork operations, and moderation-adjacent actions for this collection.
</p>
</section>
<section className="mt-8 space-y-4">
{notice ? <div className="rounded-[24px] border border-rose-300/20 bg-rose-500/10 px-5 py-4 text-sm text-rose-100">{notice}</div> : null}
{entries.length ? entries.map((entry) => (
<article key={entry.id} className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">{String(entry.action_type || 'updated').replace(/_/g, ' ')}</span>
{entry.actor?.username ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-300">@{entry.actor.username}</span> : <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">System</span>}
{entry.can_restore ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-100">Restorable</span> : null}
</div>
<h2 className="mt-4 text-xl font-semibold text-white">{entry.summary || 'Collection updated'}</h2>
{entry.can_restore && Array.isArray(entry.restore_fields) && entry.restore_fields.length ? <p className="mt-3 text-xs uppercase tracking-[0.18em] text-slate-400">Restores: {entry.restore_fields.join(', ')}</p> : null}
</div>
<div className="flex flex-col items-end gap-3">
<div className="text-sm text-slate-400">{formatDateTime(entry.created_at)}</div>
{props.canRestoreHistory && entry.can_restore ? (
<button
type="button"
onClick={() => handleRestore(entry)}
disabled={busyId === entry.id}
className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-100 transition hover:bg-emerald-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<i className="fa-solid fa-rotate-left fa-fw text-[10px]" />
{busyId === entry.id ? 'Restoring…' : 'Restore'}
</button>
) : null}
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<FieldChanges label="Before" value={entry.before} />
<FieldChanges label="After" value={entry.after} />
</div>
</article>
)) : (
<div className="rounded-[30px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-14 text-sm text-slate-300">No audit entries have been recorded for this collection yet.</div>
)}
</section>
{Number(meta.last_page || 1) > 1 ? (
<div className="mt-8 flex flex-wrap items-center justify-between gap-3 rounded-[28px] border border-white/10 bg-white/[0.04] px-5 py-4 text-sm text-slate-300">
<div>Page {meta.current_page || 1} of {meta.last_page || 1}</div>
<div className="flex gap-2">
{(meta.current_page || 1) > 1 ? <a href={buildPageUrl((meta.current_page || 1) - 1)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-left fa-fw text-[10px]" />Previous</a> : null}
{(meta.current_page || 1) < (meta.last_page || 1) ? <a href={buildPageUrl((meta.current_page || 1) + 1)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 font-semibold text-white transition hover:bg-white/[0.07]">Next<i className="fa-solid fa-arrow-right fa-fw text-[10px]" /></a> : null}
</div>
</div>
) : null}
</div>
</div>
</>
)
}