2085 lines
109 KiB
JavaScript
2085 lines
109 KiB
JavaScript
import React, { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Head, Link, router, useForm } from '@inertiajs/react'
|
||
import { createPortal } from 'react-dom'
|
||
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'
|
||
import ShareToast from '../../../components/ui/ShareToast'
|
||
|
||
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 lessonActivityBadgeMeta(lesson) {
|
||
const isActive = Boolean(lesson?.active)
|
||
|
||
return {
|
||
label: isActive ? 'Active' : 'Inactive',
|
||
className: isActive
|
||
? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100'
|
||
: 'border-amber-300/20 bg-amber-300/10 text-amber-200',
|
||
}
|
||
}
|
||
|
||
function lessonPublicationBadgeMeta(lesson) {
|
||
const state = String(lesson?.publication_state || 'draft')
|
||
const label = String(lesson?.publication_label || (state === 'published' ? 'Published' : state === 'scheduled' ? 'Scheduled' : 'Unscheduled'))
|
||
|
||
if (state === 'published') {
|
||
return {
|
||
label,
|
||
className: 'border-sky-300/20 bg-sky-300/10 text-sky-100',
|
||
}
|
||
}
|
||
|
||
if (state === 'scheduled') {
|
||
return {
|
||
label,
|
||
className: 'border-fuchsia-300/20 bg-fuchsia-300/10 text-fuchsia-100',
|
||
}
|
||
}
|
||
|
||
return {
|
||
label,
|
||
className: 'border-white/10 bg-white/[0.04] text-slate-400',
|
||
}
|
||
}
|
||
|
||
function CourseSectionCreateCard({ storeUrl, nextOrderNum }) {
|
||
const form = useForm({
|
||
title: '',
|
||
slug: '',
|
||
description: '',
|
||
order_num: nextOrderNum,
|
||
is_visible: true,
|
||
})
|
||
|
||
useEffect(() => {
|
||
form.setData('order_num', nextOrderNum)
|
||
}, [nextOrderNum])
|
||
|
||
const createSection = () => {
|
||
if (!storeUrl) return
|
||
|
||
form.post(storeUrl, {
|
||
preserveScroll: true,
|
||
onSuccess: () => {
|
||
form.setData({
|
||
title: '',
|
||
slug: '',
|
||
description: '',
|
||
order_num: nextOrderNum,
|
||
is_visible: true,
|
||
})
|
||
},
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">New section</p>
|
||
<p className="mt-2 text-sm leading-6 text-slate-400">Create a new course section here, then assign lessons into it below.</p>
|
||
</div>
|
||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">Order {Number(nextOrderNum || 0) + 1}</span>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||
<label className="grid gap-2 text-sm text-slate-200">
|
||
<span>Title</span>
|
||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} placeholder="Section 1 - Foundations" className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||
<FieldError message={form.errors.title} />
|
||
</label>
|
||
<label className="grid gap-2 text-sm text-slate-200">
|
||
<span>Slug</span>
|
||
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} placeholder="section-1-foundations" className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||
<FieldError message={form.errors.slug} />
|
||
</label>
|
||
<label className="grid gap-2 text-sm text-slate-200 lg:col-span-2">
|
||
<span>Description</span>
|
||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} rows={3} placeholder="What this section covers and why it matters." className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||
<FieldError message={form.errors.description} />
|
||
</label>
|
||
<label className="grid gap-2 text-sm text-slate-200">
|
||
<span>Order</span>
|
||
<input type="number" min="0" value={form.data.order_num} onChange={(event) => form.setData('order_num', event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||
<FieldError message={form.errors.order_num} />
|
||
</label>
|
||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||
<input type="checkbox" checked={Boolean(form.data.is_visible)} onChange={(event) => form.setData('is_visible', event.target.checked)} className="h-4 w-4 rounded border-white/20 bg-slate-950 text-sky-300" />
|
||
<span>Visible on the public course outline</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap gap-3">
|
||
<button type="button" onClick={createSection} disabled={form.processing || !String(form.data.title || '').trim()} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100 disabled:opacity-40">{form.processing ? 'Creating…' : 'Create section'}</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CourseSectionCard({ section }) {
|
||
const form = useForm({
|
||
title: section.title || '',
|
||
slug: section.slug || '',
|
||
description: section.description || '',
|
||
order_num: section.order_num || 0,
|
||
is_visible: Boolean(section.is_visible),
|
||
})
|
||
|
||
useEffect(() => {
|
||
form.setData({
|
||
title: section.title || '',
|
||
slug: section.slug || '',
|
||
description: section.description || '',
|
||
order_num: section.order_num || 0,
|
||
is_visible: Boolean(section.is_visible),
|
||
})
|
||
}, [section.description, section.is_visible, section.order_num, section.slug, section.title])
|
||
|
||
const saveSection = () => {
|
||
if (!section.update_url) return
|
||
form.patch(section.update_url, { preserveScroll: true })
|
||
}
|
||
|
||
const deleteSection = () => {
|
||
if (!section.destroy_url) return
|
||
if (!window.confirm(`Delete section "${section.title}"? Lessons assigned to it will become unsectioned.`)) return
|
||
router.delete(section.destroy_url, { preserveScroll: true })
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<label className="grid gap-2 text-sm text-slate-200">
|
||
<span>Title</span>
|
||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||
<FieldError message={form.errors.title} />
|
||
</label>
|
||
<label className="grid gap-2 text-sm text-slate-200">
|
||
<span>Slug</span>
|
||
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||
<FieldError message={form.errors.slug} />
|
||
</label>
|
||
<label className="grid gap-2 text-sm text-slate-200 lg:col-span-2">
|
||
<span>Description</span>
|
||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} rows={3} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||
<FieldError message={form.errors.description} />
|
||
</label>
|
||
<label className="grid gap-2 text-sm text-slate-200">
|
||
<span>Order</span>
|
||
<input type="number" min="0" value={form.data.order_num} onChange={(event) => form.setData('order_num', event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" />
|
||
<FieldError message={form.errors.order_num} />
|
||
</label>
|
||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-200">
|
||
<input type="checkbox" checked={Boolean(form.data.is_visible)} onChange={(event) => form.setData('is_visible', event.target.checked)} className="h-4 w-4 rounded border-white/20 bg-slate-950 text-sky-300" />
|
||
<span>Visible on the public course outline</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap gap-3">
|
||
<button type="button" onClick={saveSection} disabled={form.processing || !String(form.data.title || '').trim()} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100 disabled:opacity-40">{form.processing ? 'Saving…' : 'Save section'}</button>
|
||
<button type="button" onClick={deleteSection} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Delete</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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(/ /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 firstErrorMessage(errors, fallback = 'Please correct the highlighted fields and try again.') {
|
||
const queue = [errors]
|
||
|
||
while (queue.length > 0) {
|
||
const current = queue.shift()
|
||
|
||
if (typeof current === 'string') {
|
||
const message = current.trim()
|
||
|
||
if (message) {
|
||
return message
|
||
}
|
||
|
||
continue
|
||
}
|
||
|
||
if (Array.isArray(current)) {
|
||
queue.push(...current)
|
||
continue
|
||
}
|
||
|
||
if (current && typeof current === 'object') {
|
||
queue.push(...Object.values(current))
|
||
}
|
||
}
|
||
|
||
return fallback
|
||
}
|
||
|
||
function normalizeImportBoolean(value, fallback = false) {
|
||
if (typeof value === 'boolean') return value
|
||
|
||
if (typeof value === 'string') {
|
||
const normalized = value.trim().toLowerCase()
|
||
|
||
if (['true', '1', 'yes', 'y'].includes(normalized)) return true
|
||
if (['false', '0', 'no', 'n'].includes(normalized)) return false
|
||
}
|
||
|
||
if (typeof value === 'number') {
|
||
if (value === 1) return true
|
||
if (value === 0) return false
|
||
}
|
||
|
||
return fallback
|
||
}
|
||
|
||
function normalizeImportTags(value) {
|
||
if (Array.isArray(value)) {
|
||
return value.map((tag) => String(tag || '').trim()).filter(Boolean)
|
||
}
|
||
|
||
if (typeof value === 'string') {
|
||
return value.split(/[\n,]/).map((tag) => tag.trim()).filter(Boolean)
|
||
}
|
||
|
||
return []
|
||
}
|
||
|
||
function normalizeImportedDateTime(value) {
|
||
const raw = String(value || '').trim()
|
||
if (!raw) return ''
|
||
|
||
const dateTimeMatch = raw.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2})(?::\d{2})?)?$/)
|
||
if (dateTimeMatch) {
|
||
return dateTimeMatch[2] ? `${dateTimeMatch[1]}T${dateTimeMatch[2]}` : dateTimeMatch[1]
|
||
}
|
||
|
||
const parsed = new Date(raw)
|
||
if (Number.isNaN(parsed.getTime())) {
|
||
return raw
|
||
}
|
||
|
||
const pad = (input) => String(input).padStart(2, '0')
|
||
|
||
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}T${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`
|
||
}
|
||
|
||
function buildCourseJsonImportPrompt(courseTitle) {
|
||
return [
|
||
'Create valid JSON only for a Skinbase Academy course import.',
|
||
'Do not wrap the answer in markdown fences or extra explanation.',
|
||
'Return a single object with course fields as keys.',
|
||
'Use strings for text values, booleans for featured flags, and YYYY-MM-DDTHH:mm for published_at when present.',
|
||
'Allowed keys: title, slug, subtitle, excerpt, description, cover_image, teaser_image, access_level, difficulty, status, order_num, estimated_minutes, published_at, seo_title, seo_description, meta_keywords, og_title, og_description, og_image, is_featured.',
|
||
'meta_keywords may be an array of strings or a comma-separated string.',
|
||
'Omit unknown keys.',
|
||
'Example shape:',
|
||
'{',
|
||
' "title": "Course title",',
|
||
' "slug": "course-title",',
|
||
' "subtitle": "Short positioning line",',
|
||
' "excerpt": "Short summary for cards.",',
|
||
' "description": "Long form course description.",',
|
||
' "access_level": "free",',
|
||
' "difficulty": "beginner",',
|
||
' "status": "draft",',
|
||
' "is_featured": false',
|
||
'}',
|
||
`Course title context: ${String(courseTitle || 'Untitled academy course')}`,
|
||
].join('\n')
|
||
}
|
||
|
||
function parseCourseJsonImport(rawText) {
|
||
let parsed
|
||
|
||
try {
|
||
parsed = JSON.parse(String(rawText || ''))
|
||
} catch {
|
||
throw new Error('Could not parse JSON.')
|
||
}
|
||
|
||
const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||
? (parsed.course && typeof parsed.course === 'object' && !Array.isArray(parsed.course)
|
||
? parsed.course
|
||
: parsed.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data)
|
||
? parsed.data
|
||
: parsed.record && typeof parsed.record === 'object' && !Array.isArray(parsed.record)
|
||
? parsed.record
|
||
: parsed)
|
||
: null
|
||
|
||
if (!root || typeof root !== 'object' || Array.isArray(root)) {
|
||
throw new Error('Import JSON must be an object.')
|
||
}
|
||
|
||
const next = {}
|
||
|
||
const applyString = (targetKey, sourceKeys = [targetKey]) => {
|
||
const keys = Array.isArray(sourceKeys) ? sourceKeys : [sourceKeys]
|
||
|
||
for (const key of keys) {
|
||
if (root[key] == null) continue
|
||
const value = String(root[key]).trim()
|
||
if (!value) continue
|
||
next[targetKey] = value
|
||
return
|
||
}
|
||
}
|
||
|
||
const applyNumber = (targetKey, sourceKeys = [targetKey]) => {
|
||
const keys = Array.isArray(sourceKeys) ? sourceKeys : [sourceKeys]
|
||
|
||
for (const key of keys) {
|
||
if (root[key] == null || String(root[key]).trim() === '') continue
|
||
const value = Number(root[key])
|
||
if (!Number.isFinite(value)) continue
|
||
next[targetKey] = value
|
||
return
|
||
}
|
||
}
|
||
|
||
const applyBoolean = (targetKey, sourceKeys = [targetKey]) => {
|
||
const keys = Array.isArray(sourceKeys) ? sourceKeys : [sourceKeys]
|
||
|
||
for (const key of keys) {
|
||
if (root[key] == null) continue
|
||
next[targetKey] = normalizeImportBoolean(root[key], false)
|
||
return
|
||
}
|
||
}
|
||
|
||
applyString('title')
|
||
applyString('slug')
|
||
applyString('subtitle')
|
||
applyString('excerpt')
|
||
applyString('description')
|
||
applyString('cover_image', ['cover_image', 'cover_image_url', 'hero_image', 'hero_image_url'])
|
||
applyString('teaser_image', ['teaser_image', 'teaser_image_url'])
|
||
applyString('access_level')
|
||
applyString('difficulty')
|
||
applyString('status')
|
||
applyNumber('order_num')
|
||
applyNumber('estimated_minutes')
|
||
applyString('published_at')
|
||
applyString('seo_title')
|
||
applyString('seo_description')
|
||
applyString('og_title')
|
||
applyString('og_description')
|
||
applyString('og_image')
|
||
applyBoolean('is_featured', ['is_featured', 'featured'])
|
||
|
||
if (root.meta_keywords != null) {
|
||
next.meta_keywords = Array.isArray(root.meta_keywords)
|
||
? root.meta_keywords.map((keyword) => String(keyword || '').trim()).filter(Boolean).join(', ')
|
||
: String(root.meta_keywords).trim()
|
||
}
|
||
|
||
if (!next.slug && next.title) {
|
||
next.slug = slugifyCourseTitle(next.title)
|
||
}
|
||
|
||
if (next.published_at) {
|
||
next.published_at = normalizeImportedDateTime(next.published_at)
|
||
}
|
||
|
||
return next
|
||
}
|
||
|
||
function buildCourseLessonImportPrompt(courseTitle, difficulty) {
|
||
return [
|
||
'Create valid JSON only for a Skinbase Academy course lesson import.',
|
||
'Do not wrap the answer in markdown fences.',
|
||
'Return an object with this shape:',
|
||
'{',
|
||
' "defaults": {',
|
||
` "difficulty": "${String(difficulty || 'beginner')}",`,
|
||
' "access_level": "free",',
|
||
' "lesson_type": "article",',
|
||
' "active": false',
|
||
' },',
|
||
' "lessons": [',
|
||
' {',
|
||
' "order": 1,',
|
||
' "title": "Lesson title",',
|
||
' "slug": "lesson-title",',
|
||
' "goal": "One sentence learning goal for the lesson."',
|
||
' }',
|
||
' ]',
|
||
'}',
|
||
'Requirements:',
|
||
'- One lesson object per TOC row.',
|
||
'- Keep slugs lowercase and hyphenated.',
|
||
'- Keep goal concise and outcome-focused.',
|
||
'- Do not include body content or HTML. These should stay empty lesson shells.',
|
||
'- If category is known, you may include category or category_slug.',
|
||
`Course title: ${String(courseTitle || 'Untitled academy course')}`,
|
||
].join('\n')
|
||
}
|
||
|
||
function CourseJsonImportDialog({ open, value, error, exampleValue, promptValue, actionLabel = 'Apply JSON', processing = false, onChange, onClose, onApply, onCopyExample, onCopyPrompt }) {
|
||
const backdropRef = useRef(null)
|
||
const [activeReferenceTab, setActiveReferenceTab] = useState('import')
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
setActiveReferenceTab('import')
|
||
}
|
||
}, [open])
|
||
|
||
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-start justify-center overflow-y-auto bg-[#04070dcc] px-4 py-4 backdrop-blur-md sm:items-center sm:px-6 sm:py-6"
|
||
onClick={(event) => {
|
||
if (event.target === backdropRef.current) {
|
||
onClose?.()
|
||
}
|
||
}}
|
||
role="presentation"
|
||
>
|
||
<div role="dialog" aria-modal="true" aria-labelledby="course-json-import-title" className="flex max-h-[calc(100vh-2rem)] w-full max-w-6xl flex-col 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)] sm:max-h-[calc(100vh-3rem)]">
|
||
<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">Course JSON import</p>
|
||
<h3 id="course-json-import-title" className="mt-2 text-lg font-semibold text-white">Paste course import JSON</h3>
|
||
<p className="mt-2 max-w-4xl text-sm leading-6 text-white/65">Use this to seed the course form from AI output, editorial drafts, or migrated course data without opening each field manually.</p>
|
||
</div>
|
||
|
||
<div className="border-b border-white/[0.06] px-4 py-4">
|
||
<div className="grid gap-2 md:grid-cols-3">
|
||
{[
|
||
{ id: 'import', label: 'Import', description: 'Paste course JSON and apply it to the form.' },
|
||
{ id: 'docs', label: 'Documentation', description: 'Field guide and import rules.' },
|
||
{ id: 'prompts', label: 'Prompt library', description: 'Copy prompts for ChatGPT.' },
|
||
].map((tab) => {
|
||
const isActive = tab.id === activeReferenceTab
|
||
|
||
return (
|
||
<button key={tab.id} type="button" onClick={() => setActiveReferenceTab(tab.id)} className={[
|
||
'rounded-2xl border px-4 py-3 text-left 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-slate-300 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white',
|
||
].join(' ')}>
|
||
<div className="text-sm font-semibold">{tab.label}</div>
|
||
<div className="mt-1 text-xs leading-5 text-current/70">{tab.description}</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||
{activeReferenceTab === 'import' ? (
|
||
<div className="grid min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||
<div className="grid gap-3">
|
||
<textarea
|
||
value={value}
|
||
onChange={(event) => onChange?.(event.target.value)}
|
||
rows={18}
|
||
placeholder={exampleValue}
|
||
className="w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-4 font-mono text-sm leading-6 text-white outline-none placeholder:text-white/30"
|
||
/>
|
||
{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="grid content-start gap-4">
|
||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Example JSON</div>
|
||
<button type="button" onClick={onCopyExample} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]">Copy example</button>
|
||
</div>
|
||
<pre className="mt-3 max-h-[360px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300 whitespace-pre-wrap">{exampleValue}</pre>
|
||
</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">Recognized keys</div>
|
||
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
||
<p>title, slug, subtitle, excerpt, description</p>
|
||
<p>cover_image, teaser_image</p>
|
||
<p>access_level, difficulty, status</p>
|
||
<p>order_num, estimated_minutes, published_at</p>
|
||
<p>seo_title, seo_description</p>
|
||
<p>meta_keywords</p>
|
||
<p>og_title, og_description, og_image</p>
|
||
<p>is_featured</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{activeReferenceTab === 'docs' ? (
|
||
<div className="grid min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-300">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Field guide</div>
|
||
<div className="mt-3 space-y-3 text-slate-400">
|
||
<p><strong className="text-slate-200">title</strong> - public course name used on cards, headers, and metadata.</p>
|
||
<p><strong className="text-slate-200">slug</strong> - URL slug. If omitted, it will be generated from the title.</p>
|
||
<p><strong className="text-slate-200">subtitle</strong> - compact supporting line shown near the title.</p>
|
||
<p><strong className="text-slate-200">excerpt</strong> - short summary for cards and compact previews.</p>
|
||
<p><strong className="text-slate-200">description</strong> - longer editorial description or syllabus overview.</p>
|
||
<p><strong className="text-slate-200">cover_image</strong> and <strong className="text-slate-200">teaser_image</strong> - stored paths or external URLs.</p>
|
||
<p><strong className="text-slate-200">access_level</strong> - use <strong className="text-slate-200">free</strong>, <strong className="text-slate-200">premium</strong>, or <strong className="text-slate-200">mixed</strong>.</p>
|
||
<p><strong className="text-slate-200">difficulty</strong> - use <strong className="text-slate-200">beginner</strong>, <strong className="text-slate-200">intermediate</strong>, or <strong className="text-slate-200">advanced</strong>.</p>
|
||
<p><strong className="text-slate-200">status</strong> - use <strong className="text-slate-200">draft</strong>, <strong className="text-slate-200">review</strong>, <strong className="text-slate-200">published</strong>, or <strong className="text-slate-200">archived</strong>.</p>
|
||
<p><strong className="text-slate-200">published_at</strong> - accepts ISO strings or <strong className="text-slate-200">YYYY-MM-DDTHH:mm</strong>.</p>
|
||
<p><strong className="text-slate-200">meta_keywords</strong> - array or comma-separated string.</p>
|
||
<p><strong className="text-slate-200">is_featured</strong> - JSON boolean.</p>
|
||
</div>
|
||
</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">Import rules</div>
|
||
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
||
<p>Unknown keys are ignored, so broader AI output is safe to paste.</p>
|
||
<p>Use JSON booleans for <strong className="text-slate-200">is_featured</strong>.</p>
|
||
<p>Keep <strong className="text-slate-200">order_num</strong> and <strong className="text-slate-200">estimated_minutes</strong> numeric.</p>
|
||
<p>Use <strong className="text-slate-200">published_at</strong> only when you want a prefilled publish timestamp.</p>
|
||
<p>Separate title, summary, and metadata so the course form stays readable after import.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{activeReferenceTab === 'prompts' ? (
|
||
<div className="grid min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||
<div className="grid min-w-0 gap-4">
|
||
{[
|
||
{
|
||
title: 'Draft a course JSON object from a syllabus',
|
||
prompt: `Create valid JSON only for a Skinbase Academy course import.\n\nReturn a single object with these keys when relevant: title, slug, subtitle, excerpt, description, cover_image, teaser_image, access_level, difficulty, status, order_num, estimated_minutes, published_at, seo_title, seo_description, meta_keywords, og_title, og_description, og_image, is_featured.\n\nRules:\n- Do not use markdown fences or explanation text.\n- Omit unknown keys.\n- Use strings for text, booleans for featured flags, and YYYY-MM-DDTHH:mm for published_at.\n- meta_keywords may be an array or comma-separated string.\n- Keep the tone editorial, concise, and import-safe.`,
|
||
},
|
||
{
|
||
title: 'Turn AI notes into course import JSON',
|
||
prompt: `You are preparing JSON for a Skinbase Academy course form. Output JSON only.\n\nWrite a single object with clean course metadata. Use lowercase hyphenated slugs, concise excerpts, and production-ready SEO fields. If the source notes do not mention a field, omit it. Set is_featured to true only when the course should be highlighted.`,
|
||
},
|
||
].map((example) => (
|
||
<div key={example.title} className="min-w-0 overflow-hidden rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div className="min-w-0 flex-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200/70">{example.title}</div>
|
||
<button type="button" onClick={() => onCopyPrompt?.(example.prompt, example.title)} className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]">Copy prompt</button>
|
||
</div>
|
||
<pre className="mt-3 max-h-56 min-w-0 overflow-auto whitespace-pre-wrap break-words rounded-[18px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-6 text-slate-200 [overflow-wrap:anywhere]">{example.prompt}</pre>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="min-w-0 self-start 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">Prompt tips</div>
|
||
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
||
<p>Ask the model to return JSON only, with no markdown or commentary.</p>
|
||
<p>Tell it to omit any field it cannot populate confidently.</p>
|
||
<p>Use <strong className="text-slate-200">slug</strong> only when you need a custom URL, otherwise let the form derive it from the title.</p>
|
||
<p>Keep <strong className="text-slate-200">meta_keywords</strong> short and focused on search intent.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</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?.()} disabled={processing} 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 disabled:cursor-not-allowed disabled:opacity-60">{processing ? 'Working...' : actionLabel}</button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)
|
||
}
|
||
|
||
function parseCourseLessonImport(rawText, categoryOptions = []) {
|
||
let parsed
|
||
|
||
try {
|
||
parsed = JSON.parse(String(rawText || ''))
|
||
} catch {
|
||
throw new Error('Could not parse JSON.')
|
||
}
|
||
|
||
const root = Array.isArray(parsed) ? { lessons: parsed } : parsed
|
||
|
||
if (!root || typeof root !== 'object' || Array.isArray(root)) {
|
||
throw new Error('Import JSON must be an array or an object with a lessons array.')
|
||
}
|
||
|
||
const lessonSource = Array.isArray(root.lessons)
|
||
? root.lessons
|
||
: Array.isArray(root.toc)
|
||
? root.toc
|
||
: Array.isArray(root.items)
|
||
? root.items
|
||
: null
|
||
|
||
if (!Array.isArray(lessonSource) || lessonSource.length === 0) {
|
||
throw new Error('Import JSON must contain a lessons array with at least one item.')
|
||
}
|
||
|
||
const categoryMatches = Array.isArray(categoryOptions) ? categoryOptions : []
|
||
const defaultsSource = root.defaults && typeof root.defaults === 'object' && !Array.isArray(root.defaults) ? root.defaults : {}
|
||
const defaults = {}
|
||
|
||
if (defaultsSource.category_id != null) defaults.category_id = Number(defaultsSource.category_id)
|
||
if (defaultsSource.category_slug != null) defaults.category_slug = String(defaultsSource.category_slug).trim()
|
||
if (defaultsSource.category != null) defaults.category = String(defaultsSource.category).trim()
|
||
if (defaultsSource.difficulty != null) defaults.difficulty = String(defaultsSource.difficulty).trim()
|
||
if (defaultsSource.access_level != null) defaults.access_level = String(defaultsSource.access_level).trim()
|
||
if (defaultsSource.lesson_type != null) defaults.lesson_type = String(defaultsSource.lesson_type).trim()
|
||
if (defaultsSource.series_name != null) defaults.series_name = String(defaultsSource.series_name).trim()
|
||
if (defaultsSource.active != null) defaults.active = normalizeImportBoolean(defaultsSource.active, false)
|
||
|
||
const lessons = lessonSource
|
||
.map((item, index) => {
|
||
const source = item && typeof item === 'object' && !Array.isArray(item)
|
||
? item
|
||
: { title: String(item || '') }
|
||
const title = String(source.title ?? source.lesson_title ?? source.lesson ?? source.name ?? '').trim()
|
||
|
||
if (!title) {
|
||
return null
|
||
}
|
||
|
||
const next = {
|
||
title,
|
||
slug: String(source.slug ?? '').trim(),
|
||
goal: String(source.goal ?? source.objective ?? source.summary ?? source.description ?? '').trim(),
|
||
excerpt: String(source.excerpt ?? '').trim(),
|
||
difficulty: String(source.difficulty ?? '').trim(),
|
||
access_level: String(source.access_level ?? source.access ?? '').trim(),
|
||
lesson_type: String(source.lesson_type ?? source.type ?? '').trim(),
|
||
series_name: String(source.series_name ?? '').trim(),
|
||
tags: normalizeImportTags(source.tags),
|
||
active: source.active == null ? undefined : normalizeImportBoolean(source.active, false),
|
||
_sortOrder: Number(source.order ?? source.lesson_number ?? source.position ?? (index + 1)),
|
||
}
|
||
|
||
const requestedCategory = String(source.category_id ?? source.category_slug ?? source.category ?? '').trim().toLowerCase()
|
||
|
||
if (requestedCategory) {
|
||
const match = categoryMatches.find((option) => [option.id, option.value, option.slug, option.name, option.label]
|
||
.filter((candidate) => candidate != null)
|
||
.map((candidate) => String(candidate).trim().toLowerCase())
|
||
.includes(requestedCategory))
|
||
|
||
if (match?.id != null) {
|
||
next.category_id = Number(match.id)
|
||
} else if (source.category_slug != null) {
|
||
next.category_slug = String(source.category_slug).trim()
|
||
} else if (source.category != null) {
|
||
next.category = String(source.category).trim()
|
||
}
|
||
}
|
||
|
||
return next
|
||
})
|
||
.filter(Boolean)
|
||
.sort((a, b) => a._sortOrder - b._sortOrder)
|
||
.map(({ _sortOrder, ...lesson }) => lesson)
|
||
|
||
if (lessons.length === 0) {
|
||
throw new Error('The JSON did not contain any lesson rows with a title.')
|
||
}
|
||
|
||
return { defaults, lessons }
|
||
}
|
||
|
||
function CourseLessonJsonImportDialog({ open, value, error, exampleValue, promptValue, onChange, onClose, onApply, onCopyExample, onCopyPrompt }) {
|
||
const backdropRef = useRef(null)
|
||
const [activeReferenceTab, setActiveReferenceTab] = useState('structure')
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
setActiveReferenceTab('structure')
|
||
}
|
||
}, [open])
|
||
|
||
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-start justify-center overflow-y-auto bg-[#04070dcc] px-4 py-4 backdrop-blur-md sm:items-center sm:px-6 sm:py-6"
|
||
onClick={(event) => {
|
||
if (event.target === backdropRef.current) {
|
||
onClose?.()
|
||
}
|
||
}}
|
||
role="presentation"
|
||
>
|
||
<div className="flex max-h-[calc(100vh-2rem)] w-full max-w-6xl flex-col 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)] sm:max-h-[calc(100vh-3rem)]">
|
||
<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">Course TOC Import</p>
|
||
<h3 className="mt-2 text-lg font-semibold text-white">Paste lesson import JSON</h3>
|
||
<p className="mt-2 max-w-4xl text-sm leading-6 text-white/65">Paste a table-of-contents payload here and the editor will create empty lesson pages already attached to this course in the imported order.</p>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||
<div className="grid gap-5 px-6 py-5 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
|
||
<div className="grid gap-3">
|
||
<textarea
|
||
value={value}
|
||
onChange={(event) => onChange?.(event.target.value)}
|
||
rows={16}
|
||
placeholder={exampleValue}
|
||
className="min-h-[320px] 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="grid content-start gap-4">
|
||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-2">
|
||
<div className="flex flex-wrap gap-2" role="tablist" aria-label="Import help panels">
|
||
{[
|
||
{ id: 'structure', label: 'Structure', icon: 'fa-brackets-curly' },
|
||
{ id: 'prompt', label: 'Prompt', icon: 'fa-wand-magic-sparkles' },
|
||
{ id: 'applied', label: 'Applied', icon: 'fa-list-check' },
|
||
].map((tab) => {
|
||
const isActive = tab.id === activeReferenceTab
|
||
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={isActive}
|
||
onClick={() => setActiveReferenceTab(tab.id)}
|
||
className={[
|
||
'inline-flex items-center gap-2 rounded-2xl border px-3.5 py-2 text-xs font-semibold uppercase tracking-[0.14em] 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-slate-400 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white',
|
||
].join(' ')}
|
||
>
|
||
<i className={`fa-solid ${tab.icon} text-[10px]`} />
|
||
<span>{tab.label}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="mt-3 rounded-[20px] border border-white/10 bg-slate-950/50 p-4 text-sm text-slate-300">
|
||
{activeReferenceTab === 'structure' ? (
|
||
<div>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Accepted structure</div>
|
||
<button type="button" onClick={onCopyExample} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]">Copy example</button>
|
||
</div>
|
||
<pre className="mt-3 max-h-[360px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300">{exampleValue}</pre>
|
||
</div>
|
||
) : null}
|
||
|
||
{activeReferenceTab === 'prompt' ? (
|
||
<div>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">ChatGPT helper prompt</div>
|
||
<button type="button" onClick={onCopyPrompt} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]">Copy prompt</button>
|
||
</div>
|
||
<pre className="mt-3 max-h-[360px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300 whitespace-pre-wrap">{promptValue}</pre>
|
||
</div>
|
||
) : null}
|
||
|
||
{activeReferenceTab === 'applied' ? (
|
||
<div>
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">What gets applied</div>
|
||
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
||
<p>Each row creates a new Academy lesson record.</p>
|
||
<p>The lesson is attached to this course in the imported order.</p>
|
||
<p>Title, slug, goal/excerpt, category, difficulty, access, and lesson type can be imported.</p>
|
||
<p>Body content stays empty so the lesson can be written later.</p>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</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 gap-2 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">
|
||
<i className="fa-solid fa-file-import text-xs" />
|
||
<span>Import lessons</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)
|
||
}
|
||
|
||
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 courseSectionsSource = useMemo(() => Array.isArray(editorContext?.courseSections) ? editorContext.courseSections : [], [editorContext])
|
||
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 importLessonsUrl = editorContext?.importLessonsUrl || null
|
||
const sectionStoreUrl = editorContext?.sectionStoreUrl || 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 lessonCategoryOptions = useMemo(() => Array.isArray(editorContext?.lessonCategoryOptions) ? editorContext.lessonCategoryOptions : [], [editorContext])
|
||
const courseImportUrl = editorContext?.courseImportUrl || null
|
||
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 [jsonImportOpen, setJsonImportOpen] = useState(false)
|
||
const [jsonImportValue, setJsonImportValue] = useState('')
|
||
const [jsonImportError, setJsonImportError] = useState('')
|
||
const [courseJsonImportOpen, setCourseJsonImportOpen] = useState(false)
|
||
const [courseJsonImportValue, setCourseJsonImportValue] = useState('')
|
||
const [courseJsonImportError, setCourseJsonImportError] = useState('')
|
||
const [courseJsonImportProcessing, setCourseJsonImportProcessing] = useState(false)
|
||
const [toast, setToast] = useState({ id: 0, visible: false, message: '', variant: 'success' })
|
||
const lessonManagerIsDirty = useMemo(() => lessonManagerSignature(lessonManagerDraft) !== lessonManagerSignature(courseLessonsSource), [lessonManagerDraft, courseLessonsSource])
|
||
const importPromptValue = useMemo(() => buildCourseLessonImportPrompt(form.data.title || title, form.data.difficulty || 'beginner'), [form.data.difficulty, form.data.title, title])
|
||
const importExampleValue = useMemo(() => JSON.stringify({
|
||
defaults: {
|
||
difficulty: String(form.data.difficulty || 'beginner'),
|
||
access_level: 'free',
|
||
lesson_type: 'article',
|
||
active: false,
|
||
},
|
||
lessons: [
|
||
{
|
||
order: 1,
|
||
title: 'What Makes a Great Wallpaper Prompt?',
|
||
slug: 'what-makes-a-great-wallpaper-prompt',
|
||
goal: 'Explain what separates random AI images from clean, usable wallpapers.',
|
||
},
|
||
{
|
||
order: 2,
|
||
title: 'The Anatomy of a Strong AI Art Prompt',
|
||
slug: 'the-anatomy-of-a-strong-ai-art-prompt',
|
||
goal: 'Teach the core prompt structure: subject, scene, style, lighting, composition, quality, and restrictions.',
|
||
},
|
||
],
|
||
}, null, 2), [form.data.difficulty])
|
||
const courseImportPromptValue = useMemo(() => buildCourseJsonImportPrompt(form.data.title || title), [form.data.title, title])
|
||
const courseImportExampleValue = useMemo(() => JSON.stringify({
|
||
title: form.data.title || 'Editorial course title',
|
||
slug: slugifyCourseTitle(form.data.title || 'Editorial course title'),
|
||
subtitle: 'Short positioning line for the course.',
|
||
excerpt: 'One or two sentences that summarize the course.',
|
||
description: 'Long form course description, syllabus overview, or editorial pitch.',
|
||
cover_image: 'https://files.skinbase.org/path/to/course-cover.webp',
|
||
teaser_image: 'https://files.skinbase.org/path/to/course-teaser.webp',
|
||
access_level: String(form.data.access_level || 'free'),
|
||
difficulty: String(form.data.difficulty || 'beginner'),
|
||
status: String(form.data.status || 'draft'),
|
||
order_num: Number(form.data.order_num || 0),
|
||
estimated_minutes: Number(form.data.estimated_minutes || 45),
|
||
published_at: '2026-05-17T10:00',
|
||
seo_title: 'Editorial course SEO title',
|
||
seo_description: 'Editorial course SEO description for search and social previews.',
|
||
meta_keywords: ['academy course', 'editorial workflow', 'learning path'],
|
||
og_title: 'OpenGraph title',
|
||
og_description: 'OpenGraph description',
|
||
og_image: 'https://files.skinbase.org/path/to/course-og.webp',
|
||
is_featured: Boolean(form.data.is_featured),
|
||
}, null, 2), [form.data.access_level, form.data.difficulty, form.data.estimated_minutes, form.data.is_featured, form.data.order_num, form.data.status, form.data.title, title])
|
||
const showToast = (message, variant = 'error') => {
|
||
setToast({
|
||
id: Date.now() + Math.random(),
|
||
visible: true,
|
||
message,
|
||
variant,
|
||
})
|
||
}
|
||
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])
|
||
const sectionOptions = useMemo(() => [{ value: '', label: 'No section' }, ...courseSectionsSource.map((section) => ({ value: String(section.id), label: section.title }))], [courseSectionsSource])
|
||
const nextSectionOrderNum = useMemo(() => courseSectionsSource.reduce((maxOrder, section) => Math.max(maxOrder, Number(section?.order_num || 0)), -1) + 1, [courseSectionsSource])
|
||
|
||
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: courseSectionsSource.map((section) => ({
|
||
id: section.id,
|
||
order_num: Number(section.order_num || 0),
|
||
})),
|
||
lessons: lessonManagerDraft.map((l) => ({
|
||
id: l.id,
|
||
order_num: l.order_num,
|
||
section_id: l.section_id ?? null,
|
||
})),
|
||
}, {
|
||
preserveScroll: true,
|
||
onFinish: () => setLessonSaveProcessing(false),
|
||
})
|
||
}
|
||
|
||
const copyImportText = async (value, label) => {
|
||
try {
|
||
await navigator.clipboard.writeText(value)
|
||
showToast(`${label} copied.`, 'success')
|
||
} catch {
|
||
showToast(`Could not copy ${label.toLowerCase()}.`, 'error')
|
||
}
|
||
}
|
||
|
||
const applyCourseJsonImport = () => {
|
||
try {
|
||
const payload = parseCourseJsonImport(courseJsonImportValue)
|
||
setCourseJsonImportError('')
|
||
|
||
if (courseImportUrl && method === 'post') {
|
||
setCourseJsonImportProcessing(true)
|
||
|
||
router.post(courseImportUrl, payload, {
|
||
preserveScroll: true,
|
||
onError: (errors) => {
|
||
const message = firstErrorMessage(errors, 'Could not import the course JSON.')
|
||
setCourseJsonImportError(message)
|
||
showToast(message, 'error')
|
||
},
|
||
onSuccess: () => {
|
||
setCourseJsonImportOpen(false)
|
||
setCourseJsonImportValue('')
|
||
setCourseJsonImportError('')
|
||
showToast('Course JSON imported.', 'success')
|
||
},
|
||
onFinish: () => setCourseJsonImportProcessing(false),
|
||
})
|
||
|
||
return
|
||
}
|
||
|
||
Object.entries(payload).forEach(([key, value]) => {
|
||
form.setData(key, value)
|
||
})
|
||
|
||
if (!payload.slug && payload.title) {
|
||
form.setData('slug', slugifyCourseTitle(payload.title))
|
||
}
|
||
|
||
setCourseJsonImportOpen(false)
|
||
setCourseJsonImportValue('')
|
||
showToast('Course JSON applied.', 'success')
|
||
} catch (error) {
|
||
setCourseJsonImportError(error instanceof Error ? error.message : 'Could not parse JSON.')
|
||
}
|
||
}
|
||
|
||
const applyLessonImport = () => {
|
||
if (!importLessonsUrl) return
|
||
|
||
try {
|
||
const payload = parseCourseLessonImport(jsonImportValue, lessonCategoryOptions)
|
||
setJsonImportError('')
|
||
|
||
router.post(importLessonsUrl, payload, {
|
||
preserveScroll: true,
|
||
onError: (errors) => {
|
||
const message = firstErrorMessage(errors, 'Could not import the lesson TOC.')
|
||
setJsonImportError(message)
|
||
showToast(message, 'error')
|
||
},
|
||
onSuccess: () => {
|
||
setJsonImportOpen(false)
|
||
setJsonImportValue('')
|
||
setJsonImportError('')
|
||
showToast('Lesson TOC imported.', 'success')
|
||
},
|
||
})
|
||
} catch (error) {
|
||
setJsonImportError(error instanceof Error ? error.message : 'Could not parse JSON.')
|
||
}
|
||
}
|
||
|
||
const updateDraftLessonSection = (lessonId, nextSectionId) => {
|
||
setLessonManagerDraft((current) => normalizeLessonManagerLessons(current.map((lesson) => {
|
||
if (Number(lesson.id) !== Number(lessonId)) {
|
||
return lesson
|
||
}
|
||
|
||
return {
|
||
...lesson,
|
||
section_id: nextSectionId === '' ? null : Number(nextSectionId),
|
||
section_title: nextSectionId === ''
|
||
? ''
|
||
: (courseSectionsSource.find((section) => String(section.id) === String(nextSectionId))?.title || ''),
|
||
}
|
||
})))
|
||
}
|
||
|
||
const handleManualTeaserChange = (nextValue) => {
|
||
setStagedTeaserPath('')
|
||
form.setData('teaser_image', nextValue)
|
||
setTeaserPreviewUrl(normalizeAssetPreview(nextValue, editorContext.coverCdnBaseUrl))
|
||
}
|
||
|
||
const submit = (event) => {
|
||
event.preventDefault()
|
||
|
||
const submitOptions = {
|
||
preserveScroll: true,
|
||
onError: (errors) => {
|
||
const nextTab = firstCourseErrorTab(errors)
|
||
|
||
if (nextTab) {
|
||
setActiveTab(nextTab)
|
||
}
|
||
|
||
showToast(firstErrorMessage(errors), 'error')
|
||
},
|
||
}
|
||
|
||
if (method === 'patch') {
|
||
form.patch(submitUrl, submitOptions)
|
||
return
|
||
}
|
||
|
||
form.post(submitUrl, submitOptions)
|
||
}
|
||
|
||
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">
|
||
{importLessonsUrl ? (
|
||
<button type="button" onClick={() => setJsonImportOpen(true)} className="inline-flex items-center gap-2 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]">
|
||
<i className="fa-solid fa-file-import text-xs" />
|
||
<span>Import lessons JSON</span>
|
||
</button>
|
||
) : null}
|
||
<button type="button" onClick={() => setCourseJsonImportOpen(true)} className="inline-flex items-center gap-2 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]">
|
||
<i className="fa-solid fa-file-import text-xs" />
|
||
<span>Import JSON</span>
|
||
</button>
|
||
{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={
|
||
<div className="flex flex-wrap gap-2">
|
||
{importLessonsUrl ? (
|
||
<button type="button" onClick={() => setJsonImportOpen(true)} className="inline-flex items-center gap-2 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]">
|
||
<i className="fa-solid fa-file-import text-xs" />
|
||
<span>Import TOC JSON</span>
|
||
</button>
|
||
) : null}
|
||
{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}
|
||
</div>
|
||
}
|
||
>
|
||
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||
<CourseSectionCreateCard storeUrl={sectionStoreUrl} nextOrderNum={nextSectionOrderNum} />
|
||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Sections in this course</p>
|
||
{courseSectionsSource.length > 0 ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{courseSectionsSource.length} total</span> : null}
|
||
</div>
|
||
{courseSectionsSource.length === 0 ? (
|
||
<div className="mt-4 rounded-[20px] border border-dashed border-white/10 bg-black/20 px-4 py-5 text-sm text-slate-400">No sections yet. Create one here, then assign lessons into it below.</div>
|
||
) : (
|
||
<div className="mt-4 grid gap-3">
|
||
{courseSectionsSource.map((section) => <CourseSectionCard key={section.id} section={section} />)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Current lesson sequence */}
|
||
<div className="mt-6 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" />
|
||
{lesson.cover_image_url ? (
|
||
<div className="h-14 w-20 overflow-hidden rounded-2xl border border-white/10 bg-black/20">
|
||
<img src={lesson.cover_image_url} alt="" className="h-full w-full object-cover" loading="lazy" />
|
||
</div>
|
||
) : null}
|
||
<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}
|
||
<span className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${lessonActivityBadgeMeta(lesson).className}`}>
|
||
{lessonActivityBadgeMeta(lesson).label}
|
||
</span>
|
||
<span className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${lessonPublicationBadgeMeta(lesson).className}`}>
|
||
{lessonPublicationBadgeMeta(lesson).label}
|
||
</span>
|
||
</div>
|
||
<p className="mt-1.5 truncate text-sm font-semibold text-white">{lesson.title}</p>
|
||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||
<label className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Section</label>
|
||
<select
|
||
value={lesson.section_id == null ? '' : String(lesson.section_id)}
|
||
onChange={(event) => updateDraftLessonSection(lesson.id, event.target.value)}
|
||
className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-xs font-semibold text-white outline-none"
|
||
>
|
||
{sectionOptions.map((option) => (
|
||
<option key={option.value || 'none'} value={option.value} className="bg-slate-950 text-white">{option.label}</option>
|
||
))}
|
||
</select>
|
||
<span className="text-xs text-slate-500">Pick a section here, then save order to apply the grouping.</span>
|
||
</div>
|
||
</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 unassigned 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 unassigned 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 unassigned lessons match your search.' : 'All lessons are already assigned to a 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="flex min-w-0 items-center gap-3">
|
||
{lesson.cover_image_url ? (
|
||
<div className="h-14 w-20 overflow-hidden rounded-2xl border border-white/10 bg-black/20">
|
||
<img src={lesson.cover_image_url} alt="" className="h-full w-full object-cover" loading="lazy" />
|
||
</div>
|
||
) : null}
|
||
<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}
|
||
<span className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${lessonActivityBadgeMeta(lesson).className}`}>
|
||
{lessonActivityBadgeMeta(lesson).label}
|
||
</span>
|
||
<span className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${lessonPublicationBadgeMeta(lesson).className}`}>
|
||
{lessonPublicationBadgeMeta(lesson).label}
|
||
</span>
|
||
</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">
|
||
{lesson.edit_url ? (
|
||
<a href={lesson.edit_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">
|
||
<i className="fa-solid fa-pen-to-square text-[10px]" />
|
||
<span>Edit lesson</span>
|
||
</a>
|
||
) : null}
|
||
<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>
|
||
)}
|
||
</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 section controls above to group them into chapters.`
|
||
: '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>
|
||
|
||
<CourseJsonImportDialog
|
||
open={courseJsonImportOpen}
|
||
value={courseJsonImportValue}
|
||
error={courseJsonImportError}
|
||
exampleValue={courseImportExampleValue}
|
||
promptValue={courseImportPromptValue}
|
||
actionLabel={courseImportUrl && method === 'post' ? 'Create course from JSON' : 'Apply JSON'}
|
||
processing={courseJsonImportProcessing}
|
||
onChange={setCourseJsonImportValue}
|
||
onClose={() => {
|
||
setCourseJsonImportOpen(false)
|
||
setCourseJsonImportError('')
|
||
}}
|
||
onApply={applyCourseJsonImport}
|
||
onCopyExample={() => copyImportText(courseImportExampleValue, 'Course example')}
|
||
onCopyPrompt={() => copyImportText(courseImportPromptValue, 'Course prompt')}
|
||
/>
|
||
|
||
<CourseLessonJsonImportDialog
|
||
open={jsonImportOpen}
|
||
value={jsonImportValue}
|
||
error={jsonImportError}
|
||
exampleValue={importExampleValue}
|
||
promptValue={importPromptValue}
|
||
onChange={setJsonImportValue}
|
||
onClose={() => setJsonImportOpen(false)}
|
||
onApply={applyLessonImport}
|
||
onCopyExample={() => copyImportText(importExampleValue, 'Example JSON')}
|
||
onCopyPrompt={() => copyImportText(importPromptValue, 'ChatGPT prompt')}
|
||
/>
|
||
|
||
<ShareToast
|
||
key={toast.id}
|
||
message={toast.message}
|
||
visible={toast.visible}
|
||
variant={toast.variant}
|
||
duration={toast.variant === 'error' ? 3200 : 2200}
|
||
onHide={() => setToast((current) => ({ ...current, visible: false }))}
|
||
/>
|
||
</AdminLayout>
|
||
)
|
||
} |