Save workspace changes
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
|
||||
function MetricCard({ label, value, delta, icon }) {
|
||||
return (
|
||||
<div className="rounded-[26px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">{label}</div>
|
||||
<i className={`fa-solid ${icon} text-slate-500`} />
|
||||
</div>
|
||||
<div className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
|
||||
<div className="mt-2 text-sm text-slate-300">{Number(delta || 0).toLocaleString()} in the selected range</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineChart({ timeline }) {
|
||||
const safeTimeline = Array.isArray(timeline) ? timeline : []
|
||||
const maxValue = safeTimeline.reduce((largest, item) => Math.max(largest, item.views || 0, item.likes || 0, item.saves || 0), 0) || 1
|
||||
|
||||
return (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Timeline</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Engagement trend</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{safeTimeline.length} days</span>
|
||||
</div>
|
||||
|
||||
{safeTimeline.length ? (
|
||||
<div className="mt-6 flex h-64 items-end gap-2 overflow-x-auto rounded-[24px] border border-white/10 bg-slate-950/40 px-4 py-5">
|
||||
{safeTimeline.map((point) => (
|
||||
<div key={point.date} className="flex min-w-[32px] flex-1 flex-col items-center justify-end gap-2">
|
||||
<div className="flex h-full w-full items-end gap-1">
|
||||
<div className="w-1/3 rounded-t-full bg-sky-300/80" style={{ height: `${Math.max(6, ((point.views || 0) / maxValue) * 100)}%` }} title={`Views: ${point.views || 0}`} />
|
||||
<div className="w-1/3 rounded-t-full bg-emerald-300/75" style={{ height: `${Math.max(6, ((point.likes || 0) / maxValue) * 100)}%` }} title={`Likes: ${point.likes || 0}`} />
|
||||
<div className="w-1/3 rounded-t-full bg-amber-300/75" style={{ height: `${Math.max(6, ((point.saves || 0) / maxValue) * 100)}%` }} title={`Saves: ${point.saves || 0}`} />
|
||||
</div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{String(point.date || '').slice(5)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">Analytics are enabled, but there are not enough daily snapshots yet to render a timeline.</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-4 text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">
|
||||
<span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-full bg-sky-300/80" />Views</span>
|
||||
<span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-full bg-emerald-300/75" />Likes</span>
|
||||
<span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-full bg-amber-300/75" />Saves</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CollectionAnalytics() {
|
||||
const { props } = usePage()
|
||||
const collection = props.collection || {}
|
||||
const analytics = props.analytics || {}
|
||||
const totals = analytics.totals || {}
|
||||
const range = analytics.range || {}
|
||||
const topArtworks = Array.isArray(analytics.top_artworks) ? analytics.top_artworks : []
|
||||
const seo = props.seo || {}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo.title || `${collection.title || 'Collection'} Analytics — Skinbase Nova`}</title>
|
||||
<meta name="description" content={seo.description || 'Collection analytics overview.'} />
|
||||
{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.18), transparent 26%), radial-gradient(circle at 86% 18%, rgba(16,185,129,0.16), 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-7xl 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.historyUrl ? <a href={props.historyUrl} 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-timeline fa-fw text-[11px]" />History</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">Performance</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{collection.title || 'Collection analytics'}</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
||||
Review activity velocity, audience response, and the artworks carrying the most discovery value over the last {range.days || 30} days.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<MetricCard label="Views" value={totals.views} delta={range.views_delta} icon="fa-eye" />
|
||||
<MetricCard label="Likes" value={totals.likes} delta={range.likes_delta} icon="fa-heart" />
|
||||
<MetricCard label="Follows" value={totals.follows} delta={range.follows_delta} icon="fa-bell" />
|
||||
<MetricCard label="Saves" value={totals.saves} delta={range.saves_delta} icon="fa-bookmark" />
|
||||
<MetricCard label="Comments" value={totals.comments} delta={range.comments_delta} icon="fa-comments" />
|
||||
<MetricCard label="Submissions" value={totals.submissions} delta={totals.submissions} icon="fa-inbox" />
|
||||
</section>
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
<TimelineChart timeline={analytics.timeline} />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Artworks</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Top artwork drivers</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{topArtworks.length}</span>
|
||||
</div>
|
||||
|
||||
{topArtworks.length ? (
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{topArtworks.map((artwork) => (
|
||||
<div key={artwork.id} className="overflow-hidden rounded-[24px] border border-white/10 bg-slate-950/40">
|
||||
<div className="aspect-square bg-slate-950/60">
|
||||
{artwork.thumb ? <img src={artwork.thumb} alt={artwork.title} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-image text-3xl" /></div>}
|
||||
</div>
|
||||
<div className="space-y-2 p-4">
|
||||
<div className="truncate text-sm font-semibold text-white">{artwork.title}</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-slate-300">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">Views: {Number(artwork.views || 0).toLocaleString()}</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">Favs: {Number(artwork.favourites || 0).toLocaleString()}</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">Shares: {Number(artwork.shares || 0).toLocaleString()}</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">Rank: {Number(artwork.ranking_score || 0).toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">Attach or publish more artworks before artwork-level ranking can be surfaced here.</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
|
||||
const DEFAULT_SEARCH_FILTERS = {
|
||||
q: '',
|
||||
type: '',
|
||||
visibility: '',
|
||||
lifecycle_state: '',
|
||||
workflow_state: '',
|
||||
health_state: '',
|
||||
placement_eligibility: '',
|
||||
}
|
||||
|
||||
const DEFAULT_BULK_FORM = {
|
||||
action: 'archive',
|
||||
campaign_key: '',
|
||||
campaign_label: '',
|
||||
lifecycle_state: 'archived',
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
function titleize(value) {
|
||||
return String(value || '')
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function buildSearchUrl(baseUrl, filters) {
|
||||
const url = new URL(baseUrl, window.location.origin)
|
||||
|
||||
Object.entries(filters || {}).forEach(([key, value]) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
url.searchParams.set(key, String(value))
|
||||
})
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function fetchSearchResults(baseUrl, filters) {
|
||||
const response = await fetch(buildSearchUrl(baseUrl, filters), {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Search failed.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
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 SummaryCard({ label, value, icon, tone = 'sky' }) {
|
||||
const toneClasses = {
|
||||
sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100',
|
||||
amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100',
|
||||
rose: 'border-rose-300/15 bg-rose-400/10 text-rose-100',
|
||||
emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<div className={`inline-flex h-12 w-12 items-center justify-center rounded-2xl border ${toneClasses[tone] || toneClasses.sky}`}>
|
||||
<i className={`fa-solid ${icon}`} />
|
||||
</div>
|
||||
<div className="mt-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">{label}</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionStrip({ title, eyebrow, collections, emptyLabel, endpoints }) {
|
||||
function resolve(pattern, collectionId) {
|
||||
return pattern ? pattern.replace('__COLLECTION__', String(collectionId)) : null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.24)] backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{eyebrow}</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">{title}</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{collections.length}</span>
|
||||
</div>
|
||||
|
||||
{collections.length ? (
|
||||
<div className="mt-6 grid gap-5 xl:grid-cols-2">
|
||||
{collections.map((collection) => (
|
||||
<div key={collection.id} className="space-y-3">
|
||||
<CollectionCard collection={collection} isOwner />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{resolve(endpoints?.managePattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.managePattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]">
|
||||
<i className="fa-solid fa-pen-to-square fa-fw text-[10px]" />Manage
|
||||
</a>
|
||||
) : null}
|
||||
{resolve(endpoints?.analyticsPattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.analyticsPattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15">
|
||||
<i className="fa-solid fa-chart-column fa-fw text-[10px]" />Analytics
|
||||
</a>
|
||||
) : null}
|
||||
{resolve(endpoints?.historyPattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.historyPattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]">
|
||||
<i className="fa-solid fa-timeline fa-fw text-[10px]" />History
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">{emptyLabel}</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function WarningList({ warnings, endpoints }) {
|
||||
function resolve(pattern, collectionId) {
|
||||
return pattern ? pattern.replace('__COLLECTION__', String(collectionId)) : null
|
||||
}
|
||||
|
||||
if (!warnings.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.24)] backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Health</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Warnings and blockers</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{warnings.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 xl:grid-cols-2">
|
||||
{warnings.map((warning) => (
|
||||
<div key={warning.collection_id} className="rounded-[24px] border border-white/10 bg-[#0d1726] p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-white">{warning.title}</p>
|
||||
<p className="mt-2 text-sm text-slate-300">State: {warning.health?.health_state || 'unknown'}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-xs font-semibold text-amber-100">
|
||||
{warning.health?.health_score ?? 'n/a'}
|
||||
</span>
|
||||
</div>
|
||||
{Array.isArray(warning.health?.flags) && warning.health.flags.length ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{warning.health.flags.map((flag) => (
|
||||
<span key={flag} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-300">{flag}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{resolve(endpoints?.managePattern, warning.collection_id) ? <a href={resolve(endpoints.managePattern, warning.collection_id)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen-to-square fa-fw text-[10px]" />Manage</a> : null}
|
||||
{resolve(endpoints?.healthPattern, warning.collection_id) ? <a href={resolve(endpoints.healthPattern, warning.collection_id)} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/15"><i className="fa-solid fa-shield-heart fa-fw text-[10px]" />Health JSON</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchField({ label, value, onChange, children }) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</span>
|
||||
{children || (
|
||||
<input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function BulkActionsPanel({
|
||||
selectedCount,
|
||||
totalCount,
|
||||
form,
|
||||
onFormChange,
|
||||
onApply,
|
||||
onClear,
|
||||
onToggleAll,
|
||||
busy,
|
||||
error,
|
||||
notice,
|
||||
}) {
|
||||
if (!selectedCount && !notice && !error) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 rounded-[26px] border border-white/10 bg-[#0d1726] p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Bulk actions</p>
|
||||
<h3 className="mt-1 text-lg font-semibold text-white">Apply safe actions to selected collections</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs font-semibold">
|
||||
<button type="button" onClick={onToggleAll} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-slate-200 transition hover:bg-white/[0.07]">
|
||||
{selectedCount === totalCount && totalCount > 0 ? 'Clear visible' : 'Select visible'}
|
||||
</button>
|
||||
{selectedCount ? (
|
||||
<button type="button" onClick={onClear} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-slate-200 transition hover:bg-white/[0.07]">
|
||||
Clear selection
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-sky-100">{selectedCount} selected</span>
|
||||
</div>
|
||||
|
||||
{(notice || error) ? (
|
||||
<div className={`mt-4 rounded-2xl px-4 py-3 text-sm ${error ? 'border border-rose-300/20 bg-rose-400/10 text-rose-100' : 'border border-emerald-300/20 bg-emerald-400/10 text-emerald-100'}`}>
|
||||
{error || notice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-4">
|
||||
<SearchField label="Action">
|
||||
<select value={form.action} onChange={(event) => onFormChange('action', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="archive">Archive</option>
|
||||
<option value="assign_campaign">Assign campaign</option>
|
||||
<option value="update_lifecycle">Update lifecycle</option>
|
||||
<option value="request_ai_review">Request AI review</option>
|
||||
<option value="mark_editorial_review">Mark editorial review</option>
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
{form.action === 'assign_campaign' ? (
|
||||
<>
|
||||
<SearchField label="Campaign key">
|
||||
<input value={form.campaign_key} onChange={(event) => onFormChange('campaign_key', event.target.value)} placeholder="spring-launch" className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40" />
|
||||
</SearchField>
|
||||
<SearchField label="Campaign label">
|
||||
<input value={form.campaign_label} onChange={(event) => onFormChange('campaign_label', event.target.value)} placeholder="Spring Launch" className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40" />
|
||||
</SearchField>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{form.action === 'update_lifecycle' ? (
|
||||
<SearchField label="Lifecycle state">
|
||||
<select value={form.lifecycle_state} onChange={(event) => onFormChange('lifecycle_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</SearchField>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-end">
|
||||
<button type="button" onClick={onApply} disabled={busy || !selectedCount} className="inline-flex w-full items-center justify-center gap-2 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 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
<i className="fa-solid fa-wand-magic-sparkles fa-fw text-[12px]" />Apply action
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchResults({ state, endpoints, selectedIds, onToggleSelected }) {
|
||||
function resolve(pattern, collectionId) {
|
||||
return pattern ? pattern.replace('__COLLECTION__', String(collectionId)) : null
|
||||
}
|
||||
|
||||
if (!state.hasSearched) {
|
||||
return (
|
||||
<div className="mt-6 rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">
|
||||
Run a search to slice your collection library by workflow, health, visibility, and placement readiness.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return <div className="mt-6 rounded-[26px] border border-rose-300/20 bg-rose-400/10 px-6 py-4 text-sm text-rose-100">{state.error}</div>
|
||||
}
|
||||
|
||||
if (!state.collections.length) {
|
||||
return (
|
||||
<div className="mt-6 rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">
|
||||
No collections matched the current filters.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(state.filters || {}).filter(([, value]) => value !== '' && value !== null && value !== undefined).map(([key, value]) => (
|
||||
<span key={key} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-300">
|
||||
{titleize(key)}: {value === '1' ? 'Eligible' : value === '0' ? 'Blocked' : titleize(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{state.meta ? <span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{state.meta.total || state.collections.length} results</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
{state.collections.map((collection) => (
|
||||
<div key={collection.id} className="space-y-3 rounded-[28px] border border-white/10 bg-[#0d1726] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(collection.id)}
|
||||
onChange={() => onToggleSelected(collection.id)}
|
||||
className="h-4 w-4 rounded border-white/20 bg-[#09111d] text-sky-400 focus:ring-sky-300/30"
|
||||
/>
|
||||
Select
|
||||
</label>
|
||||
</div>
|
||||
<CollectionCard collection={collection} isOwner />
|
||||
<div className="flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-400">
|
||||
{collection.workflow_state ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">Workflow: {titleize(collection.workflow_state)}</span> : null}
|
||||
{collection.health_state ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">Health: {titleize(collection.health_state)}</span> : null}
|
||||
{collection.placement_eligibility === true ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1 text-emerald-100">Placement Eligible</span> : null}
|
||||
{collection.placement_eligibility === false ? <span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-rose-100">Placement Blocked</span> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{resolve(endpoints?.managePattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.managePattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]">
|
||||
<i className="fa-solid fa-pen-to-square fa-fw text-[10px]" />Manage
|
||||
</a>
|
||||
) : null}
|
||||
{resolve(endpoints?.analyticsPattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.analyticsPattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15">
|
||||
<i className="fa-solid fa-chart-column fa-fw text-[10px]" />Analytics
|
||||
</a>
|
||||
) : null}
|
||||
{resolve(endpoints?.historyPattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.historyPattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]">
|
||||
<i className="fa-solid fa-timeline fa-fw text-[10px]" />History
|
||||
</a>
|
||||
) : null}
|
||||
{resolve(endpoints?.healthPattern, collection.id) ? (
|
||||
<a href={resolve(endpoints.healthPattern, collection.id)} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/15">
|
||||
<i className="fa-solid fa-shield-heart fa-fw text-[10px]" />Health
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CollectionDashboard() {
|
||||
const { props } = usePage()
|
||||
const [summary, setSummary] = React.useState(props.summary || {})
|
||||
const topPerforming = Array.isArray(props.topPerforming) ? props.topPerforming : []
|
||||
const needsAttention = Array.isArray(props.needsAttention) ? props.needsAttention : []
|
||||
const expiringCampaigns = Array.isArray(props.expiringCampaigns) ? props.expiringCampaigns : []
|
||||
const healthWarnings = Array.isArray(props.healthWarnings) ? props.healthWarnings : []
|
||||
const filterOptions = props.filterOptions || {}
|
||||
const endpoints = props.endpoints || {}
|
||||
const seo = props.seo || {}
|
||||
const [searchFilters, setSearchFilters] = React.useState(DEFAULT_SEARCH_FILTERS)
|
||||
const [searchState, setSearchState] = React.useState({
|
||||
busy: false,
|
||||
error: '',
|
||||
collections: [],
|
||||
meta: null,
|
||||
filters: null,
|
||||
hasSearched: false,
|
||||
})
|
||||
const [selectedIds, setSelectedIds] = React.useState([])
|
||||
const [bulkForm, setBulkForm] = React.useState(DEFAULT_BULK_FORM)
|
||||
const [bulkState, setBulkState] = React.useState({ busy: false, error: '', notice: '' })
|
||||
|
||||
React.useEffect(() => {
|
||||
setSummary(props.summary || {})
|
||||
}, [props.summary])
|
||||
|
||||
React.useEffect(() => {
|
||||
const visibleIds = new Set((searchState.collections || []).map((collection) => Number(collection.id)))
|
||||
setSelectedIds((current) => current.filter((id) => visibleIds.has(Number(id))))
|
||||
}, [searchState.collections])
|
||||
|
||||
function updateFilter(key, value) {
|
||||
setSearchFilters((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
async function handleSearch(event) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!endpoints.search) {
|
||||
setSearchState({ busy: false, error: 'Search endpoint is unavailable.', collections: [], meta: null, filters: null, hasSearched: true })
|
||||
return
|
||||
}
|
||||
|
||||
setSearchState((current) => ({ ...current, busy: true, error: '', hasSearched: true }))
|
||||
|
||||
try {
|
||||
const payload = await fetchSearchResults(endpoints.search, searchFilters)
|
||||
setSearchState({
|
||||
busy: false,
|
||||
error: '',
|
||||
collections: Array.isArray(payload.collections) ? payload.collections : [],
|
||||
meta: payload.meta || null,
|
||||
filters: payload.filters || { ...searchFilters },
|
||||
hasSearched: true,
|
||||
})
|
||||
} catch (error) {
|
||||
setSearchState({ busy: false, error: error.message || 'Search failed.', collections: [], meta: null, filters: null, hasSearched: true })
|
||||
}
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
setSearchFilters(DEFAULT_SEARCH_FILTERS)
|
||||
setSearchState({ busy: false, error: '', collections: [], meta: null, filters: null, hasSearched: false })
|
||||
setSelectedIds([])
|
||||
}
|
||||
|
||||
function updateBulkForm(key, value) {
|
||||
setBulkForm((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
function toggleSelected(collectionId) {
|
||||
setSelectedIds((current) => (
|
||||
current.includes(collectionId)
|
||||
? current.filter((id) => id !== collectionId)
|
||||
: [...current, collectionId]
|
||||
))
|
||||
}
|
||||
|
||||
function toggleSelectAllVisible() {
|
||||
const visibleIds = (searchState.collections || []).map((collection) => Number(collection.id))
|
||||
if (!visibleIds.length) {
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedIds((current) => (
|
||||
current.length === visibleIds.length && visibleIds.every((id) => current.includes(id))
|
||||
? []
|
||||
: visibleIds
|
||||
))
|
||||
}
|
||||
|
||||
async function applyBulkAction() {
|
||||
if (!selectedIds.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!endpoints.bulkActions) {
|
||||
setBulkState({ busy: false, error: 'Bulk action endpoint is unavailable.', notice: '' })
|
||||
return
|
||||
}
|
||||
|
||||
if (bulkForm.action === 'archive' && !window.confirm(`Archive ${selectedIds.length} selected collection${selectedIds.length === 1 ? '' : 's'}?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
action: bulkForm.action,
|
||||
collection_ids: selectedIds,
|
||||
}
|
||||
|
||||
if (bulkForm.action === 'assign_campaign') {
|
||||
payload.campaign_key = bulkForm.campaign_key
|
||||
payload.campaign_label = bulkForm.campaign_label
|
||||
}
|
||||
|
||||
if (bulkForm.action === 'update_lifecycle') {
|
||||
payload.lifecycle_state = bulkForm.lifecycle_state
|
||||
}
|
||||
|
||||
setBulkState({ busy: true, error: '', notice: '' })
|
||||
|
||||
try {
|
||||
const response = await requestJson(endpoints.bulkActions, { method: 'POST', body: payload })
|
||||
const updates = new Map((Array.isArray(response.collections) ? response.collections : []).map((collection) => [Number(collection.id), collection]))
|
||||
|
||||
setSearchState((current) => ({
|
||||
...current,
|
||||
collections: (current.collections || []).map((collection) => updates.get(Number(collection.id)) || collection),
|
||||
}))
|
||||
setSummary(response.summary || summary)
|
||||
setSelectedIds([])
|
||||
setBulkState({ busy: false, error: '', notice: response.message || 'Bulk action applied.' })
|
||||
} catch (error) {
|
||||
setBulkState({ busy: false, error: error.message || 'Bulk action failed.', notice: '' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo.title || 'Collections Dashboard — Skinbase Nova'}</title>
|
||||
<meta name="description" content={seo.description || 'Collection lifecycle and performance dashboard.'} />
|
||||
{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-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 12% 15%, rgba(56,189,248,0.18), transparent 28%), radial-gradient(circle at 84% 14%, rgba(245,158,11,0.16), transparent 26%), 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-7xl px-4 pt-8 md:px-6">
|
||||
<section className="overflow-hidden 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">Operations</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Collections dashboard</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
||||
A working view of collection health across lifecycle, submissions, quality, and campaign timing. Use it to decide what to publish, repair, archive, or promote next.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<SummaryCard label="Total Collections" value={summary.total ?? 0} icon="fa-layer-group" tone="sky" />
|
||||
<SummaryCard label="Drafts" value={summary.drafts ?? 0} icon="fa-file" tone="amber" />
|
||||
<SummaryCard label="Scheduled" value={summary.scheduled ?? 0} icon="fa-calendar-days" tone="emerald" />
|
||||
<SummaryCard label="Published" value={summary.published ?? 0} icon="fa-globe" tone="sky" />
|
||||
<SummaryCard label="Archived" value={summary.archived ?? 0} icon="fa-box-archive" tone="rose" />
|
||||
<SummaryCard label="Pending Submissions" value={summary.pending_submissions ?? 0} icon="fa-inbox" tone="amber" />
|
||||
<SummaryCard label="Needs Review" value={summary.needs_review ?? 0} icon="fa-triangle-exclamation" tone="amber" />
|
||||
<SummaryCard label="Duplicate Risk" value={summary.duplicate_risk ?? 0} icon="fa-id-card" tone="rose" />
|
||||
<SummaryCard label="Placement Blocked" value={summary.placement_blocked ?? 0} icon="fa-ban" tone="rose" />
|
||||
</section>
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.24)] backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Search</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Find the exact collections that need action</h2>
|
||||
</div>
|
||||
{searchState.busy ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-xs font-semibold text-sky-100">Searching...</span> : null}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="mt-6 grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
|
||||
<SearchField label="Query" value={searchFilters.q} onChange={(event) => updateFilter('q', event.target.value)}>
|
||||
<input value={searchFilters.q} onChange={(event) => updateFilter('q', event.target.value)} placeholder="Title, slug, or campaign" className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40" />
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="Type">
|
||||
<select value={searchFilters.type} onChange={(event) => updateFilter('type', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="">Any type</option>
|
||||
{(Array.isArray(filterOptions.types) ? filterOptions.types : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="Visibility">
|
||||
<select value={searchFilters.visibility} onChange={(event) => updateFilter('visibility', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="">Any visibility</option>
|
||||
{(Array.isArray(filterOptions.visibilities) ? filterOptions.visibilities : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="Lifecycle">
|
||||
<select value={searchFilters.lifecycle_state} onChange={(event) => updateFilter('lifecycle_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="">Any lifecycle</option>
|
||||
{(Array.isArray(filterOptions.lifecycleStates) ? filterOptions.lifecycleStates : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="Workflow">
|
||||
<select value={searchFilters.workflow_state} onChange={(event) => updateFilter('workflow_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="">Any workflow</option>
|
||||
{(Array.isArray(filterOptions.workflowStates) ? filterOptions.workflowStates : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="Health">
|
||||
<select value={searchFilters.health_state} onChange={(event) => updateFilter('health_state', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="">Any health state</option>
|
||||
{(Array.isArray(filterOptions.healthStates) ? filterOptions.healthStates : []).map((option) => (
|
||||
<option key={option} value={option}>{titleize(option)}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="Placement">
|
||||
<select value={searchFilters.placement_eligibility} onChange={(event) => updateFilter('placement_eligibility', event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#09111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="">Any placement state</option>
|
||||
<option value="1">Eligible</option>
|
||||
<option value="0">Blocked</option>
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
<div className="flex items-end gap-3 xl:col-span-1">
|
||||
<button type="submit" disabled={searchState.busy} className="inline-flex flex-1 items-center justify-center gap-2 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 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
<i className="fa-solid fa-magnifying-glass fa-fw text-[12px]" />Search
|
||||
</button>
|
||||
<button type="button" onClick={resetSearch} disabled={searchState.busy} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07] disabled:cursor-not-allowed disabled:opacity-60">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<BulkActionsPanel
|
||||
selectedCount={selectedIds.length}
|
||||
totalCount={(searchState.collections || []).length}
|
||||
form={bulkForm}
|
||||
onFormChange={updateBulkForm}
|
||||
onApply={applyBulkAction}
|
||||
onClear={() => setSelectedIds([])}
|
||||
onToggleAll={toggleSelectAllVisible}
|
||||
busy={bulkState.busy}
|
||||
error={bulkState.error}
|
||||
notice={bulkState.notice}
|
||||
/>
|
||||
|
||||
<SearchResults state={searchState} endpoints={endpoints} selectedIds={selectedIds} onToggleSelected={toggleSelected} />
|
||||
</section>
|
||||
|
||||
<WarningList warnings={healthWarnings} endpoints={endpoints} />
|
||||
<CollectionStrip title="Top Performing" eyebrow="Momentum" collections={topPerforming} emptyLabel="No collections have enough activity yet to rank here." endpoints={endpoints} />
|
||||
<CollectionStrip title="Needs Attention" eyebrow="Quality" collections={needsAttention} emptyLabel="No collections currently need manual intervention." endpoints={endpoints} />
|
||||
<CollectionStrip title="Expiring Campaigns" eyebrow="Timing" collections={expiringCampaigns} emptyLabel="No campaigns are approaching their sunset window." endpoints={endpoints} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
const SEARCH_SELECT_OPTIONS = {
|
||||
type: [
|
||||
{ value: 'personal', label: 'Personal' },
|
||||
{ value: 'community', label: 'Community' },
|
||||
{ value: 'editorial', label: 'Editorial' },
|
||||
],
|
||||
mode: [
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'smart', label: 'Smart' },
|
||||
],
|
||||
lifecycle_state: [
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'featured', label: 'Featured' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
],
|
||||
health_state: [
|
||||
{ value: 'healthy', label: 'Healthy' },
|
||||
{ value: 'needs_metadata', label: 'Needs metadata' },
|
||||
{ value: 'stale', label: 'Stale' },
|
||||
{ value: 'low_content', label: 'Low content' },
|
||||
{ value: 'broken_items', label: 'Broken items' },
|
||||
{ value: 'weak_cover', label: 'Weak cover' },
|
||||
{ value: 'low_engagement', label: 'Low engagement' },
|
||||
{ value: 'duplicate_risk', label: 'Duplicate risk' },
|
||||
{ value: 'merge_candidate', label: 'Merge candidate' },
|
||||
],
|
||||
sort: [
|
||||
{ value: 'trending', label: 'Trending' },
|
||||
{ value: 'recent', label: 'Recent' },
|
||||
{ value: 'quality', label: 'Quality' },
|
||||
{ value: 'evergreen', label: 'Evergreen' },
|
||||
],
|
||||
}
|
||||
|
||||
function humanizeToken(value) {
|
||||
return String(value || '')
|
||||
.replaceAll('_', ' ')
|
||||
.replaceAll('-', ' ')
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase())
|
||||
}
|
||||
|
||||
function searchChipLabel(key, value) {
|
||||
if (!value) return null
|
||||
|
||||
const option = SEARCH_SELECT_OPTIONS[key]?.find((item) => item.value === value)
|
||||
const displayValue = option?.label || humanizeToken(value)
|
||||
|
||||
return key === 'q'
|
||||
? `Query: ${value}`
|
||||
: key === 'campaign_key'
|
||||
? `Campaign: ${displayValue}`
|
||||
: key === 'program_key'
|
||||
? `Program: ${displayValue}`
|
||||
: key === 'quality_tier'
|
||||
? `Quality Tier: ${displayValue}`
|
||||
: key === 'sort'
|
||||
? `Sort: ${displayValue}`
|
||||
: `${humanizeToken(key)}: ${displayValue}`
|
||||
}
|
||||
|
||||
function buildSearchHref(filters, omitKey = null) {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
Object.entries(filters || {}).forEach(([key, value]) => {
|
||||
if (key === omitKey) return
|
||||
if (value === null || value === undefined || value === '') return
|
||||
params.set(key, value)
|
||||
})
|
||||
|
||||
const query = params.toString()
|
||||
return query ? `/collections/search?${query}` : '/collections/search'
|
||||
}
|
||||
|
||||
function activeSearchChips(filters) {
|
||||
return Object.entries(filters || {})
|
||||
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: searchChipLabel(key, value),
|
||||
href: buildSearchHref(filters, key),
|
||||
}))
|
||||
.filter((chip) => chip.label)
|
||||
}
|
||||
|
||||
function primarySaveContext({ search, campaign, program, title, eyebrow }) {
|
||||
if (search) {
|
||||
return {
|
||||
context: 'collection_search',
|
||||
meta: {
|
||||
query: search?.filters?.q || null,
|
||||
surface_label: 'collection search',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (campaign) {
|
||||
return {
|
||||
context: 'campaign_landing',
|
||||
meta: {
|
||||
campaign_key: campaign.key,
|
||||
campaign_label: campaign.label,
|
||||
surface_label: campaign.label || 'campaign landing',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (program) {
|
||||
return {
|
||||
context: 'program_landing',
|
||||
meta: {
|
||||
program_key: program.key,
|
||||
program_label: program.label,
|
||||
surface_label: program.label || 'program landing',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (eyebrow === 'Trending') return { context: 'trending_landing', meta: { surface_label: 'trending collections' } }
|
||||
if (eyebrow === 'Editorial') return { context: 'editorial_landing', meta: { surface_label: 'editorial collections' } }
|
||||
if (eyebrow === 'Community') return { context: 'community_landing', meta: { surface_label: 'community collections' } }
|
||||
if (eyebrow === 'Seasonal') return { context: 'seasonal_landing', meta: { surface_label: 'seasonal collections' } }
|
||||
if (title === 'Recommended collections' || title === 'Collections worth exploring') return { context: 'recommended_landing', meta: { surface_label: 'recommended collections' } }
|
||||
|
||||
return {
|
||||
context: 'featured_landing',
|
||||
meta: { surface_label: 'featured collections' },
|
||||
}
|
||||
}
|
||||
|
||||
function HeroStat({ icon, label, value }) {
|
||||
return (
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.05] px-4 py-4">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
<i className={`fa-solid ${icon} text-[10px]`} />
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="rounded-[32px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.05] text-slate-400">
|
||||
<i className="fa-solid fa-compass text-3xl" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-2xl font-semibold text-white">No featured collections yet</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-300">
|
||||
Featured placement is reserved for public collections with a strong visual point of view. Check back once creators start pinning their best showcases.
|
||||
</p>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/12 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-house fa-fw" />
|
||||
Back to home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchPanel({ search }) {
|
||||
if (!search) return null
|
||||
|
||||
const filters = search.filters || {}
|
||||
const options = search.options || {}
|
||||
const chips = activeSearchChips(filters)
|
||||
|
||||
return (
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Filters</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Search collections</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{search?.meta?.total ?? 0} results</span>
|
||||
</div>
|
||||
<form method="GET" action="/collections/search" className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<input name="q" defaultValue={filters.q || ''} placeholder="Search title or summary" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35 xl:col-span-2" />
|
||||
<select name="type" defaultValue={filters.type || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">All types</option>
|
||||
<option value="personal">Personal</option>
|
||||
<option value="community">Community</option>
|
||||
<option value="editorial">Editorial</option>
|
||||
</select>
|
||||
<select name="sort" defaultValue={filters.sort || 'trending'} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="trending">Trending</option>
|
||||
<option value="recent">Recent</option>
|
||||
<option value="quality">Quality</option>
|
||||
<option value="evergreen">Evergreen</option>
|
||||
</select>
|
||||
<select name="category" defaultValue={filters.category || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any category</option>
|
||||
{(options.category || []).map((item) => (
|
||||
<option key={`category-${item.value}`} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="mode" defaultValue={filters.mode || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any curation mode</option>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="smart">Smart</option>
|
||||
</select>
|
||||
<select name="style" defaultValue={filters.style || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any style signal</option>
|
||||
{(options.style || []).map((item) => (
|
||||
<option key={`style-${item.value}`} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="lifecycle_state" defaultValue={filters.lifecycle_state || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any lifecycle</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="featured">Featured</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<select name="theme" defaultValue={filters.theme || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any theme</option>
|
||||
{(options.theme || []).map((item) => (
|
||||
<option key={`theme-${item.value}`} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="health_state" defaultValue={filters.health_state || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any quality state</option>
|
||||
<option value="healthy">Healthy</option>
|
||||
<option value="needs_metadata">Needs metadata</option>
|
||||
<option value="stale">Stale</option>
|
||||
<option value="low_content">Low content</option>
|
||||
<option value="broken_items">Broken items</option>
|
||||
<option value="weak_cover">Weak cover</option>
|
||||
<option value="low_engagement">Low engagement</option>
|
||||
<option value="duplicate_risk">Duplicate risk</option>
|
||||
<option value="merge_candidate">Merge candidate</option>
|
||||
</select>
|
||||
<select name="color" defaultValue={filters.color || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any color palette</option>
|
||||
{(options.color || []).map((item) => (
|
||||
<option key={`color-${item.value}`} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input name="campaign_key" defaultValue={filters.campaign_key || ''} placeholder="Campaign key" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35" />
|
||||
<input name="program_key" defaultValue={filters.program_key || ''} placeholder="Program key" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35" />
|
||||
<select name="quality_tier" defaultValue={filters.quality_tier || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
|
||||
<option value="">Any quality tier</option>
|
||||
{(options.quality_tier || []).map((item) => (
|
||||
<option key={`quality-tier-${item.value}`} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="md:col-span-2 xl:col-span-4 flex flex-wrap gap-3">
|
||||
<button type="submit" className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15"><i className="fa-solid fa-magnifying-glass fa-fw" />Apply filters</button>
|
||||
<a href="/collections/search" className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-rotate-left fa-fw" />Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
{chips.length ? (
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{chips.map((chip) => (
|
||||
<a key={chip.key} href={chip.href} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs font-semibold text-slate-200 transition hover:bg-white/[0.08]">
|
||||
<span>{chip.label}</span>
|
||||
<i className="fa-solid fa-xmark text-[10px] text-slate-400" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{(search?.links?.prev || search?.links?.next) ? (
|
||||
<div className="mt-5 flex flex-wrap gap-3 text-sm">
|
||||
{search.links.prev ? <a href={search.links.prev} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-left fa-fw text-[10px]" />Previous</a> : null}
|
||||
{search.links.next ? <a href={search.links.next} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white transition hover:bg-white/[0.07]">Next<i className="fa-solid fa-arrow-right fa-fw text-[10px]" /></a> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CollectionFeaturedIndex() {
|
||||
const { props } = usePage()
|
||||
const seo = props.seo || {}
|
||||
const eyebrow = props.eyebrow || 'Discovery'
|
||||
const title = props.title || 'Featured collections'
|
||||
const description = props.description || 'A rotating set of standout galleries from across Skinbase Nova. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.'
|
||||
const collections = Array.isArray(props.collections) ? props.collections : []
|
||||
const communityCollections = Array.isArray(props.communityCollections) ? props.communityCollections : []
|
||||
const editorialCollections = Array.isArray(props.editorialCollections) ? props.editorialCollections : []
|
||||
const recentCollections = Array.isArray(props.recentCollections) ? props.recentCollections : []
|
||||
const trendingCollections = Array.isArray(props.trendingCollections) ? props.trendingCollections : []
|
||||
const seasonalCollections = Array.isArray(props.seasonalCollections) ? props.seasonalCollections : []
|
||||
const campaign = props.campaign || null
|
||||
const program = props.program || null
|
||||
const search = props.search || null
|
||||
const smartCount = collections.filter((collection) => collection?.mode === 'smart').length
|
||||
const totalArtworks = collections.reduce((sum, collection) => sum + (collection?.artworks_count || 0), 0)
|
||||
const mainSave = primarySaveContext({ search, campaign, program, title, eyebrow })
|
||||
const listSchema = seo?.canonical ? {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url: seo.canonical,
|
||||
mainEntity: {
|
||||
'@type': 'ItemList',
|
||||
numberOfItems: collections.length,
|
||||
itemListElement: collections.slice(0, 18).map((collection, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: collection.url,
|
||||
name: collection.title,
|
||||
})),
|
||||
},
|
||||
} : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoHead seo={seo} title={seo?.title || `${title} — Skinbase Nova`} description={seo?.description || description} jsonLd={listSchema} />
|
||||
|
||||
<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-[38rem] opacity-95"
|
||||
style={{
|
||||
background: 'radial-gradient(circle at 12% 14%, rgba(56,189,248,0.18), transparent 28%), radial-gradient(circle at 88% 16%, rgba(249,115,22,0.18), transparent 26%), 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-7xl px-4 pt-8 md:px-6">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
|
||||
<a href="/" 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]" />
|
||||
Back to home
|
||||
</a>
|
||||
<a href="/collections/featured" 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">Featured</a>
|
||||
<a href="/collections/trending" 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">Trending</a>
|
||||
<a href="/collections/community" 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">Community</a>
|
||||
<a href="/collections/editorial" 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">Editorial</a>
|
||||
<a href="/collections/seasonal" 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">Seasonal</a>
|
||||
</div>
|
||||
|
||||
<section className="mt-6 overflow-hidden 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">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.18fr)_400px] xl:items-end">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{eyebrow}</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{title}</h1>
|
||||
{campaign?.badge_label ? (
|
||||
<div className="mt-4 inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">
|
||||
{campaign.badge_label}
|
||||
</div>
|
||||
) : program?.promotion_tier ? (
|
||||
<div className="mt-4 inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">
|
||||
Promotion tier: {program.promotion_tier}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
||||
{description}
|
||||
</p>
|
||||
{campaign ? (
|
||||
<div className="mt-5 flex flex-wrap gap-3 text-xs text-slate-300">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Campaign key: {campaign.key}</span>
|
||||
{campaign.event_label ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Event: {campaign.event_label}</span> : null}
|
||||
{campaign.season_key ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Season: {campaign.season_key}</span> : null}
|
||||
{Array.isArray(campaign.active_surface_keys) && campaign.active_surface_keys.length ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Surfaces: {campaign.active_surface_keys.join(', ')}</span> : null}
|
||||
</div>
|
||||
) : program ? (
|
||||
<div className="mt-5 flex flex-wrap gap-3 text-xs text-slate-300">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Program key: {program.key}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Collections: {program.collections_count ?? collections.length}</span>
|
||||
{program.trust_tier ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Trust: {program.trust_tier}</span> : null}
|
||||
{Array.isArray(program.partner_labels) && program.partner_labels.length ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Partners: {program.partner_labels.join(', ')}</span> : null}
|
||||
{Array.isArray(program.sponsorship_labels) && program.sponsorship_labels.length ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Sponsors: {program.sponsorship_labels.join(', ')}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<HeroStat icon="fa-layer-group" label="Collections" value={collections.length.toLocaleString()} />
|
||||
<HeroStat icon="fa-wand-magic-sparkles" label="Smart" value={smartCount.toLocaleString()} />
|
||||
<HeroStat icon="fa-images" label="Artworks" value={totalArtworks.toLocaleString()} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
<SearchPanel search={search} />
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
{collections.length ? (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{collections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext={mainSave.context} saveContextMeta={mainSave.meta} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{communityCollections.length ? (
|
||||
<section className="mt-10">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Community</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Collaborative picks</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{communityCollections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="community_row" saveContextMeta={{ surface_label: 'community collections' }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{trendingCollections.length ? (
|
||||
<section className="mt-10">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Trending</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Momentum right now</h2>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{trendingCollections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="trending_row" saveContextMeta={{ surface_label: 'trending collections' }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{editorialCollections.length ? (
|
||||
<section className="mt-10">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Editorial</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Staff and campaign collections</h2>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{editorialCollections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="editorial_row" saveContextMeta={{ surface_label: 'editorial collections' }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{seasonalCollections.length ? (
|
||||
<section className="mt-10">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Seasonal</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Campaign and event spotlights</h2>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{seasonalCollections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="seasonal_row" saveContextMeta={{ surface_label: 'seasonal collections' }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{recentCollections.length ? (
|
||||
<section className="mt-10 pb-8">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Recent</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Freshly published collections</h2>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{recentCollections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="recent_row" saveContextMeta={{ surface_label: 'recent collections' }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
function StatCard({ icon, label, value }) {
|
||||
return (
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.05] px-4 py-4">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
<i className={`fa-solid ${icon} text-[10px]`} />
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CollectionSeriesShow() {
|
||||
const { props } = usePage()
|
||||
const seo = props.seo || {}
|
||||
const title = props.title || `Collection Series: ${props.seriesKey || ''}`
|
||||
const description = props.description || 'A connected sequence of public collections on Skinbase Nova.'
|
||||
const collections = Array.isArray(props.collections) ? props.collections : []
|
||||
const leadCollection = props.leadCollection || null
|
||||
const stats = props.stats || {}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoHead seo={seo} title={seo.title || `${title} — Skinbase Nova`} description={seo.description || description} />
|
||||
|
||||
<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-[36rem] opacity-95" style={{ background: 'radial-gradient(circle at 10% 15%, rgba(59,130,246,0.18), transparent 28%), radial-gradient(circle at 84% 18%, rgba(34,197,94,0.16), 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-7xl px-4 pt-8 md:px-6">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
|
||||
<a href="/collections/featured" 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]" />
|
||||
Back to collections
|
||||
</a>
|
||||
{leadCollection?.url ? <a href={leadCollection.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">Lead collection</a> : null}
|
||||
</div>
|
||||
|
||||
<section className="mt-6 overflow-hidden 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">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_400px] xl:items-end">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Series</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">{description}</p>
|
||||
{props.seriesKey ? <div className="mt-5 inline-flex rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-300">{props.seriesKey}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<StatCard icon="fa-layer-group" label="Collections" value={Number(stats.collections || collections.length).toLocaleString()} />
|
||||
<StatCard icon="fa-user-group" label="Creators" value={Number(stats.owners || 0).toLocaleString()} />
|
||||
<StatCard icon="fa-images" label="Artworks" value={Number(stats.artworks || 0).toLocaleString()} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{leadCollection ? (
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Lead Entry</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Start with the opening collection</h2>
|
||||
</div>
|
||||
{stats.latest_activity_at ? <div className="text-xs uppercase tracking-[0.16em] text-slate-400">Latest activity {new Date(stats.latest_activity_at).toLocaleDateString()}</div> : null}
|
||||
</div>
|
||||
<div className="mt-5 max-w-xl">
|
||||
<CollectionCard collection={leadCollection} isOwner={false} />
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mt-8 pb-8">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Sequence</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Public collections in order</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{collections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,963 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import ShareToast from '../../components/ui/ShareToast'
|
||||
|
||||
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 isoToLocalInput(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
|
||||
return local.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function titleize(value) {
|
||||
return String(value || '')
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function Field({ label, help, children }) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-semibold text-white">{label}</span>
|
||||
{children}
|
||||
{help ? <span className="block text-xs leading-relaxed text-slate-400">{help}</span> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, tone = 'sky' }) {
|
||||
const toneClasses = {
|
||||
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',
|
||||
}
|
||||
|
||||
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] ${toneClasses[tone] || toneClasses.sky}`}>{label}</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sortAssignments(items) {
|
||||
return [...items].sort((left, right) => {
|
||||
const keyCompare = String(left.program_key || '').localeCompare(String(right.program_key || ''))
|
||||
if (keyCompare !== 0) return keyCompare
|
||||
return (Number(right.priority) || 0) - (Number(left.priority) || 0)
|
||||
})
|
||||
}
|
||||
|
||||
function buildHooksForm(collection) {
|
||||
return {
|
||||
experiment_key: collection?.experiment_key || '',
|
||||
experiment_treatment: collection?.experiment_treatment || '',
|
||||
placement_variant: collection?.placement_variant || '',
|
||||
ranking_mode_variant: collection?.ranking_mode_variant || '',
|
||||
collection_pool_version: collection?.collection_pool_version || '',
|
||||
test_label: collection?.test_label || '',
|
||||
promotion_tier: collection?.promotion_tier || '',
|
||||
partner_key: collection?.partner_key || '',
|
||||
trust_tier: collection?.trust_tier || '',
|
||||
sponsorship_state: collection?.sponsorship_state || '',
|
||||
ownership_domain: collection?.ownership_domain || '',
|
||||
commercial_review_state: collection?.commercial_review_state || '',
|
||||
legal_review_state: collection?.legal_review_state || '',
|
||||
placement_eligibility: Boolean(collection?.placement_eligibility),
|
||||
}
|
||||
}
|
||||
|
||||
function buildDiagnostics(collection) {
|
||||
if (!collection) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
collection_id: Number(collection.id),
|
||||
workflow_state: collection.workflow_state || null,
|
||||
health_state: collection.health_state || null,
|
||||
placement_eligibility: Boolean(collection.placement_eligibility),
|
||||
experiment_key: collection.experiment_key || null,
|
||||
experiment_treatment: collection.experiment_treatment || null,
|
||||
placement_variant: collection.placement_variant || null,
|
||||
ranking_mode_variant: collection.ranking_mode_variant || null,
|
||||
collection_pool_version: collection.collection_pool_version || null,
|
||||
test_label: collection.test_label || null,
|
||||
partner_key: collection.partner_key || null,
|
||||
trust_tier: collection.trust_tier || null,
|
||||
promotion_tier: collection.promotion_tier || null,
|
||||
sponsorship_state: collection.sponsorship_state || null,
|
||||
ownership_domain: collection.ownership_domain || null,
|
||||
commercial_review_state: collection.commercial_review_state || null,
|
||||
legal_review_state: collection.legal_review_state || null,
|
||||
ranking_bucket: collection.ranking_bucket || null,
|
||||
recommendation_tier: collection.recommendation_tier || null,
|
||||
last_health_check_at: collection.last_health_check_at || null,
|
||||
last_recommendation_refresh_at: collection.last_recommendation_refresh_at || null,
|
||||
}
|
||||
}
|
||||
|
||||
function buildProgramUrl(pattern, programKey) {
|
||||
if (!pattern || !programKey) return null
|
||||
return pattern.replace('__PROGRAM__', String(programKey))
|
||||
}
|
||||
|
||||
export default function CollectionStaffProgramming() {
|
||||
const { props } = usePage()
|
||||
const initialCollectionOptions = Array.isArray(props.collectionOptions) ? props.collectionOptions : []
|
||||
const initialAssignments = Array.isArray(props.assignments) ? props.assignments : []
|
||||
const baseProgramKeys = Array.isArray(props.programKeyOptions) ? props.programKeyOptions : []
|
||||
const initialMergeQueue = props.mergeQueue || { summary: {}, pending: [], recent: [] }
|
||||
const observabilitySummary = props.observabilitySummary || { counts: {}, watchlist: [], generated_at: null }
|
||||
const endpoints = props.endpoints || {}
|
||||
const historyPattern = props.historyPattern || ''
|
||||
const seo = props.seo || {}
|
||||
const viewer = props.viewer || {}
|
||||
const [assignments, setAssignments] = React.useState(sortAssignments(initialAssignments))
|
||||
const [collectionOverrides, setCollectionOverrides] = React.useState({})
|
||||
const collectionOptions = React.useMemo(() => initialCollectionOptions.map((collection) => collectionOverrides[collection.id] || collection), [collectionOverrides, initialCollectionOptions])
|
||||
const [mergeQueue, setMergeQueue] = React.useState(initialMergeQueue)
|
||||
const [previewCollections, setPreviewCollections] = React.useState([])
|
||||
const [diagnostics, setDiagnostics] = React.useState({
|
||||
eligibility: null,
|
||||
duplicates: null,
|
||||
recommendations: null,
|
||||
})
|
||||
const [notice, setNotice] = React.useState('')
|
||||
const [toast, setToast] = React.useState({ id: 0, visible: false, message: '', variant: 'success' })
|
||||
const [busy, setBusy] = React.useState('')
|
||||
const [queueBusy, setQueueBusy] = React.useState({})
|
||||
const [selectedCollectionId, setSelectedCollectionId] = React.useState(initialCollectionOptions[0]?.id || '')
|
||||
const [previewForm, setPreviewForm] = React.useState({
|
||||
program_key: baseProgramKeys[0] || '',
|
||||
limit: 8,
|
||||
})
|
||||
const [assignmentForm, setAssignmentForm] = React.useState({
|
||||
id: null,
|
||||
collection_id: collectionOptions[0]?.id || '',
|
||||
program_key: baseProgramKeys[0] || '',
|
||||
campaign_key: '',
|
||||
placement_scope: '',
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
priority: 0,
|
||||
notes: '',
|
||||
})
|
||||
const selectedCollection = React.useMemo(() => collectionOptions.find((collection) => String(collection.id) === String(selectedCollectionId)) || null, [collectionOptions, selectedCollectionId])
|
||||
const [hooksForm, setHooksForm] = React.useState(buildHooksForm(initialCollectionOptions[0] || null))
|
||||
const [hooksDiagnostics, setHooksDiagnostics] = React.useState(buildDiagnostics(initialCollectionOptions[0] || null))
|
||||
|
||||
const programKeyOptions = React.useMemo(() => {
|
||||
return Array.from(new Set([
|
||||
...baseProgramKeys,
|
||||
...assignments.map((assignment) => assignment.program_key).filter(Boolean),
|
||||
...collectionOptions.map((collection) => collection.program_key).filter(Boolean),
|
||||
assignmentForm.program_key || null,
|
||||
previewForm.program_key || null,
|
||||
].filter(Boolean))).sort((left, right) => String(left).localeCompare(String(right)))
|
||||
}, [assignmentForm.program_key, assignments, baseProgramKeys, collectionOptions, previewForm.program_key])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!assignmentForm.collection_id && collectionOptions[0]?.id) {
|
||||
setAssignmentForm((current) => ({ ...current, collection_id: collectionOptions[0].id }))
|
||||
}
|
||||
|
||||
if (!selectedCollectionId && collectionOptions[0]?.id) {
|
||||
setSelectedCollectionId(collectionOptions[0].id)
|
||||
}
|
||||
}, [assignmentForm.collection_id, collectionOptions, selectedCollectionId])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!assignmentForm.program_key && programKeyOptions[0]) {
|
||||
setAssignmentForm((current) => ({ ...current, program_key: programKeyOptions[0] }))
|
||||
}
|
||||
|
||||
if (!previewForm.program_key && programKeyOptions[0]) {
|
||||
setPreviewForm((current) => ({ ...current, program_key: programKeyOptions[0] }))
|
||||
}
|
||||
}, [assignmentForm.program_key, previewForm.program_key, programKeyOptions])
|
||||
|
||||
React.useEffect(() => {
|
||||
setMergeQueue(initialMergeQueue)
|
||||
}, [initialMergeQueue])
|
||||
|
||||
React.useEffect(() => {
|
||||
setHooksForm(buildHooksForm(selectedCollection))
|
||||
setHooksDiagnostics(buildDiagnostics(selectedCollection))
|
||||
}, [selectedCollection])
|
||||
|
||||
function showToast(message, variant = 'success') {
|
||||
setToast({
|
||||
id: Date.now() + Math.random(),
|
||||
visible: true,
|
||||
message,
|
||||
variant,
|
||||
})
|
||||
}
|
||||
|
||||
function resetAssignmentForm() {
|
||||
setAssignmentForm({
|
||||
id: null,
|
||||
collection_id: collectionOptions[0]?.id || '',
|
||||
program_key: programKeyOptions[0] || '',
|
||||
campaign_key: '',
|
||||
placement_scope: '',
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
priority: 0,
|
||||
notes: '',
|
||||
})
|
||||
}
|
||||
|
||||
function hydrateAssignment(assignment) {
|
||||
setAssignmentForm({
|
||||
id: assignment.id,
|
||||
collection_id: assignment.collection?.id || '',
|
||||
program_key: assignment.program_key || '',
|
||||
campaign_key: assignment.campaign_key || '',
|
||||
placement_scope: assignment.placement_scope || '',
|
||||
starts_at: isoToLocalInput(assignment.starts_at),
|
||||
ends_at: isoToLocalInput(assignment.ends_at),
|
||||
priority: assignment.priority || 0,
|
||||
notes: assignment.notes || '',
|
||||
})
|
||||
}
|
||||
|
||||
async function handleAssignmentSubmit(event) {
|
||||
event.preventDefault()
|
||||
setBusy('assignment')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const url = assignmentForm.id
|
||||
? endpoints.updatePattern?.replace('__PROGRAM__', String(assignmentForm.id))
|
||||
: endpoints.store
|
||||
|
||||
const payload = await requestJson(url, {
|
||||
method: assignmentForm.id ? 'PATCH' : 'POST',
|
||||
body: {
|
||||
collection_id: Number(assignmentForm.collection_id),
|
||||
program_key: assignmentForm.program_key,
|
||||
campaign_key: assignmentForm.campaign_key || null,
|
||||
placement_scope: assignmentForm.placement_scope || null,
|
||||
starts_at: assignmentForm.starts_at ? new Date(assignmentForm.starts_at).toISOString() : null,
|
||||
ends_at: assignmentForm.ends_at ? new Date(assignmentForm.ends_at).toISOString() : null,
|
||||
priority: Number(assignmentForm.priority || 0),
|
||||
notes: assignmentForm.notes || null,
|
||||
},
|
||||
})
|
||||
|
||||
setAssignments((current) => sortAssignments([...current.filter((item) => item.id !== payload.assignment.id), payload.assignment]))
|
||||
setNotice(assignmentForm.id ? 'Programming assignment updated.' : 'Programming assignment created.')
|
||||
resetAssignmentForm()
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to save programming assignment.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreview(event) {
|
||||
event.preventDefault()
|
||||
setBusy('preview')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(endpoints.preview, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
program_key: previewForm.program_key,
|
||||
limit: Number(previewForm.limit || 8),
|
||||
},
|
||||
})
|
||||
|
||||
setPreviewCollections(Array.isArray(payload.collections) ? payload.collections : [])
|
||||
setNotice('Preview refreshed.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to preview this program key.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function runDiagnostic(kind) {
|
||||
const endpointMap = {
|
||||
eligibility: endpoints.refreshEligibility,
|
||||
duplicates: endpoints.duplicateScan,
|
||||
recommendations: endpoints.refreshRecommendations,
|
||||
}
|
||||
|
||||
const url = endpointMap[kind]
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(kind)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(url, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
collection_id: selectedCollectionId ? Number(selectedCollectionId) : null,
|
||||
},
|
||||
})
|
||||
|
||||
setDiagnostics((current) => ({ ...current, [kind]: payload.result || null }))
|
||||
setNotice(payload?.result?.message || `${titleize(kind)} queued.`)
|
||||
} catch (error) {
|
||||
setNotice(error.message || `Failed to run ${kind}.`)
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQueueAction(kind, item) {
|
||||
const sourceId = item?.source?.id
|
||||
const targetId = item?.target?.id
|
||||
|
||||
if (!sourceId || !targetId) {
|
||||
return
|
||||
}
|
||||
|
||||
const endpointMap = {
|
||||
canonicalize: endpoints.canonicalizeCandidate,
|
||||
merge: endpoints.mergeCandidate,
|
||||
reject: endpoints.rejectCandidate,
|
||||
}
|
||||
|
||||
const confirmationMap = {
|
||||
canonicalize: `Designate "${item.target?.title || 'this collection'}" as the canonical target for "${item.source?.title || 'this collection'}"?`,
|
||||
merge: `Merge "${item.source?.title || 'this collection'}" into "${item.target?.title || 'this collection'}" from the staff queue?`,
|
||||
reject: `Mark "${item.target?.title || 'this collection'}" as not a duplicate of "${item.source?.title || 'this collection'}"?`,
|
||||
}
|
||||
|
||||
const url = endpointMap[kind]
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.confirm(confirmationMap[kind] || 'Continue?')) {
|
||||
return
|
||||
}
|
||||
|
||||
setQueueBusy((current) => ({ ...current, [item.id]: kind }))
|
||||
|
||||
try {
|
||||
const payload = await requestJson(url, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
source_collection_id: Number(sourceId),
|
||||
target_collection_id: Number(targetId),
|
||||
},
|
||||
})
|
||||
|
||||
if (payload?.mergeQueue) {
|
||||
setMergeQueue(payload.mergeQueue)
|
||||
}
|
||||
|
||||
showToast(payload?.message || `${titleize(kind)} action completed.`, 'success')
|
||||
} catch (error) {
|
||||
showToast(error.message || `Failed to ${kind} this queue item.`, 'error')
|
||||
} finally {
|
||||
setQueueBusy((current) => {
|
||||
const next = { ...current }
|
||||
delete next[item.id]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHooksSubmit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!selectedCollectionId || !endpoints.metadataUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy('hooks')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(endpoints.metadataUpdate, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
collection_id: Number(selectedCollectionId),
|
||||
experiment_key: hooksForm.experiment_key || null,
|
||||
experiment_treatment: hooksForm.experiment_treatment || null,
|
||||
placement_variant: hooksForm.placement_variant || null,
|
||||
ranking_mode_variant: hooksForm.ranking_mode_variant || null,
|
||||
collection_pool_version: hooksForm.collection_pool_version || null,
|
||||
test_label: hooksForm.test_label || null,
|
||||
promotion_tier: hooksForm.promotion_tier || null,
|
||||
partner_key: viewer.isAdmin ? (hooksForm.partner_key || null) : undefined,
|
||||
trust_tier: viewer.isAdmin ? (hooksForm.trust_tier || null) : undefined,
|
||||
sponsorship_state: viewer.isAdmin ? (hooksForm.sponsorship_state || null) : undefined,
|
||||
ownership_domain: viewer.isAdmin ? (hooksForm.ownership_domain || null) : undefined,
|
||||
commercial_review_state: viewer.isAdmin ? (hooksForm.commercial_review_state || null) : undefined,
|
||||
legal_review_state: viewer.isAdmin ? (hooksForm.legal_review_state || null) : undefined,
|
||||
placement_eligibility: Boolean(hooksForm.placement_eligibility),
|
||||
},
|
||||
})
|
||||
|
||||
if (payload?.collection?.id) {
|
||||
setCollectionOverrides((current) => ({
|
||||
...current,
|
||||
[payload.collection.id]: payload.collection,
|
||||
}))
|
||||
}
|
||||
|
||||
setHooksDiagnostics(payload?.diagnostics || buildDiagnostics(payload?.collection || selectedCollection))
|
||||
setHooksForm(buildHooksForm(payload?.collection || selectedCollection))
|
||||
setNotice(payload?.message || 'Experiment and program governance hooks updated.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to update experiment and program governance hooks.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const totalPrograms = Array.from(new Set(assignments.map((assignment) => assignment.program_key).filter(Boolean))).length
|
||||
const eligibleAssignments = assignments.filter((assignment) => assignment.collection?.placement_eligibility === true).length
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo.title || 'Collection Programming — Skinbase Nova'}</title>
|
||||
<meta name="description" content={seo.description || 'Staff programming tools for collections.'} />
|
||||
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
|
||||
<meta name="robots" content={seo.robots || 'noindex,follow'} />
|
||||
</Head>
|
||||
|
||||
<ShareToast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
visible={toast.visible}
|
||||
variant={toast.variant}
|
||||
duration={toast.variant === 'error' ? 3200 : 2200}
|
||||
onHide={() => setToast((current) => ({ ...current, visible: false }))}
|
||||
/>
|
||||
|
||||
<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-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 18% 15%, rgba(56,189,248,0.18), transparent 28%), radial-gradient(circle at 85% 14%, rgba(132,204,22,0.16), transparent 26%), 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-7xl px-4 pt-8 md:px-6">
|
||||
<section className="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">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80">Staff Programming</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Collections programming studio</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
||||
Manage program assignments, preview live pools, and run targeted diagnostics before collections hit discovery surfaces.
|
||||
</p>
|
||||
{notice ? <p className="mt-4 text-sm text-sky-100">{notice}</p> : null}
|
||||
</div>
|
||||
{endpoints.surfaces ? <a href={endpoints.surfaces} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-thumbtack fa-fw text-[11px]" />Open placement studio</a> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-5 md:grid-cols-3">
|
||||
<StatCard label="Assignments" value={assignments.length} tone="sky" />
|
||||
<StatCard label="Program Keys" value={totalPrograms} tone="amber" />
|
||||
<StatCard label="Eligible" value={eligibleAssignments} tone="emerald" />
|
||||
</section>
|
||||
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-rose-200/80">Merge Queue</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Pending duplicates and recent decisions</h2>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-300">
|
||||
Review what still needs merge attention and what staff already resolved. Each row links back into the collection studio for full compare-and-confirm actions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Pending" value={mergeQueue?.summary?.pending || 0} tone="amber" />
|
||||
<StatCard label="Approved" value={mergeQueue?.summary?.approved || 0} tone="sky" />
|
||||
<StatCard label="Rejected" value={mergeQueue?.summary?.rejected || 0} tone="emerald" />
|
||||
<StatCard label="Merged" value={mergeQueue?.summary?.completed || 0} tone="sky" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<div className="rounded-[28px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-200/80">Needs Review</p>
|
||||
<h3 className="mt-2 text-xl font-semibold text-white">Suggested duplicate pairs</h3>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{mergeQueue?.pending?.length || 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
{(mergeQueue?.pending || []).length ? mergeQueue.pending.map((item) => {
|
||||
const activeQueueAction = queueBusy[item.id] || ''
|
||||
const cardBusy = Boolean(activeQueueAction)
|
||||
|
||||
return (
|
||||
<div key={`merge-pending-${item.id}`} className={`rounded-[24px] border border-white/10 bg-white/[0.04] p-4 transition ${cardBusy ? 'ring-1 ring-sky-300/25' : ''}`}>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(item.comparison?.match_reasons || []).map((reason) => (
|
||||
<span key={reason} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{titleize(reason)}</span>
|
||||
))}
|
||||
</div>
|
||||
{cardBusy ? <span className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-sky-100"><i className="fa-solid fa-circle-notch fa-spin fa-fw text-[10px]" />Processing {titleize(activeQueueAction)}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Source</p>
|
||||
{item.source ? <CollectionCard collection={item.source} isOwner /> : null}
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Candidate</p>
|
||||
{item.target ? <CollectionCard collection={item.target} isOwner /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 md:grid-cols-3 text-xs text-slate-400">
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">Shared artworks: <span className="font-semibold text-white">{item.comparison?.shared_artworks_count ?? 0}</span></div>
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">Source count: <span className="font-semibold text-white">{item.comparison?.source_artworks_count ?? 0}</span></div>
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2">Target count: <span className="font-semibold text-white">{item.comparison?.target_artworks_count ?? 0}</span></div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{item.source?.manage_url ? <a href={item.source.manage_url} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15"><i className="fa-solid fa-code-compare fa-fw text-[10px]" />Review source</a> : null}
|
||||
{item.target?.manage_url ? <a href={item.target.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" />Open target</a> : null}
|
||||
{item.source?.id && historyPattern ? <a href={historyPattern.replace('__COLLECTION__', String(item.source.id))} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-timeline fa-fw text-[10px]" />History</a> : null}
|
||||
<button type="button" onClick={() => handleQueueAction('canonicalize', item)} disabled={cardBusy} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${activeQueueAction === 'canonicalize' ? 'fa-circle-notch fa-spin' : 'fa-badge-check'} fa-fw text-[10px]`} />{activeQueueAction === 'canonicalize' ? 'Canonicalizing...' : 'Canonicalize'}</button>
|
||||
<button type="button" onClick={() => handleQueueAction('merge', item)} disabled={cardBusy} 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 text-emerald-100 transition hover:bg-emerald-400/15 disabled:opacity-60"><i className={`fa-solid ${activeQueueAction === 'merge' ? 'fa-circle-notch fa-spin' : 'fa-code-merge'} fa-fw text-[10px]`} />{activeQueueAction === 'merge' ? 'Merging...' : 'Merge now'}</button>
|
||||
<button type="button" onClick={() => handleQueueAction('reject', item)} disabled={cardBusy} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${activeQueueAction === 'reject' ? 'fa-circle-notch fa-spin' : 'fa-ban'} fa-fw text-[10px]`} />{activeQueueAction === 'reject' ? 'Rejecting...' : 'Reject'}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}) : <div className="rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-10 text-sm text-slate-300">No pending merge candidates right now.</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Recent Decisions</p>
|
||||
<h3 className="mt-2 text-xl font-semibold text-white">Canonical, reject, and merge history</h3>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{mergeQueue?.recent?.length || 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
{(mergeQueue?.recent || []).length ? mergeQueue.recent.map((item) => (
|
||||
<div key={`merge-recent-${item.id}`} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{titleize(item.action_type)}</span>
|
||||
{item.summary ? <p className="mt-2 text-sm text-slate-300">{item.summary}</p> : null}
|
||||
<p className="mt-2 text-xs text-slate-500">{item.updated_at ? new Date(item.updated_at).toLocaleString() : 'Unknown time'}{item.actor?.username ? ` • @${item.actor.username}` : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2 text-sm text-slate-300">
|
||||
<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">Source</div>
|
||||
<div className="mt-1 font-semibold text-white">{item.source?.title || 'Collection'}</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">Target</div>
|
||||
<div className="mt-1 font-semibold text-white">{item.target?.title || 'Collection'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{item.source?.manage_url ? <a href={item.source.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen-to-square fa-fw text-[10px]" />Open source</a> : null}
|
||||
{item.target?.manage_url ? <a href={item.target.manage_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" />Open target</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-10 text-sm text-slate-300">No recent merge decisions yet.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||
<form onSubmit={handleAssignmentSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Assignment</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Program key and scope</h2>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Collection">
|
||||
<select value={assignmentForm.collection_id} onChange={(event) => setAssignmentForm((current) => ({ ...current, collection_id: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">
|
||||
{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Program Key" help="Use stable internal names like discover-spring or homepage-hero.">
|
||||
<input list="program-key-options" value={assignmentForm.program_key} onChange={(event) => setAssignmentForm((current) => ({ ...current, program_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Placement Scope" help="Optional placement scope such as homepage.hero or discover.rail.">
|
||||
<input value={assignmentForm.placement_scope} onChange={(event) => setAssignmentForm((current) => ({ ...current, placement_scope: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Campaign Key">
|
||||
<input value={assignmentForm.campaign_key} onChange={(event) => setAssignmentForm((current) => ({ ...current, campaign_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Priority">
|
||||
<input type="number" min="-100" max="100" value={assignmentForm.priority} onChange={(event) => setAssignmentForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Starts At"><input type="datetime-local" value={assignmentForm.starts_at} onChange={(event) => setAssignmentForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Ends At"><input type="datetime-local" value={assignmentForm.ends_at} onChange={(event) => setAssignmentForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
</div>
|
||||
<Field label="Notes" help="Operational note for launch timing, overrides, or review context."><textarea value={assignmentForm.notes} onChange={(event) => setAssignmentForm((current) => ({ ...current, notes: event.target.value }))} className="mt-4 min-h-[120px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={1000} /></Field>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<button type="submit" disabled={busy === 'assignment'} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'assignment' ? 'fa-circle-notch fa-spin' : 'fa-sliders'} fa-fw`} />{assignmentForm.id ? 'Update Assignment' : 'Save Assignment'}</button>
|
||||
{assignmentForm.id ? <button type="button" onClick={resetAssignmentForm} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-rotate-left fa-fw" />Cancel Edit</button> : null}
|
||||
</div>
|
||||
<datalist id="program-key-options">
|
||||
{programKeyOptions.map((option) => <option key={option} value={option} />)}
|
||||
</datalist>
|
||||
</form>
|
||||
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handlePreview} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Preview</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Inspect a live program pool</h2>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_140px_auto]">
|
||||
<Field label="Program Key"><input list="program-key-options" value={previewForm.program_key} onChange={(event) => setPreviewForm((current) => ({ ...current, program_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
|
||||
<Field label="Limit"><input type="number" min="1" max="24" value={previewForm.limit} onChange={(event) => setPreviewForm((current) => ({ ...current, limit: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<div className="flex items-end"><button type="submit" disabled={busy === 'preview'} className="inline-flex h-[50px] w-full items-center justify-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'preview' ? 'fa-circle-notch fa-spin' : 'fa-binoculars'} fa-fw`} />Preview</button></div>
|
||||
</div>
|
||||
|
||||
{previewCollections.length ? (
|
||||
<div className="mt-6 grid gap-4 xl:grid-cols-2">
|
||||
{previewCollections.map((collection) => (
|
||||
<div key={collection.id} className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4">
|
||||
<CollectionCard collection={collection} isOwner />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-5 py-8 text-sm text-slate-300">Run a preview to inspect which collections currently qualify for a given program key.</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80">Diagnostics</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Eligibility, duplicate risk, and ranking refresh</h2>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
|
||||
<p className="font-semibold text-white">Operations summary</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-400">Stale health</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{Number(observabilitySummary?.counts?.stale_health || 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-400">Stale recommendations</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{Number(observabilitySummary?.counts?.stale_recommendations || 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-400">Placement blocked</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{Number(observabilitySummary?.counts?.placement_blocked || 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-slate-400">Duplicate risk</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{Number(observabilitySummary?.counts?.duplicate_risk || 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{observabilitySummary?.generated_at ? <p className="mt-4 text-xs text-slate-400">Generated {new Date(observabilitySummary.generated_at).toLocaleString()}</p> : null}
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
|
||||
<p className="font-semibold text-white">Watchlist</p>
|
||||
{Array.isArray(observabilitySummary?.watchlist) && observabilitySummary.watchlist.length ? (
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
||||
{observabilitySummary.watchlist.map((collection) => (
|
||||
<div key={`watch-${collection.id}`} className="rounded-[20px] border border-white/10 bg-white/[0.04] p-3">
|
||||
<CollectionCard collection={collection} isOwner />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="mt-3">No watchlist items are currently flagged.</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Field label="Target Collection" help="Leave a selection in place to inspect one collection. Change it any time before running a diagnostic.">
|
||||
<select value={selectedCollectionId} onChange={(event) => setSelectedCollectionId(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">
|
||||
{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="flex items-end gap-3">
|
||||
<button type="button" onClick={() => runDiagnostic('eligibility')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-4 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'eligibility' ? 'fa-circle-notch fa-spin' : 'fa-shield-check'} fa-fw`} />Eligibility</button>
|
||||
<button type="button" onClick={() => runDiagnostic('duplicates')} disabled={busy !== ''} className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'duplicates' ? 'fa-circle-notch fa-spin' : 'fa-id-card'} fa-fw`} />Duplicates</button>
|
||||
<button type="button" onClick={() => runDiagnostic('recommendations')} disabled={busy !== ''} className="inline-flex items-center gap-2 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 disabled:opacity-60"><i className={`fa-solid ${busy === 'recommendations' ? 'fa-circle-notch fa-spin' : 'fa-arrows-rotate'} fa-fw`} />Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-3">
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
|
||||
<p className="font-semibold text-white">Eligibility</p>
|
||||
{diagnostics.eligibility ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p>{diagnostics.eligibility.status === 'queued' ? `${diagnostics.eligibility.count} collection(s) queued.` : `${diagnostics.eligibility.count} collection(s) evaluated.`}</p>
|
||||
{diagnostics.eligibility.message ? <div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{diagnostics.eligibility.message}</div> : null}
|
||||
{(diagnostics.eligibility.items || []).map((item) => <div key={item.collection_id} className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{item.health_state || 'unknown'} · {item.readiness_state || 'unknown'} · {item.placement_eligibility ? 'eligible' : 'blocked'}</div>)}
|
||||
</div>
|
||||
) : <p className="mt-3">Run an eligibility refresh to verify readiness and public placement safety.</p>}
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
|
||||
<p className="font-semibold text-white">Duplicate candidates</p>
|
||||
{diagnostics.duplicates ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p>{diagnostics.duplicates.status === 'queued' ? `${diagnostics.duplicates.count} collection(s) queued.` : `${diagnostics.duplicates.count} collection(s) with candidates.`}</p>
|
||||
{diagnostics.duplicates.message ? <div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{diagnostics.duplicates.message}</div> : null}
|
||||
{(diagnostics.duplicates.items || []).map((item) => (
|
||||
<div key={item.collection_id} className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">
|
||||
{item.candidates?.length ? item.candidates.map((candidate) => candidate.title).join(', ') : 'No candidates'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="mt-3">Run duplicate scan to surface overlap before programming a collection widely.</p>}
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/40 p-4 text-sm text-slate-300">
|
||||
<p className="font-semibold text-white">Recommendation refresh</p>
|
||||
{diagnostics.recommendations ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p>{diagnostics.recommendations.status === 'queued' ? `${diagnostics.recommendations.count} collection(s) queued.` : `${diagnostics.recommendations.count} collection(s) refreshed.`}</p>
|
||||
{diagnostics.recommendations.message ? <div className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{diagnostics.recommendations.message}</div> : null}
|
||||
{(diagnostics.recommendations.items || []).map((item) => <div key={item.collection_id} className="rounded-2xl border border-white/10 bg-white/[0.04] px-3 py-2">{titleize(item.recommendation_tier || 'unknown')} · {titleize(item.ranking_bucket || 'unknown')} · {titleize(item.search_boost_tier || 'unknown')}</div>)}
|
||||
</div>
|
||||
) : <p className="mt-3">Run a recommendation refresh to update ranking and search tiers for this collection.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form onSubmit={handleHooksSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-fuchsia-200/80">Hooks</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Experiment and program governance</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-300">Control experiment keys, promotion tiers, and staff-only program governance hooks for the selected collection without leaving the programming studio.</p>
|
||||
</div>
|
||||
{selectedCollection?.program_key && endpoints.publicProgramPattern ? (
|
||||
<a href={buildProgramUrl(endpoints.publicProgramPattern, selectedCollection.program_key)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.07]">
|
||||
<i className="fa-solid fa-arrow-up-right-from-square fa-fw text-[11px]" />Open public program landing
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Field label="Experiment Key" help="Internal test or treatment key for cross-surface collection experiments.">
|
||||
<input value={hooksForm.experiment_key} onChange={(event) => setHooksForm((current) => ({ ...current, experiment_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Treatment" help="Variant or treatment label tied to the experiment key.">
|
||||
<input value={hooksForm.experiment_treatment} onChange={(event) => setHooksForm((current) => ({ ...current, experiment_treatment: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Placement Variant" help="Surface-specific placement variant such as homepage_a or search_dense.">
|
||||
<input value={hooksForm.placement_variant} onChange={(event) => setHooksForm((current) => ({ ...current, placement_variant: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Ranking Variant" help="Override or annotate ranking mode experiments without changing the live pool logic.">
|
||||
<input value={hooksForm.ranking_mode_variant} onChange={(event) => setHooksForm((current) => ({ ...current, ranking_mode_variant: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Pool Version" help="Snapshot or rollout version for the collection pool definition.">
|
||||
<input value={hooksForm.collection_pool_version} onChange={(event) => setHooksForm((current) => ({ ...current, collection_pool_version: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Test Label" help="Human-readable campaign or experiment label for operations and diagnostics.">
|
||||
<input value={hooksForm.test_label} onChange={(event) => setHooksForm((current) => ({ ...current, test_label: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} />
|
||||
</Field>
|
||||
<Field label="Promotion Tier" help="Optional internal tier for elevated or restrained programming treatment.">
|
||||
<input value={hooksForm.promotion_tier} onChange={(event) => setHooksForm((current) => ({ ...current, promotion_tier: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
|
||||
</Field>
|
||||
{viewer.isAdmin ? (
|
||||
<>
|
||||
<Field label="Partner Key" help="Admin-only internal key for trusted partner or program ownership.">
|
||||
<input value={hooksForm.partner_key} onChange={(event) => setHooksForm((current) => ({ ...current, partner_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Trust Tier" help="Admin-only trust marker used for internal partner/program review logic.">
|
||||
<input value={hooksForm.trust_tier} onChange={(event) => setHooksForm((current) => ({ ...current, trust_tier: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
|
||||
</Field>
|
||||
<Field label="Sponsorship State" help="Admin-only state for sponsored, pending, or cleared program treatment.">
|
||||
<input value={hooksForm.sponsorship_state} onChange={(event) => setHooksForm((current) => ({ ...current, sponsorship_state: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
|
||||
</Field>
|
||||
<Field label="Ownership Domain" help="Admin-only internal ownership domain such as editorial, partner, creator_program, or events.">
|
||||
<input value={hooksForm.ownership_domain} onChange={(event) => setHooksForm((current) => ({ ...current, ownership_domain: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} />
|
||||
</Field>
|
||||
<Field label="Commercial Review" help="Admin-only commercial review status for future partner and sponsor programs.">
|
||||
<input value={hooksForm.commercial_review_state} onChange={(event) => setHooksForm((current) => ({ ...current, commercial_review_state: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
|
||||
</Field>
|
||||
<Field label="Legal Review" help="Admin-only legal review status when collections need compliance approval before wider promotion.">
|
||||
<input value={hooksForm.legal_review_state} onChange={(event) => setHooksForm((current) => ({ ...current, legal_review_state: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={40} />
|
||||
</Field>
|
||||
</>
|
||||
) : <div className="rounded-[24px] border border-dashed border-white/12 bg-white/[0.03] px-4 py-4 text-sm text-slate-300 md:col-span-2 xl:col-span-3">Partner, sponsorship, ownership, and review metadata remain admin-only. Moderators can still manage experiment and promotion hooks here.</div>}
|
||||
</div>
|
||||
|
||||
<label className="mt-4 flex items-center gap-3 rounded-[20px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-200">
|
||||
<input type="checkbox" checked={hooksForm.placement_eligibility} onChange={(event) => setHooksForm((current) => ({ ...current, placement_eligibility: event.target.checked }))} className="h-4 w-4 rounded border-white/20 bg-white/[0.04] text-sky-400 focus:ring-sky-300/40" />
|
||||
Placement eligible override
|
||||
</label>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Experiment</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.experiment_key || selectedCollection?.experiment_key || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Treatment</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.experiment_treatment || selectedCollection?.experiment_treatment || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Placement Variant</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.placement_variant || selectedCollection?.placement_variant || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Workflow</div>
|
||||
<div className="mt-1 font-semibold text-white">{titleize(hooksDiagnostics?.workflow_state || selectedCollection?.workflow_state || 'unknown')}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Health</div>
|
||||
<div className="mt-1 font-semibold text-white">{titleize(hooksDiagnostics?.health_state || selectedCollection?.health_state || 'unknown')}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Recommendation Tier</div>
|
||||
<div className="mt-1 font-semibold text-white">{titleize(hooksDiagnostics?.recommendation_tier || selectedCollection?.recommendation_tier || 'unknown')}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Ranking Bucket</div>
|
||||
<div className="mt-1 font-semibold text-white">{titleize(hooksDiagnostics?.ranking_bucket || selectedCollection?.ranking_bucket || 'unknown')}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Ranking Variant</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.ranking_mode_variant || selectedCollection?.ranking_mode_variant || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Pool Version</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.collection_pool_version || selectedCollection?.collection_pool_version || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Test Label</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.test_label || selectedCollection?.test_label || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Promotion Tier</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.promotion_tier || selectedCollection?.promotion_tier || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Partner Key</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.partner_key || selectedCollection?.partner_key || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Trust Tier</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.trust_tier || selectedCollection?.trust_tier || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Sponsorship State</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.sponsorship_state || selectedCollection?.sponsorship_state || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Ownership Domain</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.ownership_domain || selectedCollection?.ownership_domain || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Commercial Review</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.commercial_review_state || selectedCollection?.commercial_review_state || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Legal Review</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.legal_review_state || selectedCollection?.legal_review_state || 'Not set'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Last Health Check</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.last_health_check_at ? new Date(hooksDiagnostics.last_health_check_at).toLocaleString() : 'Not yet'}</div>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Last Recommendation Refresh</div>
|
||||
<div className="mt-1 font-semibold text-white">{hooksDiagnostics?.last_recommendation_refresh_at ? new Date(hooksDiagnostics.last_recommendation_refresh_at).toLocaleString() : 'Not yet'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<button type="submit" disabled={busy === 'hooks' || !selectedCollectionId} className="inline-flex items-center gap-2 rounded-2xl border border-fuchsia-300/20 bg-fuchsia-400/10 px-5 py-3 text-sm font-semibold text-fuchsia-100 transition hover:bg-fuchsia-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'hooks' ? 'fa-circle-notch fa-spin' : 'fa-flask-vial'} fa-fw`} />Save Hooks</button>
|
||||
{selectedCollection?.manage_url ? <a href={selectedCollection.manage_url} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-up-right-from-square fa-fw" />Open collection</a> : null}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Assignments</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Current programming inventory</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{assignments.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-5">
|
||||
{assignments.length ? assignments.map((assignment) => (
|
||||
<div key={assignment.id} className="rounded-[28px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<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">{assignment.program_key}</span>
|
||||
{assignment.placement_scope ? <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">{assignment.placement_scope}</span> : null}
|
||||
<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">priority {assignment.priority}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => hydrateAssignment(assignment)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen fa-fw text-[10px]" />Edit</button>
|
||||
{endpoints.managePattern ? <a href={endpoints.managePattern.replace('__COLLECTION__', String(assignment.collection?.id || ''))} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-up-right-from-square fa-fw text-[10px]" />Manage</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<div>
|
||||
{assignment.collection ? <CollectionCard collection={assignment.collection} isOwner /> : null}
|
||||
</div>
|
||||
<div className="space-y-3 text-sm text-slate-300">
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Campaign: {assignment.campaign_key || 'None'}</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Starts: {assignment.starts_at ? new Date(assignment.starts_at).toLocaleString() : 'Immediate'}</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Ends: {assignment.ends_at ? new Date(assignment.ends_at).toLocaleString() : 'Open-ended'}</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Placement: {assignment.collection?.placement_eligibility ? 'Eligible' : 'Blocked'}</div>
|
||||
{assignment.notes ? <div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">{assignment.notes}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)) : <div className="rounded-[26px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-12 text-sm text-slate-300">No programming assignments yet. Create the first one above.</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
|
||||
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 isoToLocalInput(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
|
||||
return local.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function rulesJsonToText(rulesJson) {
|
||||
if (!rulesJson) return '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}'
|
||||
|
||||
try {
|
||||
return JSON.stringify(rulesJson, null, 2)
|
||||
} catch {
|
||||
return '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}'
|
||||
}
|
||||
}
|
||||
|
||||
function Field({ label, help, children }) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-semibold text-white">{label}</span>
|
||||
{children}
|
||||
{help ? <span className="block text-xs leading-relaxed text-slate-400">{help}</span> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CollectionStaffSurfaces() {
|
||||
const { props } = usePage()
|
||||
const collectionOptions = Array.isArray(props.collectionOptions) ? props.collectionOptions : []
|
||||
const [definitions, setDefinitions] = React.useState(Array.isArray(props.definitions) ? props.definitions : [])
|
||||
const [placements, setPlacements] = React.useState(Array.isArray(props.placements) ? props.placements : [])
|
||||
const [conflicts, setConflicts] = React.useState(Array.isArray(props.conflicts) ? props.conflicts : [])
|
||||
const [definitionForm, setDefinitionForm] = React.useState({
|
||||
id: null,
|
||||
surface_key: '',
|
||||
title: '',
|
||||
description: '',
|
||||
mode: 'manual',
|
||||
ranking_mode: 'ranking_score',
|
||||
max_items: 12,
|
||||
is_active: true,
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
fallback_surface_key: '',
|
||||
rules_json: '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}',
|
||||
})
|
||||
const [placementForm, setPlacementForm] = React.useState({
|
||||
id: null,
|
||||
surface_key: props.surfaceKeyOptions?.[0] || '',
|
||||
collection_id: collectionOptions[0]?.id || '',
|
||||
placement_type: 'manual',
|
||||
priority: 0,
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
is_active: true,
|
||||
campaign_key: '',
|
||||
notes: '',
|
||||
})
|
||||
const [batchForm, setBatchForm] = React.useState({
|
||||
collection_ids: [],
|
||||
campaign_key: '',
|
||||
campaign_label: '',
|
||||
event_label: '',
|
||||
season_key: '',
|
||||
editorial_notes: '',
|
||||
surface_key: props.surfaceKeyOptions?.[0] || '',
|
||||
placement_type: 'campaign',
|
||||
priority: 0,
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
is_active: true,
|
||||
notes: '',
|
||||
})
|
||||
const [batchResult, setBatchResult] = React.useState(null)
|
||||
const [notice, setNotice] = React.useState('')
|
||||
const [busy, setBusy] = React.useState('')
|
||||
const seo = props.seo || {}
|
||||
const surfaceKeyOptions = React.useMemo(() => {
|
||||
const keys = definitions.map((definition) => definition.surface_key).filter(Boolean)
|
||||
return Array.from(new Set(keys)).sort((left, right) => String(left).localeCompare(String(right)))
|
||||
}, [definitions])
|
||||
const conflictPlacementIds = React.useMemo(() => {
|
||||
return new Set(conflicts.flatMap((conflict) => Array.isArray(conflict.placement_ids) ? conflict.placement_ids : []))
|
||||
}, [conflicts])
|
||||
|
||||
React.useEffect(() => {
|
||||
setPlacementForm((current) => {
|
||||
if (current.surface_key && surfaceKeyOptions.includes(current.surface_key)) {
|
||||
return current
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
surface_key: surfaceKeyOptions[0] || '',
|
||||
}
|
||||
})
|
||||
|
||||
setBatchForm((current) => {
|
||||
if (!current.surface_key || surfaceKeyOptions.includes(current.surface_key)) {
|
||||
return current
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
surface_key: surfaceKeyOptions[0] || '',
|
||||
}
|
||||
})
|
||||
}, [surfaceKeyOptions])
|
||||
|
||||
function resetDefinitionForm() {
|
||||
setDefinitionForm({
|
||||
id: null,
|
||||
surface_key: '',
|
||||
title: '',
|
||||
description: '',
|
||||
mode: 'manual',
|
||||
ranking_mode: 'ranking_score',
|
||||
max_items: 12,
|
||||
is_active: true,
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
fallback_surface_key: '',
|
||||
rules_json: '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}',
|
||||
})
|
||||
}
|
||||
|
||||
function resetPlacementForm() {
|
||||
setPlacementForm({
|
||||
id: null,
|
||||
surface_key: surfaceKeyOptions[0] || '',
|
||||
collection_id: collectionOptions[0]?.id || '',
|
||||
placement_type: 'manual',
|
||||
priority: 0,
|
||||
starts_at: '',
|
||||
ends_at: '',
|
||||
is_active: true,
|
||||
campaign_key: '',
|
||||
notes: '',
|
||||
})
|
||||
}
|
||||
|
||||
function toggleBatchCollection(collectionId) {
|
||||
setBatchForm((current) => {
|
||||
const currentIds = Array.isArray(current.collection_ids) ? current.collection_ids : []
|
||||
const nextIds = currentIds.includes(collectionId)
|
||||
? currentIds.filter((id) => id !== collectionId)
|
||||
: [...currentIds, collectionId]
|
||||
|
||||
return {
|
||||
...current,
|
||||
collection_ids: nextIds,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDefinitionSubmit(event) {
|
||||
event.preventDefault()
|
||||
setBusy('definition')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const rulesJson = definitionForm.rules_json.trim() ? JSON.parse(definitionForm.rules_json) : null
|
||||
const url = definitionForm.id
|
||||
? props.endpoints?.definitionsUpdatePattern?.replace('__DEFINITION__', String(definitionForm.id))
|
||||
: props.endpoints?.definitionsStore
|
||||
const payload = await requestJson(url, {
|
||||
method: definitionForm.id ? 'PATCH' : 'POST',
|
||||
body: {
|
||||
...definitionForm,
|
||||
max_items: Number(definitionForm.max_items || 12),
|
||||
starts_at: definitionForm.starts_at ? new Date(definitionForm.starts_at).toISOString() : null,
|
||||
ends_at: definitionForm.ends_at ? new Date(definitionForm.ends_at).toISOString() : null,
|
||||
fallback_surface_key: definitionForm.fallback_surface_key || null,
|
||||
rules_json: rulesJson,
|
||||
},
|
||||
})
|
||||
|
||||
setDefinitions((current) => {
|
||||
const next = current.filter((definition) => definition.id !== payload.definition.id)
|
||||
return [...next, payload.definition].sort((left, right) => String(left.surface_key).localeCompare(String(right.surface_key)))
|
||||
})
|
||||
setNotice(definitionForm.id ? 'Surface definition updated.' : 'Surface definition saved.')
|
||||
resetDefinitionForm()
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to save definition.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlacementSubmit(event) {
|
||||
event.preventDefault()
|
||||
setBusy('placement')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const url = placementForm.id
|
||||
? props.endpoints?.placementsUpdatePattern?.replace('__PLACEMENT__', String(placementForm.id))
|
||||
: props.endpoints?.placementsStore
|
||||
const payload = await requestJson(url, {
|
||||
method: placementForm.id ? 'PATCH' : 'POST',
|
||||
body: {
|
||||
...placementForm,
|
||||
collection_id: Number(placementForm.collection_id),
|
||||
priority: Number(placementForm.priority || 0),
|
||||
starts_at: placementForm.starts_at ? new Date(placementForm.starts_at).toISOString() : null,
|
||||
ends_at: placementForm.ends_at ? new Date(placementForm.ends_at).toISOString() : null,
|
||||
},
|
||||
})
|
||||
|
||||
setPlacements((current) => {
|
||||
const next = current.filter((placement) => placement.id !== payload.placement.id)
|
||||
return [...next, payload.placement].sort((left, right) => {
|
||||
if (left.surface_key === right.surface_key) return (right.priority || 0) - (left.priority || 0)
|
||||
return String(left.surface_key).localeCompare(String(right.surface_key))
|
||||
})
|
||||
})
|
||||
setConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : [])
|
||||
setNotice(placementForm.id ? 'Surface placement updated.' : 'Surface placement saved.')
|
||||
resetPlacementForm()
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to save placement.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchEditorial(mode) {
|
||||
setBusy(`batch-${mode}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(props.endpoints?.batchEditorial, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
...batchForm,
|
||||
starts_at: batchForm.starts_at ? new Date(batchForm.starts_at).toISOString() : null,
|
||||
ends_at: batchForm.ends_at ? new Date(batchForm.ends_at).toISOString() : null,
|
||||
collection_ids: (batchForm.collection_ids || []).map((id) => Number(id)),
|
||||
priority: Number(batchForm.priority || 0),
|
||||
surface_key: batchForm.surface_key || null,
|
||||
apply: mode === 'apply',
|
||||
},
|
||||
})
|
||||
|
||||
setBatchResult(payload.plan || null)
|
||||
|
||||
if (mode === 'apply') {
|
||||
setPlacements(Array.isArray(payload.placements) ? payload.placements : [])
|
||||
setConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : [])
|
||||
setNotice('Batch editorial changes applied.')
|
||||
} else {
|
||||
setNotice('Batch editorial preview generated.')
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Batch editorial tools failed.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateDefinition(definition) {
|
||||
setDefinitionForm({
|
||||
id: definition.id,
|
||||
surface_key: definition.surface_key || '',
|
||||
title: definition.title || '',
|
||||
description: definition.description || '',
|
||||
mode: definition.mode || 'manual',
|
||||
ranking_mode: definition.ranking_mode || 'ranking_score',
|
||||
max_items: definition.max_items || 12,
|
||||
is_active: definition.is_active !== false,
|
||||
starts_at: isoToLocalInput(definition.starts_at),
|
||||
ends_at: isoToLocalInput(definition.ends_at),
|
||||
fallback_surface_key: definition.fallback_surface_key || '',
|
||||
rules_json: rulesJsonToText(definition.rules_json),
|
||||
})
|
||||
}
|
||||
|
||||
function hydratePlacement(placement) {
|
||||
setPlacementForm({
|
||||
id: placement.id,
|
||||
surface_key: placement.surface_key || '',
|
||||
collection_id: placement.collection?.id || '',
|
||||
placement_type: placement.placement_type || 'manual',
|
||||
priority: placement.priority || 0,
|
||||
starts_at: isoToLocalInput(placement.starts_at),
|
||||
ends_at: isoToLocalInput(placement.ends_at),
|
||||
is_active: placement.is_active !== false,
|
||||
campaign_key: placement.campaign_key || '',
|
||||
notes: placement.notes || '',
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDeleteDefinition(definition) {
|
||||
if (!window.confirm(`Delete surface definition "${definition.surface_key}"?`)) return
|
||||
|
||||
setBusy(`delete-definition-${definition.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const url = props.endpoints?.definitionsDeletePattern?.replace('__DEFINITION__', String(definition.id))
|
||||
await requestJson(url, { method: 'DELETE' })
|
||||
setDefinitions((current) => current.filter((item) => item.id !== definition.id))
|
||||
if (definitionForm.id === definition.id) {
|
||||
resetDefinitionForm()
|
||||
}
|
||||
setNotice('Surface definition deleted.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to delete definition.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePlacement(placement) {
|
||||
if (!window.confirm(`Delete placement for "${placement.collection?.title || 'this collection'}" on ${placement.surface_key}?`)) return
|
||||
|
||||
setBusy(`delete-placement-${placement.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const url = props.endpoints?.placementsDeletePattern?.replace('__PLACEMENT__', String(placement.id))
|
||||
const payload = await requestJson(url, { method: 'DELETE' })
|
||||
setPlacements((current) => current.filter((item) => item.id !== placement.id))
|
||||
setConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : [])
|
||||
if (placementForm.id === placement.id) {
|
||||
resetPlacementForm()
|
||||
}
|
||||
setNotice('Surface placement deleted.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to delete placement.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo.title || 'Collection Surfaces — Skinbase Nova'}</title>
|
||||
<meta name="description" content={seo.description || 'Staff tools for collection surfaces.'} />
|
||||
{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-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 15% 14%, rgba(245,158,11,0.16), transparent 26%), radial-gradient(circle at 82% 18%, rgba(56,189,248,0.16), 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-7xl px-4 pt-8 md:px-6">
|
||||
<section className="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-amber-200/80">Staff Surfaces</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Collections placement studio</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
||||
Define reusable discovery surfaces, then place eligible public collections into manual or campaign-specific slots with clear timing and notes.
|
||||
</p>
|
||||
{notice ? <p className="mt-4 text-sm text-sky-100">{notice}</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||
<form onSubmit={handleDefinitionSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Surface Definition</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Rules and ranking</h2>
|
||||
</div>
|
||||
{definitionForm.id ? <p className="mt-3 text-sm text-slate-300">Editing <span className="font-semibold text-white">{definitionForm.surface_key}</span></p> : null}
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Surface Key" help={definitionForm.id ? 'Surface keys stay stable during edits so existing placements remain attached.' : null}><input value={definitionForm.surface_key} onChange={(event) => setDefinitionForm((current) => ({ ...current, surface_key: event.target.value }))} disabled={Boolean(definitionForm.id)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none disabled:cursor-not-allowed disabled:opacity-60" maxLength={120} /></Field>
|
||||
<Field label="Title"><input value={definitionForm.title} onChange={(event) => setDefinitionForm((current) => ({ ...current, title: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={160} /></Field>
|
||||
<Field label="Mode"><select value={definitionForm.mode} onChange={(event) => setDefinitionForm((current) => ({ ...current, mode: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="manual">Manual</option><option value="automatic">Automatic</option><option value="hybrid">Hybrid</option></select></Field>
|
||||
<Field label="Ranking"><select value={definitionForm.ranking_mode} onChange={(event) => setDefinitionForm((current) => ({ ...current, ranking_mode: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="ranking_score">Ranking score</option><option value="recent_activity">Recent activity</option><option value="quality_score">Quality score</option></select></Field>
|
||||
<Field label="Max Items"><input type="number" min="1" max="24" value={definitionForm.max_items} onChange={(event) => setDefinitionForm((current) => ({ ...current, max_items: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Starts At" help="Optional activation window for the full surface definition."><input type="datetime-local" value={definitionForm.starts_at} onChange={(event) => setDefinitionForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Ends At" help="Leave blank when the surface should stay live until staff changes it."><input type="datetime-local" value={definitionForm.ends_at} onChange={(event) => setDefinitionForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Fallback Surface Key" help="Optional fallback when this definition is inactive, scheduled out, or resolves no items."><input value={definitionForm.fallback_surface_key} onChange={(event) => setDefinitionForm((current) => ({ ...current, fallback_surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={definitionForm.is_active} onChange={(event) => setDefinitionForm((current) => ({ ...current, is_active: event.target.checked }))} />Active</label>
|
||||
</div>
|
||||
<Field label="Description" help="Operational note for staff browsing this surface later."><textarea value={definitionForm.description} onChange={(event) => setDefinitionForm((current) => ({ ...current, description: event.target.value }))} className="mt-4 min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={400} /></Field>
|
||||
<Field label="Rules JSON" help="Supported filters include campaign, event, season, type, presentation_style, theme_token, collaboration_mode, owner_username or owner_usernames, commercial_eligible_only, analytics_enabled_only, min_quality_score, min_ranking_score, include_collection_ids, exclude_collection_ids, and featured_only."><textarea value={definitionForm.rules_json} onChange={(event) => setDefinitionForm((current) => ({ ...current, rules_json: event.target.value }))} className="mt-4 min-h-[160px] w-full rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 font-mono text-sm text-white outline-none" spellCheck={false} /></Field>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<button type="submit" disabled={busy === 'definition'} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'definition' ? 'fa-circle-notch fa-spin' : 'fa-layer-group'} fa-fw`} />{definitionForm.id ? 'Update Definition' : 'Save Definition'}</button>
|
||||
{definitionForm.id ? <button type="button" onClick={resetDefinitionForm} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-rotate-left fa-fw" />Cancel Edit</button> : null}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form onSubmit={handlePlacementSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Surface Placement</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Manual and campaign slots</h2>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Surface"><select value={placementForm.surface_key} onChange={(event) => setPlacementForm((current) => ({ ...current, surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">{surfaceKeyOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select></Field>
|
||||
<Field label="Collection"><select value={placementForm.collection_id} onChange={(event) => setPlacementForm((current) => ({ ...current, collection_id: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}</select></Field>
|
||||
<Field label="Placement Type"><select value={placementForm.placement_type} onChange={(event) => setPlacementForm((current) => ({ ...current, placement_type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="manual">Manual</option><option value="campaign">Campaign</option><option value="scheduled_override">Scheduled override</option></select></Field>
|
||||
<Field label="Priority"><input type="number" min="-100" max="100" value={placementForm.priority} onChange={(event) => setPlacementForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Starts At"><input type="datetime-local" value={placementForm.starts_at} onChange={(event) => setPlacementForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Ends At"><input type="datetime-local" value={placementForm.ends_at} onChange={(event) => setPlacementForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Campaign Key" help="Optional campaign label for reporting and grouped overrides."><input value={placementForm.campaign_key} onChange={(event) => setPlacementForm((current) => ({ ...current, campaign_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={placementForm.is_active} onChange={(event) => setPlacementForm((current) => ({ ...current, is_active: event.target.checked }))} />Active placement</label>
|
||||
</div>
|
||||
<Field label="Notes" help="Internal note for why this collection owns the slot."><textarea value={placementForm.notes} onChange={(event) => setPlacementForm((current) => ({ ...current, notes: event.target.value }))} className="mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={1000} /></Field>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<button type="submit" disabled={busy === 'placement'} className="inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'placement' ? 'fa-circle-notch fa-spin' : 'fa-thumbtack'} fa-fw`} />{placementForm.id ? 'Update Placement' : 'Save Placement'}</button>
|
||||
{placementForm.id ? <button type="button" onClick={resetPlacementForm} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-rotate-left fa-fw" />Cancel Edit</button> : null}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80">Batch Editorial Tools</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Campaign planning in one pass</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{batchForm.collection_ids.length} selected</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<p className="text-sm font-semibold text-white">Choose collections</p>
|
||||
<p className="mt-2 text-sm text-slate-300">The selector uses current public discovery candidates so staff can quickly prepare a seasonal or editorial run.</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{collectionOptions.map((option) => {
|
||||
const checked = batchForm.collection_ids.includes(option.id)
|
||||
return (
|
||||
<label key={option.id} className={`flex cursor-pointer items-start gap-3 rounded-[22px] border px-4 py-3 transition ${checked ? 'border-lime-300/30 bg-lime-400/10' : 'border-white/10 bg-white/[0.04] hover:bg-white/[0.07]'}`}>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggleBatchCollection(option.id)} className="mt-1" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-white">{option.title}</span>
|
||||
<span className="mt-1 block text-xs text-slate-400">{option.type || 'collection'} · {option.visibility || 'public'}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<p className="text-sm font-semibold text-white">Campaign metadata</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Campaign Key"><input value={batchForm.campaign_key} onChange={(event) => setBatchForm((current) => ({ ...current, campaign_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
|
||||
<Field label="Campaign Label"><input value={batchForm.campaign_label} onChange={(event) => setBatchForm((current) => ({ ...current, campaign_label: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field>
|
||||
<Field label="Event Label"><input value={batchForm.event_label} onChange={(event) => setBatchForm((current) => ({ ...current, event_label: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field>
|
||||
<Field label="Season Key"><input value={batchForm.season_key} onChange={(event) => setBatchForm((current) => ({ ...current, season_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
|
||||
</div>
|
||||
<Field label="Editorial Notes" help="Shared context recorded on each selected collection."><textarea value={batchForm.editorial_notes} onChange={(event) => setBatchForm((current) => ({ ...current, editorial_notes: event.target.value }))} className="mt-4 min-h-[120px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={4000} /></Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<p className="text-sm font-semibold text-white">Optional placement plan</p>
|
||||
<p className="mt-2 text-sm text-slate-300">If you set a surface, the preview shows which collections can safely be placed and which ones will be skipped.</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<Field label="Surface"><select value={batchForm.surface_key} onChange={(event) => setBatchForm((current) => ({ ...current, surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="">No placement</option>{surfaceKeyOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select></Field>
|
||||
<Field label="Placement Type"><select value={batchForm.placement_type} onChange={(event) => setBatchForm((current) => ({ ...current, placement_type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="campaign">Campaign</option><option value="manual">Manual</option><option value="scheduled_override">Scheduled override</option></select></Field>
|
||||
<Field label="Priority"><input type="number" min="-100" max="100" value={batchForm.priority} onChange={(event) => setBatchForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={batchForm.is_active} onChange={(event) => setBatchForm((current) => ({ ...current, is_active: event.target.checked }))} />Active placement</label>
|
||||
<Field label="Starts At"><input type="datetime-local" value={batchForm.starts_at} onChange={(event) => setBatchForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
<Field label="Ends At"><input type="datetime-local" value={batchForm.ends_at} onChange={(event) => setBatchForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
||||
</div>
|
||||
<Field label="Placement Notes"><textarea value={batchForm.notes} onChange={(event) => setBatchForm((current) => ({ ...current, notes: event.target.value }))} className="mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={1000} /></Field>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<button type="button" onClick={() => handleBatchEditorial('preview')} disabled={busy === 'batch-preview'} className="inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-5 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'batch-preview' ? 'fa-circle-notch fa-spin' : 'fa-flask'} fa-fw`} />Preview Batch</button>
|
||||
<button type="button" onClick={() => handleBatchEditorial('apply')} disabled={busy === 'batch-apply'} className="inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'batch-apply' ? 'fa-circle-notch fa-spin' : 'fa-wand-magic-sparkles'} fa-fw`} />Apply Batch</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batchResult ? (
|
||||
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Preview results</p>
|
||||
<p className="mt-1 text-sm text-slate-300">{batchResult.collections_count} collections reviewed, {batchResult.placement_eligible_count} placement-ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(batchResult.items || []).map((item) => (
|
||||
<div key={item.collection?.id} className="rounded-[22px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{item.collection?.title}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{item.collection?.visibility} · {item.collection?.lifecycle_state} · {item.collection?.moderation_status}</p>
|
||||
</div>
|
||||
{item.placement ? (
|
||||
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${item.placement.eligible ? 'border-lime-300/20 bg-lime-400/10 text-lime-100' : 'border-rose-300/20 bg-rose-400/10 text-rose-100'}`}>
|
||||
{item.placement.eligible ? `ready for ${item.placement.surface_key}` : 'placement skipped'}
|
||||
</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-300">metadata only</span>}
|
||||
</div>
|
||||
{item.eligibility?.reasons?.length ? <p className="mt-3 text-xs text-amber-100/80">Campaign readiness: {item.eligibility.reasons.join(' ')}</p> : null}
|
||||
{item.placement?.reasons?.length ? <p className="mt-2 text-xs text-rose-100/80">Placement: {item.placement.reasons.join(' ')}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Definitions</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Registered surfaces</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{definitions.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
{definitions.map((definition) => (
|
||||
<div key={definition.id} className="rounded-[24px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<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">{definition.surface_key}</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-300">{definition.mode}</span>
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold text-white">{definition.title}</h3>
|
||||
{definition.description ? <p className="mt-2 text-sm text-slate-300">{definition.description}</p> : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{definition.ranking_mode}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">max {definition.max_items}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{definition.is_active ? 'active' : 'inactive'}</span>
|
||||
{definition.starts_at ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">starts {new Date(definition.starts_at).toLocaleString()}</span> : null}
|
||||
{definition.ends_at ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">ends {new Date(definition.ends_at).toLocaleString()}</span> : null}
|
||||
{definition.fallback_surface_key ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">fallback {definition.fallback_surface_key}</span> : null}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => hydrateDefinition(definition)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen fa-fw text-[10px]" />Edit Definition</button>
|
||||
<button type="button" onClick={() => handleDeleteDefinition(definition)} disabled={busy === `delete-definition-${definition.id}`} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `delete-definition-${definition.id}` ? 'fa-circle-notch fa-spin' : 'fa-trash'} fa-fw text-[10px]`} />Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{conflicts.length ? (
|
||||
<section className="mt-8 rounded-[32px] border border-rose-300/20 bg-rose-500/10 p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-rose-100/80">Conflicts</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Schedule overlaps need review</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-xs font-semibold text-rose-100">{conflicts.length}</span>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
{conflicts.map((conflict, index) => (
|
||||
<div key={`${conflict.surface_key}-${index}`} className="rounded-[24px] border border-rose-300/20 bg-slate-950/40 p-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-100">{conflict.surface_key}</span>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-rose-50">{conflict.summary}</p>
|
||||
<p className="mt-3 text-xs text-rose-100/70">
|
||||
Window: {conflict.window?.starts_at ? new Date(conflict.window.starts_at).toLocaleString() : 'Immediate'} to {conflict.window?.ends_at ? new Date(conflict.window.ends_at).toLocaleString() : 'Open-ended'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Placements</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Active and scheduled slots</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{placements.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-5">
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.id} className="rounded-[28px] border border-white/10 bg-slate-950/40 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">{placement.surface_key}</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-300">{placement.placement_type}</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-300">priority {placement.priority}</span>
|
||||
{conflictPlacementIds.has(placement.id) || placement.has_conflict ? <span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-100">conflict</span> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => hydratePlacement(placement)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen fa-fw text-[10px]" />Edit</button>
|
||||
<button type="button" onClick={() => handleDeletePlacement(placement)} disabled={busy === `delete-placement-${placement.id}`} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `delete-placement-${placement.id}` ? 'fa-circle-notch fa-spin' : 'fa-trash'} fa-fw text-[10px]`} />Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<div>
|
||||
{placement.collection ? <CollectionCard collection={placement.collection} isOwner /> : null}
|
||||
</div>
|
||||
<div className="space-y-3 text-sm text-slate-300">
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Starts: {placement.starts_at ? new Date(placement.starts_at).toLocaleString() : 'Immediate'}</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Ends: {placement.ends_at ? new Date(placement.ends_at).toLocaleString() : 'Open-ended'}</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Campaign: {placement.campaign_key || 'None'}</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Status: {placement.is_active ? 'Active' : 'Inactive'}</div>
|
||||
{placement.notes ? <div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3 text-slate-300">{placement.notes}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
|
||||
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 || payload?.errors?.artwork_id?.[0] || payload?.errors?.is_active?.[0] || payload?.errors?.force_hero?.[0] || 'Request failed.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function isoToLocalInput(value) {
|
||||
if (!value) return ''
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
|
||||
return local.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function localInputToIso(value) {
|
||||
if (!value) return null
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
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({ label, tone = 'slate' }) {
|
||||
const toneClasses = {
|
||||
slate: 'border-white/10 bg-white/10 text-slate-100',
|
||||
sky: 'border-sky-300/20 bg-sky-400/15 text-sky-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/15 text-emerald-100',
|
||||
amber: 'border-amber-300/20 bg-amber-400/15 text-amber-100',
|
||||
rose: 'border-rose-300/20 bg-rose-400/15 text-rose-100',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${toneClasses[tone] || toneClasses.slate}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, help, children }) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-semibold text-white">{label}</span>
|
||||
{children}
|
||||
{help ? <span className="block text-xs leading-relaxed text-slate-400">{help}</span> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, tone = 'sky' }) {
|
||||
const toneClasses = {
|
||||
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',
|
||||
}
|
||||
|
||||
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] ${toneClasses[tone] || toneClasses.sky}`}>{label}</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
artwork_id: '',
|
||||
priority: 100,
|
||||
featured_at: isoToLocalInput(new Date().toISOString()),
|
||||
expires_at: '',
|
||||
is_active: true,
|
||||
}
|
||||
}
|
||||
|
||||
function mapEntryToCandidate(entry) {
|
||||
if (!entry) return null
|
||||
|
||||
return {
|
||||
...entry.artwork,
|
||||
medals: entry.medals,
|
||||
eligibility: entry.eligibility,
|
||||
existing_feature_count: entry.duplicate_count,
|
||||
already_featured: entry.duplicate_count > 0,
|
||||
}
|
||||
}
|
||||
|
||||
function compareEntries(left, right, sortKey, direction) {
|
||||
const dir = direction === 'asc' ? 1 : -1
|
||||
const value = (entry) => {
|
||||
switch (sortKey) {
|
||||
case 'featured_at':
|
||||
return new Date(entry.featured_at || 0).getTime() || 0
|
||||
case 'expires_at':
|
||||
return new Date(entry.expires_at || 0).getTime() || 0
|
||||
case 'score_30d':
|
||||
return Number(entry.medals?.score_30d || 0)
|
||||
default:
|
||||
return Number(entry.priority || 0)
|
||||
}
|
||||
}
|
||||
|
||||
const leftValue = value(left)
|
||||
const rightValue = value(right)
|
||||
if (leftValue !== rightValue) {
|
||||
return (leftValue > rightValue ? 1 : -1) * dir
|
||||
}
|
||||
|
||||
const leftFeatured = new Date(left.featured_at || 0).getTime() || 0
|
||||
const rightFeatured = new Date(right.featured_at || 0).getTime() || 0
|
||||
if (leftFeatured !== rightFeatured) {
|
||||
return (leftFeatured > rightFeatured ? 1 : -1) * dir
|
||||
}
|
||||
|
||||
return Number(right.id || 0) - Number(left.id || 0)
|
||||
}
|
||||
|
||||
export default function FeaturedArtworksAdmin() {
|
||||
const { props } = usePage()
|
||||
const endpoints = props.endpoints || {}
|
||||
const capabilities = props.capabilities || {}
|
||||
const seo = props.seo || {}
|
||||
const [entries, setEntries] = React.useState(Array.isArray(props.entries) ? props.entries : [])
|
||||
const [winner, setWinner] = React.useState(props.winner || null)
|
||||
const [stats, setStats] = React.useState(props.stats || {})
|
||||
const [notice, setNotice] = React.useState('')
|
||||
const [busy, setBusy] = React.useState('')
|
||||
const [filter, setFilter] = React.useState('all')
|
||||
const [sortKey, setSortKey] = React.useState('priority')
|
||||
const [sortDirection, setSortDirection] = React.useState('desc')
|
||||
const [listQuery, setListQuery] = React.useState('')
|
||||
const [searchQuery, setSearchQuery] = React.useState('')
|
||||
const [searchResults, setSearchResults] = React.useState([])
|
||||
const [selectedArtwork, setSelectedArtwork] = React.useState(null)
|
||||
const [editingId, setEditingId] = React.useState(null)
|
||||
const [form, setForm] = React.useState(emptyForm())
|
||||
|
||||
React.useEffect(() => {
|
||||
setEntries(Array.isArray(props.entries) ? props.entries : [])
|
||||
setWinner(props.winner || null)
|
||||
setStats(props.stats || {})
|
||||
}, [props.entries, props.stats, props.winner])
|
||||
|
||||
function syncPayload(payload) {
|
||||
setEntries(Array.isArray(payload.entries) ? payload.entries : [])
|
||||
setWinner(payload.winner || null)
|
||||
setStats(payload.stats || {})
|
||||
if (payload.message) {
|
||||
setNotice(payload.message)
|
||||
}
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
setEditingId(null)
|
||||
setSelectedArtwork(null)
|
||||
setSearchResults([])
|
||||
setSearchQuery('')
|
||||
setForm(emptyForm())
|
||||
}
|
||||
|
||||
async function handleArtworkSearch(event) {
|
||||
event.preventDefault()
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
|
||||
setBusy('search')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const url = `${endpoints.search}?q=${encodeURIComponent(searchQuery.trim())}`
|
||||
const payload = await requestJson(url, { method: 'GET' })
|
||||
setSearchResults(Array.isArray(payload.results) ? payload.results : [])
|
||||
if ((payload.results || []).length === 0) {
|
||||
setNotice('No artworks matched that search.')
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Artwork search failed.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
function chooseArtwork(artwork) {
|
||||
setSelectedArtwork(artwork)
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
artwork_id: artwork.id,
|
||||
}))
|
||||
}
|
||||
|
||||
function editEntry(entry) {
|
||||
setEditingId(entry.id)
|
||||
setSelectedArtwork(mapEntryToCandidate(entry))
|
||||
setSearchResults([])
|
||||
setSearchQuery('')
|
||||
setForm({
|
||||
artwork_id: entry.artwork_id,
|
||||
priority: entry.priority,
|
||||
featured_at: isoToLocalInput(entry.featured_at),
|
||||
expires_at: isoToLocalInput(entry.expires_at),
|
||||
is_active: Boolean(entry.is_active),
|
||||
})
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault()
|
||||
if (!editingId && !form.artwork_id) {
|
||||
setNotice('Select an artwork first.')
|
||||
return
|
||||
}
|
||||
|
||||
setBusy('submit')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(
|
||||
editingId
|
||||
? endpoints.updatePattern.replace('__FEATURE__', String(editingId))
|
||||
: endpoints.store,
|
||||
{
|
||||
method: editingId ? 'PATCH' : 'POST',
|
||||
body: {
|
||||
artwork_id: Number(form.artwork_id),
|
||||
priority: Number(form.priority || 0),
|
||||
featured_at: localInputToIso(form.featured_at),
|
||||
expires_at: localInputToIso(form.expires_at),
|
||||
is_active: Boolean(form.is_active),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
syncPayload(payload)
|
||||
resetEditor()
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to save this featured entry.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(entry) {
|
||||
setBusy(`toggle-${entry.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(endpoints.togglePattern.replace('__FEATURE__', String(entry.id)), {
|
||||
method: 'PATCH',
|
||||
})
|
||||
syncPayload(payload)
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to change active state.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(entry) {
|
||||
if (typeof window !== 'undefined' && !window.confirm(`Delete featured entry #${entry.id}?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(`delete-${entry.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(endpoints.destroyPattern.replace('__FEATURE__', String(entry.id)), {
|
||||
method: 'DELETE',
|
||||
})
|
||||
syncPayload(payload)
|
||||
|
||||
if (editingId === entry.id) {
|
||||
resetEditor()
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to delete this featured entry.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleForceHero(entry) {
|
||||
setBusy(`force-${entry.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(endpoints.forceHeroPattern.replace('__FEATURE__', String(entry.id)), {
|
||||
method: 'PATCH',
|
||||
})
|
||||
syncPayload(payload)
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to change force hero state.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEntries = React.useMemo(() => {
|
||||
const query = listQuery.trim().toLowerCase()
|
||||
|
||||
return entries
|
||||
.filter((entry) => {
|
||||
if (filter === 'active') return Boolean(entry.is_active)
|
||||
if (filter === 'inactive') return !entry.is_active
|
||||
if (filter === 'expired') return Boolean(entry.is_expired)
|
||||
if (filter === 'winner') return Boolean(entry.is_winner)
|
||||
if (filter === 'eligible') return Boolean(entry.eligibility?.is_eligible)
|
||||
if (filter === 'ineligible') return !entry.eligibility?.is_eligible
|
||||
return true
|
||||
})
|
||||
.filter((entry) => {
|
||||
if (!query) return true
|
||||
|
||||
const haystack = [
|
||||
entry.artwork?.title,
|
||||
entry.artwork?.owner?.display_name,
|
||||
entry.artwork?.owner?.username,
|
||||
entry.artwork?.id,
|
||||
].join(' ').toLowerCase()
|
||||
|
||||
return haystack.includes(query)
|
||||
})
|
||||
.sort((left, right) => compareEntries(left, right, sortKey, sortDirection))
|
||||
}, [entries, filter, listQuery, sortDirection, sortKey])
|
||||
|
||||
const duplicateSelection = !editingId && selectedArtwork?.already_featured
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo.title || 'Featured Artworks'}</title>
|
||||
{seo.description ? <meta name="description" content={seo.description} /> : null}
|
||||
{seo.robots ? <meta name="robots" content={seo.robots} /> : null}
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen bg-[#07111c] text-white">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<section className="overflow-hidden rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(245,158,11,0.14),_transparent_35%),linear-gradient(180deg,_rgba(6,14,25,0.92),_rgba(8,18,32,0.96))] p-8 shadow-[0_28px_90px_rgba(2,6,23,0.45)]">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<div className="inline-flex 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">Featured Artworks</div>
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-white sm:text-5xl">Homepage hero control, with the real winner logic exposed.</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300 sm:text-base">Editors can create, update, activate, expire, and remove featured entries here. The winner summary below mirrors the public homepage selection order: priority, recent medal score, featured date, then published date.</p>
|
||||
</div>
|
||||
<div className="grid w-full max-w-xl grid-cols-2 gap-4 md:grid-cols-3">
|
||||
<StatCard label="Entries" value={stats.total || 0} tone="sky" />
|
||||
<StatCard label="Eligible" value={stats.eligible || 0} tone="emerald" />
|
||||
<StatCard label="Expired" value={stats.expired || 0} tone="amber" />
|
||||
<StatCard label="Active" value={stats.active || 0} tone="sky" />
|
||||
<StatCard label="Inactive" value={stats.inactive || 0} tone="rose" />
|
||||
<StatCard label="Not Eligible" value={stats.ineligible || 0} tone="rose" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{notice ? (
|
||||
<div className="rounded-2xl border border-sky-300/15 bg-sky-400/10 px-4 py-3 text-sm text-sky-50">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Current Homepage Hero</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{winner ? winner.artwork?.title : 'No eligible featured artwork'}</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-7 text-slate-300">
|
||||
{winner?.selection_reason || 'There is no active, non-expired, eligible featured artwork right now.'}
|
||||
</p>
|
||||
{winner?.is_force_hero ? (
|
||||
<div className="mt-4 max-w-2xl rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm leading-6 text-amber-50">
|
||||
Forced by editor. This artwork bypasses the normal hero winner order until Force Hero is disabled on its featured row.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{winner ? <Badge label="Winner" tone="amber" /> : <Badge label="No Winner" tone="rose" />}
|
||||
{winner?.is_force_hero ? <Badge label="Force Hero" tone="amber" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{winner ? (
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[220px_1fr]">
|
||||
<a href={winner.artwork?.canonical_url || '#'} className="overflow-hidden rounded-[24px] border border-white/10 bg-[#09121f]" target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={winner.artwork?.thumbnail?.url}
|
||||
alt={winner.artwork?.title || 'Winner preview'}
|
||||
className="h-full min-h-[180px] w-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Artist</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{winner.artwork?.owner?.display_name || 'Unknown'}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">{winner.artwork?.owner?.type === 'group' ? 'Group publisher' : `@${winner.artwork?.owner?.username || ''}`}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Medal Score (30d)</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{winner.medals?.score_30d || 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Priority</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{winner.priority}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Featured Since</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{formatDateTime(winner.featured_at)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4 sm:col-span-2">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Published At</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{formatDateTime(winner.artwork?.published_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">{editingId ? 'Edit Entry' : 'Create Entry'}</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{editingId ? `Featured entry #${editingId}` : 'Add an artwork to the featured pool'}</h2>
|
||||
</div>
|
||||
{editingId ? (
|
||||
<button type="button" onClick={resetEditor} className="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-200 transition hover:border-white/20 hover:bg-white/5">
|
||||
Cancel edit
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!editingId ? (
|
||||
<form onSubmit={handleArtworkSearch} className="mt-6 space-y-4 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<Field label="Artwork selector" help="Search by artwork ID, title, slug, artist, or group. Pick a result to lock it into the form.">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
placeholder="Try an artwork ID, title, or creator"
|
||||
/>
|
||||
<button type="submit" disabled={busy === 'search'} className="rounded-2xl bg-sky-400 px-4 py-3 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{busy === 'search' ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{searchResults.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{searchResults.map((artwork) => (
|
||||
<button
|
||||
type="button"
|
||||
key={artwork.id}
|
||||
onClick={() => chooseArtwork(artwork)}
|
||||
className={`grid gap-4 rounded-2xl border p-3 text-left transition sm:grid-cols-[88px_1fr] ${selectedArtwork?.id === artwork.id ? 'border-sky-300/40 bg-sky-400/10' : 'border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.04]'}`}
|
||||
>
|
||||
<img src={artwork.thumbnail?.url} alt={artwork.title} className="h-24 w-full rounded-2xl object-cover" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-white">{artwork.title}</span>
|
||||
<span className="text-xs text-slate-400">#{artwork.id}</span>
|
||||
{artwork.already_featured ? <Badge label="Already Featured" tone="amber" /> : null}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">{artwork.owner?.display_name || 'Unknown'} • Medal Score (30d): {artwork.medals?.score_30d || 0}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(artwork.eligibility?.is_eligible ? [{ label: 'Eligible', tone: 'emerald' }] : [{ label: 'Not eligible', tone: 'rose' }]).concat(
|
||||
(artwork.eligibility?.reasons || []).map((reason) => ({
|
||||
label: reason,
|
||||
tone: reason === 'Missing preview' ? 'rose' : 'slate',
|
||||
}))
|
||||
).slice(0, 4).map((badge) => (
|
||||
<Badge key={`${artwork.id}-${badge.label}`} label={badge.label} tone={badge.tone} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{selectedArtwork ? (
|
||||
<div className="mt-6 grid gap-4 rounded-[24px] border border-white/10 bg-black/20 p-4 sm:grid-cols-[108px_1fr]">
|
||||
<img src={selectedArtwork.thumbnail?.url} alt={selectedArtwork.title || 'Artwork preview'} className="h-28 w-full rounded-2xl object-cover" />
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-slate-400">Selected Artwork</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{selectedArtwork.title}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">#{selectedArtwork.id} • {selectedArtwork.owner?.display_name || 'Unknown'} • Medal Score (30d): {selectedArtwork.medals?.score_30d || 0}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(selectedArtwork.eligibility?.is_eligible ? [{ label: 'Currently eligible', tone: 'emerald' }] : [{ label: 'Currently ineligible', tone: 'rose' }]).concat(
|
||||
(selectedArtwork.eligibility?.reasons || []).map((reason) => ({
|
||||
label: reason,
|
||||
tone: reason === 'Missing preview' ? 'rose' : 'slate',
|
||||
}))
|
||||
).map((badge) => (
|
||||
<Badge key={`selected-${badge.label}`} label={badge.label} tone={badge.tone} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{duplicateSelection ? (
|
||||
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
|
||||
This artwork already has a featured entry. Edit the existing row instead of creating a duplicate.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Priority" help="Higher priority always wins before medal score is considered.">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.priority}
|
||||
onChange={(event) => setForm((current) => ({ ...current, priority: event.target.value }))}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Active" help="Inactive rows stay visible in admin but cannot win the homepage hero.">
|
||||
<label className="flex h-[52px] items-center gap-3 rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-slate-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(form.is_active)}
|
||||
onChange={(event) => setForm((current) => ({ ...current, is_active: event.target.checked }))}
|
||||
className="h-4 w-4 rounded border-white/20 bg-transparent text-sky-400 focus:ring-sky-300/30"
|
||||
/>
|
||||
<span>{form.is_active ? 'Active on save' : 'Inactive on save'}</span>
|
||||
</label>
|
||||
</Field>
|
||||
|
||||
<Field label="Featured Since">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.featured_at}
|
||||
onChange={(event) => setForm((current) => ({ ...current, featured_at: event.target.value }))}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Expires">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form.expires_at}
|
||||
onChange={(event) => setForm((current) => ({ ...current, expires_at: event.target.value }))}
|
||||
className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="sm:col-span-2 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy === 'submit' || (!editingId && !selectedArtwork) || duplicateSelection}
|
||||
className="rounded-2xl bg-white px-5 py-3 text-sm font-semibold text-slate-950 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy === 'submit' ? 'Saving…' : editingId ? 'Save Changes' : 'Create Featured Entry'}
|
||||
</button>
|
||||
{editingId ? (
|
||||
<button type="button" onClick={resetEditor} className="rounded-2xl border border-white/10 px-5 py-3 text-sm font-semibold text-slate-100 transition hover:border-white/20 hover:bg-white/5">
|
||||
Reset
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Featured Pool</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">Every featured row, with eligibility and winner state visible.</h2>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:w-[720px]">
|
||||
<input
|
||||
type="text"
|
||||
value={listQuery}
|
||||
onChange={(event) => setListQuery(event.target.value)}
|
||||
placeholder="Filter by title, artist, or artwork ID"
|
||||
className="rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
|
||||
/>
|
||||
<select value={filter} onChange={(event) => setFilter(event.target.value)} className="rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="all">All rows</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="winner">Winner</option>
|
||||
<option value="eligible">Eligible</option>
|
||||
<option value="ineligible">Not eligible</option>
|
||||
</select>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<select value={sortKey} onChange={(event) => setSortKey(event.target.value)} className="rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40">
|
||||
<option value="priority">Priority</option>
|
||||
<option value="featured_at">Featured Since</option>
|
||||
<option value="expires_at">Expires</option>
|
||||
<option value="score_30d">Medal Score (30d)</option>
|
||||
</select>
|
||||
<button type="button" onClick={() => setSortDirection((current) => current === 'desc' ? 'asc' : 'desc')} className="rounded-2xl border border-white/10 px-4 py-3 text-sm font-semibold text-slate-100 transition hover:border-white/20 hover:bg-white/5">
|
||||
{sortDirection === 'desc' ? 'Desc' : 'Asc'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 overflow-hidden rounded-[24px] border border-white/10">
|
||||
<div className="hidden grid-cols-[1.2fr_1fr_0.5fr_0.9fr_0.9fr_0.7fr_1.5fr_0.9fr] gap-4 border-b border-white/10 bg-black/20 px-5 py-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400 lg:grid">
|
||||
<div>Artwork</div>
|
||||
<div>Artist / Owner</div>
|
||||
<div>Priority</div>
|
||||
<div>Featured Since</div>
|
||||
<div>Expires</div>
|
||||
<div>Score (30d)</div>
|
||||
<div>Status</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-white/10">
|
||||
{filteredEntries.length === 0 ? (
|
||||
<div className="px-5 py-10 text-center text-sm text-slate-400">No featured entries match the current filter.</div>
|
||||
) : filteredEntries.map((entry) => (
|
||||
<div key={entry.id} className="grid gap-5 bg-white/[0.02] px-5 py-5 lg:grid-cols-[1.2fr_1fr_0.5fr_0.9fr_0.9fr_0.7fr_1.5fr_0.9fr] lg:items-center">
|
||||
<div className="grid gap-4 sm:grid-cols-[92px_1fr]">
|
||||
<a href={entry.artwork?.canonical_url || '#'} target="_blank" rel="noreferrer" className="overflow-hidden rounded-2xl border border-white/10 bg-[#08111d]">
|
||||
<img src={entry.artwork?.thumbnail?.url} alt={entry.artwork?.title || 'Artwork preview'} className="h-24 w-full object-cover" />
|
||||
</a>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-white">{entry.artwork?.title || 'Missing artwork'}</span>
|
||||
<span className="text-xs text-slate-400">#{entry.artwork?.id || entry.artwork_id}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-slate-400">Visibility: {entry.artwork?.visibility || '—'} • Published: {entry.artwork?.published_at ? 'Yes' : 'No'}</div>
|
||||
{entry.is_winner && entry.winner_reason ? <div className="mt-2 text-xs leading-6 text-amber-100">{entry.winner_reason}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{entry.artwork?.owner?.display_name || 'Unknown'}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{entry.artwork?.owner?.type === 'group' ? 'Group publisher' : `@${entry.artwork?.owner?.username || ''}`}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-semibold text-white">{entry.priority}</div>
|
||||
<div className="text-sm text-slate-200">{formatDateTime(entry.featured_at)}</div>
|
||||
<div className="text-sm text-slate-200">{formatDateTime(entry.expires_at)}</div>
|
||||
<div className="text-sm font-semibold text-white">{entry.medals?.score_30d || 0}</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(entry.status_badges || []).map((badge, index) => (
|
||||
<Badge key={`${entry.id}-${badge.label}-${index}`} label={badge.label} tone={badge.tone} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
<button type="button" onClick={() => editEntry(entry)} className="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-100 transition hover:border-white/20 hover:bg-white/5">
|
||||
Edit
|
||||
</button>
|
||||
{capabilities.forceHeroEnabled ? (
|
||||
<button type="button" onClick={() => handleForceHero(entry)} disabled={busy === `force-${entry.id}`} className={`rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition disabled:cursor-not-allowed disabled:opacity-60 ${entry.is_force_hero ? 'border-amber-300/25 text-amber-100 hover:border-amber-300/40 hover:bg-amber-400/10' : 'border-amber-300/15 text-amber-50 hover:border-amber-300/30 hover:bg-amber-400/5'}`}>
|
||||
{busy === `force-${entry.id}` ? 'Saving…' : entry.is_force_hero ? 'Disable Force Hero' : 'Force Hero'}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => handleToggle(entry)} disabled={busy === `toggle-${entry.id}`} className="rounded-full border border-sky-300/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-400/10 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{busy === `toggle-${entry.id}` ? 'Saving…' : entry.is_active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDelete(entry)} disabled={busy === `delete-${entry.id}`} className="rounded-full border border-rose-300/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-rose-100 transition hover:border-rose-300/40 hover:bg-rose-400/10 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{busy === `delete-${entry.id}` ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage } from '@inertiajs/react'
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
export default function NovaCardsAssetPackAdmin() {
|
||||
const { props } = usePage()
|
||||
const [packs, setPacks] = React.useState(props.packs || [])
|
||||
const [selectedId, setSelectedId] = React.useState(null)
|
||||
const [form, setForm] = React.useState({ slug: '', name: '', description: '', type: 'asset', preview_image: '', manifest_json: {}, official: true, active: true, order_num: 0 })
|
||||
const endpoints = props.endpoints || {}
|
||||
|
||||
function loadPack(pack) {
|
||||
setSelectedId(pack.id)
|
||||
setForm({
|
||||
slug: pack.slug,
|
||||
name: pack.name,
|
||||
description: pack.description || '',
|
||||
type: pack.type || 'asset',
|
||||
preview_image: pack.preview_image || '',
|
||||
manifest_json: pack.manifest_json || {},
|
||||
official: Boolean(pack.official),
|
||||
active: Boolean(pack.active),
|
||||
order_num: pack.order_num || 0,
|
||||
})
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setSelectedId(null)
|
||||
setForm({ slug: '', name: '', description: '', type: 'asset', preview_image: '', manifest_json: {}, official: true, active: true, order_num: packs.length })
|
||||
}
|
||||
|
||||
async function savePack() {
|
||||
const isExisting = Boolean(selectedId)
|
||||
const url = isExisting ? String(endpoints.updatePattern || '').replace('__PACK__', String(selectedId)) : endpoints.store
|
||||
const response = await requestJson(url, { method: isExisting ? 'PATCH' : 'POST', body: form })
|
||||
if (isExisting) {
|
||||
setPacks((current) => current.map((pack) => (pack.id === selectedId ? response.pack : pack)))
|
||||
} else {
|
||||
setPacks((current) => [...current, response.pack])
|
||||
setSelectedId(response.pack.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
|
||||
<Head title="Nova Cards Asset Packs" />
|
||||
|
||||
<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">V2 pack system</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Official asset and template packs</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Control the official packs exposed in the v2 editor and public pack directories.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={resetForm} className="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]">New pack</button>
|
||||
<Link href={endpoints.cards || '/cp/cards'} className="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]">Back to cards</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
|
||||
<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">Existing packs</div>
|
||||
<div className="space-y-3">
|
||||
{packs.map((pack) => (
|
||||
<button key={pack.id} type="button" onClick={() => loadPack(pack)} className={`w-full rounded-[22px] border p-4 text-left transition ${selectedId === pack.id ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold tracking-[-0.03em] text-white">{pack.name}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{pack.slug}</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200">{pack.type}</span>
|
||||
</div>
|
||||
{pack.description ? <div className="mt-2 text-sm text-slate-400">{pack.description}</div> : null}
|
||||
</button>
|
||||
))}
|
||||
</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">Pack editor</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Pack name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Type</span>
|
||||
<select value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
<option value="asset">asset</option>
|
||||
<option value="template">template</option>
|
||||
</select>
|
||||
</label>
|
||||
<input value={form.preview_image} onChange={(event) => setForm((current) => ({ ...current, preview_image: event.target.value }))} placeholder="Preview image URL" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<textarea value={JSON.stringify(form.manifest_json || {}, null, 2)} onChange={(event) => {
|
||||
try {
|
||||
setForm((current) => ({ ...current, manifest_json: JSON.parse(event.target.value || '{}') }))
|
||||
} catch {
|
||||
// ignore invalid json until user fixes it
|
||||
}
|
||||
}} rows={10} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 font-mono text-sm text-white md:col-span-2" />
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.active)} onChange={(event) => setForm((current) => ({ ...current, active: event.target.checked }))} className="h-4 w-4" /> Active</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official</label>
|
||||
</div>
|
||||
<button type="button" onClick={savePack} className="mt-5 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">{selectedId ? 'Update pack' : 'Create pack'}</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage } from '@inertiajs/react'
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
export default function NovaCardsChallengeAdmin() {
|
||||
const { props } = usePage()
|
||||
const [challenges, setChallenges] = React.useState(props.challenges || [])
|
||||
const [selectedId, setSelectedId] = React.useState(null)
|
||||
const [form, setForm] = React.useState({ slug: '', title: '', description: '', prompt: '', rules_json: {}, status: 'draft', official: true, featured: false, winner_card_id: '', starts_at: '', ends_at: '' })
|
||||
const endpoints = props.endpoints || {}
|
||||
const cards = props.cards || []
|
||||
|
||||
function loadChallenge(challenge) {
|
||||
setSelectedId(challenge.id)
|
||||
setForm({
|
||||
slug: challenge.slug,
|
||||
title: challenge.title,
|
||||
description: challenge.description || '',
|
||||
prompt: challenge.prompt || '',
|
||||
rules_json: challenge.rules_json || {},
|
||||
status: challenge.status || 'draft',
|
||||
official: Boolean(challenge.official),
|
||||
featured: Boolean(challenge.featured),
|
||||
winner_card_id: challenge.winner_card_id || '',
|
||||
starts_at: challenge.starts_at || '',
|
||||
ends_at: challenge.ends_at || '',
|
||||
})
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setSelectedId(null)
|
||||
setForm({ slug: '', title: '', description: '', prompt: '', rules_json: {}, status: 'draft', official: true, featured: false, winner_card_id: '', starts_at: '', ends_at: '' })
|
||||
}
|
||||
|
||||
async function saveChallenge() {
|
||||
const isExisting = Boolean(selectedId)
|
||||
const url = isExisting ? String(endpoints.updatePattern || '').replace('__CHALLENGE__', String(selectedId)) : endpoints.store
|
||||
const response = await requestJson(url, { method: isExisting ? 'PATCH' : 'POST', body: { ...form, winner_card_id: form.winner_card_id || null } })
|
||||
if (isExisting) {
|
||||
setChallenges((current) => current.map((challenge) => (challenge.id === selectedId ? response.challenge : challenge)))
|
||||
} else {
|
||||
setChallenges((current) => [...current, response.challenge])
|
||||
setSelectedId(response.challenge.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
|
||||
<Head title="Nova Cards Challenges" />
|
||||
|
||||
<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">Challenge system</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Nova Cards challenge programming</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Program official challenge prompts, track featured runs, and connect winner cards.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={resetForm} className="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]">New challenge</button>
|
||||
<Link href={endpoints.cards || '/cp/cards'} className="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]">Back to cards</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
|
||||
<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">Existing challenges</div>
|
||||
<div className="space-y-3">
|
||||
{challenges.map((challenge) => (
|
||||
<button key={challenge.id} type="button" onClick={() => loadChallenge(challenge)} className={`w-full rounded-[22px] border p-4 text-left transition ${selectedId === challenge.id ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold tracking-[-0.03em] text-white">{challenge.title}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{challenge.slug}</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200">{challenge.status}</span>
|
||||
</div>
|
||||
{challenge.description ? <div className="mt-2 text-sm text-slate-400">{challenge.description}</div> : null}
|
||||
</button>
|
||||
))}
|
||||
</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">Challenge editor</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<input value={form.title} onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))} placeholder="Title" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
|
||||
<textarea value={form.prompt} onChange={(event) => setForm((current) => ({ ...current, prompt: event.target.value }))} placeholder="Prompt" rows={4} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Status</span>
|
||||
<select value={form.status} onChange={(event) => setForm((current) => ({ ...current, status: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{['draft', 'active', 'completed', 'archived'].map((status) => <option key={status} value={status}>{status}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Winner card</span>
|
||||
<select value={form.winner_card_id} onChange={(event) => setForm((current) => ({ ...current, winner_card_id: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
<option value="">No winner</option>
|
||||
{cards.map((card) => <option key={card.id} value={card.id}>{card.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Starts at</span>
|
||||
<input type="datetime-local" value={form.starts_at} onChange={(event) => setForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Ends at</span>
|
||||
<input type="datetime-local" value={form.ends_at} onChange={(event) => setForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
</label>
|
||||
<textarea value={JSON.stringify(form.rules_json || {}, null, 2)} onChange={(event) => {
|
||||
try {
|
||||
setForm((current) => ({ ...current, rules_json: JSON.parse(event.target.value || '{}') }))
|
||||
} catch {
|
||||
// ignore invalid json until fixed
|
||||
}
|
||||
}} rows={10} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 font-mono text-sm text-white md:col-span-2" />
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.featured)} onChange={(event) => setForm((current) => ({ ...current, featured: event.target.checked }))} className="h-4 w-4" /> Featured</label>
|
||||
</div>
|
||||
<button type="button" onClick={saveChallenge} className="mt-5 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">{selectedId ? 'Update challenge' : 'Create challenge'}</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage } from '@inertiajs/react'
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
export default function NovaCardsCollectionAdmin() {
|
||||
const { props } = usePage()
|
||||
const [collections, setCollections] = React.useState(props.collections || [])
|
||||
const [selectedId, setSelectedId] = React.useState(props.collections?.[0]?.id || null)
|
||||
const [cardId, setCardId] = React.useState('')
|
||||
const [cardNote, setCardNote] = React.useState('')
|
||||
const endpoints = props.endpoints || {}
|
||||
const admins = props.admins || []
|
||||
const cards = props.cards || []
|
||||
|
||||
const selected = React.useMemo(() => collections.find((entry) => entry.id === selectedId) || null, [collections, selectedId])
|
||||
const [form, setForm] = React.useState(() => ({
|
||||
user_id: admins[0]?.id || '',
|
||||
slug: '',
|
||||
name: '',
|
||||
description: '',
|
||||
visibility: 'public',
|
||||
official: true,
|
||||
featured: false,
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selected) {
|
||||
setForm({ user_id: admins[0]?.id || '', slug: '', name: '', description: '', visibility: 'public', official: true, featured: false })
|
||||
return
|
||||
}
|
||||
|
||||
setForm({
|
||||
user_id: selected.owner?.id || admins[0]?.id || '',
|
||||
slug: selected.slug || '',
|
||||
name: selected.name || '',
|
||||
description: selected.description || '',
|
||||
visibility: selected.visibility || 'public',
|
||||
official: Boolean(selected.official),
|
||||
featured: Boolean(selected.featured),
|
||||
})
|
||||
}, [admins, selected])
|
||||
|
||||
async function saveCollection() {
|
||||
const isExisting = Boolean(selectedId)
|
||||
const url = isExisting ? String(endpoints.updatePattern || '').replace('__COLLECTION__', String(selectedId)) : endpoints.store
|
||||
const response = await requestJson(url, { method: isExisting ? 'PATCH' : 'POST', body: form })
|
||||
|
||||
if (isExisting) {
|
||||
setCollections((current) => current.map((entry) => (entry.id === selectedId ? response.collection : entry)))
|
||||
} else {
|
||||
setCollections((current) => [response.collection, ...current])
|
||||
setSelectedId(response.collection.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function attachCard() {
|
||||
if (!selectedId || !cardId) return
|
||||
|
||||
const response = await requestJson(String(endpoints.attachCardPattern || '').replace('__COLLECTION__', String(selectedId)), {
|
||||
method: 'POST',
|
||||
body: { card_id: Number(cardId), note: cardNote || null },
|
||||
})
|
||||
setCollections((current) => current.map((entry) => (entry.id === selectedId ? response.collection : entry)))
|
||||
setCardId('')
|
||||
setCardNote('')
|
||||
}
|
||||
|
||||
async function detachCard(collectionId, currentCardId) {
|
||||
const response = await requestJson(
|
||||
String(endpoints.detachCardPattern || '')
|
||||
.replace('__COLLECTION__', String(collectionId))
|
||||
.replace('__CARD__', String(currentCardId)),
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
setCollections((current) => current.map((entry) => (entry.id === collectionId ? response.collection : entry)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
|
||||
<Head title="Nova Cards Collections" />
|
||||
|
||||
<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">Editorial layer</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Official and public card collections</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Create editorial collections, assign owners, and curate the public card sets that the v2 browse surface links to.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={() => setSelectedId(null)} className="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]">New collection</button>
|
||||
<Link href={endpoints.cards || '/cp/cards'} className="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]">Back to cards</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<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">Collections</div>
|
||||
<div className="space-y-3">
|
||||
{collections.map((collection) => (
|
||||
<button key={collection.id} type="button" onClick={() => setSelectedId(collection.id)} className={`w-full rounded-[22px] border p-4 text-left transition ${selectedId === collection.id ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold tracking-[-0.03em] text-white">{collection.name}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{collection.featured ? 'Featured • ' : ''}{collection.official ? 'Official' : '@' + (collection.owner?.username || 'creator')}</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200">{collection.cards_count} cards</span>
|
||||
</div>
|
||||
{collection.description ? <div className="mt-2 text-sm text-slate-400">{collection.description}</div> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
<div 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">Collection editor</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Owner</span>
|
||||
<select value={form.user_id} onChange={(event) => setForm((current) => ({ ...current, user_id: Number(event.target.value) }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{admins.map((admin) => <option key={admin.id} value={admin.id}>{admin.name || admin.username}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Visibility</span>
|
||||
<select value={form.visibility} onChange={(event) => setForm((current) => ({ ...current, visibility: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
<option value="public">public</option>
|
||||
<option value="private">private</option>
|
||||
</select>
|
||||
</label>
|
||||
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Collection name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={4} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official collection</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.featured)} onChange={(event) => setForm((current) => ({ ...current, featured: event.target.checked }))} className="h-4 w-4" /> Featured collection</label>
|
||||
</div>
|
||||
{selected?.public_url ? <a href={selected.public_url} className="text-sky-100 transition hover:text-white" target="_blank" rel="noreferrer">Open public page</a> : null}
|
||||
</div>
|
||||
<button type="button" onClick={saveCollection} className="mt-5 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">{selectedId ? 'Update collection' : 'Create collection'}</button>
|
||||
</div>
|
||||
|
||||
<div 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">Curate cards</div>
|
||||
{!selectedId ? (
|
||||
<div className="rounded-2xl border border-dashed border-white/12 bg-white/[0.03] px-4 py-8 text-center text-sm text-slate-400">Create or select a collection first.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||
<select value={cardId} onChange={(event) => setCardId(event.target.value)} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
<option value="">Select a card</option>
|
||||
{cards.map((card) => <option key={card.id} value={card.id}>{card.title}</option>)}
|
||||
</select>
|
||||
<input value={cardNote} onChange={(event) => setCardNote(event.target.value)} placeholder="Optional curator note" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<button type="button" onClick={attachCard} className="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">Add</button>
|
||||
</div>
|
||||
<div className="mt-5 space-y-3">
|
||||
{(selected?.items || []).map((item) => (
|
||||
<div key={item.id} className="flex items-start justify-between gap-4 rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">{item.card?.title}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">#{item.sort_order} {item.card?.creator?.username ? `• @${item.card.creator.username}` : ''}</div>
|
||||
{item.note ? <div className="mt-2 text-sm text-slate-400">{item.note}</div> : null}
|
||||
</div>
|
||||
<button type="button" onClick={() => detachCard(selectedId, item.card.id)} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15">Remove</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage } from '@inertiajs/react'
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
export default function NovaCardsTemplateAdmin() {
|
||||
const { props } = usePage()
|
||||
const [templates, setTemplates] = React.useState(props.templates || [])
|
||||
const [selectedId, setSelectedId] = React.useState(null)
|
||||
const [form, setForm] = React.useState({
|
||||
slug: '',
|
||||
name: '',
|
||||
description: '',
|
||||
supported_formats: ['square'],
|
||||
active: true,
|
||||
official: true,
|
||||
order_num: templates.length,
|
||||
config_json: {
|
||||
font_preset: 'modern-sans',
|
||||
gradient_preset: 'midnight-nova',
|
||||
text_align: 'center',
|
||||
layout: 'quote_heavy',
|
||||
text_color: '#ffffff',
|
||||
overlay_style: 'dark-soft',
|
||||
},
|
||||
})
|
||||
const endpoints = props.endpoints || {}
|
||||
const formats = props.editorOptions?.formats || []
|
||||
const fonts = props.editorOptions?.font_presets || []
|
||||
const gradients = props.editorOptions?.gradient_presets || []
|
||||
|
||||
function loadTemplate(template) {
|
||||
setSelectedId(template.id)
|
||||
setForm({
|
||||
slug: template.slug,
|
||||
name: template.name,
|
||||
description: template.description || '',
|
||||
preview_image: template.preview_image || null,
|
||||
supported_formats: template.supported_formats || [],
|
||||
active: Boolean(template.active),
|
||||
official: Boolean(template.official),
|
||||
order_num: template.order_num || 0,
|
||||
config_json: template.config_json || {},
|
||||
})
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setSelectedId(null)
|
||||
setForm({
|
||||
slug: '',
|
||||
name: '',
|
||||
description: '',
|
||||
supported_formats: ['square'],
|
||||
active: true,
|
||||
official: true,
|
||||
order_num: templates.length,
|
||||
config_json: {
|
||||
font_preset: 'modern-sans',
|
||||
gradient_preset: 'midnight-nova',
|
||||
text_align: 'center',
|
||||
layout: 'quote_heavy',
|
||||
text_color: '#ffffff',
|
||||
overlay_style: 'dark-soft',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
const isExisting = Boolean(selectedId)
|
||||
const url = isExisting
|
||||
? String(endpoints.updatePattern || '').replace('__TEMPLATE__', String(selectedId))
|
||||
: endpoints.store
|
||||
const response = await requestJson(url, {
|
||||
method: isExisting ? 'PATCH' : 'POST',
|
||||
body: form,
|
||||
})
|
||||
|
||||
if (isExisting) {
|
||||
setTemplates((current) => current.map((template) => (template.id === selectedId ? response.template : template)))
|
||||
} else {
|
||||
setTemplates((current) => [...current, response.template])
|
||||
setSelectedId(response.template.id)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFormat(key) {
|
||||
setForm((current) => {
|
||||
const exists = current.supported_formats.includes(key)
|
||||
return {
|
||||
...current,
|
||||
supported_formats: exists ? current.supported_formats.filter((item) => item !== key) : [...current.supported_formats, key],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-20 pt-8 sm:px-6 lg:px-8">
|
||||
<Head title="Nova Cards Templates" />
|
||||
|
||||
<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">Template system</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Official Nova Cards templates</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">Keep starter templates config-driven so the editor and render pipeline stay aligned as new card styles ship.</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={resetForm} className="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]">New template</button>
|
||||
<Link href={endpoints.cards || '/cp/cards'} className="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]">Back to cards</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)]">
|
||||
<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">Existing templates</div>
|
||||
<div className="space-y-3">
|
||||
{templates.map((template) => (
|
||||
<button key={template.id} type="button" onClick={() => loadTemplate(template)} className={`w-full rounded-[22px] border p-4 text-left transition ${selectedId === template.id ? 'border-sky-300/35 bg-sky-400/10' : 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold tracking-[-0.03em] text-white">{template.name}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{template.slug}</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200">{template.supported_formats?.join(', ')}</span>
|
||||
</div>
|
||||
{template.description ? <div className="mt-2 text-sm text-slate-400">{template.description}</div> : null}
|
||||
</button>
|
||||
))}
|
||||
</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">Template editor</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Template name" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="Slug" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white" />
|
||||
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Description" rows={3} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white md:col-span-2" />
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Font preset</span>
|
||||
<select value={form.config_json?.font_preset || 'modern-sans'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, font_preset: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{fonts.map((font) => <option key={font.key} value={font.key}>{font.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Gradient preset</span>
|
||||
<select value={form.config_json?.gradient_preset || 'midnight-nova'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, gradient_preset: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{gradients.map((gradient) => <option key={gradient.key} value={gradient.key}>{gradient.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Layout preset</span>
|
||||
<select value={form.config_json?.layout || 'quote_heavy'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, layout: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{['quote_heavy', 'author_emphasis', 'centered', 'minimal'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Text alignment</span>
|
||||
<select value={form.config_json?.text_align || 'center'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, text_align: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{['left', 'center', 'right'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Overlay style</span>
|
||||
<select value={form.config_json?.overlay_style || 'dark-soft'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, overlay_style: event.target.value } }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white">
|
||||
{['none', 'dark-soft', 'dark-strong', 'light-soft'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300">
|
||||
<span className="mb-2 block">Text color</span>
|
||||
<input type="color" value={form.config_json?.text_color || '#ffffff'} onChange={(event) => setForm((current) => ({ ...current, config_json: { ...current.config_json, text_color: event.target.value } }))} className="h-12 w-full rounded-2xl border border-white/10 bg-[#0d1726] p-2" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<div className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">Supported formats</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{formats.map((format) => (
|
||||
<label key={format.key} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-200">
|
||||
<input type="checkbox" checked={form.supported_formats.includes(format.key)} onChange={() => toggleFormat(format.key)} className="h-4 w-4" />
|
||||
{format.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.active)} onChange={(event) => setForm((current) => ({ ...current, active: event.target.checked }))} className="h-4 w-4" /> Active</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(form.official)} onChange={(event) => setForm((current) => ({ ...current, official: event.target.checked }))} className="h-4 w-4" /> Official</label>
|
||||
</div>
|
||||
<button type="button" onClick={saveTemplate} className="mt-5 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">{selectedId ? 'Update template' : 'Create template'}</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
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 buildFilterUrl(next, baseUrl) {
|
||||
if (typeof window === 'undefined' && !baseUrl) return '#'
|
||||
|
||||
const url = new URL(baseUrl || window.location.href, window.location.origin)
|
||||
Object.entries(next).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined || value === '' || value === 'all') {
|
||||
url.searchParams.delete(key)
|
||||
} else {
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
})
|
||||
|
||||
return `${url.pathname}?${url.searchParams.toString()}`.replace(/\?$/, '')
|
||||
}
|
||||
|
||||
function buildSearchActionUrl(baseUrl) {
|
||||
if (typeof window === 'undefined' && !baseUrl) return '#'
|
||||
|
||||
const url = new URL(baseUrl || window.location.href, window.location.origin)
|
||||
url.searchParams.delete('q')
|
||||
|
||||
return `${url.pathname}?${url.searchParams.toString()}`.replace(/\?$/, '')
|
||||
}
|
||||
|
||||
function reorderCollectionIds(collections, collectionId, direction) {
|
||||
const currentIndex = collections.findIndex((item) => Number(item.id) === Number(collectionId))
|
||||
if (currentIndex === -1) return null
|
||||
|
||||
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1
|
||||
if (targetIndex < 0 || targetIndex >= collections.length) return null
|
||||
|
||||
const nextCollections = [...collections]
|
||||
const [movedCollection] = nextCollections.splice(currentIndex, 1)
|
||||
nextCollections.splice(targetIndex, 0, movedCollection)
|
||||
|
||||
return nextCollections.map((item) => Number(item.id))
|
||||
}
|
||||
|
||||
function EmptyState({ browseUrl }) {
|
||||
return (
|
||||
<div className="rounded-[32px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.05] text-slate-400">
|
||||
<i className="fa-solid fa-bookmark text-3xl" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-2xl font-semibold text-white">No saved collections yet</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-300">
|
||||
Save collections to build a personal reference library for inspiration, campaigns, and creators you want to revisit.
|
||||
</p>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<a href={browseUrl} className="inline-flex items-center gap-2 rounded-2xl border border-white/12 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-compass fa-fw" />
|
||||
Browse collections
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterLink({ href, active, children, count }) {
|
||||
return (
|
||||
<a href={href} className={`flex items-center justify-between rounded-2xl border px-4 py-3 text-sm transition ${active ? 'border-sky-300/20 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.07]'}`}>
|
||||
<span>{children}</span>
|
||||
{typeof count === 'number' ? <span className={`text-xs ${active ? 'text-sky-100/80' : 'text-slate-400'}`}>{count}</span> : null}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return null
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
export default function SavedCollections() {
|
||||
const { props } = usePage()
|
||||
const seo = props.seo || {}
|
||||
const initialCollections = Array.isArray(props.collections) ? props.collections : []
|
||||
const recentlyRevisited = Array.isArray(props.recentlyRevisited) ? props.recentlyRevisited : []
|
||||
const recommendedCollections = Array.isArray(props.recommendedCollections) ? props.recommendedCollections : []
|
||||
const browseUrl = props.browseUrl || '/collections/featured'
|
||||
const libraryUrl = props.libraryUrl || '/me/saved/collections'
|
||||
const activeFilters = props.activeFilters || { q: '', filter: 'all', sort: 'saved_desc', list: null }
|
||||
const activeList = props.activeList || null
|
||||
const filterOptions = Array.isArray(props.filterOptions) ? props.filterOptions : []
|
||||
const sortOptions = Array.isArray(props.sortOptions) ? props.sortOptions : []
|
||||
const searchBaseUrl = activeList?.url || libraryUrl
|
||||
const [collections, setCollections] = React.useState(initialCollections)
|
||||
const [savedLists, setSavedLists] = React.useState(Array.isArray(props.savedLists) ? props.savedLists : [])
|
||||
const [newListTitle, setNewListTitle] = React.useState('')
|
||||
const [selectedLists, setSelectedLists] = React.useState({})
|
||||
const [notes, setNotes] = React.useState(() => Object.fromEntries(initialCollections.map((collection) => [collection.id, collection.saved_note || ''])))
|
||||
const [search, setSearch] = React.useState(activeFilters.q || '')
|
||||
const [notice, setNotice] = React.useState('')
|
||||
const [busy, setBusy] = React.useState('')
|
||||
React.useEffect(() => {
|
||||
setCollections(initialCollections)
|
||||
setNotes(Object.fromEntries(initialCollections.map((collection) => [collection.id, collection.saved_note || ''])))
|
||||
}, [initialCollections])
|
||||
|
||||
React.useEffect(() => {
|
||||
setSearch(activeFilters.q || '')
|
||||
}, [activeFilters.q])
|
||||
|
||||
React.useEffect(() => {
|
||||
setSavedLists(Array.isArray(props.savedLists) ? props.savedLists : [])
|
||||
}, [props.savedLists])
|
||||
|
||||
const listSchema = seo?.canonical ? {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: 'Saved collections',
|
||||
description: seo?.description || 'Your saved collections on Skinbase Nova.',
|
||||
url: seo.canonical,
|
||||
mainEntity: {
|
||||
'@type': 'ItemList',
|
||||
numberOfItems: collections.length,
|
||||
itemListElement: collections.slice(0, 18).map((collection, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: collection.url,
|
||||
name: collection.title,
|
||||
})),
|
||||
},
|
||||
} : null
|
||||
|
||||
async function handleCreateList(event) {
|
||||
event.preventDefault()
|
||||
if (!newListTitle.trim() || !props.endpoints?.createList) return
|
||||
|
||||
setBusy('create-list')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(props.endpoints.createList, {
|
||||
method: 'POST',
|
||||
body: { title: newListTitle.trim() },
|
||||
})
|
||||
|
||||
setSavedLists((current) => [...current, payload.list].sort((left, right) => String(left.title).localeCompare(String(right.title))))
|
||||
setNewListTitle('')
|
||||
setNotice('Saved list created.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to create list.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddToList(collectionId) {
|
||||
const listId = selectedLists[collectionId] || savedLists[0]?.id
|
||||
if (!listId || !props.endpoints?.addToListPattern) return
|
||||
|
||||
setBusy(`list-${collectionId}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(props.endpoints.addToListPattern.replace('__COLLECTION__', String(collectionId)), {
|
||||
method: 'POST',
|
||||
body: { saved_list_id: Number(listId) },
|
||||
})
|
||||
|
||||
setSavedLists((current) => current.map((list) => (
|
||||
Number(list.id) === Number(listId)
|
||||
? { ...list, items_count: Number(payload?.list?.items_count || list.items_count || 0) }
|
||||
: list
|
||||
)))
|
||||
setNotice(payload?.added ? 'Collection added to saved list.' : 'Collection already exists in that saved list.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to add collection to list.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnsave(collection) {
|
||||
if (!collection?.id || !props.endpoints?.unsavePattern) return
|
||||
|
||||
setBusy(`unsave-${collection.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
await requestJson(props.endpoints.unsavePattern.replace('__COLLECTION__', String(collection.id)), {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
setCollections((current) => current.filter((item) => Number(item.id) !== Number(collection.id)))
|
||||
setSavedLists((current) => current.map((list) => {
|
||||
const isMember = Array.isArray(collection.saved_list_ids) && collection.saved_list_ids.includes(Number(list.id))
|
||||
|
||||
return isMember
|
||||
? { ...list, items_count: Math.max(0, Number(list.items_count || 0) - 1) }
|
||||
: list
|
||||
}))
|
||||
setNotice('Collection removed from your saved library.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to remove collection from your saved library.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveFromList(collection) {
|
||||
if (!activeList?.id || !collection?.id || !props.endpoints?.removeFromListPattern) return
|
||||
|
||||
setBusy(`remove-${collection.id}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(
|
||||
props.endpoints.removeFromListPattern
|
||||
.replace('__LIST__', String(activeList.id))
|
||||
.replace('__COLLECTION__', String(collection.id)),
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
|
||||
setCollections((current) => current.filter((item) => Number(item.id) !== Number(collection.id)))
|
||||
setSavedLists((current) => current.map((list) => (
|
||||
Number(list.id) === Number(payload?.list?.id)
|
||||
? { ...list, items_count: Number(payload?.list?.items_count || 0) }
|
||||
: list
|
||||
)))
|
||||
setNotice('Collection removed from this saved list.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to remove collection from this saved list.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReorderCollection(collectionId, direction) {
|
||||
if (!activeList?.id || !props.endpoints?.reorderItemsPattern) return
|
||||
|
||||
const reorderedCollectionIds = reorderCollectionIds(collections, collectionId, direction)
|
||||
if (!reorderedCollectionIds) return
|
||||
|
||||
setBusy(`reorder-${collectionId}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
await requestJson(
|
||||
props.endpoints.reorderItemsPattern.replace('__LIST__', String(activeList.id)),
|
||||
{
|
||||
method: 'POST',
|
||||
body: { collection_ids: reorderedCollectionIds },
|
||||
},
|
||||
)
|
||||
|
||||
setCollections((current) => {
|
||||
const order = new Map(reorderedCollectionIds.map((id, index) => [Number(id), index]))
|
||||
|
||||
return [...current].sort((left, right) => {
|
||||
const leftOrder = order.get(Number(left.id)) ?? Number.MAX_SAFE_INTEGER
|
||||
const rightOrder = order.get(Number(right.id)) ?? Number.MAX_SAFE_INTEGER
|
||||
|
||||
return leftOrder - rightOrder
|
||||
})
|
||||
})
|
||||
setNotice('Saved list order updated.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to update saved list order.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveNote(collectionId) {
|
||||
if (!props.endpoints?.updateNotePattern) return
|
||||
|
||||
setBusy(`note-${collectionId}`)
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(
|
||||
props.endpoints.updateNotePattern.replace('__COLLECTION__', String(collectionId)),
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: { note: notes[collectionId] || '' },
|
||||
},
|
||||
)
|
||||
|
||||
setCollections((current) => current.map((collection) => (
|
||||
Number(collection.id) === Number(collectionId)
|
||||
? { ...collection, saved_note: payload?.note?.note || null }
|
||||
: collection
|
||||
)))
|
||||
setNotice(payload?.note ? 'Saved note updated.' : 'Saved note removed.')
|
||||
} catch (error) {
|
||||
setNotice(error.message || 'Failed to update saved note.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoHead seo={seo} title={seo?.title || 'Saved Collections — Skinbase Nova'} description={seo?.description || 'Your saved collections on Skinbase Nova.'} jsonLd={listSchema} />
|
||||
|
||||
<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-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 15% 14%, rgba(245,158,11,0.16), transparent 26%), radial-gradient(circle at 82% 18%, rgba(56,189,248,0.16), 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-7xl px-4 pt-8 md:px-6">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
|
||||
<a href={browseUrl} 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]" />
|
||||
Browse collections
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<section className="mt-6 overflow-hidden 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-amber-200/80">Library</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Saved collections</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
||||
A personal shortlist of collections worth revisiting. Organize them into saved lists, pivot by editorial or campaign relevance, and keep a working shelf of what should influence your next publish.
|
||||
</p>
|
||||
{activeList ? <p className="mt-4 inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100"><i className="fa-solid fa-folder-open fa-fw" />Viewing list: {activeList.title}</p> : null}
|
||||
{activeFilters.q ? <p className="mt-4 inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-sm font-semibold text-amber-100"><i className="fa-solid fa-magnifying-glass fa-fw" />Search: {activeFilters.q}</p> : null}
|
||||
{notice ? <p className="mt-4 text-sm text-sky-100">{notice}</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside className="space-y-5">
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Search</p>
|
||||
<form method="GET" action={buildSearchActionUrl(searchBaseUrl)} className="mt-4 space-y-3">
|
||||
<input name="q" value={search} onChange={(event) => setSearch(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" placeholder="Search titles, notes context, or curator" maxLength={120} />
|
||||
{activeFilters.filter && activeFilters.filter !== 'all' ? <input type="hidden" name="filter" value={activeFilters.filter} /> : null}
|
||||
{activeFilters.sort && activeFilters.sort !== 'saved_desc' ? <input type="hidden" name="sort" value={activeFilters.sort} /> : null}
|
||||
<div className="flex gap-3">
|
||||
<button type="submit" className="inline-flex flex-1 items-center justify-center gap-2 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"><i className="fa-solid fa-magnifying-glass fa-fw" />Apply</button>
|
||||
{(activeFilters.q || search) ? <a href={buildFilterUrl({ q: null }, searchBaseUrl)} className="inline-flex items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]">Clear</a> : null}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Filters</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{filterOptions.map((option) => (
|
||||
<FilterLink
|
||||
key={option.key}
|
||||
href={buildFilterUrl({ q: activeFilters.q, filter: option.key, sort: activeFilters.sort }, searchBaseUrl)}
|
||||
active={activeFilters.filter === option.key}
|
||||
count={option.count}
|
||||
>
|
||||
{option.label}
|
||||
</FilterLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Sort</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{sortOptions.map((option) => (
|
||||
<FilterLink
|
||||
key={option.key}
|
||||
href={buildFilterUrl({ q: activeFilters.q, filter: activeFilters.filter, sort: option.key }, searchBaseUrl)}
|
||||
active={activeFilters.sort === option.key}
|
||||
>
|
||||
{option.label}
|
||||
</FilterLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Saved Lists</p>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{savedLists.length}</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<FilterLink href={buildFilterUrl({ q: activeFilters.q, filter: activeFilters.filter, sort: activeFilters.sort }, libraryUrl)} active={!activeFilters.list}>All saved collections</FilterLink>
|
||||
{savedLists.map((list) => (
|
||||
<FilterLink key={list.id} href={buildFilterUrl({ q: activeFilters.q, filter: activeFilters.filter, sort: activeFilters.sort }, list.url || libraryUrl)} active={Number(activeFilters.list) === Number(list.id)} count={list.items_count}>{list.title}</FilterLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateList} className="mt-5 space-y-3">
|
||||
<input value={newListTitle} onChange={(event) => setNewListTitle(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35" placeholder="Create a saved list" maxLength={120} />
|
||||
<button type="submit" disabled={busy === 'create-list'} className="inline-flex w-full items-center justify-center gap-2 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 disabled:opacity-60"><i className={`fa-solid ${busy === 'create-list' ? 'fa-circle-notch fa-spin' : 'fa-folder-plus'} fa-fw`} />Create list</button>
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div className="space-y-8">
|
||||
{recentlyRevisited.length ? (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Recently Revisited</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Jump back into active references</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{recentlyRevisited.length}</span>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-1 gap-5 xl:grid-cols-3">
|
||||
{recentlyRevisited.map((collection) => (
|
||||
<CollectionCard key={`revisited-${collection.id}`} collection={collection} isOwner={false} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
{collections.length ? (
|
||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2">
|
||||
{collections.map((collection, index) => (
|
||||
<div key={collection.id} className="space-y-3">
|
||||
<CollectionCard collection={collection} isOwner={false} />
|
||||
{(collection.saved_because || collection.last_viewed_at) ? (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
||||
{collection.saved_because ? <span className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-2 text-xs font-semibold text-amber-100"><i className="fa-solid fa-lightbulb fa-fw" />{collection.saved_because}</span> : null}
|
||||
{collection.last_viewed_at ? <span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-[#0d1726] px-3 py-2 text-xs font-semibold text-slate-200"><i className="fa-solid fa-clock-rotate-left fa-fw" />Last revisited {formatDateTime(collection.last_viewed_at)}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<button type="button" onClick={() => handleUnsave(collection)} disabled={busy === `unsave-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-2.5 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `unsave-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-bookmark-slash'} fa-fw`} />Remove from saved</button>
|
||||
{activeList ? <button type="button" onClick={() => handleRemoveFromList(collection)} disabled={busy === `remove-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.07] disabled:opacity-60"><i className={`fa-solid ${busy === `remove-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-folder-minus'} fa-fw`} />Remove from list</button> : null}
|
||||
{activeList && collections.length > 1 ? (
|
||||
<div className="ml-auto inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-[#0d1726] p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReorderCollection(collection.id, 'up')}
|
||||
disabled={index === 0 || busy === `reorder-${collection.id}`}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-xl text-sm text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={`Move ${collection.title} up`}
|
||||
>
|
||||
<i className={`fa-solid ${busy === `reorder-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-arrow-up'} fa-fw`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReorderCollection(collection.id, 'down')}
|
||||
disabled={index === collections.length - 1 || busy === `reorder-${collection.id}`}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-xl text-sm text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={`Move ${collection.title} down`}
|
||||
>
|
||||
<i className={`fa-solid ${busy === `reorder-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-arrow-down'} fa-fw`} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{savedLists.length ? (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<select value={selectedLists[collection.id] || savedLists[0]?.id || ''} onChange={(event) => setSelectedLists((current) => ({ ...current, [collection.id]: event.target.value }))} className="min-w-[180px] rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-2.5 text-sm text-white outline-none">
|
||||
{savedLists.map((list) => <option key={list.id} value={list.id}>{list.title}</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={() => handleAddToList(collection.id)} disabled={busy === `list-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.07] disabled:opacity-60"><i className={`fa-solid ${busy === `list-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-folder-plus'} fa-fw`} />Add to list</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Private Note</p>
|
||||
<button type="button" onClick={() => handleSaveNote(collection.id)} disabled={busy === `note-${collection.id}`} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-xs font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `note-${collection.id}` ? 'fa-circle-notch fa-spin' : 'fa-note-sticky'} fa-fw`} />Save note</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={notes[collection.id] || ''}
|
||||
onChange={(event) => setNotes((current) => ({ ...current, [collection.id]: event.target.value }))}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
placeholder="Why did you save this collection? Add campaign context, inspiration notes, or follow-up ideas."
|
||||
className="mt-3 w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
activeFilters.q || activeFilters.filter !== 'all' || activeFilters.sort !== 'saved_desc' || activeFilters.list
|
||||
? <div className="rounded-[32px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center text-sm text-slate-300">No saved collections match the current search or filters.</div>
|
||||
: <EmptyState browseUrl={browseUrl} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{recommendedCollections.length ? (
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Recommended Next</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Because of what you save</h2>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{recommendedCollections.length}</span>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-1 gap-5 xl:grid-cols-3">
|
||||
{recommendedCollections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} isOwner={false} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user