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

960 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { Head, Link, router, useForm } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
import RichTextEditor from '../../../components/forum/RichTextEditor'
import WorldMediaUploadField from '../../../components/worlds/editor/WorldMediaUploadField'
import DateTimePicker from '../../../components/ui/DateTimePicker'
import NovaSelect from '../../../components/ui/NovaSelect'
const COURSE_EDITOR_TABS = [
{
id: 'overview',
label: 'Overview',
description: 'Title, slug, positioning, and the short summary shown on course cards.',
icon: 'fa-compass-drafting',
sections: ['course-identity'],
},
{
id: 'content',
label: 'Content',
description: 'Use the richer WYSIWYG surface for the main course description and learning pitch.',
icon: 'fa-pen-nib',
sections: ['course-description'],
},
{
id: 'media',
label: 'Media',
description: 'Upload and tune the cover and teaser visuals used across the public course surfaces.',
icon: 'fa-images',
sections: ['course-media'],
},
{
id: 'lessons',
label: 'Lessons',
description: 'Build the lesson sequence, drag to reorder, and add or remove lessons without opening the full builder.',
icon: 'fa-list-ol',
sections: ['course-lessons-manager'],
},
{
id: 'publish',
label: 'Publish',
description: 'Control access, status, ordering, scheduling, and featured placement.',
icon: 'fa-rocket-launch',
sections: ['course-publishing', 'course-seo'],
},
{
id: 'preview',
label: 'Preview',
description: 'Scan the public-facing course card, media, and rendered long description before publishing.',
icon: 'fa-eye',
sections: ['course-preview'],
},
]
const COURSE_FIELD_TAB_MAP = {
title: 'overview',
slug: 'overview',
subtitle: 'overview',
excerpt: 'overview',
description: 'content',
cover_image: 'media',
teaser_image: 'media',
access_level: 'publish',
difficulty: 'publish',
status: 'publish',
order_num: 'publish',
estimated_minutes: 'publish',
published_at: 'publish',
is_featured: 'publish',
seo_title: 'publish',
seo_description: 'publish',
meta_keywords: 'publish',
og_title: 'publish',
og_description: 'publish',
og_image: 'publish',
}
function getField(fields, name) {
return fields.find((field) => field.name === name) || null
}
function FieldError({ message }) {
if (!message) return null
return <p className="text-xs text-rose-300">{message}</p>
}
function SectionCard({ id, eyebrow, title, description, actions, children, tone = 'default', className = '', 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="Course 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={`course-editor-panel-${tab.id}`}
id={`course-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('course-', '').replace(/-/g, ' ')}</span>
))}
</div>
</div>
</div>
)
}
function TextField({ label, value, onChange, error, hint, ...rest }) {
return (
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
<input value={value ?? ''} onChange={onChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" {...rest} />
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
<FieldError message={error} />
</label>
)
}
function TextAreaField({ label, value, onChange, error, rows = 4, hint }) {
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 CheckboxCardField({ label, checked, onChange, description, 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-lg font-semibold tracking-[-0.02em] text-white">{label}</span>
{description ? <span className="mt-1 block text-sm leading-6 text-slate-300">{description}</span> : null}
<FieldError message={error} />
</span>
</label>
)
}
function OutlineSectionPill({ section }) {
return (
<div className="rounded-[20px] border border-white/10 bg-black/20 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-white">{section.title}</p>
<p className="mt-1 text-[11px] uppercase tracking-[0.16em] text-slate-500">{section.is_visible ? 'Visible section' : 'Hidden section'}</p>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold text-slate-200">{section.lesson_count}</span>
</div>
</div>
)
}
function slugifyCourseTitle(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 180)
}
function formatLessonStep(orderNum) {
const numeric = Number(orderNum)
if (!Number.isFinite(numeric) || numeric < 0) return null
return `Step ${String(numeric + 1).padStart(2, '0')}`
}
function normalizeLessonManagerLessons(lessons) {
return (Array.isArray(lessons) ? [...lessons] : [])
.sort((a, b) => {
const diff = Number(a?.order_num || 0) - Number(b?.order_num || 0)
return diff !== 0 ? diff : Number(a?.id || 0) - Number(b?.id || 0)
})
.map((lesson, index) => ({ ...lesson, order_num: index, display_order: index + 1 }))
}
function reorderLessonManagerLessons(lessons, draggedId, targetId) {
const current = normalizeLessonManagerLessons(lessons)
const di = current.findIndex((l) => Number(l.id) === Number(draggedId))
const ti = current.findIndex((l) => Number(l.id) === Number(targetId))
if (di === -1 || ti === -1 || di === ti) return current
const next = [...current]
const [moved] = next.splice(di, 1)
next.splice(ti, 0, moved)
return normalizeLessonManagerLessons(next)
}
function moveLessonManagerLesson(lessons, lessonId, direction) {
const current = normalizeLessonManagerLessons(lessons)
const idx = current.findIndex((l) => Number(l.id) === Number(lessonId))
const nextIdx = idx + direction
if (idx === -1 || nextIdx < 0 || nextIdx >= current.length) return current
const next = [...current]
const [moved] = next.splice(idx, 1)
next.splice(nextIdx, 0, moved)
return normalizeLessonManagerLessons(next)
}
function lessonManagerSignature(lessons) {
return JSON.stringify(normalizeLessonManagerLessons(lessons).map((l) => ({
id: Number(l.id),
order_num: Number(l.order_num || 0),
section_id: l.section_id == null ? null : Number(l.section_id),
})))
}
function stripHtml(value) {
return String(value || '')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function countWords(value) {
const text = stripHtml(value)
return text ? text.split(/\s+/).length : 0
}
function normalizeAssetPreview(value, cdnBaseUrl) {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
if (trimmed.startsWith('http://') || trimmed.startsWith('https://') || trimmed.startsWith('/')) return trimmed
return `${String(cdnBaseUrl || '').replace(/\/$/, '')}/${trimmed.replace(/^\//, '')}`
}
function firstCourseErrorTab(errors) {
const firstKey = Object.keys(errors || {})[0]
if (!firstKey) return null
return COURSE_FIELD_TAB_MAP[firstKey] || null
}
function courseTabErrorCounts(errors) {
const counts = {}
Object.keys(errors || {}).forEach((key) => {
const tabId = COURSE_FIELD_TAB_MAP[key]
if (!tabId) return
counts[tabId] = Number(counts[tabId] || 0) + 1
})
return counts
}
function renderMetaKeywords(value) {
return String(value || '')
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 6)
}
export default function CourseEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext = {} }) {
const form = useForm({
...record,
description: String(record.description || ''),
cover_image: String(record.cover_image || ''),
teaser_image: String(record.teaser_image || ''),
})
const slugTouchedRef = useRef(Boolean(String(record.slug || '').trim()))
const [activeTab, setActiveTab] = useState('overview')
const [coverPreviewUrl, setCoverPreviewUrl] = useState(record.cover_image_url || normalizeAssetPreview(record.cover_image, editorContext.coverCdnBaseUrl))
const [teaserPreviewUrl, setTeaserPreviewUrl] = useState(record.teaser_image_url || normalizeAssetPreview(record.teaser_image, editorContext.coverCdnBaseUrl))
const [stagedCoverPath, setStagedCoverPath] = useState('')
const [stagedTeaserPath, setStagedTeaserPath] = useState('')
const accessField = useMemo(() => getField(fields, 'access_level'), [fields])
const difficultyField = useMemo(() => getField(fields, 'difficulty'), [fields])
const statusField = useMemo(() => getField(fields, 'status'), [fields])
const wordCount = useMemo(() => countWords(form.data.description), [form.data.description])
const excerptLength = String(form.data.excerpt || '').length
const tabErrorCounts = useMemo(() => courseTabErrorCounts(form.errors), [form.errors])
const deferredDescription = useDeferredValue(form.data.description || '')
const visibleSections = useMemo(() => new Set((COURSE_EDITOR_TABS.find((tab) => tab.id === activeTab)?.sections) || []), [activeTab])
const activeTabMeta = useMemo(() => COURSE_EDITOR_TABS.find((tab) => tab.id === activeTab) || COURSE_EDITOR_TABS[0], [activeTab])
const sectionClassName = (sectionId, className = '') => `${visibleSections.has(sectionId) ? '' : 'hidden'} ${className}`.trim()
const editorLinks = editorContext?.links || {}
const outlineSummary = editorContext?.outlineSummary || null
const coursePathPreview = form.data.slug ? `/academy/courses/${form.data.slug}` : '/academy/courses/course-slug'
const metaKeywordItems = renderMetaKeywords(form.data.meta_keywords)
const attachLessonUrl = editorContext?.attachLessonUrl || null
const reorderUrl = editorContext?.reorderUrl || null
const courseLessonsSource = useMemo(() => Array.isArray(editorContext?.courseLessons) ? editorContext.courseLessons : [], [editorContext])
const availableLessons = useMemo(() => Array.isArray(editorContext?.availableLessons) ? editorContext.availableLessons : [], [editorContext])
const [lessonManagerDraft, setLessonManagerDraft] = useState(() => normalizeLessonManagerLessons(Array.isArray(editorContext?.courseLessons) ? editorContext.courseLessons : []))
const [lessonDragActive, setLessonDragActive] = useState(null)
const [lessonSaveProcessing, setLessonSaveProcessing] = useState(false)
const [lessonSearch, setLessonSearch] = useState('')
const lessonManagerIsDirty = useMemo(() => lessonManagerSignature(lessonManagerDraft) !== lessonManagerSignature(courseLessonsSource), [lessonManagerDraft, courseLessonsSource])
const filteredAvailableLessons = useMemo(() => {
const q = lessonSearch.trim().toLowerCase()
const unattached = availableLessons.filter((l) => !l.attached)
if (!q) return unattached
return unattached.filter((l) => l.title.toLowerCase().includes(q) || l.category.toLowerCase().includes(q))
}, [availableLessons, lessonSearch])
useEffect(() => {
setLessonManagerDraft(normalizeLessonManagerLessons(courseLessonsSource))
}, [courseLessonsSource])
useEffect(() => {
if (slugTouchedRef.current) return
form.setData('slug', slugifyCourseTitle(form.data.title))
}, [form, form.data.title])
useEffect(() => {
const nextTab = firstCourseErrorTab(form.errors)
if (!nextTab) return
setActiveTab(nextTab)
}, [form.errors])
const handleManualCoverChange = (nextValue) => {
setStagedCoverPath('')
form.setData('cover_image', nextValue)
setCoverPreviewUrl(normalizeAssetPreview(nextValue, editorContext.coverCdnBaseUrl))
}
const attachLesson = (lesson) => {
if (!attachLessonUrl) return
router.post(attachLessonUrl, {
lesson_id: lesson.id,
order_num: courseLessonsSource.length,
is_required: true,
}, { preserveScroll: true })
}
const detachLesson = (courseLesson) => {
if (!courseLesson.destroy_url) return
if (!window.confirm(`Remove "${courseLesson.title}" from this course?`)) return
router.delete(courseLesson.destroy_url, { preserveScroll: true })
}
const saveLessonOrder = () => {
if (!reorderUrl) return
setLessonSaveProcessing(true)
router.patch(reorderUrl, {
sections: [],
lessons: lessonManagerDraft.map((l) => ({
id: l.id,
order_num: l.order_num,
section_id: l.section_id ?? null,
})),
}, {
preserveScroll: true,
onFinish: () => setLessonSaveProcessing(false),
})
}
const handleManualTeaserChange = (nextValue) => {
setStagedTeaserPath('')
form.setData('teaser_image', nextValue)
setTeaserPreviewUrl(normalizeAssetPreview(nextValue, editorContext.coverCdnBaseUrl))
}
const submit = (event) => {
event.preventDefault()
if (method === 'patch') {
form.patch(submitUrl)
return
}
form.post(submitUrl)
}
const deleteCourse = () => {
if (!destroyUrl) return
if (!window.confirm('Delete this course?')) return
router.delete(destroyUrl)
}
return (
<AdminLayout title={title} subtitle={subtitle}>
<Head title={`Admin · ${title}`} />
<form onSubmit={submit} className="space-y-6 pb-16">
<section className="overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur">
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]">Back to courses</Link>
<span>{destroyUrl ? 'Edit course' : 'New course'}</span>
</div>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy course'}</h1>
<p className="mt-2 max-w-3xl text-sm leading-7 text-slate-300">Design the course like a polished editorial landing page: keep the structure clear, use the rich description editor, and upload visuals that look intentional on the public cards and hero.</p>
</div>
<div className="flex flex-wrap gap-3">
{editorLinks.builder ? <Link href={editorLinks.builder} className="rounded-2xl border border-amber-300/20 bg-amber-300/10 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:brightness-110">Open builder</Link> : null}
{editorLinks.preview ? <Link href={editorLinks.preview} 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]">Preview public page</Link> : null}
<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 course'}</button>
</div>
</div>
</section>
<EditorWorkspaceTabs tabs={COURSE_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">{wordCount.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="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start">
<div className="min-w-0 space-y-6" role="tabpanel" id={`course-editor-panel-${activeTab}`} aria-labelledby={`course-editor-tab-${activeTab}`}>
<SectionCard id="course-identity" eyebrow="Positioning" title="Identity and summary" description="Start with the public-facing identity shown on the course index, hero, and internal Academy modules." tone="feature" className={sectionClassName('course-identity')}>
<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="AI-Assisted Digital Art Foundations"
/>
<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', slugifyCourseTitle(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="ai-assisted-digital-art-foundations"
maxLength={180}
/>
<span className="text-xs leading-5 text-slate-500">The public course URL updates from the title until you override it.</span>
<FieldError message={form.errors.slug} />
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<TextField
label="Subtitle"
value={form.data.subtitle}
onChange={(event) => form.setData('subtitle', event.target.value)}
error={form.errors.subtitle}
maxLength={255}
placeholder="A guided path for Skinbase creators"
/>
<TextField
label="Estimated minutes"
value={form.data.estimated_minutes ?? ''}
onChange={(event) => form.setData('estimated_minutes', event.target.value)}
error={form.errors.estimated_minutes}
type="number"
min="1"
placeholder="90"
hint="Shown on public course cards and the course hero."
/>
</div>
<TextAreaField
label="Excerpt"
value={form.data.excerpt}
onChange={(event) => form.setData('excerpt', event.target.value)}
error={form.errors.excerpt}
rows={5}
hint="Keep this tight and outcome-focused. This summary is reused on cards, related modules, and SEO helpers."
/>
</SectionCard>
<SectionCard id="course-description" eyebrow="Long-form content" title="Course description" description="Use the same richer WYSIWYG surface as lessons so the course page can carry structured copy, lists, and supporting media." tone="feature" className={sectionClassName('course-description')}>
<RichTextEditor
content={form.data.description}
onChange={(nextHtml) => form.setData('description', nextHtml)}
placeholder="Explain what the course covers, who it is for, what workflows it teaches, and why a Skinbase creator should follow this path from start to finish."
error={form.errors.description}
minHeight={24}
maxHeightRem={42}
autofocus={false}
advancedNews
mediaSupport={{
uploadUrl: editorContext.bodyMediaUploadUrl,
deleteUrl: editorContext.bodyMediaDeleteUrl,
assetsUrl: editorContext.bodyMediaAssetsUrl,
slot: 'body',
}}
/>
</SectionCard>
<SectionCard id="course-media" eyebrow="Visual system" title="Cover and teaser media" description="Upload clean landscape images that work across the featured course rail, the course index cards, and the public course hero." className={sectionClassName('course-media')}>
<div className="grid gap-6 xl:grid-cols-2">
<div className="space-y-4">
<WorldMediaUploadField
label="Cover image"
slot="cover"
value={form.data.cover_image}
previewUrl={coverPreviewUrl}
emptyLabel="Course cover"
helperText="Preferred 1600×900 at 16:9. Minimum upload is 1200×630. Use this as the main hero image for the course page and featured cards."
uploadUrl={editorContext.coverUploadUrl}
deleteUrl={editorContext.coverDeleteUrl}
isTemporaryValue={Boolean(stagedCoverPath) && stagedCoverPath === form.data.cover_image}
onChange={({ path, url }) => {
setStagedCoverPath(path || '')
form.setData('cover_image', path || '')
setCoverPreviewUrl(url || normalizeAssetPreview(path || '', editorContext.coverCdnBaseUrl))
}}
/>
<TextField
label="Cover image path override"
value={form.data.cover_image}
onChange={(event) => handleManualCoverChange(event.target.value)}
error={form.errors.cover_image}
placeholder="academy/lessons/covers/..."
/>
</div>
<div className="space-y-4">
<WorldMediaUploadField
label="Teaser image"
slot="cover"
value={form.data.teaser_image}
previewUrl={teaserPreviewUrl}
emptyLabel="Course teaser"
helperText="Preferred 1600×900 at 16:9. Use this as the lighter secondary image for index cards or fallback thumbnail treatment when the main cover is too dense."
uploadUrl={editorContext.coverUploadUrl}
deleteUrl={editorContext.coverDeleteUrl}
isTemporaryValue={Boolean(stagedTeaserPath) && stagedTeaserPath === form.data.teaser_image}
onChange={({ path, url }) => {
setStagedTeaserPath(path || '')
form.setData('teaser_image', path || '')
setTeaserPreviewUrl(url || normalizeAssetPreview(path || '', editorContext.coverCdnBaseUrl))
}}
/>
<TextField
label="Teaser image path override"
value={form.data.teaser_image}
onChange={(event) => handleManualTeaserChange(event.target.value)}
error={form.errors.teaser_image}
placeholder="academy/lessons/covers/..."
/>
</div>
</div>
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 px-4 py-4 text-sm leading-7 text-slate-300">
The public course index and the course hero both render landscape imagery first. If you only prepare one asset, prioritize the cover image. If you prepare both, keep them in the same visual family so the course feels consistent across list and detail pages.
</div>
</SectionCard>
<SectionCard
id="course-lessons-manager"
eyebrow="Lesson sequence"
title="Manage course lessons"
description="Add lessons from the library, drag rows to reorder, use the arrows for precision, and save the updated sequence. Removing a lesson detaches it from this course immediately."
tone="feature"
className={sectionClassName('course-lessons-manager')}
actions={
editorLinks.builder
? <a href={editorLinks.builder} className="rounded-2xl border border-amber-300/20 bg-amber-300/10 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:brightness-110">Open full builder</a>
: null
}
>
{/* Current lesson sequence */}
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
Lesson sequence
{lessonManagerDraft.length > 0 ? <span className="ml-2 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] text-slate-300">{lessonManagerDraft.length}</span> : null}
{lessonManagerIsDirty ? <span className="ml-2 rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-0.5 text-[10px] text-amber-200">Unsaved order</span> : null}
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setLessonManagerDraft(normalizeLessonManagerLessons(courseLessonsSource))}
disabled={!lessonManagerIsDirty || lessonSaveProcessing}
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={saveLessonOrder}
disabled={!lessonManagerIsDirty || lessonSaveProcessing}
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"
>
{lessonSaveProcessing ? 'Saving…' : 'Save order'}
</button>
</div>
</div>
{lessonManagerDraft.length === 0 ? (
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.03] px-5 py-6 text-sm text-slate-400">
No lessons attached to this course yet. Add lessons from the library below.
</div>
) : lessonManagerDraft.map((lesson, lessonIndex) => (
<div
key={lesson.id}
draggable
onDragStart={() => setLessonDragActive({ id: lesson.id })}
onDragEnd={() => setLessonDragActive(null)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
if (!lessonDragActive) return
setLessonManagerDraft(reorderLessonManagerLessons(lessonManagerDraft, lessonDragActive.id, lesson.id))
setLessonDragActive(null)
}}
className={[
'flex flex-wrap items-center justify-between gap-3 rounded-2xl border px-4 py-3 transition',
'border-white/10 bg-white/[0.03] cursor-grab',
lessonDragActive && Number(lessonDragActive.id) === Number(lesson.id) ? 'opacity-50 border-sky-300/30' : '',
].join(' ')}
>
<div className="flex min-w-0 items-center gap-3">
<i className="fa-solid fa-grip-vertical text-xs text-slate-600" />
<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-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">
{formatLessonStep(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-0.5 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-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">{lesson.section_title}</span>
) : null}
{lesson.difficulty ? (
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">{lesson.difficulty}</span>
) : null}
</div>
<p className="mt-1.5 truncate text-sm font-semibold text-white">{lesson.title}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setLessonManagerDraft(moveLessonManagerLesson(lessonManagerDraft, lesson.id, -1))}
disabled={lessonIndex === 0}
className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1.5 text-xs font-semibold text-white disabled:opacity-30"
title="Move up"
>
<i className="fa-solid fa-arrow-up" />
</button>
<button
type="button"
onClick={() => setLessonManagerDraft(moveLessonManagerLesson(lessonManagerDraft, lesson.id, 1))}
disabled={lessonIndex === lessonManagerDraft.length - 1}
className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1.5 text-xs font-semibold text-white disabled:opacity-30"
title="Move down"
>
<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">
Edit
</a>
) : null}
<button
type="button"
onClick={() => detachLesson(lesson)}
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>
))}
</div>
{/* Available lessons library */}
<div className="mt-6 space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Add from lesson library</p>
<div className="relative">
<i className="fa-solid fa-magnifying-glass absolute left-4 top-1/2 -translate-y-1/2 text-xs text-slate-500" />
<input
type="search"
value={lessonSearch}
onChange={(e) => setLessonSearch(e.target.value)}
placeholder="Search lessons by title or category…"
className="w-full rounded-2xl border border-white/10 bg-black/20 py-2.5 pl-9 pr-4 text-sm text-white outline-none placeholder:text-slate-600"
/>
</div>
{filteredAvailableLessons.length === 0 ? (
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.03] px-5 py-4 text-sm text-slate-500">
{lessonSearch.trim() ? 'No unattached lessons match your search.' : 'All lessons are already attached to this course.'}
</div>
) : (
<div className="grid gap-2">
{filteredAvailableLessons.map((lesson) => (
<div key={lesson.id} className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
{lesson.difficulty ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">{lesson.difficulty}</span> : null}
{lesson.category ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">{lesson.category}</span> : null}
{!lesson.active ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-200">Inactive</span> : null}
</div>
<p className="mt-1.5 truncate text-sm font-semibold text-white">{lesson.title}</p>
</div>
<button
type="button"
onClick={() => attachLesson(lesson)}
className="rounded-full border border-sky-300/25 bg-sky-300/12 px-3 py-1.5 text-xs font-semibold text-sky-100"
>
Add to course
</button>
</div>
))}
</div>
)}
</div>
</SectionCard>
<SectionCard id="course-publishing" eyebrow="Release controls" title="Access, status, and placement" description="Choose how the course appears in Academy discovery surfaces and when it goes live." className={sectionClassName('course-publishing')}>
<div className="grid gap-4 md:grid-cols-3">
<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 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="Status" value={form.data.status || ''} onChange={(nextValue) => form.setData('status', String(nextValue || ''))} options={statusField?.options || []} searchable={false} className="bg-black/20" error={form.errors.status} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<TextField
label="Order number"
value={form.data.order_num ?? ''}
onChange={(event) => form.setData('order_num', event.target.value)}
error={form.errors.order_num}
type="number"
min="0"
placeholder="10"
hint="Lower numbers float higher in featured and published course lists."
/>
<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" />
<span className="text-xs leading-5 text-slate-500">If the status is set to published and this is empty, the backend will timestamp it automatically.</span>
<FieldError message={form.errors.published_at} />
</div>
</div>
<CheckboxCardField
label="Feature on newsroom surfaces"
checked={Boolean(form.data.is_featured)}
onChange={(event) => form.setData('is_featured', event.target.checked)}
description="Use the featured treatment on Academy homepage rails and the course index. Keep this for courses with strong cover art and a finished outline."
error={form.errors.is_featured}
/>
</SectionCard>
<SectionCard id="course-seo" eyebrow="Search surfaces" title="SEO and OpenGraph" description="Keep the course crawlable and shareable without overstuffing the main title." className={sectionClassName('course-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="OpenGraph title" value={form.data.og_title} onChange={(event) => form.setData('og_title', event.target.value)} error={form.errors.og_title} maxLength={180} placeholder="Optional social title" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<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 short and aligned with the course promise." />
<TextAreaField label="OpenGraph description" value={form.data.og_description} onChange={(event) => form.setData('og_description', event.target.value)} error={form.errors.og_description} rows={4} hint="Used when the course page is shared into external platforms." />
</div>
<div className="grid gap-4 md:grid-cols-2">
<TextAreaField label="Meta keywords" value={form.data.meta_keywords} onChange={(event) => form.setData('meta_keywords', event.target.value)} error={form.errors.meta_keywords} rows={3} hint="Comma-separated terms. Keep this focused and editorial, not spammy." />
<TextField label="OpenGraph image" value={form.data.og_image} onChange={(event) => form.setData('og_image', event.target.value)} error={form.errors.og_image} placeholder="Leave empty to fall back to the course artwork" />
</div>
</SectionCard>
<SectionCard id="course-preview" eyebrow="Public preview" title="Rendered course snapshot" description="Use this tab to scan the media mix, course promise, and rendered long description without the rest of the form competing for attention." tone="feature" className={sectionClassName('course-preview')}>
<div className="space-y-5">
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-slate-950">
{coverPreviewUrl || teaserPreviewUrl ? (
<img src={coverPreviewUrl || teaserPreviewUrl} alt="Course hero preview" className="h-64 w-full object-cover" />
) : (
<div className="flex h-64 items-center justify-center px-6 text-center text-sm text-slate-500">No course artwork selected yet.</div>
)}
</div>
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6">
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">{form.data.difficulty || 'beginner'}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200">{form.data.access_level || 'free'}</span>
{form.data.is_featured ? <span className="rounded-full border border-amber-300/20 bg-amber-300/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">Featured</span> : null}
</div>
<h3 className="mt-4 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy course'}</h3>
{form.data.subtitle ? <p className="mt-2 text-sm font-semibold uppercase tracking-[0.18em] text-amber-100">{form.data.subtitle}</p> : null}
<p className="mt-4 text-sm leading-7 text-slate-300">{form.data.excerpt || 'Add a short course summary to explain what this path helps creators accomplish.'}</p>
</div>
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Description preview</p>
{String(deferredDescription || '').trim() ? (
<div className="prose prose-invert mt-4 max-w-none prose-headings:tracking-[-0.03em] prose-p:text-slate-300 prose-li:text-slate-300" dangerouslySetInnerHTML={{ __html: deferredDescription }} />
) : (
<p className="mt-4 text-sm leading-7 text-slate-400">The long description is still empty.</p>
)}
</div>
</div>
</SectionCard>
</div>
<div className="space-y-6 xl:sticky xl:top-6 xl:self-start">
<SectionCard eyebrow="At a glance" title="Course summary" description="A compact view of the public URL, media readiness, and the metadata editors see most often.">
<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">{coursePathPreview}</p>
<p className="mt-2 text-sm leading-6 text-slate-400">Use a concise slug so the course URL stays readable in search results and internal links.</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Cover</p>
<p className="mt-2 text-sm font-semibold text-white">{coverPreviewUrl ? 'Ready' : 'Missing'}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Teaser</p>
<p className="mt-2 text-sm font-semibold text-white">{teaserPreviewUrl ? 'Ready' : 'Missing'}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
<p className="mt-2 text-sm font-semibold text-white">{form.data.status || 'draft'}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Duration</p>
<p className="mt-2 text-sm font-semibold text-white">{form.data.estimated_minutes ? `${form.data.estimated_minutes} min` : 'Flexible'}</p>
</div>
</div>
</SectionCard>
{outlineSummary ? (
<SectionCard eyebrow="Builder pulse" title="Course outline" description="A quick summary of what the course builder currently contains so editors do not need to leave this form just to check structure.">
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Sections</p>
<p className="mt-2 text-sm font-semibold text-white">{outlineSummary.section_count}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visible sections</p>
<p className="mt-2 text-sm font-semibold text-white">{outlineSummary.visible_section_count}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Attached lessons</p>
<p className="mt-2 text-sm font-semibold text-white">{outlineSummary.lesson_count}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Required lessons</p>
<p className="mt-2 text-sm font-semibold text-white">{outlineSummary.required_lesson_count}</p>
</div>
</div>
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 px-4 py-4 text-sm leading-7 text-slate-300">
{outlineSummary.unsectioned_lesson_count > 0
? `${outlineSummary.unsectioned_lesson_count} lesson${outlineSummary.unsectioned_lesson_count === 1 ? '' : 's'} still sit outside sections. Use the builder if you want the outline to read like a guided chapter path.`
: 'All attached lessons are currently grouped into sections.'}
</div>
{outlineSummary.sections?.length ? (
<div className="grid gap-3">
{outlineSummary.sections.map((section) => <OutlineSectionPill key={section.id} section={section} />)}
</div>
) : (
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.03] px-4 py-5 text-sm text-slate-400">No sections yet. The builder will still allow unsectioned lessons, but adding chapters usually makes the public course easier to scan.</div>
)}
</SectionCard>
) : null}
<SectionCard eyebrow="Metadata pulse" title="Search and share" description="A quick scan of the metadata that most often gets missed before publish.">
<div className="grid gap-3">
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">SEO title</p>
<p className="mt-2 text-sm text-white">{form.data.seo_title || 'Uses course title by default'}</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Keywords</p>
<div className="mt-2 flex flex-wrap gap-2">
{metaKeywordItems.length ? metaKeywordItems.map((item) => <span key={item} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold text-slate-200">{item}</span>) : <span className="text-sm text-slate-400">No meta keywords yet.</span>}
</div>
</div>
</div>
</SectionCard>
</div>
</div>
<div className="flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save course'}</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={deleteCourse} 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>
</AdminLayout>
)
}