optimizations
This commit is contained in:
143
resources/js/Pages/Collection/CollectionAnalytics.jsx
Normal file
143
resources/js/Pages/Collection/CollectionAnalytics.jsx
Normal file
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
674
resources/js/Pages/Collection/CollectionDashboard.jsx
Normal file
674
resources/js/Pages/Collection/CollectionDashboard.jsx
Normal file
@@ -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-clone" 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
489
resources/js/Pages/Collection/CollectionFeaturedIndex.jsx
Normal file
489
resources/js/Pages/Collection/CollectionFeaturedIndex.jsx
Normal file
@@ -0,0 +1,489 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo?.title || `${title} — Skinbase Nova`}</title>
|
||||
<meta name="description" content={seo?.description || description} />
|
||||
{seo?.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
|
||||
<meta name="robots" content={seo?.robots || 'index,follow'} />
|
||||
<meta property="og:title" content={seo?.title || `${title} — Skinbase Nova`} />
|
||||
<meta property="og:description" content={seo?.description || description} />
|
||||
{seo?.canonical ? <meta property="og:url" content={seo.canonical} /> : null}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={seo?.title || `${title} — Skinbase Nova`} />
|
||||
<meta name="twitter:description" content={seo?.description || description} />
|
||||
{listSchema ? <script type="application/ld+json">{JSON.stringify(listSchema)}</script> : null}
|
||||
</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-[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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
169
resources/js/Pages/Collection/CollectionHistory.jsx
Normal file
169
resources/js/Pages/Collection/CollectionHistory.jsx
Normal file
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
3303
resources/js/Pages/Collection/CollectionManage.jsx
Normal file
3303
resources/js/Pages/Collection/CollectionManage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
98
resources/js/Pages/Collection/CollectionSeriesShow.jsx
Normal file
98
resources/js/Pages/Collection/CollectionSeriesShow.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo.title || `${title} — Skinbase Nova`}</title>
|
||||
<meta name="description" content={seo.description || description} />
|
||||
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
|
||||
<meta name="robots" content={seo.robots || 'index,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-[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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
959
resources/js/Pages/Collection/CollectionShow.jsx
Normal file
959
resources/js/Pages/Collection/CollectionShow.jsx
Normal file
@@ -0,0 +1,959 @@
|
||||
import React from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import ArtworkGallery from '../../components/artwork/ArtworkGallery'
|
||||
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
||||
import CollectionVisibilityBadge from '../../components/profile/collections/CollectionVisibilityBadge'
|
||||
import CommentForm from '../../components/social/CommentForm'
|
||||
import CommentList from '../../components/social/CommentList'
|
||||
import useWebShare from '../../hooks/useWebShare'
|
||||
|
||||
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 TypeBadge({ collection }) {
|
||||
const label = collection?.type === 'editorial'
|
||||
? 'Editorial'
|
||||
: collection?.type === 'community'
|
||||
? 'Community'
|
||||
: 'Personal'
|
||||
|
||||
return <span className="inline-flex items-center 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">{label}</span>
|
||||
}
|
||||
|
||||
function CollaboratorCard({ member }) {
|
||||
return (
|
||||
<a href={member?.user?.profile_url || '#'} className="flex items-center gap-3 rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-4 transition hover:bg-white/[0.07]">
|
||||
<img src={member?.user?.avatar_url} alt={member?.user?.name || member?.user?.username} className="h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-white">{member?.user?.name || member?.user?.username}</div>
|
||||
<div className="truncate text-xs uppercase tracking-[0.16em] text-slate-400">{member?.role} {member?.status === 'pending' ? '• invited' : ''}</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function SubmissionCard({ submission, onApprove, onReject, onWithdraw, onReport }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{submission?.artwork?.thumb ? <img src={submission.artwork.thumb} alt={submission.artwork.title} className="h-20 w-20 rounded-2xl object-cover" /> : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="truncate text-sm font-semibold text-white">{submission?.artwork?.title || 'Artwork submission'}</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{submission?.status}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-400">Submitted by @{submission?.user?.username}</p>
|
||||
{submission?.message ? <p className="mt-2 text-sm text-slate-300">{submission.message}</p> : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{submission?.can_review ? <button type="button" onClick={() => onApprove?.(submission)} className="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-3 py-2 text-xs font-semibold text-emerald-100">Approve</button> : null}
|
||||
{submission?.can_review ? <button type="button" onClick={() => onReject?.(submission)} className="rounded-full border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold text-rose-100">Reject</button> : null}
|
||||
{submission?.can_withdraw ? <button type="button" onClick={() => onWithdraw?.(submission)} className="rounded-full border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold text-white">Withdraw</button> : null}
|
||||
{submission?.can_report ? <button type="button" onClick={() => onReport?.(submission)} className="rounded-full border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold text-white">Report</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaRow({ icon, label, value, compact = false }) {
|
||||
const title = `${label}: ${value}`
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-col items-center rounded-2xl border border-white/10 bg-white/[0.05] px-3 py-4 text-center"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
>
|
||||
<i className={`fa-solid ${icon} text-base text-slate-300`} />
|
||||
<div className="mt-3 text-xl font-semibold text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3" title={title}>
|
||||
<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-1 text-lg font-semibold text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getSpotlightClasses(style) {
|
||||
switch (style) {
|
||||
case 'editorial':
|
||||
return 'border-amber-300/20 bg-[linear-gradient(135deg,rgba(120,53,15,0.45),rgba(2,6,23,0.82))] text-amber-50'
|
||||
case 'seasonal':
|
||||
return 'border-emerald-300/20 bg-[linear-gradient(135deg,rgba(6,78,59,0.5),rgba(2,6,23,0.82))] text-emerald-50'
|
||||
case 'challenge':
|
||||
return 'border-fuchsia-300/20 bg-[linear-gradient(135deg,rgba(112,26,117,0.48),rgba(2,6,23,0.82))] text-fuchsia-50'
|
||||
case 'community':
|
||||
return 'border-sky-300/20 bg-[linear-gradient(135deg,rgba(3,105,161,0.45),rgba(2,6,23,0.82))] text-sky-50'
|
||||
default:
|
||||
return 'border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.9),rgba(30,41,59,0.72))] text-white'
|
||||
}
|
||||
}
|
||||
|
||||
function EmptyCollectionState({ isOwner, smart = false }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-14 text-center">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.06] text-slate-400">
|
||||
<i className={`fa-solid ${smart ? 'fa-wand-magic-sparkles' : 'fa-images'} text-3xl`} />
|
||||
</div>
|
||||
<h2 className="mt-5 text-2xl font-semibold text-white">This collection is still taking shape</h2>
|
||||
<p className="mx-auto mt-3 max-w-lg text-sm leading-relaxed text-slate-300">
|
||||
{isOwner
|
||||
? (smart ? 'Adjust the smart rules to broaden the match or publish more artworks that fit this set.' : 'Add artworks to start building the visual rhythm, cover image, and sequence for this showcase.')
|
||||
: (smart ? 'This smart collection does not have visible matches right now.' : 'There are no visible artworks in this collection right now.')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OwnerCard({ owner, collectionType }) {
|
||||
const label = owner?.is_system
|
||||
? 'Editorial Owner'
|
||||
: collectionType === 'editorial'
|
||||
? 'Editorial Curator'
|
||||
: 'Curator'
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{owner?.avatar_url ? (
|
||||
<img src={owner.avatar_url} alt={owner?.name || owner?.username} className="h-14 w-14 rounded-2xl object-cover ring-1 ring-white/10" />
|
||||
) : (
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-400">
|
||||
<i className="fa-solid fa-user-astronaut" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold text-white">{owner?.name || owner?.username || 'Skinbase Curator'}</div>
|
||||
{owner?.username ? <div className="text-sm text-slate-400">@{owner.username}</div> : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (owner?.profile_url) {
|
||||
return <a href={owner.profile_url} className="mt-6 inline-flex items-center gap-4 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4 transition hover:bg-white/[0.07]">{body}</a>
|
||||
}
|
||||
|
||||
return <div className="mt-6 inline-flex items-center gap-4 rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4">{body}</div>
|
||||
}
|
||||
|
||||
function PageSection({ eyebrow, title, count, children }) {
|
||||
return (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex 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>
|
||||
{count !== undefined ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{count}</span> : null}
|
||||
</div>
|
||||
<div className="mt-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EntityLinkCard({ item }) {
|
||||
return (
|
||||
<a href={item?.url || '#'} className="flex h-full flex-col gap-4 rounded-[24px] border border-white/10 bg-white/[0.04] p-5 transition hover:bg-white/[0.07]">
|
||||
<div className="flex items-start gap-4">
|
||||
{item?.image_url ? <img src={item.image_url} alt={item?.title} className="h-16 w-16 rounded-[18px] object-cover ring-1 ring-white/10" /> : <div className="flex h-16 w-16 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.05] text-slate-400"><i className="fa-solid fa-diagram-project" /></div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-lg font-semibold text-white">{item?.title}</h3>
|
||||
{item?.meta ? <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-300">{item.meta}</span> : null}
|
||||
</div>
|
||||
{item?.subtitle ? <p className="mt-1 text-sm text-slate-400">{item.subtitle}</p> : null}
|
||||
{item?.relationship_type ? <p className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200/80">{item.relationship_type}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{item?.description ? <p className="text-sm leading-relaxed text-slate-300">{item.description}</p> : null}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function humanizeToken(value) {
|
||||
return String(value || '')
|
||||
.replaceAll('_', ' ')
|
||||
.replaceAll('-', ' ')
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase())
|
||||
}
|
||||
|
||||
function groupEntityLinks(items) {
|
||||
return (Array.isArray(items) ? items : []).reduce((groups, item) => {
|
||||
const key = item?.linked_type || 'other'
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(item)
|
||||
return groups
|
||||
}, {})
|
||||
}
|
||||
|
||||
function recommendationReasons(currentCollection, candidate) {
|
||||
const reasons = []
|
||||
|
||||
if (candidate?.event_key && currentCollection?.event_key && candidate.event_key === currentCollection.event_key) {
|
||||
reasons.push('Same event context')
|
||||
}
|
||||
|
||||
if (candidate?.campaign_key && currentCollection?.campaign_key && candidate.campaign_key === currentCollection.campaign_key) {
|
||||
reasons.push('Same campaign')
|
||||
}
|
||||
|
||||
if (candidate?.theme_token && currentCollection?.theme_token && candidate.theme_token === currentCollection.theme_token) {
|
||||
reasons.push('Shared theme')
|
||||
}
|
||||
|
||||
if (candidate?.type && currentCollection?.type && candidate.type === currentCollection.type) {
|
||||
reasons.push(`${humanizeToken(candidate.type)} collection`)
|
||||
}
|
||||
|
||||
if (candidate?.owner?.id && currentCollection?.owner?.id && candidate.owner.id === currentCollection.owner.id) {
|
||||
reasons.push('Same curator')
|
||||
}
|
||||
|
||||
if (candidate?.trust_tier && currentCollection?.trust_tier && candidate.trust_tier === currentCollection.trust_tier) {
|
||||
reasons.push(`${humanizeToken(candidate.trust_tier)} trust tier`)
|
||||
}
|
||||
|
||||
return reasons.slice(0, 3)
|
||||
}
|
||||
|
||||
function ContextSignalCard({ item }) {
|
||||
const wrapperClassName = 'flex h-full flex-col gap-3 rounded-[24px] border border-white/10 bg-white/[0.04] p-5 transition hover:bg-white/[0.07]'
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{item.meta}</span>
|
||||
{item.kicker ? <span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200/80">{item.kicker}</span> : null}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
{item.subtitle ? <p className="mt-1 text-sm text-slate-400">{item.subtitle}</p> : null}
|
||||
</div>
|
||||
{item.description ? <p className="text-sm leading-relaxed text-slate-300">{item.description}</p> : null}
|
||||
</>
|
||||
)
|
||||
|
||||
if (item.url) {
|
||||
return <a href={item.url} className={wrapperClassName}>{body}</a>
|
||||
}
|
||||
|
||||
return <div className={wrapperClassName}>{body}</div>
|
||||
}
|
||||
|
||||
export default function CollectionShow() {
|
||||
const { props } = usePage()
|
||||
const {
|
||||
collection: initialCollection,
|
||||
artworks,
|
||||
owner,
|
||||
isOwner,
|
||||
manageUrl,
|
||||
editUrl,
|
||||
analyticsUrl,
|
||||
historyUrl,
|
||||
profileCollectionsUrl,
|
||||
featuredCollectionsUrl,
|
||||
engagement,
|
||||
seo,
|
||||
members: initialMembers,
|
||||
comments: initialComments,
|
||||
submissions: initialSubmissions,
|
||||
entityLinks,
|
||||
relatedCollections,
|
||||
commentsEndpoint,
|
||||
submitEndpoint,
|
||||
submissionArtworkOptions,
|
||||
seriesContext,
|
||||
canSubmit,
|
||||
canComment,
|
||||
reportEndpoint,
|
||||
} = props
|
||||
const [collection, setCollection] = React.useState(initialCollection)
|
||||
const [comments, setComments] = React.useState(initialComments || [])
|
||||
const [submissions, setSubmissions] = React.useState(initialSubmissions || [])
|
||||
const [selectedArtworkId, setSelectedArtworkId] = React.useState(submissionArtworkOptions?.[0]?.id || '')
|
||||
const [state, setState] = React.useState({
|
||||
liked: Boolean(engagement?.liked),
|
||||
following: Boolean(engagement?.following),
|
||||
saved: Boolean(engagement?.saved),
|
||||
notice: '',
|
||||
busy: false,
|
||||
})
|
||||
const artworkItems = artworks?.data ?? []
|
||||
const creatorIds = Array.from(new Set(artworkItems.map((artwork) => artwork?.author?.id).filter(Boolean)))
|
||||
const featuringCreatorsCount = creatorIds.length
|
||||
const showArtworkAuthors = collection?.type !== 'personal' || featuringCreatorsCount > 1
|
||||
const enabledModules = Array.isArray(collection?.layout_modules)
|
||||
? collection.layout_modules.filter((module) => module?.enabled !== false)
|
||||
: []
|
||||
const enabledModuleKeys = new Set(enabledModules.map((module) => module?.key).filter(Boolean))
|
||||
const showIntroBlock = enabledModuleKeys.size === 0 || enabledModuleKeys.has('intro_block')
|
||||
const metaOwnerName = owner?.name || owner?.username || collection?.owner?.name || 'Skinbase Curator'
|
||||
const metaTitle = seo?.title || `${collection?.title} — Skinbase Nova`
|
||||
const metaDescription = seo?.description || collection?.summary || collection?.description || ''
|
||||
const collectionSchema = seo?.canonical ? {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: collection?.title,
|
||||
description: metaDescription,
|
||||
url: seo.canonical,
|
||||
image: seo?.og_image || collection?.cover_image || undefined,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: 'Skinbase Nova',
|
||||
url: typeof window !== 'undefined' ? window.location.origin : undefined,
|
||||
},
|
||||
author: owner ? {
|
||||
'@type': 'Person',
|
||||
name: metaOwnerName,
|
||||
url: owner.profile_url,
|
||||
} : undefined,
|
||||
keywords: [collection?.type, collection?.mode, collection?.badge_label].filter(Boolean).join(', ') || undefined,
|
||||
mainEntity: {
|
||||
'@type': 'ItemList',
|
||||
numberOfItems: collection?.artworks_count || artworkItems.length || 0,
|
||||
itemListElement: artworkItems.slice(0, 12).map((artwork, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: artwork.url,
|
||||
name: artwork.title,
|
||||
})),
|
||||
},
|
||||
} : null
|
||||
const [members] = React.useState(initialMembers || [])
|
||||
const spotlightClasses = getSpotlightClasses(collection?.spotlight_style)
|
||||
const groupedEntityLinks = groupEntityLinks(entityLinks)
|
||||
const storyLinks = groupedEntityLinks.story || []
|
||||
const taxonomyLinks = [...(groupedEntityLinks.category || []), ...(groupedEntityLinks.tag || [])]
|
||||
const contributorLinks = [...(groupedEntityLinks.creator || []), ...(groupedEntityLinks.artwork || [])]
|
||||
const linkedContextSignals = [
|
||||
...(groupedEntityLinks.campaign || []).map((item) => ({
|
||||
meta: item.meta || 'Campaign',
|
||||
kicker: item.relationship_type || 'Linked campaign',
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
description: item.description,
|
||||
url: item.url,
|
||||
})),
|
||||
...(groupedEntityLinks.event || []).map((item) => ({
|
||||
meta: item.meta || 'Event',
|
||||
kicker: item.relationship_type || 'Linked event',
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
description: item.description,
|
||||
url: item.url,
|
||||
})),
|
||||
]
|
||||
const contextSignals = [
|
||||
collection?.campaign_key ? {
|
||||
meta: 'Campaign',
|
||||
kicker: 'Discover surface',
|
||||
title: collection.campaign_label || humanizeToken(collection.campaign_key),
|
||||
subtitle: collection.campaign_key,
|
||||
description: 'This collection is programmed into a campaign surface and can be explored alongside other campaign-ready sets.',
|
||||
url: `/collections/campaigns/${encodeURIComponent(collection.campaign_key)}`,
|
||||
} : null,
|
||||
collection?.program_key ? {
|
||||
meta: 'Program',
|
||||
kicker: 'Partner context',
|
||||
title: humanizeToken(collection.program_key),
|
||||
subtitle: collection.partner_label || collection.partner_key || 'Collection program',
|
||||
description: 'This collection is attached to a program or partner-ready surface, which affects how it is grouped and surfaced.',
|
||||
url: `/collections/program/${encodeURIComponent(collection.program_key)}`,
|
||||
} : null,
|
||||
(collection?.event_label || collection?.event_key) ? {
|
||||
meta: 'Event',
|
||||
kicker: 'Seasonal context',
|
||||
title: collection.event_label || humanizeToken(collection.event_key),
|
||||
subtitle: collection.season_key ? `Season ${humanizeToken(collection.season_key)}` : 'Event-linked collection',
|
||||
description: 'This collection is tied to an event or seasonal programming window, so related recommendations favor matching event context.',
|
||||
url: null,
|
||||
} : null,
|
||||
collection?.theme_token ? {
|
||||
meta: 'Theme',
|
||||
kicker: 'Visual language',
|
||||
title: humanizeToken(collection.theme_token),
|
||||
subtitle: collection.presentation_style ? `Presentation ${humanizeToken(collection.presentation_style)}` : null,
|
||||
description: 'Theme and presentation signals help similar collections cluster together in discovery and recommendation surfaces.',
|
||||
url: `/collections/search?theme=${encodeURIComponent(collection.theme_token)}`,
|
||||
} : null,
|
||||
collection?.trust_tier ? {
|
||||
meta: 'Quality Tier',
|
||||
kicker: 'Placement signal',
|
||||
title: humanizeToken(collection.trust_tier),
|
||||
subtitle: collection?.quality_score != null ? `Quality score ${Number(collection.quality_score).toFixed(1)}` : null,
|
||||
description: 'Trust tier and quality score shape how aggressively this collection can be used in premium or partner-facing placements.',
|
||||
url: `/collections/search?quality_tier=${encodeURIComponent(collection.trust_tier)}`,
|
||||
} : null,
|
||||
...linkedContextSignals,
|
||||
].filter(Boolean).filter((item, index, items) => {
|
||||
const key = `${item.meta}:${item.title}:${item.subtitle || ''}`
|
||||
return items.findIndex((candidate) => `${candidate.meta}:${candidate.title}:${candidate.subtitle || ''}` === key) === index
|
||||
})
|
||||
|
||||
const { share } = useWebShare({
|
||||
onFallback: async ({ url }) => {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setState((current) => ({ ...current, notice: 'Collection link copied.' }))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async function handleLike() {
|
||||
if (!engagement?.can_interact) {
|
||||
if (engagement?.login_url) window.location.assign(engagement.login_url)
|
||||
return
|
||||
}
|
||||
|
||||
setState((current) => ({ ...current, busy: true, notice: '' }))
|
||||
try {
|
||||
const payload = await requestJson(state.liked ? engagement.unlike_url : engagement.like_url, {
|
||||
method: state.liked ? 'DELETE' : 'POST',
|
||||
})
|
||||
setState((current) => ({ ...current, liked: Boolean(payload?.liked), busy: false }))
|
||||
setCollection((current) => ({ ...current, likes_count: payload?.likes_count ?? current.likes_count }))
|
||||
} catch (error) {
|
||||
setState((current) => ({ ...current, busy: false, notice: error.message }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFollow() {
|
||||
if (!engagement?.can_interact) {
|
||||
if (engagement?.login_url) window.location.assign(engagement.login_url)
|
||||
return
|
||||
}
|
||||
|
||||
setState((current) => ({ ...current, busy: true, notice: '' }))
|
||||
try {
|
||||
const payload = await requestJson(state.following ? engagement.unfollow_url : engagement.follow_url, {
|
||||
method: state.following ? 'DELETE' : 'POST',
|
||||
})
|
||||
setState((current) => ({ ...current, following: Boolean(payload?.following), busy: false }))
|
||||
setCollection((current) => ({ ...current, followers_count: payload?.followers_count ?? current.followers_count }))
|
||||
} catch (error) {
|
||||
setState((current) => ({ ...current, busy: false, notice: error.message }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShare() {
|
||||
try {
|
||||
const payload = await requestJson(engagement?.share_url, { method: 'POST' })
|
||||
setCollection((current) => ({ ...current, shares_count: payload?.shares_count ?? current.shares_count }))
|
||||
await share({
|
||||
title: collection?.title,
|
||||
text: collection?.summary || collection?.description || `Explore ${collection?.title} on Skinbase Nova.`,
|
||||
url: collection?.public_url,
|
||||
})
|
||||
} catch (error) {
|
||||
setState((current) => ({ ...current, notice: error.message }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!engagement?.save_url) {
|
||||
if (engagement?.login_url) window.location.assign(engagement.login_url)
|
||||
return
|
||||
}
|
||||
|
||||
setState((current) => ({ ...current, busy: true, notice: '' }))
|
||||
try {
|
||||
const payload = await requestJson(state.saved ? engagement.unsave_url : engagement.save_url, {
|
||||
method: state.saved ? 'DELETE' : 'POST',
|
||||
body: state.saved ? undefined : {
|
||||
context: 'collection_detail',
|
||||
context_meta: {
|
||||
collection_type: collection?.type || null,
|
||||
collection_mode: collection?.mode || null,
|
||||
},
|
||||
},
|
||||
})
|
||||
setState((current) => ({ ...current, saved: Boolean(payload?.saved), busy: false }))
|
||||
setCollection((current) => ({ ...current, saves_count: payload?.saves_count ?? current.saves_count }))
|
||||
} catch (error) {
|
||||
setState((current) => ({ ...current, busy: false, notice: error.message }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommentSubmit(body) {
|
||||
const payload = await requestJson(commentsEndpoint, {
|
||||
method: 'POST',
|
||||
body: { body },
|
||||
})
|
||||
setComments(payload?.comments || [])
|
||||
setCollection((current) => ({ ...current, comments_count: payload?.comments_count ?? current.comments_count }))
|
||||
}
|
||||
|
||||
async function handleDeleteComment(commentId) {
|
||||
const payload = await requestJson(`${commentsEndpoint}/${commentId}`, { method: 'DELETE' })
|
||||
setComments(payload?.comments || [])
|
||||
setCollection((current) => ({ ...current, comments_count: payload?.comments_count ?? current.comments_count }))
|
||||
}
|
||||
|
||||
async function handleSubmitArtwork() {
|
||||
if (!submitEndpoint || !selectedArtworkId) return
|
||||
|
||||
try {
|
||||
const payload = await requestJson(submitEndpoint, {
|
||||
method: 'POST',
|
||||
body: { artwork_id: selectedArtworkId },
|
||||
})
|
||||
setSubmissions(payload?.submissions || [])
|
||||
setState((current) => ({ ...current, notice: 'Artwork submitted for review.' }))
|
||||
} catch (error) {
|
||||
setState((current) => ({ ...current, notice: error.message }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmissionAction(submission, action) {
|
||||
const url = action === 'approve'
|
||||
? `/collections/submissions/${submission.id}/approve`
|
||||
: action === 'reject'
|
||||
? `/collections/submissions/${submission.id}/reject`
|
||||
: `/collections/submissions/${submission.id}`
|
||||
|
||||
const payload = await requestJson(url, {
|
||||
method: action === 'withdraw' ? 'DELETE' : 'POST',
|
||||
})
|
||||
|
||||
setSubmissions(payload?.submissions || [])
|
||||
if (action === 'approve') {
|
||||
setCollection((current) => ({ ...current, artworks_count: (current?.artworks_count ?? 0) + 1 }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReport(targetType, targetId) {
|
||||
if (!reportEndpoint) {
|
||||
if (engagement?.login_url) window.location.assign(engagement.login_url)
|
||||
return
|
||||
}
|
||||
|
||||
const reason = window.prompt('Why are you reporting this? (required)')
|
||||
if (!reason || !reason.trim()) return
|
||||
|
||||
try {
|
||||
await requestJson(reportEndpoint, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
target_type: targetType,
|
||||
target_id: targetId,
|
||||
reason: reason.trim(),
|
||||
},
|
||||
})
|
||||
setState((current) => ({ ...current, notice: 'Report submitted. Thank you.' }))
|
||||
} catch (error) {
|
||||
setState((current) => ({ ...current, notice: error.message }))
|
||||
}
|
||||
}
|
||||
|
||||
function renderModule(module) {
|
||||
if (!module?.key) return null
|
||||
|
||||
if (module.key === 'intro_block') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (module.key === 'featured_artworks') {
|
||||
if (!artworkItems.length) return null
|
||||
|
||||
return (
|
||||
<PageSection eyebrow="Highlights" title="Featured artworks" count={Math.min(artworkItems.length, 3)}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm leading-relaxed text-slate-300">
|
||||
Start with the standout pieces from this collection before diving into the full sequence.
|
||||
</p>
|
||||
<ArtworkGallery items={artworkItems.slice(0, 3)} showAuthor={showArtworkAuthors} className="grid-cols-1 gap-5 md:grid-cols-3" />
|
||||
</div>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (module.key === 'editorial_note') {
|
||||
if (collection?.type !== 'editorial') return null
|
||||
|
||||
return (
|
||||
<PageSection eyebrow="Editorial" title="Editorial context">
|
||||
<div className="space-y-3 text-sm leading-relaxed text-slate-300">
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] px-4 py-4">
|
||||
{collection?.description || 'A staff-curated collection prepared for premium discovery placement.'}
|
||||
</div>
|
||||
{(collection?.event_label || collection?.badge_label) ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{collection?.event_label ? <span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white">{collection.event_label}</span> : null}
|
||||
{collection?.badge_label ? <span className="inline-flex items-center 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">{collection.badge_label}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (module.key === 'artwork_grid') {
|
||||
return (
|
||||
<section>
|
||||
{artworkItems.length ? <ArtworkGallery items={artworkItems} showAuthor={showArtworkAuthors} className="grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" /> : <EmptyCollectionState isOwner={isOwner} smart={collection?.mode === 'smart'} />}
|
||||
|
||||
{(artworks?.links?.prev || artworks?.links?.next) ? (
|
||||
<div className="mt-8 flex items-center justify-between gap-4">
|
||||
<a href={artworks?.links?.prev || '#'} className={`inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm font-semibold transition ${artworks?.links?.prev ? 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]' : 'pointer-events-none border-white/8 bg-white/[0.02] text-slate-500'}`}><i className="fa-solid fa-arrow-left fa-fw" />Previous</a>
|
||||
<a href={artworks?.links?.next || '#'} className={`inline-flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm font-semibold transition ${artworks?.links?.next ? 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]' : 'pointer-events-none border-white/8 bg-white/[0.02] text-slate-500'}`}>Next<i className="fa-solid fa-arrow-right fa-fw" /></a>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (module.key === 'discussion') {
|
||||
if (!collection?.allow_comments) return null
|
||||
|
||||
return (
|
||||
<PageSection eyebrow="Discussion" title="Collection comments" count={(collection?.comments_count ?? comments.length).toLocaleString()}>
|
||||
{canComment ? <div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4"><CommentForm onSubmit={handleCommentSubmit} placeholder="Talk about the curation, mood, or standout pieces…" submitLabel="Post comment" /></div> : null}
|
||||
<div className={canComment ? 'mt-5' : ''}>
|
||||
<CommentList comments={comments} canReply={false} onDelete={handleDeleteComment} onReport={(comment) => handleReport('collection_comment', comment.id)} emptyMessage="No comments yet." />
|
||||
</div>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (module.key === 'related_collections') {
|
||||
if (!Array.isArray(relatedCollections) || !relatedCollections.length) return null
|
||||
|
||||
return (
|
||||
<PageSection eyebrow="More to Explore" title="Related collections">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{relatedCollections.map((item) => (
|
||||
<div key={item.id} className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recommendationReasons(collection, item).map((reason) => (
|
||||
<span key={`${item.id}-${reason}`} className="inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold text-sky-100">
|
||||
{reason}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<CollectionCard collection={item} isOwner={false} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (module.key === 'collaborators') {
|
||||
return (
|
||||
<PageSection eyebrow="Contributors" title="Curation team">
|
||||
<div className="space-y-3">
|
||||
{members.length ? members.filter((member) => member?.status === 'active').map((member) => <CollaboratorCard key={member.id} member={member} />) : <p className="text-sm text-slate-400">This collection is curated by a single owner right now.</p>}
|
||||
</div>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (module.key === 'submissions') {
|
||||
if (!collection?.allow_submissions) return null
|
||||
|
||||
return (
|
||||
<PageSection eyebrow="Submissions" title="Submit to this collection">
|
||||
{canSubmit && submissionArtworkOptions?.length ? (
|
||||
<div className="space-y-3">
|
||||
<select value={selectedArtworkId} onChange={(event) => setSelectedArtworkId(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
|
||||
{submissionArtworkOptions.map((artwork) => (
|
||||
<option key={artwork.id} value={artwork.id}>{artwork.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={handleSubmitArtwork} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/25 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-paper-plane fa-fw" />Submit artwork</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">Sign in with at least one artwork on your account to submit here.</p>
|
||||
)}
|
||||
<div className="mt-5 space-y-3">
|
||||
{submissions.length ? submissions.map((submission) => (
|
||||
<SubmissionCard
|
||||
key={submission.id}
|
||||
submission={submission}
|
||||
onApprove={(item) => handleSubmissionAction(item, 'approve')}
|
||||
onReject={(item) => handleSubmissionAction(item, 'reject')}
|
||||
onWithdraw={(item) => handleSubmissionAction(item, 'withdraw')}
|
||||
onReport={(item) => handleReport('collection_submission', item.id)}
|
||||
/>
|
||||
)) : <p className="text-sm text-slate-400">No submissions yet.</p>}
|
||||
</div>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const renderedFullModules = enabledModules.filter((module) => module.slot === 'full').map(renderModule).filter(Boolean)
|
||||
const renderedMainModules = enabledModules.filter((module) => module.slot === 'main').map(renderModule).filter(Boolean)
|
||||
const renderedSidebarModules = enabledModules.filter((module) => module.slot === 'sidebar').map(renderModule).filter(Boolean)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{metaTitle}</title>
|
||||
{metaDescription ? <meta name="description" content={metaDescription} /> : null}
|
||||
{seo?.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
|
||||
{seo?.robots ? <meta name="robots" content={seo.robots} /> : null}
|
||||
<meta property="og:title" content={metaTitle} />
|
||||
{metaDescription ? <meta property="og:description" content={metaDescription} /> : null}
|
||||
{seo?.og_image ? <meta property="og:image" content={seo.og_image} /> : null}
|
||||
{seo?.canonical ? <meta property="og:url" content={seo.canonical} /> : null}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={metaTitle} />
|
||||
{metaDescription ? <meta name="twitter:description" content={metaDescription} /> : null}
|
||||
{seo?.og_image ? <meta name="twitter:image" content={seo.og_image} /> : null}
|
||||
{collectionSchema ? <script type="application/ld+json">{JSON.stringify(collectionSchema)}</script> : null}
|
||||
</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-[36rem] opacity-95" style={{ background: 'radial-gradient(circle at top left, rgba(56,189,248,0.18), transparent 32%), radial-gradient(circle at 82% 10%, 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={profileCollectionsUrl} 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>
|
||||
{isOwner && manageUrl ? <a href={manageUrl} 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-grip fa-fw text-[11px]" />Manage artworks</a> : null}
|
||||
{isOwner && editUrl ? <a href={editUrl} 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]" />Edit details</a> : null}
|
||||
{isOwner && analyticsUrl ? <a href={analyticsUrl} 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-chart-column fa-fw text-[11px]" />Analytics</a> : null}
|
||||
{isOwner && historyUrl ? <a href={historyUrl} 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-timeline fa-fw text-[11px]" />History</a> : null}
|
||||
</div>
|
||||
|
||||
<section className="mt-6 overflow-hidden rounded-[34px] border border-white/10 bg-white/[0.04] shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm">
|
||||
<div className="grid gap-6 p-5 md:p-7 xl:grid-cols-[minmax(0,1.2fr)_420px]">
|
||||
<div className="relative overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/60">
|
||||
{collection?.cover_image ? <img src={collection.cover_image} alt={collection.title} className="aspect-[16/10] h-full w-full object-cover" /> : <div className="flex aspect-[16/10] items-center justify-center bg-[linear-gradient(135deg,#08111f,#0f172a,#08111f)] text-slate-500"><i className="fa-solid fa-layer-group text-5xl" /></div>}
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgba(2,6,23,0.8),rgba(2,6,23,0.08))]" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between">
|
||||
<div>
|
||||
{collection?.banner_text ? (
|
||||
<div className={`mb-4 inline-flex max-w-full items-center gap-2 rounded-[22px] border px-4 py-3 text-sm font-medium shadow-[0_18px_40px_rgba(2,6,23,0.2)] ${spotlightClasses}`}>
|
||||
<i className="fa-solid fa-sparkles text-[12px]" />
|
||||
<span className="truncate">{collection.banner_text}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{collection?.is_featured ? <span className="inline-flex items-center rounded-full border border-amber-300/25 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">Featured Collection</span> : null}
|
||||
{collection?.mode === 'smart' ? <span className="inline-flex items-center 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">Smart Collection</span> : null}
|
||||
<TypeBadge collection={collection} />
|
||||
{collection?.event_label ? <span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">{collection.event_label}</span> : null}
|
||||
{collection?.campaign_label ? <span className="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">{collection.campaign_label}</span> : null}
|
||||
{collection?.badge_label ? <span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">{collection.badge_label}</span> : null}
|
||||
{collection?.series_key ? <span className="inline-flex items-center rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">Series {collection.series_order ? `#${collection.series_order}` : ''}</span> : null}
|
||||
{isOwner ? <CollectionVisibilityBadge visibility={collection?.visibility} /> : null}
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white md:text-5xl">{collection?.title}</h1>
|
||||
{showIntroBlock ? (
|
||||
<>
|
||||
{collection?.subtitle ? <p className="mt-3 text-base text-slate-300">{collection.subtitle}</p> : null}
|
||||
{collection?.description ? <p className="mt-4 max-w-2xl text-sm leading-relaxed text-slate-300 md:text-[15px]">{collection.description}</p> : <p className="mt-4 max-w-2xl text-sm leading-relaxed text-slate-400 md:text-[15px]">A curated selection from @{owner?.username}, assembled as a focused gallery rather than a simple archive.</p>}
|
||||
{collection?.smart_summary ? <p className="mt-3 max-w-2xl text-sm leading-relaxed text-sky-100/90">{collection.smart_summary}</p> : null}
|
||||
{featuringCreatorsCount > 1 ? <p className="mt-3 text-sm text-slate-300">Featuring artworks by {featuringCreatorsCount} creators.</p> : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button type="button" onClick={handleLike} disabled={state.busy || !engagement?.like_url} className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${state.liked ? 'border-rose-400/20 bg-rose-400/10 text-rose-100' : 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]'}`}><i className={`fa-solid ${state.liked ? 'fa-heart' : 'fa-heart-circle-plus'} fa-fw`} />{state.liked ? 'Liked' : 'Like Collection'}</button>
|
||||
<button type="button" onClick={handleFollow} disabled={state.busy || !engagement?.follow_url} className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${state.following ? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-100' : 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]'}`}><i className={`fa-solid ${state.following ? 'fa-bell' : 'fa-bell-concierge'} fa-fw`} />{state.following ? 'Following' : 'Follow Collection'}</button>
|
||||
<button type="button" onClick={handleSave} disabled={state.busy || (!engagement?.save_url && !engagement?.unsave_url)} className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${state.saved ? 'border-violet-300/20 bg-violet-400/10 text-violet-100' : 'border-white/12 bg-white/[0.05] text-white hover:bg-white/[0.08]'}`}><i className={`fa-solid ${state.saved ? 'fa-bookmark' : 'fa-bookmark-circle'} fa-fw`} />{state.saved ? 'Saved' : 'Save Collection'}</button>
|
||||
<button type="button" onClick={handleShare} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-share-nodes fa-fw" />Share</button>
|
||||
{reportEndpoint && !isOwner ? <button type="button" onClick={() => handleReport('collection', collection?.id)} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-flag fa-fw" />Report</button> : null}
|
||||
{featuredCollectionsUrl ? <a href={featuredCollectionsUrl} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]"><i className="fa-solid fa-compass fa-fw" />Featured Collections</a> : null}
|
||||
</div>
|
||||
|
||||
{state.notice ? <p className="mt-3 text-sm text-sky-100">{state.notice}</p> : null}
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-3 xl:grid-cols-5">
|
||||
<MetaRow compact icon="fa-images" label="Artworks" value={(collection?.artworks_count ?? 0).toLocaleString()} />
|
||||
<MetaRow compact icon="fa-heart" label="Likes" value={(collection?.likes_count ?? 0).toLocaleString()} />
|
||||
<MetaRow compact icon="fa-bell" label="Followers" value={(collection?.followers_count ?? 0).toLocaleString()} />
|
||||
<MetaRow compact icon="fa-eye" label="Views" value={(collection?.views_count ?? 0).toLocaleString()} />
|
||||
<MetaRow compact icon="fa-bookmark" label="Saves" value={(collection?.saves_count ?? 0).toLocaleString()} />
|
||||
</div>
|
||||
|
||||
{(collection?.presentation_style && collection.presentation_style !== 'standard') || collection?.quality_score != null || collection?.ranking_score != null || collection?.campaign_key ? (
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{collection?.presentation_style && collection.presentation_style !== 'standard' ? <MetaRow icon="fa-panorama" label="Presentation" value={String(collection.presentation_style).replace(/_/g, ' ')} /> : null}
|
||||
{collection?.quality_score != null ? <MetaRow icon="fa-gauge-high" label="Quality" value={Number(collection.quality_score).toFixed(1)} /> : null}
|
||||
{collection?.ranking_score != null ? <MetaRow icon="fa-ranking-star" label="Ranking" value={Number(collection.ranking_score).toFixed(1)} /> : null}
|
||||
{collection?.campaign_key ? <MetaRow icon="fa-bullhorn" label="Campaign" value={collection.campaign_label || collection.campaign_key} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<OwnerCard owner={owner} collectionType={collection?.type} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(seriesContext?.url || seriesContext?.previous || seriesContext?.next || (Array.isArray(seriesContext?.siblings) && seriesContext.siblings.length)) ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<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">Series</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">{seriesContext?.title || 'Connected collection sequence'}</h2>
|
||||
{seriesContext?.description ? <p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-300">{seriesContext.description}</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{collection?.series_key ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{collection.series_key}</span> : null}
|
||||
{seriesContext?.url ? <a href={seriesContext.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 uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-list fa-fw text-[10px]" />View full series</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
{seriesContext?.previous ? (
|
||||
<a href={seriesContext.previous.url} className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 transition hover:bg-white/[0.07]">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Previous</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{seriesContext.previous.title}</div>
|
||||
</div>
|
||||
<i className="fa-solid fa-arrow-left text-slate-500" />
|
||||
</a>
|
||||
) : null}
|
||||
{seriesContext?.next ? (
|
||||
<a href={seriesContext.next.url} className="flex items-center justify-between rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4 transition hover:bg-white/[0.07]">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Next</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{seriesContext.next.title}</div>
|
||||
</div>
|
||||
<i className="fa-solid fa-arrow-right text-slate-500" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{Array.isArray(seriesContext?.siblings) && seriesContext.siblings.length ? (
|
||||
<div className="grid gap-4">
|
||||
{seriesContext.siblings.slice(0, 2).map((item) => (
|
||||
<CollectionCard key={item.id} collection={item} isOwner={false} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{contextSignals.length ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<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">Related Context</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Campaign, event, and quality context</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">{contextSignals.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{contextSignals.map((item) => (
|
||||
<ContextSignalCard key={`${item.meta}-${item.title}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{storyLinks.length ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<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">Stories</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Stories and editorial references linked to this collection</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">{storyLinks.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{storyLinks.map((item) => (
|
||||
<EntityLinkCard key={`${item.linked_type}-${item.linked_id}-${item.id}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{taxonomyLinks.length ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-violet-200/80">Browse The Theme</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Categories and tags that anchor this collection</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">{taxonomyLinks.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{taxonomyLinks.map((item) => (
|
||||
<EntityLinkCard key={`${item.linked_type}-${item.linked_id}-${item.id}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{contributorLinks.length ? (
|
||||
<section className="mt-8 rounded-[30px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm">
|
||||
<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">Connected Creators</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Creators and artworks that give the set its shape</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">{contributorLinks.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{contributorLinks.map((item) => (
|
||||
<EntityLinkCard key={`${item.linked_type}-${item.linked_id}-${item.id}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{renderedFullModules.length ? <div className="mt-8 space-y-6">{renderedFullModules}</div> : null}
|
||||
|
||||
{(renderedMainModules.length || renderedSidebarModules.length) ? (
|
||||
<section className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-6">{renderedMainModules}</div>
|
||||
<div className="space-y-6">{renderedSidebarModules}</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
963
resources/js/Pages/Collection/CollectionStaffProgramming.jsx
Normal file
963
resources/js/Pages/Collection/CollectionStaffProgramming.jsx
Normal file
@@ -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-clone'} 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
644
resources/js/Pages/Collection/CollectionStaffSurfaces.jsx
Normal file
644
resources/js/Pages/Collection/CollectionStaffSurfaces.jsx
Normal file
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
638
resources/js/Pages/Collection/NovaCardsAdminIndex.jsx
Normal file
638
resources/js/Pages/Collection/NovaCardsAdminIndex.jsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
129
resources/js/Pages/Collection/NovaCardsAssetPackAdmin.jsx
Normal file
129
resources/js/Pages/Collection/NovaCardsAssetPackAdmin.jsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
146
resources/js/Pages/Collection/NovaCardsChallengeAdmin.jsx
Normal file
146
resources/js/Pages/Collection/NovaCardsChallengeAdmin.jsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
196
resources/js/Pages/Collection/NovaCardsCollectionAdmin.jsx
Normal file
196
resources/js/Pages/Collection/NovaCardsCollectionAdmin.jsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
210
resources/js/Pages/Collection/NovaCardsTemplateAdmin.jsx
Normal file
210
resources/js/Pages/Collection/NovaCardsTemplateAdmin.jsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
542
resources/js/Pages/Collection/SavedCollections.jsx
Normal file
542
resources/js/Pages/Collection/SavedCollections.jsx
Normal file
@@ -0,0 +1,542 @@
|
||||
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 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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{seo?.title || 'Saved Collections — Skinbase Nova'}</title>
|
||||
<meta name="description" content={seo?.description || 'Your saved collections on Skinbase Nova.'} />
|
||||
{seo?.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
|
||||
<meta name="robots" content={seo?.robots || 'noindex,follow'} />
|
||||
<meta property="og:title" content={seo?.title || 'Saved Collections — Skinbase Nova'} />
|
||||
<meta property="og:description" content={seo?.description || 'Your saved collections on Skinbase Nova.'} />
|
||||
{seo?.canonical ? <meta property="og:url" content={seo.canonical} /> : null}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content={seo?.title || 'Saved Collections — Skinbase Nova'} />
|
||||
<meta name="twitter:description" content={seo?.description || 'Your saved collections on Skinbase Nova.'} />
|
||||
{listSchema ? <script type="application/ld+json">{JSON.stringify(listSchema)}</script> : null}
|
||||
</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">
|
||||
<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