675 lines
34 KiB
JavaScript
675 lines
34 KiB
JavaScript
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>
|
|
</>
|
|
)
|
|
}
|