import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import Checkbox from '../../components/ui/Checkbox'
import NovaSelect from '../../components/ui/NovaSelect'
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
async function requestJson(url, { method = 'POST', body } = {}) {
const response = await fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.errors?.artwork_id?.[0] || payload?.errors?.is_active?.[0] || payload?.errors?.force_hero?.[0] || 'Request failed.')
}
return payload
}
function isoToLocalInput(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
return local.toISOString().slice(0, 16)
}
function localInputToIso(value) {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date.toISOString()
}
function formatDateTime(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date)
}
function Badge({ label, tone = 'slate' }) {
const toneClasses = {
slate: 'border-white/10 bg-white/10 text-slate-100',
sky: 'border-sky-300/20 bg-sky-400/15 text-sky-100',
emerald: 'border-emerald-300/20 bg-emerald-400/15 text-emerald-100',
amber: 'border-amber-300/20 bg-amber-400/15 text-amber-100',
rose: 'border-rose-300/20 bg-rose-400/15 text-rose-100',
}
return (
{label}
)
}
function Field({ label, help, children }) {
return (
{label}
{children}
{help ? {help} : null}
)
}
function StatCard({ label, value, tone = 'sky' }) {
const toneClasses = {
sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100',
amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100',
emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100',
rose: 'border-rose-300/15 bg-rose-400/10 text-rose-100',
}
return (
)
}
function emptyForm() {
return {
artwork_id: '',
priority: 100,
featured_at: isoToLocalInput(new Date().toISOString()),
expires_at: '',
is_active: true,
}
}
function mapEntryToCandidate(entry) {
if (!entry) return null
return {
...entry.artwork,
medals: entry.medals,
eligibility: entry.eligibility,
existing_feature_count: entry.duplicate_count,
already_featured: entry.duplicate_count > 0,
}
}
function compareEntries(left, right, sortKey, direction) {
const dir = direction === 'asc' ? 1 : -1
const value = (entry) => {
switch (sortKey) {
case 'featured_at':
return new Date(entry.featured_at || 0).getTime() || 0
case 'expires_at':
return new Date(entry.expires_at || 0).getTime() || 0
case 'score_30d':
return Number(entry.medals?.score_30d || 0)
default:
return Number(entry.priority || 0)
}
}
const leftValue = value(left)
const rightValue = value(right)
if (leftValue !== rightValue) {
return (leftValue > rightValue ? 1 : -1) * dir
}
const leftFeatured = new Date(left.featured_at || 0).getTime() || 0
const rightFeatured = new Date(right.featured_at || 0).getTime() || 0
if (leftFeatured !== rightFeatured) {
return (leftFeatured > rightFeatured ? 1 : -1) * dir
}
return Number(right.id || 0) - Number(left.id || 0)
}
export default function FeaturedArtworksAdmin() {
const { props } = usePage()
const endpoints = props.endpoints || {}
const capabilities = props.capabilities || {}
const seo = props.seo || {}
const [entries, setEntries] = React.useState(Array.isArray(props.entries) ? props.entries : [])
const [winner, setWinner] = React.useState(props.winner || null)
const [stats, setStats] = React.useState(props.stats || {})
const [notice, setNotice] = React.useState('')
const [busy, setBusy] = React.useState('')
const [filter, setFilter] = React.useState('all')
const [sortKey, setSortKey] = React.useState('priority')
const [sortDirection, setSortDirection] = React.useState('desc')
const [listQuery, setListQuery] = React.useState('')
const [searchQuery, setSearchQuery] = React.useState('')
const [searchResults, setSearchResults] = React.useState([])
const [selectedArtwork, setSelectedArtwork] = React.useState(null)
const [editingId, setEditingId] = React.useState(null)
const [form, setForm] = React.useState(emptyForm())
React.useEffect(() => {
setEntries(Array.isArray(props.entries) ? props.entries : [])
setWinner(props.winner || null)
setStats(props.stats || {})
}, [props.entries, props.stats, props.winner])
function syncPayload(payload) {
setEntries(Array.isArray(payload.entries) ? payload.entries : [])
setWinner(payload.winner || null)
setStats(payload.stats || {})
if (payload.message) {
setNotice(payload.message)
}
}
function resetEditor() {
setEditingId(null)
setSelectedArtwork(null)
setSearchResults([])
setSearchQuery('')
setForm(emptyForm())
}
async function handleArtworkSearch(event) {
event.preventDefault()
if (!searchQuery.trim()) {
setSearchResults([])
return
}
setBusy('search')
setNotice('')
try {
const url = `${endpoints.search}?q=${encodeURIComponent(searchQuery.trim())}`
const payload = await requestJson(url, { method: 'GET' })
setSearchResults(Array.isArray(payload.results) ? payload.results : [])
if ((payload.results || []).length === 0) {
setNotice('No artworks matched that search.')
}
} catch (error) {
setNotice(error.message || 'Artwork search failed.')
} finally {
setBusy('')
}
}
function chooseArtwork(artwork) {
setSelectedArtwork(artwork)
setForm((current) => ({
...current,
artwork_id: artwork.id,
}))
}
function editEntry(entry) {
setEditingId(entry.id)
setSelectedArtwork(mapEntryToCandidate(entry))
setSearchResults([])
setSearchQuery('')
setForm({
artwork_id: entry.artwork_id,
priority: entry.priority,
featured_at: isoToLocalInput(entry.featured_at),
expires_at: isoToLocalInput(entry.expires_at),
is_active: Boolean(entry.is_active),
})
if (typeof window !== 'undefined') {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
async function handleSubmit(event) {
event.preventDefault()
if (!editingId && !form.artwork_id) {
setNotice('Select an artwork first.')
return
}
setBusy('submit')
setNotice('')
try {
const payload = await requestJson(
editingId
? endpoints.updatePattern.replace('__FEATURE__', String(editingId))
: endpoints.store,
{
method: editingId ? 'PATCH' : 'POST',
body: {
artwork_id: Number(form.artwork_id),
priority: Number(form.priority || 0),
featured_at: localInputToIso(form.featured_at),
expires_at: localInputToIso(form.expires_at),
is_active: Boolean(form.is_active),
},
},
)
syncPayload(payload)
resetEditor()
} catch (error) {
setNotice(error.message || 'Failed to save this featured entry.')
} finally {
setBusy('')
}
}
async function handleToggle(entry) {
setBusy(`toggle-${entry.id}`)
setNotice('')
try {
const payload = await requestJson(endpoints.togglePattern.replace('__FEATURE__', String(entry.id)), {
method: 'PATCH',
})
syncPayload(payload)
} catch (error) {
setNotice(error.message || 'Failed to change active state.')
} finally {
setBusy('')
}
}
async function handleDelete(entry) {
if (typeof window !== 'undefined' && !window.confirm(`Delete featured entry #${entry.id}?`)) {
return
}
setBusy(`delete-${entry.id}`)
setNotice('')
try {
const payload = await requestJson(endpoints.destroyPattern.replace('__FEATURE__', String(entry.id)), {
method: 'DELETE',
})
syncPayload(payload)
if (editingId === entry.id) {
resetEditor()
}
} catch (error) {
setNotice(error.message || 'Failed to delete this featured entry.')
} finally {
setBusy('')
}
}
async function handleForceHero(entry) {
setBusy(`force-${entry.id}`)
setNotice('')
try {
const payload = await requestJson(endpoints.forceHeroPattern.replace('__FEATURE__', String(entry.id)), {
method: 'PATCH',
})
syncPayload(payload)
} catch (error) {
setNotice(error.message || 'Failed to change force hero state.')
} finally {
setBusy('')
}
}
const filteredEntries = React.useMemo(() => {
const query = listQuery.trim().toLowerCase()
return entries
.filter((entry) => {
if (filter === 'active') return Boolean(entry.is_active)
if (filter === 'inactive') return !entry.is_active
if (filter === 'expired') return Boolean(entry.is_expired)
if (filter === 'winner') return Boolean(entry.is_winner)
if (filter === 'eligible') return Boolean(entry.eligibility?.is_eligible)
if (filter === 'ineligible') return !entry.eligibility?.is_eligible
return true
})
.filter((entry) => {
if (!query) return true
const haystack = [
entry.artwork?.title,
entry.artwork?.owner?.display_name,
entry.artwork?.owner?.username,
entry.artwork?.id,
].join(' ').toLowerCase()
return haystack.includes(query)
})
.sort((left, right) => compareEntries(left, right, sortKey, sortDirection))
}, [entries, filter, listQuery, sortDirection, sortKey])
const duplicateSelection = !editingId && selectedArtwork?.already_featured
return (
<>
{seo.title || 'Featured Artworks'}
{seo.description ? : null}
{seo.robots ? : null}
Featured Artworks
Homepage hero control, with the real winner logic exposed.
Editors can create, update, activate, expire, and remove featured entries here. The winner summary below mirrors the public homepage selection order: priority, recent medal score, featured date, then published date.
{notice ? (
{notice}
) : null}
Current Homepage Hero
{winner ? winner.artwork?.title : 'No eligible featured artwork'}
{winner?.selection_reason || 'There is no active, non-expired, eligible featured artwork right now.'}
{winner?.is_force_hero ? (
Forced by editor. This artwork bypasses the normal hero winner order until Force Hero is disabled on its featured row.
) : null}
{winner ? : }
{winner?.is_force_hero ? : null}
{winner ? (
Artist
{winner.artwork?.owner?.display_name || 'Unknown'}
{winner.artwork?.owner?.type === 'group' ? 'Group publisher' : `@${winner.artwork?.owner?.username || ''}`}
Medal Score (30d)
{winner.medals?.score_30d || 0}
Priority
{winner.priority}
Featured Since
{formatDateTime(winner.featured_at)}
Published At
{formatDateTime(winner.artwork?.published_at)}
) : null}
{editingId ? 'Edit Entry' : 'Create Entry'}
{editingId ? `Featured entry #${editingId}` : 'Add an artwork to the featured pool'}
{editingId ? (
Cancel edit
) : null}
{!editingId ? (
) : null}
{selectedArtwork ? (
Selected Artwork
{selectedArtwork.title}
#{selectedArtwork.id} • {selectedArtwork.owner?.display_name || 'Unknown'} • Medal Score (30d): {selectedArtwork.medals?.score_30d || 0}
{(selectedArtwork.eligibility?.is_eligible ? [{ label: 'Currently eligible', tone: 'emerald' }] : [{ label: 'Currently ineligible', tone: 'rose' }]).concat(
(selectedArtwork.eligibility?.reasons || []).map((reason) => ({
label: reason,
tone: reason === 'Missing preview' ? 'rose' : 'slate',
}))
).map((badge) => (
))}
) : null}
{duplicateSelection ? (
This artwork already has a featured entry. Edit the existing row instead of creating a duplicate.
) : null}
Featured Pool
Every featured row, with eligibility and winner state visible.
setListQuery(event.target.value)}
placeholder="Filter by title, artist, or artwork ID"
className="rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
/>
setFilter(val)} searchable={false} options={[{ value: 'all', label: 'All rows' }, { value: 'active', label: 'Active' }, { value: 'inactive', label: 'Inactive' }, { value: 'expired', label: 'Expired' }, { value: 'winner', label: 'Winner' }, { value: 'eligible', label: 'Eligible' }, { value: 'ineligible', label: 'Not eligible' }]} />
setSortKey(val)} searchable={false} options={[{ value: 'priority', label: 'Priority' }, { value: 'featured_at', label: 'Featured Since' }, { value: 'expires_at', label: 'Expires' }, { value: 'score_30d', label: 'Medal Score (30d)' }]} />
setSortDirection((current) => current === 'desc' ? 'asc' : 'desc')} className="rounded-2xl border border-white/10 px-4 py-3 text-sm font-semibold text-slate-100 transition hover:border-white/20 hover:bg-white/5">
{sortDirection === 'desc' ? 'Desc' : 'Asc'}
Artwork
Artist / Owner
Priority
Featured Since
Expires
Score (30d)
Status
Actions
{filteredEntries.length === 0 ? (
No featured entries match the current filter.
) : filteredEntries.map((entry) => (
{entry.artwork?.title || 'Missing artwork'}
#{entry.artwork?.id || entry.artwork_id}
Visibility: {entry.artwork?.visibility || '—'} • Published: {entry.artwork?.published_at ? 'Yes' : 'No'}
{entry.is_winner && entry.winner_reason ?
{entry.winner_reason}
: null}
{entry.artwork?.owner?.display_name || 'Unknown'}
{entry.artwork?.owner?.type === 'group' ? 'Group publisher' : `@${entry.artwork?.owner?.username || ''}`}
{entry.priority}
{formatDateTime(entry.featured_at)}
{formatDateTime(entry.expires_at)}
{entry.medals?.score_30d || 0}
{(entry.status_badges || []).map((badge, index) => (
))}
editEntry(entry)} className="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-100 transition hover:border-white/20 hover:bg-white/5">
Edit
{capabilities.forceHeroEnabled ? (
handleForceHero(entry)} disabled={busy === `force-${entry.id}`} className={`rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition disabled:cursor-not-allowed disabled:opacity-60 ${entry.is_force_hero ? 'border-amber-300/25 text-amber-100 hover:border-amber-300/40 hover:bg-amber-400/10' : 'border-amber-300/15 text-amber-50 hover:border-amber-300/30 hover:bg-amber-400/5'}`}>
{busy === `force-${entry.id}` ? 'Saving…' : entry.is_force_hero ? 'Disable Force Hero' : 'Force Hero'}
) : null}
handleToggle(entry)} disabled={busy === `toggle-${entry.id}`} className="rounded-full border border-sky-300/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-400/10 disabled:cursor-not-allowed disabled:opacity-60">
{busy === `toggle-${entry.id}` ? 'Saving…' : entry.is_active ? 'Deactivate' : 'Activate'}
handleDelete(entry)} disabled={busy === `delete-${entry.id}`} className="rounded-full border border-rose-300/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-rose-100 transition hover:border-rose-300/40 hover:bg-rose-400/10 disabled:cursor-not-allowed disabled:opacity-60">
{busy === `delete-${entry.id}` ? 'Deleting…' : 'Delete'}
))}
>
)
}