Files
SkinbaseNova/resources/js/Pages/Admin/Academy/LessonEditor.jsx

1127 lines
63 KiB
JavaScript

import React, { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Head, Link, router, useForm } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
import RichTextEditor from '../../../components/forum/RichTextEditor'
import WorldMediaUploadField from '../../../components/worlds/editor/WorldMediaUploadField'
import DateTimePicker from '../../../components/ui/DateTimePicker'
import NovaSelect from '../../../components/ui/NovaSelect'
function getField(fields, name) {
return fields.find((field) => field.name === name) || null
}
const LESSON_SECTION_NAV_ITEMS = [
{ id: 'lesson-story-setup', label: 'Story setup' },
{ id: 'lesson-body-editor', label: 'Main article' },
{ id: 'lesson-ai-comparisons', label: 'AI comparisons' },
{ id: 'lesson-publishing', label: 'Publishing' },
{ id: 'lesson-seo', label: 'SEO' },
{ id: 'lesson-categories', label: 'Categories' },
{ id: 'lesson-cover', label: 'Cover image' },
{ id: 'lesson-preview', label: 'Preview' },
]
let comparisonEditorSequence = 0
function nextComparisonEditorKey(prefix) {
comparisonEditorSequence += 1
return `${prefix}-${comparisonEditorSequence}`
}
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' }) {
const toneClass = tone === 'feature'
? 'bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),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}`}>
<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 SectionNav({ items }) {
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 items-center gap-3 overflow-x-auto pb-1 nova-scrollbar">
<span className="flex shrink-0 items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
<i className="fa-solid fa-compass text-[10px]" />
Sections
</span>
{items.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
className="shrink-0 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-semibold text-white/80 transition hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white"
>
{item.label}
</a>
))}
</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 }) {
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} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
{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 items-start gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200">
<input type="checkbox" checked={Boolean(checked)} onChange={onChange} className="mt-1" />
<span>
<span className="block font-semibold text-white">{label}</span>
{help ? <span className="mt-1 block text-xs leading-5 text-slate-400">{help}</span> : null}
<FieldError message={error} />
</span>
</label>
)
}
function slugifyLessonTitle(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 180)
}
function stripHtml(value) {
return String(value || '')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function countWords(value) {
const text = stripHtml(value)
return text ? text.split(/\s+/).length : 0
}
function normalizeCoverPreview(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 normalizeCategoryValue(value) {
if (value === '' || value == null) return ''
return String(value)
}
function normalizeBoolean(value, fallback = false) {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value !== 0
const normalized = String(value || '').trim().toLowerCase()
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
return fallback
}
const DEFAULT_AI_COMPARISON_TITLE = 'Same Prompt, Different AI Models'
const DEFAULT_AI_COMPARISON_PAYLOAD = {
title: DEFAULT_AI_COMPARISON_TITLE,
intro: 'We used the same prompt in multiple image generators to compare composition, mood, detail, and wallpaper quality.',
prompt: '',
negative_prompt: '',
aspect_ratio: '16:9',
criteria: ['Composition', 'Lighting', 'Wallpaper quality', 'Prompt accuracy', 'Detail quality'],
}
function normalizeCriteria(value) {
return (Array.isArray(value) ? value : [])
.map((criterion) => String(criterion || '').trim())
.filter(Boolean)
}
function normalizeComparisonResult(result, index, cdnBaseUrl) {
const imagePath = String(result?.image_path || '')
const thumbPath = String(result?.thumb_path || '')
return {
client_key: result?.client_key || nextComparisonEditorKey('comparison-result'),
id: result?.id ?? null,
provider: String(result?.provider || ''),
model_name: String(result?.model_name || ''),
image_path: imagePath,
image_url: String(result?.image_url || normalizeCoverPreview(imagePath, cdnBaseUrl) || ''),
image_temp_path: String(result?.image_temp_path || ''),
thumb_path: thumbPath,
thumb_url: String(result?.thumb_url || normalizeCoverPreview(thumbPath, cdnBaseUrl) || ''),
thumb_temp_path: String(result?.thumb_temp_path || ''),
settings: String(result?.settings || ''),
strengths: String(result?.strengths || ''),
weaknesses: String(result?.weaknesses || ''),
best_for: String(result?.best_for || ''),
score: result?.score == null || result?.score === '' ? '' : Number(result.score),
sort_order: Number(result?.sort_order ?? index),
active: normalizeBoolean(result?.active, true),
}
}
function createEmptyComparisonResult(sortOrder = 0) {
return normalizeComparisonResult({ sort_order: sortOrder, active: true }, sortOrder, '')
}
function normalizeComparisonBlock(block, index, cdnBaseUrl) {
const payload = block?.payload && typeof block.payload === 'object' ? block.payload : {}
return {
client_key: block?.client_key || nextComparisonEditorKey('comparison-block'),
id: block?.id ?? null,
type: 'ai_comparison',
title: String(block?.title || payload?.title || DEFAULT_AI_COMPARISON_TITLE),
payload: {
...DEFAULT_AI_COMPARISON_PAYLOAD,
title: String(payload?.title || block?.title || DEFAULT_AI_COMPARISON_TITLE),
intro: String(payload?.intro || ''),
prompt: String(payload?.prompt || ''),
negative_prompt: String(payload?.negative_prompt || ''),
aspect_ratio: String(payload?.aspect_ratio || DEFAULT_AI_COMPARISON_PAYLOAD.aspect_ratio),
criteria: normalizeCriteria(payload?.criteria || DEFAULT_AI_COMPARISON_PAYLOAD.criteria),
},
sort_order: Number(block?.sort_order ?? index),
active: normalizeBoolean(block?.active, true),
comparison_results: (Array.isArray(block?.comparison_results) ? block.comparison_results : [])
.map((result, resultIndex) => normalizeComparisonResult(result, resultIndex, cdnBaseUrl)),
}
}
function createEmptyComparisonBlock(sortOrder = 0, cdnBaseUrl = '') {
return normalizeComparisonBlock({
type: 'ai_comparison',
title: DEFAULT_AI_COMPARISON_TITLE,
payload: DEFAULT_AI_COMPARISON_PAYLOAD,
sort_order: sortOrder,
active: true,
comparison_results: [],
}, sortOrder, cdnBaseUrl)
}
function normalizeComparisonBlocks(blocks, cdnBaseUrl) {
return (Array.isArray(blocks) ? blocks : [])
.filter((block) => String(block?.type || 'ai_comparison') === 'ai_comparison')
.map((block, index) => normalizeComparisonBlock(block, index, cdnBaseUrl))
}
function serializeComparisonBlocks(blocks) {
return (Array.isArray(blocks) ? blocks : []).map((block, index) => ({
id: block.id || undefined,
type: 'ai_comparison',
title: String(block.title || block.payload?.title || DEFAULT_AI_COMPARISON_TITLE),
payload: {
title: String(block.payload?.title || block.title || DEFAULT_AI_COMPARISON_TITLE),
intro: String(block.payload?.intro || ''),
prompt: String(block.payload?.prompt || ''),
negative_prompt: String(block.payload?.negative_prompt || ''),
aspect_ratio: String(block.payload?.aspect_ratio || ''),
criteria: normalizeCriteria(block.payload?.criteria || []),
},
sort_order: Number(block.sort_order ?? index),
active: Boolean(block.active),
comparison_results: (Array.isArray(block.comparison_results) ? block.comparison_results : []).map((result, resultIndex) => ({
id: result.id || undefined,
provider: String(result.provider || ''),
model_name: String(result.model_name || ''),
image_path: String(result.image_path || ''),
thumb_path: String(result.thumb_path || ''),
settings: String(result.settings || ''),
strengths: String(result.strengths || ''),
weaknesses: String(result.weaknesses || ''),
best_for: String(result.best_for || ''),
score: result.score === '' || result.score == null ? null : Number(result.score),
sort_order: Number(result.sort_order ?? resultIndex),
active: Boolean(result.active),
})),
}))
}
function getFormError(errors, path) {
return errors?.[path] || ''
}
function buildLessonPayload(data) {
return {
category_id: data.category_id === '' || data.category_id == null ? null : Number(data.category_id),
title: String(data.title || ''),
slug: String(data.slug || ''),
excerpt: String(data.excerpt || ''),
content: String(data.content || ''),
difficulty: String(data.difficulty || ''),
access_level: String(data.access_level || ''),
lesson_type: String(data.lesson_type || ''),
cover_image: String(data.cover_image || ''),
video_url: String(data.video_url || ''),
reading_minutes: data.reading_minutes === '' || data.reading_minutes == null ? '' : Number(data.reading_minutes),
published_at: data.published_at || '',
seo_title: String(data.seo_title || ''),
seo_description: String(data.seo_description || ''),
featured: Boolean(data.featured),
active: Boolean(data.active),
blocks: serializeComparisonBlocks(data.blocks),
}
}
function parseLessonImport(rawText, categoryOptions) {
let parsed
try {
parsed = JSON.parse(String(rawText || ''))
} catch {
throw new Error('Could not parse JSON.')
}
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error('Import JSON must be an object.')
}
const next = {}
const applied = []
const apply = (key, value) => {
next[key] = value
applied.push(key)
}
if (parsed.title != null) apply('title', String(parsed.title))
if (parsed.slug != null) apply('slug', String(parsed.slug))
if (parsed.excerpt != null) apply('excerpt', String(parsed.excerpt))
if (parsed.content != null) apply('content', String(parsed.content))
if (parsed.body != null && parsed.content == null) apply('content', String(parsed.body))
if (parsed.html != null && parsed.content == null && parsed.body == null) apply('content', String(parsed.html))
if (parsed.difficulty != null) apply('difficulty', String(parsed.difficulty))
if (parsed.access_level != null) apply('access_level', String(parsed.access_level))
if (parsed.access != null && parsed.access_level == null) apply('access_level', String(parsed.access))
if (parsed.lesson_type != null) apply('lesson_type', String(parsed.lesson_type))
if (parsed.type != null && parsed.lesson_type == null) apply('lesson_type', String(parsed.type))
if (parsed.cover_image != null) apply('cover_image', String(parsed.cover_image))
if (parsed.cover != null && parsed.cover_image == null) apply('cover_image', String(parsed.cover))
if (parsed.cover_url != null && parsed.cover_image == null && parsed.cover == null) apply('cover_image', String(parsed.cover_url))
if (parsed.video_url != null) apply('video_url', String(parsed.video_url))
if (parsed.reading_minutes != null) apply('reading_minutes', String(parsed.reading_minutes))
if (parsed.published_at != null) apply('published_at', String(parsed.published_at))
if (parsed.seo_title != null) apply('seo_title', String(parsed.seo_title))
if (parsed.seo_description != null) apply('seo_description', String(parsed.seo_description))
if (parsed.featured != null) apply('featured', normalizeBoolean(parsed.featured))
if (parsed.active != null) apply('active', normalizeBoolean(parsed.active, true))
if (parsed.category_id != null || parsed.category_slug != null || parsed.category != null) {
const requested = String(parsed.category_id ?? parsed.category_slug ?? parsed.category).trim().toLowerCase()
const match = (Array.isArray(categoryOptions) ? categoryOptions : []).find((option) => [option.id, option.value, option.slug, option.name, option.label]
.filter((candidate) => candidate != null)
.map((candidate) => String(candidate).trim().toLowerCase())
.includes(requested))
if (match) {
apply('category_id', String(match.id ?? match.value ?? ''))
}
}
if (applied.length === 0) {
throw new Error('The JSON did not contain any recognized lesson fields.')
}
return { next, applied }
}
function JsonImportDialog({ open, value, error, onChange, onClose, onApply }) {
const backdropRef = useRef(null)
useEffect(() => {
if (!open) return undefined
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
onClose?.()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, open])
if (!open) return null
return createPortal(
<div
ref={backdropRef}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
onClick={(event) => {
if (event.target === backdropRef.current) {
onClose?.()
}
}}
role="presentation"
>
<div className="w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Structured Import</p>
<h3 className="mt-2 text-lg font-semibold text-white">Paste lesson JSON</h3>
<p className="mt-2 text-sm leading-6 text-white/65">Use this to seed the lesson form with structured content before you refine it in the editor.</p>
</div>
<div className="grid gap-5 px-6 py-5 xl:grid-cols-[minmax(0,1fr)_280px]">
<div className="grid gap-3">
<textarea
value={value}
onChange={(event) => onChange?.(event.target.value)}
rows={16}
placeholder={'{\n "title": "Prompt engineering for cleaner scene direction",\n "excerpt": "Short summary...",\n "content": "<p>Rich HTML body...</p>",\n "category": "Prompting",\n "difficulty": "beginner"\n}'}
className="rounded-[24px] border border-white/10 bg-slate-950/80 px-4 py-4 font-mono text-sm leading-6 text-slate-100 outline-none"
/>
{error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
</div>
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Accepted keys</div>
<div className="mt-3 space-y-2 leading-6 text-slate-400">
<p>title, slug, excerpt</p>
<p>content, body, html</p>
<p>category_id, category_slug, category</p>
<p>difficulty, access_level, lesson_type</p>
<p>cover_image, cover, cover_url, video_url</p>
<p>reading_minutes, published_at</p>
<p>seo_title, seo_description, featured, active</p>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
<button type="button" onClick={() => onClose?.()} className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white">Cancel</button>
<button type="button" onClick={() => onApply?.()} className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110">Apply JSON</button>
</div>
</div>
</div>,
document.body,
)
}
export default function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext = {} }) {
const cdnBaseUrl = editorContext.coverCdnBaseUrl || ''
const form = useForm({
...record,
category_id: normalizeCategoryValue(record.category_id),
blocks: normalizeComparisonBlocks(record.blocks, cdnBaseUrl),
})
const [coverPreviewUrl, setCoverPreviewUrl] = useState(record.cover_image_url || normalizeCoverPreview(record.cover_image, editorContext.coverCdnBaseUrl))
const [stagedCoverPath, setStagedCoverPath] = useState('')
const [categories, setCategories] = useState(Array.isArray(editorContext.categories) ? editorContext.categories : [])
const [jsonImportOpen, setJsonImportOpen] = useState(false)
const [jsonImportValue, setJsonImportValue] = useState('')
const [jsonImportError, setJsonImportError] = useState('')
const [categoryError, setCategoryError] = useState('')
const [categorySaving, setCategorySaving] = useState(false)
const [categoryDraft, setCategoryDraft] = useState({ type: 'lesson', name: '', slug: '', description: '', order_num: 0, active: true })
const slugTouchedRef = useRef(Boolean(String(record.slug || '').trim()))
const difficultyField = useMemo(() => getField(fields, 'difficulty'), [fields])
const accessField = useMemo(() => getField(fields, 'access_level'), [fields])
const bodyWordCount = useMemo(() => countWords(form.data.content), [form.data.content])
const excerptLength = String(form.data.excerpt || '').length
const csrfToken = useMemo(() => {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}, [])
useEffect(() => {
if (slugTouchedRef.current) return
form.setData('slug', slugifyLessonTitle(form.data.title))
}, [form.data.title])
const categoryOptions = useMemo(() => {
const next = categories.map((category) => ({ value: String(category.id), label: category.name }))
return [{ value: '', label: 'No category' }, ...next]
}, [categories])
const handleManualCoverChange = (nextValue) => {
form.setData('cover_image', nextValue)
setCoverPreviewUrl(normalizeCoverPreview(nextValue, editorContext.coverCdnBaseUrl))
}
const updateBlocks = (updater) => {
const currentBlocks = Array.isArray(form.data.blocks) ? form.data.blocks : []
const nextBlocks = typeof updater === 'function' ? updater(currentBlocks) : updater
form.setData('blocks', nextBlocks)
}
const updateBlock = (blockKey, updater) => {
updateBlocks((currentBlocks) => currentBlocks.map((block) => (
block.client_key === blockKey ? updater(block) : block
)))
}
const removeBlock = (blockKey) => {
updateBlocks((currentBlocks) => currentBlocks.filter((block) => block.client_key !== blockKey))
}
const addComparisonBlock = () => {
updateBlocks((currentBlocks) => ([
...currentBlocks,
createEmptyComparisonBlock(currentBlocks.length, cdnBaseUrl),
]))
}
const addComparisonResult = (blockKey) => {
updateBlock(blockKey, (block) => ({
...block,
comparison_results: [
...(Array.isArray(block.comparison_results) ? block.comparison_results : []),
createEmptyComparisonResult(Array.isArray(block.comparison_results) ? block.comparison_results.length : 0),
],
}))
}
const updateComparisonResult = (blockKey, resultKey, updater) => {
updateBlock(blockKey, (block) => ({
...block,
comparison_results: (Array.isArray(block.comparison_results) ? block.comparison_results : []).map((result) => (
result.client_key === resultKey ? updater(result) : result
)),
}))
}
const removeComparisonResult = (blockKey, resultKey) => {
updateBlock(blockKey, (block) => ({
...block,
comparison_results: (Array.isArray(block.comparison_results) ? block.comparison_results : []).filter((result) => result.client_key !== resultKey),
}))
}
const submit = (event) => {
event.preventDefault()
const payload = buildLessonPayload(form.data)
form.transform(() => payload)
if (method === 'patch') {
form.patch(submitUrl)
return
}
form.post(submitUrl)
}
const deleteLesson = () => {
if (!destroyUrl) return
if (!window.confirm('Delete this lesson?')) return
router.delete(destroyUrl)
}
const applyJsonImport = () => {
try {
const parsed = parseLessonImport(jsonImportValue, categories)
Object.entries(parsed.next).forEach(([key, value]) => {
form.setData(key, value)
})
if (parsed.next.cover_image != null) {
handleManualCoverChange(String(parsed.next.cover_image || ''))
}
if (parsed.next.slug != null) {
slugTouchedRef.current = true
}
setJsonImportError('')
setJsonImportOpen(false)
} catch (error) {
setJsonImportError(error instanceof Error ? error.message : 'Could not parse JSON.')
}
}
const createCategory = async () => {
setCategorySaving(true)
setCategoryError('')
try {
const response = await fetch(editorContext.categoryStoreUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({
...categoryDraft,
order_num: Number(categoryDraft.order_num || 0),
slug: categoryDraft.slug || slugifyLessonTitle(categoryDraft.name),
}),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
const firstError = payload?.errors ? Object.values(payload.errors)[0]?.[0] : null
throw new Error(firstError || payload?.message || 'Could not create category.')
}
const nextCategory = payload?.category
if (!nextCategory?.id) {
throw new Error('Category response was incomplete.')
}
setCategories((current) => [...current, nextCategory].sort((left, right) => {
if (left.order_num !== right.order_num) return Number(left.order_num || 0) - Number(right.order_num || 0)
return String(left.name || '').localeCompare(String(right.name || ''))
}))
form.setData('category_id', String(nextCategory.id))
setCategoryDraft({ type: 'lesson', name: '', slug: '', description: '', order_num: 0, active: true })
} catch (error) {
setCategoryError(error instanceof Error ? error.message : 'Could not create category.')
} finally {
setCategorySaving(false)
}
}
const comparisonBlocks = Array.isArray(form.data.blocks) ? form.data.blocks : []
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 lessons</Link>
<span>{destroyUrl ? 'Edit lesson' : 'New lesson'}</span>
</div>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy lesson'}</h1>
<p className="mt-2 max-w-3xl text-sm leading-7 text-slate-300">Use the same richer writing flow as the newsroom: drag in the cover, shape the article with the rich editor, and keep publishing details in the same place.</p>
</div>
<div className="flex flex-wrap gap-3">
<button type="button" onClick={() => setJsonImportOpen(true)} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Import JSON</button>
<button type="submit" disabled={form.processing} className="rounded-2xl border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save lesson'}</button>
</div>
</div>
</section>
<SectionNav items={LESSON_SECTION_NAV_ITEMS} />
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start">
<div className="min-w-0 space-y-6">
<SectionCard id="lesson-story-setup" eyebrow="Story setup" title="Headline and framing" description="Start with the lesson identity and summary, then move into the full article body." tone="feature">
<div className="grid gap-4 md:grid-cols-2">
<TextField
label="Title"
value={form.data.title}
onChange={(event) => form.setData('title', event.target.value)}
error={form.errors.title}
maxLength={180}
placeholder="Prompt engineering for cleaner scene direction"
/>
<label className="grid gap-2 text-sm text-slate-300">
<span className="flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
<span>Slug</span>
<button type="button" onClick={() => {
slugTouchedRef.current = false
form.setData('slug', slugifyLessonTitle(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"
placeholder="prompt-engineering-for-cleaner-scene-direction"
maxLength={180}
/>
<span className="text-xs leading-5 text-slate-500">The slug follows the title until you override it manually.</span>
<FieldError message={form.errors.slug} />
</label>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
<span>Excerpt</span>
<span>{excerptLength}/800</span>
</span>
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={5} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" placeholder="Summarize what the lesson teaches, why it matters, and what a creator will walk away with." />
<span className="text-xs leading-5 text-slate-500">This is the short summary used in cards, internal lists, and metadata surfaces.</span>
<FieldError message={form.errors.excerpt} />
</label>
</SectionCard>
<SectionCard id="lesson-body-editor" eyebrow="Main article" title="Lesson body editor" description="Write the tutorial in the same richer editing surface used for newsroom articles." actions={<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-white">{bodyWordCount.toLocaleString()} words</div>}>
<div className="grid min-w-0 gap-3 text-sm text-slate-300">
<RichTextEditor
content={form.data.content}
onChange={(nextValue) => form.setData('content', nextValue)}
placeholder="Open with the problem, explain the workflow step by step, and use headings, media, and links where the lesson needs structure."
error={form.errors.content}
minHeight={24}
autofocus={false}
advancedNews
mediaSupport={{
uploadUrl: editorContext.bodyMediaUploadUrl,
deleteUrl: editorContext.bodyMediaDeleteUrl,
assetsUrl: editorContext.bodyMediaAssetsUrl,
slot: 'body',
}}
/>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-400">
Tutorial workflow suggestion: define the outcome, break the process into clear steps, call out traps or quality checks, then finish with a practical next move.
</div>
</div>
</SectionCard>
<SectionCard
id="lesson-ai-comparisons"
eyebrow="Structured blocks"
title="AI model comparisons"
description="Add reusable same-prompt comparison blocks without burying the data inside the lesson HTML body."
actions={<button type="button" onClick={addComparisonBlock} className="rounded-full border border-[#ff9e8c]/25 bg-[#ff9e8c]/12 px-4 py-2 text-sm font-semibold text-[#ffd5cd]">+ Add AI Comparison</button>}
>
<div className="space-y-5">
{comparisonBlocks.length === 0 ? (
<div className="rounded-[24px] border border-dashed border-white/10 bg-black/20 px-5 py-6 text-sm leading-7 text-slate-400">
No comparison blocks yet. Add one when a lesson needs the same prompt analyzed across multiple AI image tools.
</div>
) : comparisonBlocks.map((block, blockIndex) => (
<div key={block.client_key} className="rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(255,158,140,0.12),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.82),rgba(6,10,18,0.95))] p-5 shadow-[0_24px_60px_rgba(2,6,23,0.22)]">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-white/10 pb-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-[#ffc0b4]">AI Model Comparison</p>
<h3 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{block.payload?.title || block.title || DEFAULT_AI_COMPARISON_TITLE}</h3>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => removeBlock(block.client_key)} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-3 py-1.5 text-xs font-semibold text-rose-100">Remove block</button>
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<TextField
label="Block title"
value={block.title}
onChange={(event) => updateBlock(block.client_key, (current) => ({
...current,
title: event.target.value,
payload: { ...current.payload, title: event.target.value },
}))}
error={getFormError(form.errors, `blocks.${blockIndex}.title`) || getFormError(form.errors, `blocks.${blockIndex}.payload.title`)}
placeholder={DEFAULT_AI_COMPARISON_TITLE}
/>
<TextField
label="Aspect ratio"
value={block.payload?.aspect_ratio}
onChange={(event) => updateBlock(block.client_key, (current) => ({ ...current, payload: { ...current.payload, aspect_ratio: event.target.value } }))}
error={getFormError(form.errors, `blocks.${blockIndex}.payload.aspect_ratio`)}
placeholder="16:9"
/>
</div>
<div className="mt-4 grid gap-4">
<TextAreaField
label="Intro"
value={block.payload?.intro}
onChange={(event) => updateBlock(block.client_key, (current) => ({ ...current, payload: { ...current.payload, intro: event.target.value } }))}
error={getFormError(form.errors, `blocks.${blockIndex}.payload.intro`)}
rows={3}
/>
<TextAreaField
label="Prompt"
value={block.payload?.prompt}
onChange={(event) => updateBlock(block.client_key, (current) => ({ ...current, payload: { ...current.payload, prompt: event.target.value } }))}
error={getFormError(form.errors, `blocks.${blockIndex}.payload.prompt`)}
rows={5}
/>
<TextAreaField
label="Negative prompt"
value={block.payload?.negative_prompt}
onChange={(event) => updateBlock(block.client_key, (current) => ({ ...current, payload: { ...current.payload, negative_prompt: event.target.value } }))}
error={getFormError(form.errors, `blocks.${blockIndex}.payload.negative_prompt`)}
rows={3}
/>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_200px_200px]">
<TextAreaField
label="Criteria (one per line)"
value={(block.payload?.criteria || []).join('\n')}
onChange={(event) => updateBlock(block.client_key, (current) => ({
...current,
payload: { ...current.payload, criteria: normalizeCriteria(String(event.target.value || '').split(/\r?\n/)) },
}))}
error={getFormError(form.errors, `blocks.${blockIndex}.payload.criteria`) || getFormError(form.errors, `blocks.${blockIndex}.payload.criteria.0`)}
rows={6}
hint="Composition, lighting, wallpaper quality, and similar criteria work well here."
/>
<TextField
label="Sort order"
value={block.sort_order}
onChange={(event) => updateBlock(block.client_key, (current) => ({ ...current, sort_order: event.target.value }))}
error={getFormError(form.errors, `blocks.${blockIndex}.sort_order`)}
type="number"
min="0"
/>
<ToggleField
label="Active"
checked={Boolean(block.active)}
onChange={(event) => updateBlock(block.client_key, (current) => ({ ...current, active: event.target.checked }))}
error={getFormError(form.errors, `blocks.${blockIndex}.active`)}
help="Inactive comparison blocks stay stored but are hidden on the public lesson page."
/>
</div>
<div className="mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Results</p>
<p className="mt-1 text-sm text-slate-400">Add one card per tool or model you want to compare.</p>
</div>
<button type="button" onClick={() => addComparisonResult(block.client_key)} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100">+ Add model result</button>
</div>
<div className="mt-4 space-y-4">
{(block.comparison_results || []).length === 0 ? (
<div className="rounded-[22px] border border-dashed border-white/10 bg-white/[0.03] px-4 py-5 text-sm text-slate-500">No model results yet.</div>
) : (block.comparison_results || []).map((result, resultIndex) => (
<div key={result.client_key} className="rounded-[24px] border border-white/10 bg-slate-950/70 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{result.model_name || result.provider || `Model result ${resultIndex + 1}`}</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">Comparison card</div>
</div>
<button type="button" onClick={() => removeComparisonResult(block.client_key, result.client_key)} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-3 py-1.5 text-xs font-semibold text-rose-100">Remove</button>
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-2">
<WorldMediaUploadField
label="Generated image"
slot="body"
value={result.image_path}
previewUrl={result.image_url}
emptyLabel="Generated image"
helperText="Upload the main comparison image using the same Academy lesson media pipeline."
uploadUrl={editorContext.bodyMediaUploadUrl}
deleteUrl={editorContext.bodyMediaDeleteUrl}
isTemporaryValue={Boolean(result.image_temp_path) && result.image_temp_path === result.image_path}
onChange={({ path, url }) => updateComparisonResult(block.client_key, result.client_key, (current) => ({
...current,
image_path: path || '',
image_url: url || normalizeCoverPreview(path || '', cdnBaseUrl),
image_temp_path: path || '',
}))}
/>
<WorldMediaUploadField
label="Thumbnail image"
slot="body"
value={result.thumb_path}
previewUrl={result.thumb_url}
emptyLabel="Thumbnail"
helperText="Optional smaller variant. Leave empty to reuse the main image on the public lesson page."
uploadUrl={editorContext.bodyMediaUploadUrl}
deleteUrl={editorContext.bodyMediaDeleteUrl}
isTemporaryValue={Boolean(result.thumb_temp_path) && result.thumb_temp_path === result.thumb_path}
onChange={({ path, url }) => updateComparisonResult(block.client_key, result.client_key, (current) => ({
...current,
thumb_path: path || '',
thumb_url: url || normalizeCoverPreview(path || '', cdnBaseUrl),
thumb_temp_path: path || '',
}))}
/>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
<TextField
label="Provider"
value={result.provider}
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, provider: event.target.value }))}
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.provider`)}
placeholder="OpenAI"
/>
<TextField
label="Model"
value={result.model_name}
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, model_name: event.target.value }))}
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.model_name`)}
placeholder="ChatGPT Images"
/>
<TextField
label="Score"
value={result.score}
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, score: event.target.value }))}
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.score`)}
type="number"
min="1"
max="10"
/>
<TextField
label="Sort order"
value={result.sort_order}
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, sort_order: event.target.value }))}
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.sort_order`)}
type="number"
min="0"
/>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<TextAreaField
label="Settings"
value={result.settings}
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, settings: event.target.value }))}
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.settings`)}
rows={3}
/>
<TextAreaField
label="Strengths"
value={result.strengths}
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, strengths: event.target.value }))}
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.strengths`)}
rows={3}
/>
<TextAreaField
label="Weaknesses"
value={result.weaknesses}
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, weaknesses: event.target.value }))}
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.weaknesses`)}
rows={3}
/>
<TextAreaField
label="Best for"
value={result.best_for}
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, best_for: event.target.value }))}
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.best_for`)}
rows={3}
/>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<TextField
label="Image path override"
value={result.image_path}
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({
...current,
image_path: event.target.value,
image_url: normalizeCoverPreview(event.target.value, cdnBaseUrl),
image_temp_path: '',
}))}
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.image_path`)}
placeholder="academy/lessons/body/..."
/>
<TextField
label="Thumbnail path override"
value={result.thumb_path}
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({
...current,
thumb_path: event.target.value,
thumb_url: normalizeCoverPreview(event.target.value, cdnBaseUrl),
thumb_temp_path: '',
}))}
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.thumb_path`)}
placeholder="Optional academy/lessons/body/..."
/>
</div>
<div className="mt-4">
<ToggleField
label="Result active"
checked={Boolean(result.active)}
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, active: event.target.checked }))}
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.active`)}
help="Inactive results stay saved but do not render on the public lesson page."
/>
</div>
</div>
))}
</div>
</div>
</div>
))}
</div>
</SectionCard>
<SectionCard id="lesson-publishing" eyebrow="Publishing" title="Placement and visibility" description="Set the lesson metadata, schedule, and discovery fields before it goes live.">
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2 text-sm text-slate-300">
<NovaSelect label="Difficulty" value={form.data.difficulty || ''} onChange={(nextValue) => form.setData('difficulty', String(nextValue || ''))} options={difficultyField?.options || []} searchable={false} className="bg-black/20" error={form.errors.difficulty} />
</div>
<div className="grid gap-2 text-sm text-slate-300">
<NovaSelect 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} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<TextField label="Lesson type" value={form.data.lesson_type} onChange={(event) => form.setData('lesson_type', event.target.value)} error={form.errors.lesson_type} placeholder="article, video, walkthrough" />
<TextField label="Reading minutes" value={form.data.reading_minutes} onChange={(event) => form.setData('reading_minutes', event.target.value)} error={form.errors.reading_minutes} type="number" min="1" max="999" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2 text-sm text-slate-300">
<NovaSelect label="Category" value={form.data.category_id || ''} onChange={(nextValue) => form.setData('category_id', String(nextValue || ''))} options={categoryOptions} searchable={false} className="bg-black/20" error={form.errors.category_id} />
</div>
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span>
<DateTimePicker value={form.data.published_at || ''} onChange={(nextValue) => form.setData('published_at', nextValue || '')} clearable className="bg-black/20" />
<FieldError message={form.errors.published_at} />
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ToggleField label="Featured" checked={Boolean(form.data.featured)} onChange={(event) => form.setData('featured', event.target.checked)} help="Highlight this lesson in featured academy surfaces." error={form.errors.featured} />
<ToggleField label="Active" checked={Boolean(form.data.active)} onChange={(event) => form.setData('active', event.target.checked)} help="Keep inactive lessons hidden until the draft is ready." error={form.errors.active} />
</div>
</SectionCard>
<SectionCard id="lesson-seo" eyebrow="SEO" title="Search metadata" description="Keep the lesson search-ready without stuffing the headline.">
<div className="grid gap-4 md:grid-cols-2">
<TextField label="SEO title" value={form.data.seo_title} onChange={(event) => form.setData('seo_title', event.target.value)} error={form.errors.seo_title} maxLength={180} placeholder="Optional search title" />
<TextField label="Video URL" value={form.data.video_url} onChange={(event) => form.setData('video_url', event.target.value)} error={form.errors.video_url} placeholder="Optional lesson video URL" />
</div>
<TextAreaField label="SEO description" value={form.data.seo_description} onChange={(event) => form.setData('seo_description', event.target.value)} error={form.errors.seo_description} rows={4} hint="Keep this tighter than the excerpt and focused on search intent." />
</SectionCard>
<SectionCard id="lesson-categories" eyebrow="Lesson categories" title="Create category inline" description="Add lesson categories without leaving the writing flow." actions={<a href={editorContext.categoryManageUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Manage all categories</a>}>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="grid gap-3">
<div className="grid gap-3 md:grid-cols-2">
<input value={categoryDraft.name} onChange={(event) => setCategoryDraft((current) => ({ ...current, name: event.target.value }))} placeholder="Category name" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input value={categoryDraft.slug} onChange={(event) => setCategoryDraft((current) => ({ ...current, slug: event.target.value }))} placeholder="Optional slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</div>
<textarea value={categoryDraft.description} onChange={(event) => setCategoryDraft((current) => ({ ...current, description: event.target.value }))} rows={3} placeholder="Description" className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="flex flex-wrap items-center gap-3">
<input type="number" value={categoryDraft.order_num} min="0" onChange={(event) => setCategoryDraft((current) => ({ ...current, order_num: event.target.value }))} className="w-28 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<label className="flex items-center gap-2 text-sm text-slate-300"><input type="checkbox" checked={categoryDraft.active} onChange={(event) => setCategoryDraft((current) => ({ ...current, active: event.target.checked }))} /> Active</label>
<button type="button" onClick={() => void createCategory()} disabled={categorySaving} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">{categorySaving ? 'Creating...' : 'Create category'}</button>
</div>
{categoryError ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{categoryError}</div> : null}
</div>
<div className="grid gap-3">
{(categories || []).length === 0 ? <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-500">No lesson categories yet.</div> : categories.map((category) => (
<div key={category.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{category.name}</div>
<div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{category.slug}</div>
{category.description ? <p className="mt-2 text-sm leading-6 text-slate-400">{category.description}</p> : null}
</div>
<a href={category.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">Edit</a>
</div>
</div>
))}
</div>
</div>
</SectionCard>
</div>
<div className="space-y-6 xl:sticky xl:top-6 xl:self-start">
<SectionCard id="lesson-cover" eyebrow="Cover image" title="Hero asset" description="Use drag and drop for the lesson image, or paste a direct URL when you already have one.">
<div className="grid gap-4">
<WorldMediaUploadField
label="Lesson cover"
slot="cover"
value={form.data.cover_image}
previewUrl={coverPreviewUrl}
emptyLabel="Drop a lesson cover"
helperText="Upload the hero image directly to object storage. A wide landscape image works best for academy cards, previews, and social sharing."
uploadUrl={editorContext.coverUploadUrl}
deleteUrl={editorContext.coverDeleteUrl}
onChange={({ path, url }) => {
setStagedCoverPath(path || '')
form.setData('cover_image', path || '')
setCoverPreviewUrl(url || '')
}}
isTemporaryValue={Boolean(stagedCoverPath) && form.data.cover_image === stagedCoverPath}
/>
<FieldError message={form.errors.cover_image} />
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced cover path or URL</span>
<input value={form.data.cover_image} onChange={(event) => handleManualCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Keep this for migrations, imported lessons, or when you already know the exact asset path to use.</span>
</label>
</div>
</SectionCard>
<SectionCard id="lesson-preview" eyebrow="Preview" title="Lesson snapshot" description="A quick view of what editors and visitors will scan first.">
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/30">
{coverPreviewUrl ? (
<img src={coverPreviewUrl} alt="Lesson cover preview" className="h-56 w-full object-cover" />
) : (
<div className="flex h-56 items-center justify-center px-6 text-center text-sm text-slate-500">No cover image selected yet.</div>
)}
</div>
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Lesson summary</p>
<h3 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white">{form.data.title || 'Untitled lesson'}</h3>
<p className="mt-2 text-sm leading-7 text-slate-400">{form.data.excerpt || 'Add a concise excerpt to frame the lesson before someone opens it.'}</p>
<dl className="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-400">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Difficulty</dt><dd className="mt-1 text-sm text-white">{form.data.difficulty || ''}</dd></div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Access</dt><dd className="mt-1 text-sm text-white">{form.data.access_level || ''}</dd></div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Reading</dt><dd className="mt-1 text-sm text-white">{form.data.reading_minutes || ''} min</dd></div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Body</dt><dd className="mt-1 text-sm text-white">{bodyWordCount.toLocaleString()} words</dd></div>
</dl>
</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 lesson'}</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={deleteLesson} 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>
<JsonImportDialog
open={jsonImportOpen}
value={jsonImportValue}
error={jsonImportError}
onChange={(nextValue) => {
setJsonImportValue(nextValue)
if (jsonImportError) {
setJsonImportError('')
}
}}
onClose={() => {
setJsonImportOpen(false)
setJsonImportError('')
}}
onApply={applyJsonImport}
/>
</AdminLayout>
)
}