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

2119 lines
120 KiB
JavaScript

import React, { startTransition, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Head, Link, router, useForm } from '@inertiajs/react'
import { marked } from 'marked'
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'
let lessonMarkdownTurndown = null
let lessonMarkdownTurndownPromise = null
async function loadLessonMarkdownTurndown() {
if (lessonMarkdownTurndown) {
return lessonMarkdownTurndown
}
if (typeof window === 'undefined') {
return null
}
if (!lessonMarkdownTurndownPromise) {
lessonMarkdownTurndownPromise = import('turndown')
.then(({ default: TurndownService }) => new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
bulletListMarker: '-',
emDelimiter: '*',
}))
.then((service) => {
lessonMarkdownTurndown = service
return service
})
.catch(() => null)
}
return lessonMarkdownTurndownPromise
}
function getField(fields, name) {
return fields.find((field) => field.name === name) || null
}
const LESSON_EDITOR_TABS = [
{
id: 'write',
label: 'Write',
description: 'Headline, summary, and the full lesson article.',
icon: 'fa-pen-nib',
sections: ['lesson-story-setup', 'lesson-body-editor'],
},
{
id: 'blocks',
label: 'Blocks',
description: 'Reusable AI comparison modules and structured lesson inserts.',
icon: 'fa-layer-group',
sections: ['lesson-ai-comparisons'],
},
{
id: 'courses',
label: 'Courses',
description: 'Attach this lesson to courses, manage its public numbering, and reorder it inside guided paths.',
icon: 'fa-diagram-project',
sections: ['lesson-course-numbering', 'lesson-course-manager'],
},
{
id: 'publish',
label: 'Publish',
description: 'Visibility, discovery settings, scheduling, and search surfaces.',
icon: 'fa-rocket-launch',
sections: ['lesson-publishing', 'lesson-seo'],
},
{
id: 'assets',
label: 'Assets',
description: 'Categories, hero media, and article imagery.',
icon: 'fa-images',
sections: ['lesson-categories', 'lesson-cover', 'lesson-article-cover'],
},
{
id: 'revisions',
label: 'Revisions',
description: 'Review saved lesson snapshots and restore the full lesson or a single field.',
icon: 'fa-clock-rotate-left',
sections: ['lesson-revisions'],
},
{
id: 'preview',
label: 'Preview',
description: 'Preview the lesson card, article imagery, and rendered body.',
icon: 'fa-eye',
sections: ['lesson-preview'],
},
]
const LESSON_FIELD_TAB_MAP = {
title: 'write',
slug: 'write',
excerpt: 'write',
content: 'write',
content_markdown: 'write',
lesson_type: 'publish',
difficulty: 'publish',
access_level: 'publish',
reading_minutes: 'publish',
tags: 'publish',
series_name: 'publish',
lesson_number: 'courses',
course_order: 'courses',
course_ids: 'courses',
category_id: 'assets',
published_at: 'publish',
featured: 'publish',
active: 'publish',
seo_title: 'publish',
seo_description: 'publish',
video_url: 'publish',
cover_image: 'assets',
article_cover_image: 'assets',
}
const LESSON_REVISION_FIELD_OPTIONS = [
{ value: 'title', label: 'Title' },
{ value: 'slug', label: 'Slug' },
{ value: 'lesson_number', label: 'Lesson number' },
{ value: 'course_order', label: 'Course order' },
{ value: 'series_name', label: 'Series name' },
{ value: 'excerpt', label: 'Excerpt' },
{ value: 'content', label: 'Article body' },
{ value: 'difficulty', label: 'Difficulty' },
{ value: 'access_level', label: 'Access level' },
{ value: 'lesson_type', label: 'Lesson type' },
{ value: 'cover_image', label: 'Cover image' },
{ value: 'article_cover_image', label: 'Article cover image' },
{ value: 'tags', label: 'Microtags' },
{ value: 'video_url', label: 'Video URL' },
{ value: 'reading_minutes', label: 'Reading minutes' },
{ value: 'featured', label: 'Featured toggle' },
{ value: 'active', label: 'Active toggle' },
{ value: 'published_at', label: 'Publish date' },
{ value: 'seo_title', label: 'SEO title' },
{ value: 'seo_description', label: 'SEO description' },
{ value: 'course_ids', label: 'Course attachments' },
{ value: 'blocks', label: 'AI comparison blocks' },
]
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', className = '', contentClassName = '' }) {
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} ${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 ${contentClassName}`.trim()}>{children}</div>
</section>
)
}
function EditorWorkspaceTabs({ tabs, activeTab, onChange, errorCounts }) {
const activeMeta = tabs.find((tab) => tab.id === activeTab) || 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="Lesson editor sections">
{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={`lesson-editor-panel-${tab.id}`}
id={`lesson-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('lesson-', '').replace(/-/g, ' ')}</span>
))}
</div>
</div>
</div>
)
}
function firstLessonErrorTab(errors) {
const firstKey = Object.keys(errors || {})[0]
if (!firstKey) return null
if (firstKey.startsWith('blocks.')) return 'blocks'
return LESSON_FIELD_TAB_MAP[firstKey] || null
}
function lessonTabErrorCounts(errors) {
const counts = {}
Object.keys(errors || {}).forEach((key) => {
const tabId = key.startsWith('blocks.') ? 'blocks' : LESSON_FIELD_TAB_MAP[key]
if (!tabId) return
counts[tabId] = Number(counts[tabId] || 0) + 1
})
return counts
}
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 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 formatMissingNumbers(values) {
const items = Array.isArray(values) ? values.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value > 0) : []
if (items.length === 0) return 'No gaps right now.'
return items.join(', ')
}
function formatCourseStep(orderNum) {
const numeric = Number(orderNum)
if (!Number.isFinite(numeric) || numeric < 0) return null
return `Step ${String(numeric + 1).padStart(2, '0')}`
}
function normalizeCourseManagerLessons(lessons) {
return (Array.isArray(lessons) ? [...lessons] : [])
.sort((left, right) => {
const orderDiff = Number(left?.order_num || 0) - Number(right?.order_num || 0)
if (orderDiff !== 0) return orderDiff
return Number(left?.id || 0) - Number(right?.id || 0)
})
.map((lesson, index) => ({
...lesson,
order_num: index,
display_order: index + 1,
}))
}
function reorderCourseManagerLessons(lessons, draggedLessonId, targetLessonId) {
const current = normalizeCourseManagerLessons(lessons)
const draggedIndex = current.findIndex((lesson) => Number(lesson.id) === Number(draggedLessonId))
const targetIndex = current.findIndex((lesson) => Number(lesson.id) === Number(targetLessonId))
if (draggedIndex === -1 || targetIndex === -1 || draggedIndex === targetIndex) {
return current
}
const nextLessons = [...current]
const [draggedLesson] = nextLessons.splice(draggedIndex, 1)
nextLessons.splice(targetIndex, 0, draggedLesson)
return normalizeCourseManagerLessons(nextLessons)
}
function moveCourseManagerLesson(lessons, lessonId, direction) {
const current = normalizeCourseManagerLessons(lessons)
const lessonIndex = current.findIndex((lesson) => Number(lesson.id) === Number(lessonId))
const nextIndex = lessonIndex + direction
if (lessonIndex === -1 || nextIndex < 0 || nextIndex >= current.length) {
return current
}
const nextLessons = [...current]
const [movedLesson] = nextLessons.splice(lessonIndex, 1)
nextLessons.splice(nextIndex, 0, movedLesson)
return normalizeCourseManagerLessons(nextLessons)
}
function courseManagerSignature(lessons) {
return JSON.stringify(normalizeCourseManagerLessons(lessons).map((lesson) => ({
id: Number(lesson.id),
order_num: Number(lesson.order_num || 0),
section_id: lesson.section_id == null ? null : Number(lesson.section_id),
})))
}
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 stripMarkdown(value) {
return String(value || '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^\s{0,3}>\s?/gm, '')
.replace(/^\s*([-*+]|\d+\.)\s+/gm, '')
.replace(/\*\*|__|\*|_|~~/g, '')
.replace(/^\s*\|/gm, ' ')
.replace(/\|/g, ' ')
.replace(/^\s*[-:]{3,}\s*$/gm, ' ')
}
function convertLessonMarkdownToHtml(value) {
const markdown = String(value || '').trim()
if (!markdown) return ''
return String(marked.parse(markdown, {
async: false,
gfm: true,
breaks: false,
}) || '').trim()
}
function convertLessonHtmlToMarkdown(value) {
const html = String(value || '').trim()
if (!html) return ''
if (!lessonMarkdownTurndown) {
return stripHtml(html)
}
return lessonMarkdownTurndown.turndown(html).trim()
}
function countWords(value) {
const text = stripMarkdown(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 || ''),
lesson_number: data.lesson_number === '' || data.lesson_number == null ? '' : Number(data.lesson_number),
course_order: data.course_order === '' || data.course_order == null ? '' : Number(data.course_order),
course_ids: Array.isArray(data.course_ids) ? data.course_ids.map((courseId) => Number(courseId)).filter((courseId) => Number.isInteger(courseId) && courseId > 0) : [],
series_name: String(data.series_name || ''),
excerpt: String(data.excerpt || ''),
content: String(data.content || ''),
content_markdown: String(data.content_markdown || ''),
content_source: String(data.content_source || 'html'),
difficulty: String(data.difficulty || ''),
access_level: String(data.access_level || ''),
lesson_type: String(data.lesson_type || ''),
cover_image: String(data.cover_image || ''),
article_cover_image: String(data.article_cover_image || ''),
tags: String(data.tags || ''),
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.lesson_number != null) apply('lesson_number', String(parsed.lesson_number))
if (parsed.course_order != null) apply('course_order', String(parsed.course_order))
if (parsed.series_name != null) apply('series_name', String(parsed.series_name))
if (parsed.excerpt != null) apply('excerpt', String(parsed.excerpt))
if (parsed.content_markdown != null) apply('content_markdown', String(parsed.content_markdown))
if (parsed.markdown != null && parsed.content_markdown == null) apply('content_markdown', String(parsed.markdown))
if (parsed.md != null && parsed.content_markdown == null && parsed.markdown == null) apply('content_markdown', String(parsed.md))
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.article_cover_image != null) apply('article_cover_image', String(parsed.article_cover_image))
if (parsed.article_cover != null && parsed.article_cover_image == null) apply('article_cover_image', String(parsed.article_cover))
if (parsed.article_cover_url != null && parsed.article_cover_image == null && parsed.article_cover == null) apply('article_cover_image', String(parsed.article_cover_url))
if (parsed.tags != null) apply('tags', Array.isArray(parsed.tags) ? parsed.tags.join(', ') : String(parsed.tags))
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>lesson_number, course_order, series_name</p>
<p>content_markdown, markdown, md</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</p>
<p>article_cover_image, article_cover, article_cover_url</p>
<p>tags</p>
<p>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,
)
}
function MarkdownImportDialog({ 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-4xl 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">Markdown Import</p>
<h3 className="mt-2 text-lg font-semibold text-white">Paste lesson Markdown</h3>
<p className="mt-2 text-sm leading-6 text-white/65">Paste Markdown here and apply it to regenerate the lesson HTML. This overwrites the current article body.</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={18}
placeholder={'## Introduction\n\nPaste Markdown here to regenerate the lesson body.'}
spellCheck={false}
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">Result</div>
<div className="mt-3 space-y-2 leading-6 text-slate-400">
<p>The Markdown is stored in <span className="font-mono text-slate-200">content_markdown</span>.</p>
<p>The HTML editor is regenerated from it.</p>
<p>The visual lesson preview updates immediately.</p>
<p>Use the HTML button inside the editor for source-level HTML edits.</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 Markdown</button>
</div>
</div>
</div>,
document.body,
)
}
export default function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext = {} }) {
const recordContentMarkdown = String(record.content_markdown || '')
const recordContentHtml = String(record.content || '')
const cdnBaseUrl = editorContext.coverCdnBaseUrl || ''
const initialContentMarkdown = recordContentMarkdown.trim() !== ''
? recordContentMarkdown
: ''
const form = useForm({
...record,
content: String(record.content || convertLessonMarkdownToHtml(initialContentMarkdown) || ''),
content_markdown: initialContentMarkdown,
content_source: recordContentMarkdown.trim() !== '' ? 'markdown' : 'html',
lesson_number: record.lesson_number === '' || record.lesson_number == null ? '' : String(record.lesson_number),
course_order: record.course_order === '' || record.course_order == null ? '' : String(record.course_order),
series_name: String(record.series_name || ''),
article_cover_image: String(record.article_cover_image || ''),
course_ids: Array.isArray(record.course_ids) ? record.course_ids.map((courseId) => String(courseId)) : [],
tags: String(record.tags || ''),
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 [articleCoverPreviewUrl, setArticleCoverPreviewUrl] = useState(record.article_cover_image_url || normalizeCoverPreview(record.article_cover_image, editorContext.coverCdnBaseUrl))
const [stagedArticleCoverPath, setStagedArticleCoverPath] = 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 [markdownImportOpen, setMarkdownImportOpen] = useState(false)
const [markdownImportValue, setMarkdownImportValue] = useState('')
const [markdownImportError, setMarkdownImportError] = useState('')
const [categoryError, setCategoryError] = useState('')
const [categorySaving, setCategorySaving] = useState(false)
const [isBrowserFullscreen, setIsBrowserFullscreen] = useState(() => typeof document !== 'undefined' && Boolean(document.fullscreenElement))
const [isEditorFullHeight, setIsEditorFullHeight] = useState(false)
const [activeTab, setActiveTab] = useState('write')
const [categoryDraft, setCategoryDraft] = useState({ type: 'lesson', name: '', slug: '', description: '', order_num: 0, active: true })
const slugTouchedRef = useRef(Boolean(String(record.slug || '').trim()))
const lessonNumberAutofillRef = useRef(false)
const courseOrderAutofillRef = useRef(false)
const difficultyField = useMemo(() => getField(fields, 'difficulty'), [fields])
const accessField = useMemo(() => getField(fields, 'access_level'), [fields])
const bodyWordCount = useMemo(() => countWords(form.data.content_markdown || form.data.content), [form.data.content, form.data.content_markdown])
const estimatedReadingMinutes = useMemo(() => Math.max(1, Math.ceil(bodyWordCount / 180)), [bodyWordCount])
const excerptLength = String(form.data.excerpt || '').length
const deferredArticlePreviewHtml = useDeferredValue(form.data.content || '')
const tabErrorCounts = useMemo(() => lessonTabErrorCounts(form.errors), [form.errors])
const numberingContext = useMemo(() => editorContext.numbering || {}, [editorContext.numbering])
const courseOptions = useMemo(() => Array.isArray(editorContext.courses) ? editorContext.courses : [], [editorContext.courses])
const selectedCourses = useMemo(() => {
const selectedIds = new Set((Array.isArray(form.data.course_ids) ? form.data.course_ids : []).map((courseId) => String(courseId)))
return courseOptions.filter((course) => selectedIds.has(String(course.value)))
}, [courseOptions, form.data.course_ids])
const currentLessonId = useMemo(() => Number(editorContext.currentLessonId || 0), [editorContext.currentLessonId])
const [courseManagerDrafts, setCourseManagerDrafts] = useState({})
const [draggedCourseLesson, setDraggedCourseLesson] = useState(null)
const [courseSaveProcessing, setCourseSaveProcessing] = useState({})
const revisions = useMemo(() => Array.isArray(editorContext.revisions) ? editorContext.revisions : [], [editorContext.revisions])
const [revisionFieldSelections, setRevisionFieldSelections] = useState({})
const csrfToken = useMemo(() => {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}, [])
const handleMarkdownContentChange = (nextMarkdown) => {
const nextHtml = convertLessonMarkdownToHtml(nextMarkdown)
startTransition(() => {
form.setData('content_source', 'markdown')
form.setData('content_markdown', nextMarkdown)
form.setData('content', nextHtml)
})
}
const handleRichContentChange = (nextHtml) => {
startTransition(() => {
form.setData('content', nextHtml)
if (form.data.content_source === 'markdown') {
form.setData('content_markdown', convertLessonHtmlToMarkdown(nextHtml))
return
}
form.setData('content_markdown', '')
})
}
useEffect(() => {
void loadLessonMarkdownTurndown()
}, [])
useEffect(() => {
if (slugTouchedRef.current) return
form.setData('slug', slugifyLessonTitle(form.data.title))
}, [form.data.title])
useEffect(() => {
if (typeof document === 'undefined') return undefined
const handleFullscreenChange = () => {
setIsBrowserFullscreen(Boolean(document.fullscreenElement))
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
}, [])
useEffect(() => {
if (typeof document === 'undefined') return undefined
const previousOverflow = document.body.style.overflow
if (isEditorFullHeight) {
document.body.style.overflow = 'hidden'
}
return () => {
document.body.style.overflow = previousOverflow
}
}, [isEditorFullHeight])
useEffect(() => {
const nextTab = firstLessonErrorTab(form.errors)
if (!nextTab) return
setActiveTab(nextTab)
}, [form.errors])
useEffect(() => {
setCourseManagerDrafts(Object.fromEntries(selectedCourses.map((course) => [String(course.value), normalizeCourseManagerLessons(course.lessons)])))
}, [selectedCourses])
const categoryOptions = useMemo(() => {
const next = categories.map((category) => ({ value: String(category.id), label: category.name }))
return [{ value: '', label: 'No category' }, ...next]
}, [categories])
useEffect(() => {
if (method !== 'post' || lessonNumberAutofillRef.current) return
if (String(form.data.lesson_number || '').trim() !== '') return
const suggested = Number(numberingContext?.lesson_number?.suggested || 0)
if (!Number.isInteger(suggested) || suggested < 1) return
lessonNumberAutofillRef.current = true
form.setData('lesson_number', String(suggested))
}, [form, form.data.lesson_number, method, numberingContext])
useEffect(() => {
if (method !== 'post' || courseOrderAutofillRef.current) return
if (String(form.data.course_order || '').trim() !== '') return
const suggested = Number(numberingContext?.course_order?.suggested || 0)
if (!Number.isInteger(suggested) || suggested < 1) return
courseOrderAutofillRef.current = true
form.setData('course_order', String(suggested))
}, [form, form.data.course_order, method, numberingContext])
useEffect(() => {
const nextValue = String(estimatedReadingMinutes)
if (String(form.data.reading_minutes || '') === nextValue) return
form.setData('reading_minutes', nextValue)
}, [estimatedReadingMinutes, form, form.data.reading_minutes])
const handleManualCoverChange = (nextValue) => {
form.setData('cover_image', nextValue)
setCoverPreviewUrl(normalizeCoverPreview(nextValue, editorContext.coverCdnBaseUrl))
}
const handleManualArticleCoverChange = (nextValue) => {
form.setData('article_cover_image', nextValue)
setArticleCoverPreviewUrl(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 updateCourseDraft = (courseId, nextLessons) => {
setCourseManagerDrafts((current) => ({
...current,
[String(courseId)]: normalizeCourseManagerLessons(nextLessons),
}))
}
const attachLessonToCourseNow = (course) => {
if (!currentLessonId) return
router.post(course.attach_url, {
lesson_id: currentLessonId,
order_num: Number(course.next_order_num || 0),
is_required: true,
}, {
preserveScroll: true,
onSuccess: () => form.setData('course_ids', Array.from(new Set([...(Array.isArray(form.data.course_ids) ? form.data.course_ids : []), String(course.value)]))),
})
}
const detachLessonFromCourseNow = (course, courseLesson) => {
router.delete(courseLesson.destroy_url, {
preserveScroll: true,
onSuccess: () => form.setData('course_ids', (Array.isArray(form.data.course_ids) ? form.data.course_ids : []).filter((courseId) => String(courseId) !== String(course.value))),
})
}
const saveCourseDraftOrder = (course) => {
const nextLessons = normalizeCourseManagerLessons(courseManagerDrafts[String(course.value)] || course.lessons)
setCourseSaveProcessing((current) => ({ ...current, [String(course.value)]: true }))
router.patch(course.reorder_url, {
sections: [],
lessons: nextLessons.map((lesson) => ({
id: lesson.id,
order_num: lesson.order_num,
section_id: lesson.section_id,
})),
}, {
preserveScroll: true,
onFinish: () => setCourseSaveProcessing((current) => ({ ...current, [String(course.value)]: false })),
})
}
const applyJsonImport = () => {
try {
const parsed = parseLessonImport(jsonImportValue, categories)
Object.entries(parsed.next).forEach(([key, value]) => {
if (key === 'content_markdown') {
handleMarkdownContentChange(String(value || ''))
return
}
if (key === 'content') {
handleRichContentChange(String(value || ''))
return
}
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 openMarkdownImport = () => {
setMarkdownImportValue(String(form.data.content_markdown || ''))
setMarkdownImportError('')
setMarkdownImportOpen(true)
}
const applyMarkdownImport = () => {
const nextMarkdown = String(markdownImportValue || '').trim()
if (nextMarkdown === '') {
setMarkdownImportError('Paste Markdown before applying it to the lesson body.')
return
}
handleMarkdownContentChange(nextMarkdown)
setMarkdownImportError('')
setMarkdownImportOpen(false)
}
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 : []
const toggleBrowserFullscreen = async () => {
if (typeof document === 'undefined') return
try {
if (document.fullscreenElement) {
await document.exitFullscreen()
return
}
await document.documentElement.requestFullscreen()
} catch {
// Ignore browsers that deny fullscreen from this context.
}
}
const bodyEditorActions = (
<>
<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>
<button
type="button"
onClick={openMarkdownImport}
className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]"
>
Import Markdown
</button>
<button
type="button"
onClick={() => setIsEditorFullHeight((current) => !current)}
className={`rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] transition ${isEditorFullHeight ? 'border-sky-300/35 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]'}`}
>
{isEditorFullHeight ? 'Normal height' : 'Full-height editor'}
</button>
<button
type="button"
onClick={() => void toggleBrowserFullscreen()}
className={`rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] transition ${isBrowserFullscreen ? 'border-[#ff9e8c]/35 bg-[#ff9e8c]/12 text-[#ffd5cd]' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]'}`}
>
{isBrowserFullscreen ? 'Exit browser fullscreen' : 'Browser fullscreen'}
</button>
</>
)
const visibleSections = useMemo(() => new Set((LESSON_EDITOR_TABS.find((tab) => tab.id === activeTab)?.sections) || []), [activeTab])
const isSectionVisible = (sectionId) => visibleSections.has(sectionId)
const activeTabMeta = useMemo(() => LESSON_EDITOR_TABS.find((tab) => tab.id === activeTab) || LESSON_EDITOR_TABS[0], [activeTab])
const sectionClassName = (sectionId, className = '') => `${isSectionVisible(sectionId) ? '' : 'hidden'} ${className}`.trim()
const showWriteCompanion = activeTab === 'write' && !isEditorFullHeight
const showSupportRail = showWriteCompanion || isSectionVisible('lesson-preview')
const lessonStatusItems = [
{ label: 'Title', ready: String(form.data.title || '').trim() !== '' },
{ label: 'Excerpt', ready: String(form.data.excerpt || '').trim() !== '' },
{ label: 'Body', ready: String(form.data.content || form.data.content_markdown || '').trim() !== '' },
{ label: 'Slug', ready: String(form.data.slug || '').trim() !== '' },
]
const lessonPathPreview = form.data.slug ? `/academy/${form.data.slug}` : '/academy/lesson-slug'
const restoreLessonRevision = (revision, field = '') => {
if (!revision?.restore_url) return
const message = field
? `Restore ${LESSON_REVISION_FIELD_OPTIONS.find((option) => option.value === field)?.label || field} from revision #${revision.id}? A new safety revision will be created first.`
: `Restore the full lesson from revision #${revision.id}? A new safety revision will be created first.`
if (!window.confirm(message)) return
router.post(revision.restore_url, field ? { field } : {}, { preserveScroll: true })
}
return (
<AdminLayout title={title} subtitle={subtitle}>
<Head title={`Admin · ${title}`} />
{isEditorFullHeight ? <div className="fixed inset-0 z-[110] bg-[#02040add]/90 backdrop-blur-md" aria-hidden="true" /> : null}
<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>
<EditorWorkspaceTabs tabs={LESSON_EDITOR_TABS} activeTab={activeTab} onChange={setActiveTab} errorCounts={tabErrorCounts} />
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.14)]">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Current workspace</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{activeTabMeta.label}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">{activeTabMeta.description}</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Words</div>
<div className="mt-1 text-lg font-semibold text-white">{bodyWordCount.toLocaleString()}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Excerpt</div>
<div className="mt-1 text-lg font-semibold text-white">{excerptLength}/800</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Errors</div>
<div className="mt-1 text-lg font-semibold text-white">{Object.keys(form.errors || {}).length}</div>
</div>
</div>
</div>
</section>
<div className={showSupportRail ? 'grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start' : 'grid gap-6'}>
<div className="min-w-0 space-y-6" role="tabpanel" id={`lesson-editor-panel-${activeTab}`} aria-labelledby={`lesson-editor-tab-${activeTab}`}>
{activeTab === 'preview' ? (
<SectionCard eyebrow="Preview mode" title="Rendered lesson review" description="Use this tab to scan the public-facing lesson card, article imagery, and rendered article body without the rest of the form in the way." tone="feature">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Hero image</p>
<p className="mt-2 text-sm leading-6 text-slate-400">{coverPreviewUrl ? 'Ready' : 'Missing'} hero artwork for lesson cards and social previews.</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Article image</p>
<p className="mt-2 text-sm leading-6 text-slate-400">{articleCoverPreviewUrl ? 'Ready' : 'Missing'} inline article cover shown before the lesson body.</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Body length</p>
<p className="mt-2 text-sm leading-6 text-slate-400">{bodyWordCount.toLocaleString()} words currently in the lesson body.</p>
</div>
</div>
</SectionCard>
) : null}
<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" className={sectionClassName('lesson-story-setup')}>
<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={bodyEditorActions}
className={sectionClassName('lesson-body-editor', isEditorFullHeight ? 'fixed inset-4 z-[120] flex min-h-0 flex-col overflow-hidden border-sky-300/20 shadow-[0_32px_100px_rgba(2,6,23,0.72)]' : '')}
contentClassName={isEditorFullHeight ? 'flex min-h-0 flex-1 flex-col' : ''}
>
<div className={`grid min-w-0 gap-3 text-sm text-slate-300 ${isEditorFullHeight ? 'min-h-0 flex-1' : ''}`.trim()}>
<RichTextEditor
content={form.data.content}
onChange={handleRichContentChange}
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={isEditorFullHeight ? 30 : 24}
maxHeightRem={isEditorFullHeight ? 72 : 42}
autofocus={false}
advancedNews
mediaSupport={{
uploadUrl: editorContext.bodyMediaUploadUrl,
deleteUrl: editorContext.bodyMediaDeleteUrl,
assetsUrl: editorContext.bodyMediaAssetsUrl,
slot: 'body',
}}
/>
</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."
className={sectionClassName('lesson-ai-comparisons')}
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-course-numbering" eyebrow="Course placement" title="Courses and numbering" description="Keep series numbering and course placement in one workspace so guided lesson management stays separate from publishing." className={sectionClassName('lesson-course-numbering')}>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2 text-sm text-slate-300">
<TextField label="Series name" value={form.data.series_name} onChange={(event) => form.setData('series_name', event.target.value)} error={form.errors.series_name} maxLength={120} placeholder="AI Art Basics" hint="Shown before the lesson number on public pages. Leave empty if this lesson is not part of a named series." />
</div>
<div className="grid gap-2 text-sm text-slate-300">
<TextField label="Reading minutes" value={form.data.reading_minutes} error={form.errors.reading_minutes} type="number" min="1" max="999" readOnly hint={`Auto-calculated from the lesson body. Current estimate: ${estimatedReadingMinutes} min for ${bodyWordCount.toLocaleString()} words.`} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<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>Lesson number</span>
<button type="button" onClick={() => form.setData('lesson_number', String(numberingContext?.lesson_number?.suggested || ''))} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold text-white">Use {numberingContext?.lesson_number?.suggested || 'next'}</button>
</span>
<input value={form.data.lesson_number ?? ''} onChange={(event) => form.setData('lesson_number', event.target.value)} type="number" min="1" placeholder={String(numberingContext?.lesson_number?.suggested || 3)} 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">Visible public numbering like Lesson 03. Suggested next slot: {numberingContext?.lesson_number?.suggested || 1}. Missing numbers: {formatMissingNumbers(numberingContext?.lesson_number?.missing)}.</span>
<FieldError message={form.errors.lesson_number} />
</label>
</div>
<div className="grid gap-2">
<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>Course order</span>
<button type="button" onClick={() => form.setData('course_order', String(numberingContext?.course_order?.suggested || ''))} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold text-white">Use {numberingContext?.course_order?.suggested || 'next'}</button>
</span>
<input value={form.data.course_order ?? ''} onChange={(event) => form.setData('course_order', event.target.value)} type="number" min="1" placeholder={String(numberingContext?.course_order?.suggested || 3)} 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">Controls standalone lesson list sorting and previous or next lesson navigation. Suggested next slot: {numberingContext?.course_order?.suggested || 1}. Missing numbers: {formatMissingNumbers(numberingContext?.course_order?.missing)}.</span>
<FieldError message={form.errors.course_order} />
</label>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<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">Lesson numbering</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold text-sky-100">Suggested {numberingContext?.lesson_number?.suggested || 1}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold text-slate-200">{numberingContext?.lesson_number?.used_count || 0} used</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold text-slate-200">Highest {numberingContext?.lesson_number?.highest || 0}</span>
</div>
<p className="mt-3 text-sm leading-6 text-slate-400">Missing lesson numbers: {formatMissingNumbers(numberingContext?.lesson_number?.missing)}</p>
</div>
<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">Course order guidance</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold text-amber-100">Suggested {numberingContext?.course_order?.suggested || 1}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold text-slate-200">{numberingContext?.course_order?.used_count || 0} used</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold text-slate-200">Highest {numberingContext?.course_order?.highest || 0}</span>
</div>
<p className="mt-3 text-sm leading-6 text-slate-400">Missing course-order slots: {formatMissingNumbers(numberingContext?.course_order?.missing)}</p>
</div>
</div>
<div className="grid gap-2 text-sm text-slate-300">
<NovaSelect
multi
label="Courses"
value={form.data.course_ids || []}
onChange={(nextValue) => form.setData('course_ids', Array.isArray(nextValue) ? nextValue.map((courseId) => String(courseId)) : [])}
options={courseOptions}
className="bg-black/20"
error={form.errors.course_ids}
/>
<p className="text-xs leading-6 text-slate-400">Attach this lesson to one or more existing courses. You can also attach or remove the current lesson immediately from the course manager cards below.</p>
</div>
</SectionCard>
<SectionCard id="lesson-course-manager" eyebrow="Course manager" title="Manage course placement" description="Attach the current lesson, drag to reorder it within each selected course, and save the updated path without leaving this lesson page." className={sectionClassName('lesson-course-manager')}>
<div className="grid gap-4">
{selectedCourses.length === 0 ? (
<div className="rounded-[24px] border border-dashed border-white/10 bg-black/20 px-5 py-5 text-sm leading-7 text-slate-400">Select one or more courses above to manage where this lesson sits in guided learning paths.</div>
) : selectedCourses.map((course) => {
const currentCourseLesson = Array.isArray(course.lessons) ? course.lessons.find((lesson) => lesson.is_current) : null
const nextStepLabel = formatCourseStep(course.next_order_num)
const draftLessons = normalizeCourseManagerLessons(courseManagerDrafts[String(course.value)] || course.lessons)
const draftIsDirty = courseManagerSignature(draftLessons) !== courseManagerSignature(course.lessons)
const courseIsSaving = Boolean(courseSaveProcessing[String(course.value)])
return (
<div key={course.value} className="rounded-[26px] border border-white/10 bg-black/20 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-lg font-semibold tracking-[-0.03em] text-white">{course.label}</p>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{course.status}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{course.lesson_count} lessons</span>
{draftIsDirty ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Unsaved order</span> : null}
</div>
<p className="mt-2 text-sm leading-6 text-slate-400">
{currentCourseLesson ? `${currentCourseLesson.title} is currently attached in ${formatCourseStep(currentCourseLesson.order_num) || `slot ${currentCourseLesson.display_order}`}.` : `This lesson is not attached yet. Add it now at ${nextStepLabel || `slot ${Number(course.next_order_num || 0) + 1}`}.`}
</p>
</div>
<div className="flex flex-wrap gap-2">
{currentLessonId > 0 && !currentCourseLesson ? <button type="button" onClick={() => attachLessonToCourseNow(course)} className="rounded-full border border-[#f39a24]/25 bg-[#f39a24]/12 px-3 py-1.5 text-xs font-semibold text-[#ffd5cd]">Add this lesson now</button> : null}
{currentCourseLesson ? <button type="button" onClick={() => detachLessonFromCourseNow(course, currentCourseLesson)} 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 from course</button> : null}
<button type="button" onClick={() => updateCourseDraft(course.value, course.lessons)} disabled={!draftIsDirty || courseIsSaving} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-40">Reset order</button>
<button type="button" onClick={() => saveCourseDraftOrder(course)} disabled={!draftIsDirty || courseIsSaving} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-3 py-1.5 text-xs font-semibold text-sky-100 disabled:opacity-40">{courseIsSaving ? 'Saving...' : 'Save order'}</button>
<a href={course.builder_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">Open builder</a>
</div>
</div>
<div className="mt-4 grid gap-2">
{draftLessons.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-500">No lessons are attached to this course yet.</div>
) : draftLessons.map((lesson, lessonIndex) => (
<div
key={lesson.id}
draggable
onDragStart={() => setDraggedCourseLesson({ courseId: String(course.value), lessonId: lesson.id })}
onDragEnd={() => setDraggedCourseLesson(null)}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
if (!draggedCourseLesson || draggedCourseLesson.courseId !== String(course.value)) return
updateCourseDraft(course.value, reorderCourseManagerLessons(draftLessons, draggedCourseLesson.lessonId, lesson.id))
setDraggedCourseLesson(null)
}}
className={`flex flex-wrap items-center justify-between gap-3 rounded-2xl border px-4 py-3 transition ${lesson.is_current ? 'border-[#f39a24]/30 bg-[#f39a24]/10' : 'border-white/10 bg-white/[0.03]'} ${draggedCourseLesson?.courseId === String(course.value) && Number(draggedCourseLesson.lessonId) === Number(lesson.id) ? 'opacity-60' : ''}`}
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{formatCourseStep(lesson.order_num) || `#${lesson.display_order}`}</span>
{lesson.formatted_lesson_number ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{lesson.formatted_lesson_number}</span> : null}
{lesson.section_title ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{lesson.section_title}</span> : null}
{lesson.is_current ? <span className="rounded-full border border-[#f39a24]/25 bg-[#f39a24]/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#ffd5cd]">This lesson</span> : null}
</div>
<p className="mt-2 truncate text-sm font-semibold text-white">{lesson.title}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={() => updateCourseDraft(course.value, moveCourseManagerLesson(draftLessons, lesson.id, -1))} disabled={lessonIndex === 0} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-40"><i className="fa-solid fa-arrow-up" /></button>
<button type="button" onClick={() => updateCourseDraft(course.value, moveCourseManagerLesson(draftLessons, lesson.id, 1))} disabled={lessonIndex === draftLessons.length - 1} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-40"><i className="fa-solid fa-arrow-down" /></button>
{lesson.edit_url ? <a href={lesson.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">Open lesson</a> : null}
</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." className={sectionClassName('lesson-publishing')}>
<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" />
<div className="grid gap-2">
<TextField label="Microtags" value={form.data.tags} onChange={(event) => form.setData('tags', event.target.value)} error={form.errors.tags} placeholder="workflow, cleanup, publishing" />
<p className="text-xs leading-6 text-slate-400">Comma-separated short tags for the public lesson page and article discovery context.</p>
</div>
</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." className={sectionClassName('lesson-seo')}>
<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." className={sectionClassName('lesson-categories')} 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" />
<div className="min-w-[260px] flex-1">
<ToggleField label="Category active" checked={categoryDraft.active} onChange={(event) => setCategoryDraft((current) => ({ ...current, active: event.target.checked }))} help="Inactive categories stay available for cleanup but disappear from regular lesson assignment." />
</div>
<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>
<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." className={sectionClassName('lesson-cover')}>
<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-article-cover" eyebrow="Article cover" title="Inline article image" description="This image is rendered just before the lesson content begins." className={sectionClassName('lesson-article-cover')}>
<div className="grid gap-4">
<WorldMediaUploadField
label="Article cover"
slot="cover"
value={form.data.article_cover_image}
previewUrl={articleCoverPreviewUrl}
emptyLabel="Drop an article cover"
helperText="Upload the image that appears above the lesson body. Use a strong wide image that still reads well inside the article column."
uploadUrl={editorContext.coverUploadUrl}
deleteUrl={editorContext.coverDeleteUrl}
onChange={({ path, url }) => {
setStagedArticleCoverPath(path || '')
form.setData('article_cover_image', path || '')
setArticleCoverPreviewUrl(url || '')
}}
isTemporaryValue={Boolean(stagedArticleCoverPath) && form.data.article_cover_image === stagedArticleCoverPath}
/>
<FieldError message={form.errors.article_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 article cover path or URL</span>
<input value={form.data.article_cover_image} onChange={(event) => handleManualArticleCoverChange(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">Use this when the article image already exists in storage or needs to point to an external source.</span>
</label>
</div>
</SectionCard>
<SectionCard id="lesson-revisions" eyebrow="Safety net" title="Revision history" description="Each lesson update now saves the previous state first. Restore the full lesson or a single field when something goes wrong." className={sectionClassName('lesson-revisions')}>
<div className="space-y-4">
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4 text-sm leading-6 text-slate-300">
Restoring from a revision creates another revision first, so you can undo the restore if needed.
</div>
{revisions.length === 0 ? (
<div className="rounded-[24px] border border-dashed border-white/10 bg-black/20 px-4 py-5 text-sm leading-7 text-slate-400">No revisions yet. The first saved update will capture the current lesson state.</div>
) : revisions.map((revision) => {
const selectedField = String(revisionFieldSelections[revision.id] || 'content')
return (
<div key={revision.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Revision #{revision.id}</p>
<p className="mt-2 text-sm font-semibold text-white">{revision.created_label || 'Recently saved'} by {revision.actor_name || 'Staff'}</p>
{revision.change_note ? <p className="mt-2 text-xs leading-5 text-slate-400">{revision.change_note}</p> : null}
</div>
<button type="button" onClick={() => restoreLessonRevision(revision)} className="rounded-full border border-[#f39a24]/25 bg-[#f39a24]/12 px-3 py-1.5 text-xs font-semibold text-[#ffd5cd]">Restore full lesson</button>
</div>
<div className="mt-4 rounded-[20px] border border-white/10 bg-white/[0.03] p-3">
<p className="text-sm font-semibold text-white">{revision.snapshot?.title || 'Untitled lesson snapshot'}</p>
{revision.snapshot?.excerpt ? <p className="mt-2 text-xs leading-5 text-slate-400">{revision.snapshot.excerpt}</p> : null}
{revision.snapshot?.content_preview ? <p className="mt-2 text-xs leading-5 text-slate-500">{revision.snapshot.content_preview}</p> : null}
<div className="mt-3 flex flex-wrap gap-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">{revision.snapshot?.course_count || 0} courses</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">{revision.snapshot?.block_count || 0} blocks</span>
</div>
</div>
<div className="mt-4 grid gap-3">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Restore single field</span>
<select value={selectedField} onChange={(event) => setRevisionFieldSelections((current) => ({ ...current, [revision.id]: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
{LESSON_REVISION_FIELD_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<button type="button" onClick={() => restoreLessonRevision(revision, selectedField)} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Restore selected field</button>
</div>
</div>
)
})}
</div>
</SectionCard>
</div>
{showSupportRail ? (
<div className="space-y-6 xl:sticky xl:top-6 xl:self-start">
{showWriteCompanion ? (
<SectionCard eyebrow="Writing flow" title="Author companion" description="Keep the lesson opening tight, then expand through headings and examples. This panel stays compact so the editor remains the focus.">
<div className="grid gap-4">
<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">{lessonPathPreview}</p>
<p className="mt-2 text-sm leading-6 text-slate-400">Keep the headline specific enough that the slug reads clearly in search results and internal links.</p>
</div>
<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">Writing checklist</p>
<div className="mt-3 grid gap-2">
{lessonStatusItems.map((item) => (
<div key={item.label} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-300">
<span>{item.label}</span>
<span className={`rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.14em] ${item.ready ? 'border border-emerald-300/20 bg-emerald-300/10 text-emerald-100' : 'border border-amber-300/20 bg-amber-300/10 text-amber-100'}`}>
{item.ready ? 'Ready' : 'Missing'}
</span>
</div>
))}
</div>
</div>
<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">Article rhythm</p>
<div className="mt-3 space-y-3 text-sm leading-6 text-slate-400">
<p>Lead with the problem in the first paragraph, then break the workflow into headline-sized steps.</p>
<p>Use Markdown import when you already have a draft, then switch back to the visual editor for structure, media, and cleanup.</p>
<p>Open full-height mode once the outline is stable so the body editor takes the entire screen.</p>
</div>
</div>
</div>
</SectionCard>
) : null}
<SectionCard id="lesson-preview" eyebrow="Preview" title="Lesson snapshot" description="A quick view of what editors and visitors will scan first." className={sectionClassName('lesson-preview')}>
<div className="grid gap-4">
<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 hero cover image selected yet.</div>
)}
</div>
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/30">
{articleCoverPreviewUrl ? (
<img src={articleCoverPreviewUrl} alt="Lesson article 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 article cover image selected yet.</div>
)}
</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>
<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">Article preview</p>
{deferredArticlePreviewHtml ? (
<div
className="prose prose-invert mt-4 max-w-none prose-headings:text-white prose-p:text-slate-300 prose-p:leading-7 prose-li:text-slate-300 prose-strong:text-white prose-code:text-amber-300 prose-pre:border prose-pre:border-white/10 prose-pre:bg-slate-950/70 prose-blockquote:border-sky-300/30 prose-a:text-sky-300"
dangerouslySetInnerHTML={{ __html: deferredArticlePreviewHtml }}
/>
) : (
<p className="mt-3 text-sm leading-7 text-slate-500">Add lesson body content to see the rendered article preview here.</p>
)}
</div>
</SectionCard>
</div>
) : null}
</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}
/>
<MarkdownImportDialog
open={markdownImportOpen}
value={markdownImportValue}
error={markdownImportError}
onChange={(nextValue) => {
setMarkdownImportValue(nextValue)
if (markdownImportError) {
setMarkdownImportError('')
}
}}
onClose={() => {
setMarkdownImportOpen(false)
setMarkdownImportError('')
}}
onApply={applyMarkdownImport}
/>
</AdminLayout>
)
}