640 lines
35 KiB
JavaScript
640 lines
35 KiB
JavaScript
import React, { useMemo, useRef, useState } from 'react'
|
|
import { Head, Link, router, useForm } from '@inertiajs/react'
|
|
import AdminLayout from '../../../Layouts/AdminLayout'
|
|
import DateTimePicker from '../../../components/ui/DateTimePicker'
|
|
import NovaSelect from '../../../components/ui/NovaSelect'
|
|
import ShareToast from '../../../components/ui/ShareToast'
|
|
import WorldMediaUploadField from '../../../components/worlds/editor/WorldMediaUploadField'
|
|
|
|
const CHALLENGE_EDITOR_TABS = [
|
|
{
|
|
id: 'overview',
|
|
label: 'Overview',
|
|
description: 'Title, slug, access, and the compact summary shown in academy challenge lists.',
|
|
icon: 'fa-compass-drafting',
|
|
sections: ['challenge-identity'],
|
|
},
|
|
{
|
|
id: 'brief',
|
|
label: 'Brief',
|
|
description: 'Write the creative objective, rules, prizes, required tags, and eligible categories.',
|
|
icon: 'fa-bullseye',
|
|
sections: ['challenge-brief', 'challenge-rules'],
|
|
},
|
|
{
|
|
id: 'media',
|
|
label: 'Media',
|
|
description: 'Upload the hero cover and tune the visual used on challenge cards.',
|
|
icon: 'fa-image',
|
|
sections: ['challenge-media'],
|
|
},
|
|
{
|
|
id: 'timeline',
|
|
label: 'Timeline',
|
|
description: 'Manage submission and voting windows without mixing them into editorial copy.',
|
|
icon: 'fa-calendar-days',
|
|
sections: ['challenge-timeline'],
|
|
},
|
|
{
|
|
id: 'publish',
|
|
label: 'Publish',
|
|
description: 'Control status, visibility, featured placement, and the final public preview.',
|
|
icon: 'fa-rocket-launch',
|
|
sections: ['challenge-publishing', 'challenge-preview'],
|
|
},
|
|
]
|
|
|
|
const CHALLENGE_FIELD_TAB_MAP = {
|
|
title: 'overview',
|
|
slug: 'overview',
|
|
excerpt: 'overview',
|
|
access_level: 'overview',
|
|
description: 'brief',
|
|
brief: 'brief',
|
|
rules: 'brief',
|
|
prize_text: 'brief',
|
|
required_tags: 'brief',
|
|
allowed_categories: 'brief',
|
|
cover_image: 'media',
|
|
starts_at: 'timeline',
|
|
ends_at: 'timeline',
|
|
voting_starts_at: 'timeline',
|
|
voting_ends_at: 'timeline',
|
|
status: 'publish',
|
|
featured: 'publish',
|
|
active: 'publish',
|
|
}
|
|
|
|
function getField(fields, name) {
|
|
return fields.find((field) => field.name === name) || null
|
|
}
|
|
|
|
function FieldError({ message }) {
|
|
if (!message) return null
|
|
return <p className="text-xs text-rose-300">{message}</p>
|
|
}
|
|
|
|
function SectionCard({ id, eyebrow, title, description, actions, children, tone = 'default', className = '' }) {
|
|
const toneClass = tone === 'feature'
|
|
? 'bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.15),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] shadow-[0_24px_70px_rgba(2,6,23,0.28)]'
|
|
: 'bg-white/[0.03]'
|
|
|
|
return (
|
|
<section id={id} className={`min-w-0 scroll-mt-24 rounded-[28px] border border-white/10 p-5 ${toneClass} ${className}`.trim()}>
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div className="max-w-3xl">
|
|
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75">{eyebrow}</p> : null}
|
|
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
|
|
{description ? <p className="mt-2 text-sm leading-6 text-slate-400">{description}</p> : null}
|
|
</div>
|
|
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
|
|
</div>
|
|
<div className="mt-5">{children}</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function EditorWorkspaceTabs({ activeTab, onChange, errorCounts }) {
|
|
const activeMeta = CHALLENGE_EDITOR_TABS.find((tab) => tab.id === activeTab) || CHALLENGE_EDITOR_TABS[0]
|
|
|
|
return (
|
|
<div className="sticky top-4 z-20 rounded-[24px] border border-white/10 bg-[linear-gradient(180deg,rgba(7,11,18,0.92),rgba(5,8,14,0.88))] px-3 py-3 shadow-[0_18px_50px_rgba(2,6,23,0.18)] backdrop-blur">
|
|
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="Challenge editor sections">
|
|
{CHALLENGE_EDITOR_TABS.map((tab) => {
|
|
const isActive = tab.id === activeTab
|
|
const errorCount = Number(errorCounts?.[tab.id] || 0)
|
|
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
aria-controls={`challenge-editor-panel-${tab.id}`}
|
|
id={`challenge-editor-tab-${tab.id}`}
|
|
onClick={() => onChange(tab.id)}
|
|
className={[
|
|
'inline-flex items-center gap-2 rounded-2xl border px-4 py-2.5 text-sm font-semibold transition',
|
|
isActive
|
|
? 'border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20'
|
|
: 'border-white/10 bg-white/[0.03] text-white/80 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white',
|
|
].join(' ')}
|
|
>
|
|
<i className={`fa-solid ${tab.icon} text-xs`} />
|
|
<span>{tab.label}</span>
|
|
{errorCount > 0 ? <span className="rounded-full border border-rose-300/20 bg-rose-300/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-rose-100">{errorCount}</span> : null}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 px-1">
|
|
<p className="text-sm leading-6 text-slate-400">{activeMeta.description}</p>
|
|
<div className="flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.16em] text-slate-500">
|
|
{activeMeta.sections.map((section) => (
|
|
<span key={section} className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5">{section.replace('challenge-', '').replace(/-/g, ' ')}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TextField({ label, value, onChange, error, hint, ...rest }) {
|
|
return (
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
|
|
<input value={value ?? ''} onChange={onChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" {...rest} />
|
|
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
|
|
<FieldError message={error} />
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function TextAreaField({ label, value, onChange, error, rows = 4, hint, placeholder }) {
|
|
return (
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
|
|
<textarea value={value ?? ''} onChange={onChange} rows={rows} placeholder={placeholder} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 leading-7 text-white outline-none placeholder:text-slate-600" />
|
|
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
|
|
<FieldError message={error} />
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function ToggleField({ label, checked, onChange, help, error }) {
|
|
return (
|
|
<label className={`flex cursor-pointer items-start gap-4 rounded-[28px] border px-5 py-4 transition ${checked ? 'border-[#f39a24]/35 bg-[#f39a24]/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]'}`}>
|
|
<input type="checkbox" checked={Boolean(checked)} onChange={onChange} className="sr-only" />
|
|
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-sm transition ${checked ? 'border-[#f39a24] bg-[#f39a24] text-white' : 'border-white/10 bg-[#151a29] text-transparent'}`}>
|
|
<i className="fa-solid fa-check" />
|
|
</span>
|
|
<span className="min-w-0">
|
|
<span className="block text-base font-semibold tracking-[-0.02em] text-white">{label}</span>
|
|
{help ? <span className="mt-1 block text-sm leading-6 text-slate-300">{help}</span> : null}
|
|
<FieldError message={error} />
|
|
</span>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function PlainTextPreview({ title, value, fallback }) {
|
|
return (
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{title}</p>
|
|
<div className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-300">
|
|
{String(value || '').trim() || fallback}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function statusMeta(status) {
|
|
const normalized = String(status || 'draft')
|
|
const labels = {
|
|
draft: 'Draft',
|
|
scheduled: 'Scheduled',
|
|
active: 'Active',
|
|
voting: 'Voting',
|
|
completed: 'Completed',
|
|
archived: 'Archived',
|
|
}
|
|
|
|
const classes = {
|
|
draft: 'border-white/10 bg-white/[0.04] text-slate-300',
|
|
scheduled: 'border-fuchsia-300/20 bg-fuchsia-300/10 text-fuchsia-100',
|
|
active: 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100',
|
|
voting: 'border-sky-300/20 bg-sky-300/10 text-sky-100',
|
|
completed: 'border-amber-300/20 bg-amber-300/10 text-amber-100',
|
|
archived: 'border-slate-300/15 bg-slate-300/8 text-slate-300',
|
|
}
|
|
|
|
return {
|
|
label: labels[normalized] || normalized,
|
|
className: classes[normalized] || classes.draft,
|
|
}
|
|
}
|
|
|
|
function slugifyChallengeTitle(value) {
|
|
return String(value || '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 180)
|
|
}
|
|
|
|
function normalizeList(value) {
|
|
return String(value || '')
|
|
.split(/[,\n]/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function normalizeAssetPreview(value, cdnBaseUrl) {
|
|
const trimmed = String(value || '').trim()
|
|
if (!trimmed) return ''
|
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://') || trimmed.startsWith('/')) return trimmed
|
|
return `${String(cdnBaseUrl || '').replace(/\/$/, '')}/${trimmed.replace(/^\//, '')}`
|
|
}
|
|
|
|
function stripPlainText(value) {
|
|
return String(value || '').replace(/\s+/g, ' ').trim()
|
|
}
|
|
|
|
function countWords(value) {
|
|
const text = stripPlainText(value)
|
|
return text ? text.split(/\s+/).length : 0
|
|
}
|
|
|
|
function formatDateLabel(value, fallback = 'Not set') {
|
|
if (!value) return fallback
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) return fallback
|
|
|
|
return new Intl.DateTimeFormat('en', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}).format(date)
|
|
}
|
|
|
|
function daysBetween(start, end) {
|
|
const startDate = start ? new Date(start) : null
|
|
const endDate = end ? new Date(end) : null
|
|
|
|
if (!startDate || !endDate || Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
|
return null
|
|
}
|
|
|
|
const diff = endDate.getTime() - startDate.getTime()
|
|
if (diff < 0) return 'Dates need review'
|
|
|
|
const days = Math.max(1, Math.ceil(diff / (1000 * 60 * 60 * 24)))
|
|
return `${days} day${days === 1 ? '' : 's'}`
|
|
}
|
|
|
|
function firstErrorMessage(errors, fallback = 'Please correct the highlighted fields and try again.') {
|
|
const queue = [errors]
|
|
|
|
while (queue.length > 0) {
|
|
const current = queue.shift()
|
|
|
|
if (typeof current === 'string') {
|
|
const message = current.trim()
|
|
if (message) return message
|
|
continue
|
|
}
|
|
|
|
if (Array.isArray(current)) {
|
|
queue.push(...current)
|
|
continue
|
|
}
|
|
|
|
if (current && typeof current === 'object') {
|
|
queue.push(...Object.values(current))
|
|
}
|
|
}
|
|
|
|
return fallback
|
|
}
|
|
|
|
function firstChallengeErrorTab(errors) {
|
|
const firstKey = Object.keys(errors || {})[0]
|
|
if (!firstKey) return null
|
|
const baseKey = firstKey.split('.')[0]
|
|
return CHALLENGE_FIELD_TAB_MAP[baseKey] || null
|
|
}
|
|
|
|
function challengeTabErrorCounts(errors) {
|
|
const counts = {}
|
|
|
|
Object.keys(errors || {}).forEach((key) => {
|
|
const tabId = CHALLENGE_FIELD_TAB_MAP[key.split('.')[0]]
|
|
if (!tabId) return
|
|
counts[tabId] = Number(counts[tabId] || 0) + 1
|
|
})
|
|
|
|
return counts
|
|
}
|
|
|
|
function buildChallengePayload(data) {
|
|
return {
|
|
...data,
|
|
required_tags: normalizeList(data.required_tags),
|
|
allowed_categories: normalizeList(data.allowed_categories),
|
|
}
|
|
}
|
|
|
|
export default function ChallengeEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext = {} }) {
|
|
const form = useForm(record)
|
|
const slugTouchedRef = useRef(String(record?.slug || '').trim() !== '')
|
|
const [activeTab, setActiveTab] = useState('overview')
|
|
const [coverPreviewUrl, setCoverPreviewUrl] = useState(record?.cover_image_url || normalizeAssetPreview(record?.cover_image, editorContext.coverCdnBaseUrl))
|
|
const [stagedCoverPath, setStagedCoverPath] = useState('')
|
|
const [toast, setToast] = useState({ id: 0, visible: false, message: '', variant: 'success' })
|
|
|
|
const accessField = getField(fields, 'access_level')
|
|
const statusField = getField(fields, 'status')
|
|
const status = statusMeta(form.data.status)
|
|
const requiredTags = useMemo(() => normalizeList(form.data.required_tags), [form.data.required_tags])
|
|
const allowedCategories = useMemo(() => normalizeList(form.data.allowed_categories), [form.data.allowed_categories])
|
|
const briefWordCount = countWords(`${form.data.description || ''} ${form.data.brief || ''} ${form.data.rules || ''}`)
|
|
const submissionWindow = daysBetween(form.data.starts_at, form.data.ends_at)
|
|
const votingWindow = daysBetween(form.data.voting_starts_at, form.data.voting_ends_at)
|
|
const publicPathPreview = `/academy/challenges/${form.data.slug || 'challenge-slug'}`
|
|
const errorCounts = challengeTabErrorCounts(form.errors)
|
|
|
|
const sectionClassName = (sectionId) => {
|
|
const tab = CHALLENGE_EDITOR_TABS.find((item) => item.sections.includes(sectionId))
|
|
return tab && tab.id !== activeTab ? 'hidden' : ''
|
|
}
|
|
|
|
const showToast = (message, variant = 'error') => {
|
|
setToast({
|
|
id: Date.now() + Math.random(),
|
|
visible: true,
|
|
message,
|
|
variant,
|
|
})
|
|
}
|
|
|
|
const setTitle = (nextTitle) => {
|
|
form.setData('title', nextTitle)
|
|
|
|
if (!slugTouchedRef.current) {
|
|
form.setData('slug', slugifyChallengeTitle(nextTitle))
|
|
}
|
|
}
|
|
|
|
const handleManualCoverChange = (nextValue) => {
|
|
setStagedCoverPath('')
|
|
form.setData('cover_image', nextValue)
|
|
setCoverPreviewUrl(normalizeAssetPreview(nextValue, editorContext.coverCdnBaseUrl))
|
|
}
|
|
|
|
const submit = (event) => {
|
|
event.preventDefault()
|
|
const payload = buildChallengePayload(form.data)
|
|
form.transform(() => payload)
|
|
|
|
const submitOptions = {
|
|
preserveScroll: true,
|
|
onError: (errors) => {
|
|
const nextTab = firstChallengeErrorTab(errors)
|
|
|
|
if (nextTab) {
|
|
setActiveTab(nextTab)
|
|
}
|
|
|
|
showToast(firstErrorMessage(errors), 'error')
|
|
},
|
|
onFinish: () => form.transform((data) => data),
|
|
}
|
|
|
|
if (method === 'patch') {
|
|
form.patch(submitUrl, submitOptions)
|
|
return
|
|
}
|
|
|
|
form.post(submitUrl, submitOptions)
|
|
}
|
|
|
|
const deleteChallenge = () => {
|
|
if (!destroyUrl) return
|
|
if (!window.confirm('Delete this challenge?')) return
|
|
router.delete(destroyUrl)
|
|
}
|
|
|
|
return (
|
|
<AdminLayout title={title} subtitle={subtitle}>
|
|
<Head title={`Admin - ${title}`} />
|
|
|
|
<form onSubmit={submit} className="space-y-6 pb-16">
|
|
<section className="overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur">
|
|
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
|
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]">Back to challenges</Link>
|
|
<span>{destroyUrl ? 'Edit challenge' : 'New challenge'}</span>
|
|
</div>
|
|
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy challenge'}</h1>
|
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{form.data.excerpt || 'Shape a guided creative brief with clear rules, dates, tags, and a public-ready preview.'}</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className={`rounded-full border px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] ${status.className}`}>{status.label}</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{form.data.access_level || 'free'}</span>
|
|
{editorContext?.links?.preview ? <a href={editorContext.links.preview} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100">Preview public page</a> : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 px-5 py-5 md:grid-cols-4">
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Submission window</p>
|
|
<p className="mt-2 text-sm font-semibold text-white">{submissionWindow || 'Not scheduled'}</p>
|
|
</div>
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Voting window</p>
|
|
<p className="mt-2 text-sm font-semibold text-white">{votingWindow || 'Not scheduled'}</p>
|
|
</div>
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Required tags</p>
|
|
<p className="mt-2 text-sm font-semibold text-white">{requiredTags.length}</p>
|
|
</div>
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Brief copy</p>
|
|
<p className="mt-2 text-sm font-semibold text-white">{briefWordCount.toLocaleString()} words</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<EditorWorkspaceTabs activeTab={activeTab} onChange={setActiveTab} errorCounts={errorCounts} />
|
|
|
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
|
<div className="min-w-0 space-y-6">
|
|
<SectionCard id="challenge-identity" eyebrow="Challenge identity" title="Name and positioning" description="Keep the title clear, the slug readable, and the card summary useful for academy discovery." className={sectionClassName('challenge-identity')}>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<TextField label="Title" value={form.data.title || ''} onChange={(event) => setTitle(event.target.value)} error={form.errors.title} maxLength={180} placeholder="Creator brief: cinematic wallpaper challenge" />
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="flex items-center justify-between gap-3">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Slug</span>
|
|
<button type="button" onClick={() => {
|
|
slugTouchedRef.current = false
|
|
form.setData('slug', slugifyChallengeTitle(form.data.title))
|
|
}} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold text-white">Sync</button>
|
|
</span>
|
|
<input value={form.data.slug || ''} onChange={(event) => {
|
|
slugTouchedRef.current = String(event.target.value).trim() !== ''
|
|
form.setData('slug', event.target.value)
|
|
}} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" maxLength={180} placeholder="cinematic-wallpaper-challenge" />
|
|
<FieldError message={form.errors.slug} />
|
|
</label>
|
|
</div>
|
|
|
|
<TextAreaField label="Excerpt" value={form.data.excerpt || ''} onChange={(event) => form.setData('excerpt', event.target.value)} error={form.errors.excerpt} rows={4} hint="Short public summary used on challenge cards and academy home rails." />
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<NovaSelect label={accessField?.label || 'Access'} value={form.data.access_level || ''} onChange={(nextValue) => form.setData('access_level', String(nextValue || ''))} options={accessField?.options || []} searchable={false} className="bg-black/20" error={form.errors.access_level} />
|
|
<TextField label="Public path" value={publicPathPreview} readOnly hint="Generated from the slug. Published challenge pages use this path." />
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard id="challenge-brief" eyebrow="Creative brief" title="Brief and creator direction" description="Write the task in a way creators can execute without needing extra staff notes." tone="feature" className={sectionClassName('challenge-brief')}>
|
|
<TextAreaField label="Description" value={form.data.description || ''} onChange={(event) => form.setData('description', event.target.value)} error={form.errors.description} rows={6} hint="Overview copy for the challenge page. Plain text is safest here because public challenge pages preserve line breaks." placeholder="Introduce the creative goal, theme, and expected output." />
|
|
<TextAreaField label="Brief" value={form.data.brief || ''} onChange={(event) => form.setData('brief', event.target.value)} error={form.errors.brief} rows={10} hint="The main actionable brief. Use line breaks for steps, constraints, or judging criteria." placeholder="Objective, deliverable, style direction, and acceptance notes." />
|
|
</SectionCard>
|
|
|
|
<SectionCard id="challenge-rules" eyebrow="Rules and eligibility" title="Requirements and rewards" description="Keep challenge operations close to the brief so staff can audit everything before launch." className={sectionClassName('challenge-rules')}>
|
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(260px,0.72fr)]">
|
|
<TextAreaField label="Rules" value={form.data.rules || ''} onChange={(event) => form.setData('rules', event.target.value)} error={form.errors.rules} rows={10} hint="Submission rules, judging notes, restrictions, and moderation expectations." />
|
|
<div className="grid gap-4">
|
|
<TextField label="Prize text" value={form.data.prize_text || ''} onChange={(event) => form.setData('prize_text', event.target.value)} error={form.errors.prize_text} maxLength={180} placeholder="Featured placement plus Academy badge" />
|
|
<TextAreaField label="Required tags" value={form.data.required_tags || ''} onChange={(event) => form.setData('required_tags', event.target.value)} error={form.errors.required_tags} rows={4} hint="Comma-separated or one per line. Saved as an array." placeholder="academy-challenge, cinematic, wallpaper" />
|
|
<TextAreaField label="Allowed categories" value={form.data.allowed_categories || ''} onChange={(event) => form.setData('allowed_categories', event.target.value)} error={form.errors.allowed_categories} rows={4} hint="Comma-separated or one per line. Saved as an array." placeholder="Wallpapers, Skins, Worlds" />
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard id="challenge-media" eyebrow="Visual system" title="Cover media" description="Upload clean landscape imagery that works in public challenge cards and the challenge detail hero." className={sectionClassName('challenge-media')}>
|
|
<div className="grid gap-6 lg:grid-cols-2 lg:items-start">
|
|
<div className="space-y-4">
|
|
<WorldMediaUploadField
|
|
label="Cover image"
|
|
slot="cover"
|
|
value={form.data.cover_image}
|
|
previewUrl={coverPreviewUrl}
|
|
emptyLabel="Challenge cover"
|
|
helperText="Use a landscape JPG, PNG, or WEBP. Minimum upload is 600x315; a 16:9 cover will crop best across academy surfaces."
|
|
uploadUrl={editorContext.coverUploadUrl}
|
|
deleteUrl={editorContext.coverDeleteUrl}
|
|
isTemporaryValue={Boolean(stagedCoverPath) && stagedCoverPath === form.data.cover_image}
|
|
onChange={({ path, url }) => {
|
|
setStagedCoverPath(path || '')
|
|
form.setData('cover_image', path || '')
|
|
setCoverPreviewUrl(url || normalizeAssetPreview(path || '', editorContext.coverCdnBaseUrl))
|
|
}}
|
|
/>
|
|
<FieldError message={form.errors.cover_image} />
|
|
<TextField label="Advanced cover path or URL" value={form.data.cover_image || ''} onChange={(event) => handleManualCoverChange(event.target.value)} error={form.errors.cover_image} placeholder="Optional external URL or stored object path" />
|
|
</div>
|
|
|
|
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-slate-950">
|
|
{coverPreviewUrl ? (
|
|
<img src={coverPreviewUrl} alt="Challenge cover preview" className="h-72 w-full object-cover" />
|
|
) : (
|
|
<div className="flex h-72 items-center justify-center px-6 text-center text-sm text-slate-500">No challenge cover selected yet.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard id="challenge-timeline" eyebrow="Timeline" title="Submission and voting windows" description="Set the dates that move the challenge from scheduled, to active, to voting, then completed." className={sectionClassName('challenge-timeline')}>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<DateTimePicker label="Starts at" value={form.data.starts_at || ''} onChange={(nextValue) => form.setData('starts_at', nextValue || '')} error={form.errors.starts_at} clearable className="bg-black/20" />
|
|
<DateTimePicker label="Ends at" value={form.data.ends_at || ''} onChange={(nextValue) => form.setData('ends_at', nextValue || '')} error={form.errors.ends_at} clearable className="bg-black/20" />
|
|
<DateTimePicker label="Voting starts at" value={form.data.voting_starts_at || ''} onChange={(nextValue) => form.setData('voting_starts_at', nextValue || '')} error={form.errors.voting_starts_at} clearable className="bg-black/20" />
|
|
<DateTimePicker label="Voting ends at" value={form.data.voting_ends_at || ''} onChange={(nextValue) => form.setData('voting_ends_at', nextValue || '')} error={form.errors.voting_ends_at} clearable className="bg-black/20" />
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard id="challenge-publishing" eyebrow="Publishing" title="Status and visibility" description="Choose how this challenge behaves in academy discovery and whether it appears in featured placements." className={sectionClassName('challenge-publishing')}>
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<NovaSelect label={statusField?.label || 'Status'} value={form.data.status || ''} onChange={(nextValue) => form.setData('status', String(nextValue || ''))} options={statusField?.options || []} searchable={false} className="bg-black/20" error={form.errors.status} />
|
|
<ToggleField label="Featured" checked={Boolean(form.data.featured)} onChange={(event) => form.setData('featured', event.target.checked)} help="Promote this challenge on academy rails." error={form.errors.featured} />
|
|
<ToggleField label="Active" checked={Boolean(form.data.active)} onChange={(event) => form.setData('active', event.target.checked)} help="Inactive challenges stay hidden even when their status is public." error={form.errors.active} />
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard id="challenge-preview" eyebrow="Preview" title="Public-facing snapshot" description="Scan the challenge card, brief, rules, tags, and timeline before saving." tone="feature" className={sectionClassName('challenge-preview')}>
|
|
<div className="grid gap-5 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1fr)]">
|
|
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-slate-950">
|
|
{coverPreviewUrl ? (
|
|
<img src={coverPreviewUrl} alt="Challenge preview" className="h-64 w-full object-cover" />
|
|
) : (
|
|
<div className="flex h-64 items-center justify-center px-6 text-center text-sm text-slate-500">No cover image selected yet.</div>
|
|
)}
|
|
</div>
|
|
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
|
<div className="flex flex-wrap gap-2">
|
|
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${status.className}`}>{status.label}</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-200">{form.data.access_level || 'free'}</span>
|
|
{form.data.featured ? <span className="rounded-full border border-amber-300/20 bg-amber-300/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">Featured</span> : null}
|
|
</div>
|
|
<h3 className="mt-4 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy challenge'}</h3>
|
|
<p className="mt-3 text-sm leading-7 text-slate-300">{form.data.excerpt || 'Add a short challenge summary to explain what creators are building.'}</p>
|
|
{form.data.prize_text ? <p className="mt-4 rounded-2xl border border-[#f39a24]/20 bg-[#f39a24]/10 px-4 py-3 text-sm font-semibold text-[#ffd5cd]">{form.data.prize_text}</p> : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
|
<PlainTextPreview title="Brief preview" value={form.data.brief || form.data.description} fallback="The challenge brief is still empty." />
|
|
<PlainTextPreview title="Rules preview" value={form.data.rules} fallback="No special rules posted yet." />
|
|
</div>
|
|
</SectionCard>
|
|
</div>
|
|
|
|
<div className="space-y-6 xl:sticky xl:top-6 xl:self-start">
|
|
<SectionCard eyebrow="At a glance" title="Challenge summary" description="A compact view of the details editors usually check before launch.">
|
|
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Public path</p>
|
|
<p className="mt-2 break-all text-sm font-semibold text-white">{publicPathPreview}</p>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
|
|
<p className="mt-2 text-sm font-semibold text-white">{status.label}</p>
|
|
</div>
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Cover</p>
|
|
<p className="mt-2 text-sm font-semibold text-white">{coverPreviewUrl ? 'Ready' : 'Missing'}</p>
|
|
</div>
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Submissions</p>
|
|
<p className="mt-2 text-sm font-semibold text-white">{formatDateLabel(form.data.starts_at)} to {formatDateLabel(form.data.ends_at)}</p>
|
|
</div>
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Voting</p>
|
|
<p className="mt-2 text-sm font-semibold text-white">{formatDateLabel(form.data.voting_starts_at)} to {formatDateLabel(form.data.voting_ends_at)}</p>
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard eyebrow="Requirements" title="Tags and categories" description="A quick scan of the saved arrays before the challenge opens.">
|
|
<div className="grid gap-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Required tags</p>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{requiredTags.length ? requiredTags.map((tag) => <span key={tag} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold text-sky-100">{tag}</span>) : <span className="text-sm text-slate-500">No required tags yet.</span>}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Allowed categories</p>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{allowedCategories.length ? allowedCategories.map((category) => <span key={category} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-slate-200">{category}</span>) : <span className="text-sm text-slate-500">All categories are allowed unless you add limits.</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
|
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save challenge'}</button>
|
|
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back</Link>
|
|
{destroyUrl ? <button type="button" onClick={deleteChallenge} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete</button> : null}
|
|
</div>
|
|
</form>
|
|
|
|
<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 }))}
|
|
/>
|
|
</AdminLayout>
|
|
)
|
|
}
|