import React from 'react' import { Head, usePage } from '@inertiajs/react' function getCsrfToken() { if (typeof document === 'undefined') return '' return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' } async function requestJson(url, { method = 'POST', body } = {}) { const response = await fetch(url, { method, credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrfToken(), 'X-Requested-With': 'XMLHttpRequest', }, body: body ? JSON.stringify(body) : undefined, }) const payload = await response.json().catch(() => ({})) if (!response.ok) { throw new Error(payload?.message || payload?.errors?.artwork_id?.[0] || payload?.errors?.is_active?.[0] || payload?.errors?.force_hero?.[0] || 'Request failed.') } return payload } function isoToLocalInput(value) { if (!value) return '' const date = new Date(value) if (Number.isNaN(date.getTime())) return '' const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000)) return local.toISOString().slice(0, 16) } function localInputToIso(value) { if (!value) return null const date = new Date(value) if (Number.isNaN(date.getTime())) return null return date.toISOString() } function formatDateTime(value) { if (!value) return '—' const date = new Date(value) if (Number.isNaN(date.getTime())) return '—' return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short', }).format(date) } function Badge({ label, tone = 'slate' }) { const toneClasses = { slate: 'border-white/10 bg-white/10 text-slate-100', sky: 'border-sky-300/20 bg-sky-400/15 text-sky-100', emerald: 'border-emerald-300/20 bg-emerald-400/15 text-emerald-100', amber: 'border-amber-300/20 bg-amber-400/15 text-amber-100', rose: 'border-rose-300/20 bg-rose-400/15 text-rose-100', } return ( {label} ) } function Field({ label, help, children }) { return ( ) } 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 (
{label}
{value}
) } 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 ? (
{winner.artwork?.title
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 ? ( ) : null}
{!editingId ? (
setSearchQuery(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40" placeholder="Try an artwork ID, title, or creator" />
{searchResults.length > 0 ? (
{searchResults.map((artwork) => ( ))}
) : null}
) : null} {selectedArtwork ? (
{selectedArtwork.title
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}
setForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40" /> setForm((current) => ({ ...current, featured_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40" /> setForm((current) => ({ ...current, expires_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#08111d] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40" />
{editingId ? ( ) : 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" />
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
{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) => ( ))}
{capabilities.forceHeroEnabled ? ( ) : null}
))}
) }