2119 lines
120 KiB
JavaScript
2119 lines
120 KiB
JavaScript
import React, { startTransition, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { Head, Link, router, useForm } from '@inertiajs/react'
|
|
import { marked } from 'marked'
|
|
import AdminLayout from '../../../Layouts/AdminLayout'
|
|
import RichTextEditor from '../../../components/forum/RichTextEditor'
|
|
import WorldMediaUploadField from '../../../components/worlds/editor/WorldMediaUploadField'
|
|
import DateTimePicker from '../../../components/ui/DateTimePicker'
|
|
import NovaSelect from '../../../components/ui/NovaSelect'
|
|
|
|
let lessonMarkdownTurndown = null
|
|
let lessonMarkdownTurndownPromise = null
|
|
|
|
async function loadLessonMarkdownTurndown() {
|
|
if (lessonMarkdownTurndown) {
|
|
return lessonMarkdownTurndown
|
|
}
|
|
|
|
if (typeof window === 'undefined') {
|
|
return null
|
|
}
|
|
|
|
if (!lessonMarkdownTurndownPromise) {
|
|
lessonMarkdownTurndownPromise = import('turndown')
|
|
.then(({ default: TurndownService }) => new TurndownService({
|
|
headingStyle: 'atx',
|
|
codeBlockStyle: 'fenced',
|
|
bulletListMarker: '-',
|
|
emDelimiter: '*',
|
|
}))
|
|
.then((service) => {
|
|
lessonMarkdownTurndown = service
|
|
return service
|
|
})
|
|
.catch(() => null)
|
|
}
|
|
|
|
return lessonMarkdownTurndownPromise
|
|
}
|
|
|
|
function getField(fields, name) {
|
|
return fields.find((field) => field.name === name) || null
|
|
}
|
|
|
|
const LESSON_EDITOR_TABS = [
|
|
{
|
|
id: 'write',
|
|
label: 'Write',
|
|
description: 'Headline, summary, and the full lesson article.',
|
|
icon: 'fa-pen-nib',
|
|
sections: ['lesson-story-setup', 'lesson-body-editor'],
|
|
},
|
|
{
|
|
id: 'blocks',
|
|
label: 'Blocks',
|
|
description: 'Reusable AI comparison modules and structured lesson inserts.',
|
|
icon: 'fa-layer-group',
|
|
sections: ['lesson-ai-comparisons'],
|
|
},
|
|
{
|
|
id: 'courses',
|
|
label: 'Courses',
|
|
description: 'Attach this lesson to courses, manage its public numbering, and reorder it inside guided paths.',
|
|
icon: 'fa-diagram-project',
|
|
sections: ['lesson-course-numbering', 'lesson-course-manager'],
|
|
},
|
|
{
|
|
id: 'publish',
|
|
label: 'Publish',
|
|
description: 'Visibility, discovery settings, scheduling, and search surfaces.',
|
|
icon: 'fa-rocket-launch',
|
|
sections: ['lesson-publishing', 'lesson-seo'],
|
|
},
|
|
{
|
|
id: 'assets',
|
|
label: 'Assets',
|
|
description: 'Categories, hero media, and article imagery.',
|
|
icon: 'fa-images',
|
|
sections: ['lesson-categories', 'lesson-cover', 'lesson-article-cover'],
|
|
},
|
|
{
|
|
id: 'revisions',
|
|
label: 'Revisions',
|
|
description: 'Review saved lesson snapshots and restore the full lesson or a single field.',
|
|
icon: 'fa-clock-rotate-left',
|
|
sections: ['lesson-revisions'],
|
|
},
|
|
{
|
|
id: 'preview',
|
|
label: 'Preview',
|
|
description: 'Preview the lesson card, article imagery, and rendered body.',
|
|
icon: 'fa-eye',
|
|
sections: ['lesson-preview'],
|
|
},
|
|
]
|
|
|
|
const LESSON_FIELD_TAB_MAP = {
|
|
title: 'write',
|
|
slug: 'write',
|
|
excerpt: 'write',
|
|
content: 'write',
|
|
content_markdown: 'write',
|
|
lesson_type: 'publish',
|
|
difficulty: 'publish',
|
|
access_level: 'publish',
|
|
reading_minutes: 'publish',
|
|
tags: 'publish',
|
|
series_name: 'publish',
|
|
lesson_number: 'courses',
|
|
course_order: 'courses',
|
|
course_ids: 'courses',
|
|
category_id: 'assets',
|
|
published_at: 'publish',
|
|
featured: 'publish',
|
|
active: 'publish',
|
|
seo_title: 'publish',
|
|
seo_description: 'publish',
|
|
video_url: 'publish',
|
|
cover_image: 'assets',
|
|
article_cover_image: 'assets',
|
|
}
|
|
|
|
const LESSON_REVISION_FIELD_OPTIONS = [
|
|
{ value: 'title', label: 'Title' },
|
|
{ value: 'slug', label: 'Slug' },
|
|
{ value: 'lesson_number', label: 'Lesson number' },
|
|
{ value: 'course_order', label: 'Course order' },
|
|
{ value: 'series_name', label: 'Series name' },
|
|
{ value: 'excerpt', label: 'Excerpt' },
|
|
{ value: 'content', label: 'Article body' },
|
|
{ value: 'difficulty', label: 'Difficulty' },
|
|
{ value: 'access_level', label: 'Access level' },
|
|
{ value: 'lesson_type', label: 'Lesson type' },
|
|
{ value: 'cover_image', label: 'Cover image' },
|
|
{ value: 'article_cover_image', label: 'Article cover image' },
|
|
{ value: 'tags', label: 'Microtags' },
|
|
{ value: 'video_url', label: 'Video URL' },
|
|
{ value: 'reading_minutes', label: 'Reading minutes' },
|
|
{ value: 'featured', label: 'Featured toggle' },
|
|
{ value: 'active', label: 'Active toggle' },
|
|
{ value: 'published_at', label: 'Publish date' },
|
|
{ value: 'seo_title', label: 'SEO title' },
|
|
{ value: 'seo_description', label: 'SEO description' },
|
|
{ value: 'course_ids', label: 'Course attachments' },
|
|
{ value: 'blocks', label: 'AI comparison blocks' },
|
|
]
|
|
|
|
let comparisonEditorSequence = 0
|
|
|
|
function nextComparisonEditorKey(prefix) {
|
|
comparisonEditorSequence += 1
|
|
return `${prefix}-${comparisonEditorSequence}`
|
|
}
|
|
|
|
function FieldError({ message }) {
|
|
if (!message) return null
|
|
return <p className="text-xs text-rose-300">{message}</p>
|
|
}
|
|
|
|
function SectionCard({ id, eyebrow, title, description, actions, children, tone = 'default', className = '', contentClassName = '' }) {
|
|
const toneClass = tone === 'feature'
|
|
? 'bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] shadow-[0_24px_70px_rgba(2,6,23,0.28)]'
|
|
: 'bg-white/[0.03]'
|
|
|
|
return (
|
|
<section id={id} className={`min-w-0 scroll-mt-24 rounded-[28px] border border-white/10 p-5 ${toneClass} ${className}`.trim()}>
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div className="max-w-3xl">
|
|
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/75">{eyebrow}</p> : null}
|
|
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
|
|
{description ? <p className="mt-2 text-sm leading-6 text-slate-400">{description}</p> : null}
|
|
</div>
|
|
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
|
|
</div>
|
|
<div className={`mt-5 ${contentClassName}`.trim()}>{children}</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function EditorWorkspaceTabs({ tabs, activeTab, onChange, errorCounts }) {
|
|
const activeMeta = tabs.find((tab) => tab.id === activeTab) || tabs[0]
|
|
|
|
return (
|
|
<div className="sticky top-4 z-20 rounded-[24px] border border-white/10 bg-[linear-gradient(180deg,rgba(7,11,18,0.92),rgba(5,8,14,0.88))] px-3 py-3 shadow-[0_18px_50px_rgba(2,6,23,0.18)] backdrop-blur">
|
|
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="Lesson editor sections">
|
|
{tabs.map((tab) => {
|
|
const isActive = tab.id === activeTab
|
|
const errorCount = Number(errorCounts?.[tab.id] || 0)
|
|
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
aria-controls={`lesson-editor-panel-${tab.id}`}
|
|
id={`lesson-editor-tab-${tab.id}`}
|
|
onClick={() => onChange(tab.id)}
|
|
className={[
|
|
'inline-flex items-center gap-2 rounded-2xl border px-4 py-2.5 text-sm font-semibold transition',
|
|
isActive
|
|
? 'border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20'
|
|
: 'border-white/10 bg-white/[0.03] text-white/80 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white',
|
|
].join(' ')}
|
|
>
|
|
<i className={`fa-solid ${tab.icon} text-xs`} />
|
|
<span>{tab.label}</span>
|
|
{errorCount > 0 ? <span className="rounded-full border border-rose-300/20 bg-rose-300/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-rose-100">{errorCount}</span> : null}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 px-1">
|
|
<p className="text-sm leading-6 text-slate-400">{activeMeta.description}</p>
|
|
<div className="flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.16em] text-slate-500">
|
|
{activeMeta.sections.map((section) => (
|
|
<span key={section} className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5">{section.replace('lesson-', '').replace(/-/g, ' ')}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function firstLessonErrorTab(errors) {
|
|
const firstKey = Object.keys(errors || {})[0]
|
|
if (!firstKey) return null
|
|
if (firstKey.startsWith('blocks.')) return 'blocks'
|
|
return LESSON_FIELD_TAB_MAP[firstKey] || null
|
|
}
|
|
|
|
function lessonTabErrorCounts(errors) {
|
|
const counts = {}
|
|
|
|
Object.keys(errors || {}).forEach((key) => {
|
|
const tabId = key.startsWith('blocks.') ? 'blocks' : LESSON_FIELD_TAB_MAP[key]
|
|
if (!tabId) return
|
|
counts[tabId] = Number(counts[tabId] || 0) + 1
|
|
})
|
|
|
|
return counts
|
|
}
|
|
|
|
function TextField({ label, value, onChange, error, hint, ...rest }) {
|
|
return (
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
|
|
<input value={value ?? ''} onChange={onChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" {...rest} />
|
|
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
|
|
<FieldError message={error} />
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function TextAreaField({ label, value, onChange, error, rows = 4, hint }) {
|
|
return (
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
|
|
<textarea value={value ?? ''} onChange={onChange} rows={rows} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
|
|
<FieldError message={error} />
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function ToggleField({ label, checked, onChange, help, error }) {
|
|
return (
|
|
<label className={`flex cursor-pointer items-start gap-4 rounded-[28px] border px-5 py-4 transition ${checked ? 'border-[#f39a24]/35 bg-[#f39a24]/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]'}`}>
|
|
<input type="checkbox" checked={Boolean(checked)} onChange={onChange} className="sr-only" />
|
|
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-sm transition ${checked ? 'border-[#f39a24] bg-[#f39a24] text-white' : 'border-white/10 bg-[#151a29] text-transparent'}`}>
|
|
<i className="fa-solid fa-check" />
|
|
</span>
|
|
<span className="min-w-0">
|
|
<span className="block text-base font-semibold tracking-[-0.02em] text-white">{label}</span>
|
|
{help ? <span className="mt-1 block text-sm leading-6 text-slate-300">{help}</span> : null}
|
|
<FieldError message={error} />
|
|
</span>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function formatMissingNumbers(values) {
|
|
const items = Array.isArray(values) ? values.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value > 0) : []
|
|
|
|
if (items.length === 0) return 'No gaps right now.'
|
|
return items.join(', ')
|
|
}
|
|
|
|
function formatCourseStep(orderNum) {
|
|
const numeric = Number(orderNum)
|
|
if (!Number.isFinite(numeric) || numeric < 0) return null
|
|
return `Step ${String(numeric + 1).padStart(2, '0')}`
|
|
}
|
|
|
|
function normalizeCourseManagerLessons(lessons) {
|
|
return (Array.isArray(lessons) ? [...lessons] : [])
|
|
.sort((left, right) => {
|
|
const orderDiff = Number(left?.order_num || 0) - Number(right?.order_num || 0)
|
|
if (orderDiff !== 0) return orderDiff
|
|
return Number(left?.id || 0) - Number(right?.id || 0)
|
|
})
|
|
.map((lesson, index) => ({
|
|
...lesson,
|
|
order_num: index,
|
|
display_order: index + 1,
|
|
}))
|
|
}
|
|
|
|
function reorderCourseManagerLessons(lessons, draggedLessonId, targetLessonId) {
|
|
const current = normalizeCourseManagerLessons(lessons)
|
|
const draggedIndex = current.findIndex((lesson) => Number(lesson.id) === Number(draggedLessonId))
|
|
const targetIndex = current.findIndex((lesson) => Number(lesson.id) === Number(targetLessonId))
|
|
|
|
if (draggedIndex === -1 || targetIndex === -1 || draggedIndex === targetIndex) {
|
|
return current
|
|
}
|
|
|
|
const nextLessons = [...current]
|
|
const [draggedLesson] = nextLessons.splice(draggedIndex, 1)
|
|
nextLessons.splice(targetIndex, 0, draggedLesson)
|
|
|
|
return normalizeCourseManagerLessons(nextLessons)
|
|
}
|
|
|
|
function moveCourseManagerLesson(lessons, lessonId, direction) {
|
|
const current = normalizeCourseManagerLessons(lessons)
|
|
const lessonIndex = current.findIndex((lesson) => Number(lesson.id) === Number(lessonId))
|
|
const nextIndex = lessonIndex + direction
|
|
|
|
if (lessonIndex === -1 || nextIndex < 0 || nextIndex >= current.length) {
|
|
return current
|
|
}
|
|
|
|
const nextLessons = [...current]
|
|
const [movedLesson] = nextLessons.splice(lessonIndex, 1)
|
|
nextLessons.splice(nextIndex, 0, movedLesson)
|
|
|
|
return normalizeCourseManagerLessons(nextLessons)
|
|
}
|
|
|
|
function courseManagerSignature(lessons) {
|
|
return JSON.stringify(normalizeCourseManagerLessons(lessons).map((lesson) => ({
|
|
id: Number(lesson.id),
|
|
order_num: Number(lesson.order_num || 0),
|
|
section_id: lesson.section_id == null ? null : Number(lesson.section_id),
|
|
})))
|
|
}
|
|
|
|
function slugifyLessonTitle(value) {
|
|
return String(value || '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 180)
|
|
}
|
|
|
|
function stripHtml(value) {
|
|
return String(value || '')
|
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
}
|
|
|
|
function stripMarkdown(value) {
|
|
return String(value || '')
|
|
.replace(/```[\s\S]*?```/g, ' ')
|
|
.replace(/`([^`]+)`/g, '$1')
|
|
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
.replace(/^#{1,6}\s+/gm, '')
|
|
.replace(/^\s{0,3}>\s?/gm, '')
|
|
.replace(/^\s*([-*+]|\d+\.)\s+/gm, '')
|
|
.replace(/\*\*|__|\*|_|~~/g, '')
|
|
.replace(/^\s*\|/gm, ' ')
|
|
.replace(/\|/g, ' ')
|
|
.replace(/^\s*[-:]{3,}\s*$/gm, ' ')
|
|
}
|
|
|
|
function convertLessonMarkdownToHtml(value) {
|
|
const markdown = String(value || '').trim()
|
|
if (!markdown) return ''
|
|
|
|
return String(marked.parse(markdown, {
|
|
async: false,
|
|
gfm: true,
|
|
breaks: false,
|
|
}) || '').trim()
|
|
}
|
|
|
|
function convertLessonHtmlToMarkdown(value) {
|
|
const html = String(value || '').trim()
|
|
if (!html) return ''
|
|
|
|
if (!lessonMarkdownTurndown) {
|
|
return stripHtml(html)
|
|
}
|
|
|
|
return lessonMarkdownTurndown.turndown(html).trim()
|
|
}
|
|
|
|
function countWords(value) {
|
|
const text = stripMarkdown(stripHtml(value))
|
|
return text ? text.split(/\s+/).length : 0
|
|
}
|
|
|
|
function normalizeCoverPreview(value, cdnBaseUrl) {
|
|
const trimmed = String(value || '').trim()
|
|
if (!trimmed) return ''
|
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://') || trimmed.startsWith('/')) return trimmed
|
|
return `${String(cdnBaseUrl || '').replace(/\/$/, '')}/${trimmed.replace(/^\//, '')}`
|
|
}
|
|
|
|
function normalizeCategoryValue(value) {
|
|
if (value === '' || value == null) return ''
|
|
return String(value)
|
|
}
|
|
|
|
function normalizeBoolean(value, fallback = false) {
|
|
if (typeof value === 'boolean') return value
|
|
if (typeof value === 'number') return value !== 0
|
|
const normalized = String(value || '').trim().toLowerCase()
|
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
|
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
|
|
return fallback
|
|
}
|
|
|
|
const DEFAULT_AI_COMPARISON_TITLE = 'Same Prompt, Different AI Models'
|
|
|
|
const DEFAULT_AI_COMPARISON_PAYLOAD = {
|
|
title: DEFAULT_AI_COMPARISON_TITLE,
|
|
intro: 'We used the same prompt in multiple image generators to compare composition, mood, detail, and wallpaper quality.',
|
|
prompt: '',
|
|
negative_prompt: '',
|
|
aspect_ratio: '16:9',
|
|
criteria: ['Composition', 'Lighting', 'Wallpaper quality', 'Prompt accuracy', 'Detail quality'],
|
|
}
|
|
|
|
function normalizeCriteria(value) {
|
|
return (Array.isArray(value) ? value : [])
|
|
.map((criterion) => String(criterion || '').trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function normalizeComparisonResult(result, index, cdnBaseUrl) {
|
|
const imagePath = String(result?.image_path || '')
|
|
const thumbPath = String(result?.thumb_path || '')
|
|
|
|
return {
|
|
client_key: result?.client_key || nextComparisonEditorKey('comparison-result'),
|
|
id: result?.id ?? null,
|
|
provider: String(result?.provider || ''),
|
|
model_name: String(result?.model_name || ''),
|
|
image_path: imagePath,
|
|
image_url: String(result?.image_url || normalizeCoverPreview(imagePath, cdnBaseUrl) || ''),
|
|
image_temp_path: String(result?.image_temp_path || ''),
|
|
thumb_path: thumbPath,
|
|
thumb_url: String(result?.thumb_url || normalizeCoverPreview(thumbPath, cdnBaseUrl) || ''),
|
|
thumb_temp_path: String(result?.thumb_temp_path || ''),
|
|
settings: String(result?.settings || ''),
|
|
strengths: String(result?.strengths || ''),
|
|
weaknesses: String(result?.weaknesses || ''),
|
|
best_for: String(result?.best_for || ''),
|
|
score: result?.score == null || result?.score === '' ? '' : Number(result.score),
|
|
sort_order: Number(result?.sort_order ?? index),
|
|
active: normalizeBoolean(result?.active, true),
|
|
}
|
|
}
|
|
|
|
function createEmptyComparisonResult(sortOrder = 0) {
|
|
return normalizeComparisonResult({ sort_order: sortOrder, active: true }, sortOrder, '')
|
|
}
|
|
|
|
function normalizeComparisonBlock(block, index, cdnBaseUrl) {
|
|
const payload = block?.payload && typeof block.payload === 'object' ? block.payload : {}
|
|
|
|
return {
|
|
client_key: block?.client_key || nextComparisonEditorKey('comparison-block'),
|
|
id: block?.id ?? null,
|
|
type: 'ai_comparison',
|
|
title: String(block?.title || payload?.title || DEFAULT_AI_COMPARISON_TITLE),
|
|
payload: {
|
|
...DEFAULT_AI_COMPARISON_PAYLOAD,
|
|
title: String(payload?.title || block?.title || DEFAULT_AI_COMPARISON_TITLE),
|
|
intro: String(payload?.intro || ''),
|
|
prompt: String(payload?.prompt || ''),
|
|
negative_prompt: String(payload?.negative_prompt || ''),
|
|
aspect_ratio: String(payload?.aspect_ratio || DEFAULT_AI_COMPARISON_PAYLOAD.aspect_ratio),
|
|
criteria: normalizeCriteria(payload?.criteria || DEFAULT_AI_COMPARISON_PAYLOAD.criteria),
|
|
},
|
|
sort_order: Number(block?.sort_order ?? index),
|
|
active: normalizeBoolean(block?.active, true),
|
|
comparison_results: (Array.isArray(block?.comparison_results) ? block.comparison_results : [])
|
|
.map((result, resultIndex) => normalizeComparisonResult(result, resultIndex, cdnBaseUrl)),
|
|
}
|
|
}
|
|
|
|
function createEmptyComparisonBlock(sortOrder = 0, cdnBaseUrl = '') {
|
|
return normalizeComparisonBlock({
|
|
type: 'ai_comparison',
|
|
title: DEFAULT_AI_COMPARISON_TITLE,
|
|
payload: DEFAULT_AI_COMPARISON_PAYLOAD,
|
|
sort_order: sortOrder,
|
|
active: true,
|
|
comparison_results: [],
|
|
}, sortOrder, cdnBaseUrl)
|
|
}
|
|
|
|
function normalizeComparisonBlocks(blocks, cdnBaseUrl) {
|
|
return (Array.isArray(blocks) ? blocks : [])
|
|
.filter((block) => String(block?.type || 'ai_comparison') === 'ai_comparison')
|
|
.map((block, index) => normalizeComparisonBlock(block, index, cdnBaseUrl))
|
|
}
|
|
|
|
function serializeComparisonBlocks(blocks) {
|
|
return (Array.isArray(blocks) ? blocks : []).map((block, index) => ({
|
|
id: block.id || undefined,
|
|
type: 'ai_comparison',
|
|
title: String(block.title || block.payload?.title || DEFAULT_AI_COMPARISON_TITLE),
|
|
payload: {
|
|
title: String(block.payload?.title || block.title || DEFAULT_AI_COMPARISON_TITLE),
|
|
intro: String(block.payload?.intro || ''),
|
|
prompt: String(block.payload?.prompt || ''),
|
|
negative_prompt: String(block.payload?.negative_prompt || ''),
|
|
aspect_ratio: String(block.payload?.aspect_ratio || ''),
|
|
criteria: normalizeCriteria(block.payload?.criteria || []),
|
|
},
|
|
sort_order: Number(block.sort_order ?? index),
|
|
active: Boolean(block.active),
|
|
comparison_results: (Array.isArray(block.comparison_results) ? block.comparison_results : []).map((result, resultIndex) => ({
|
|
id: result.id || undefined,
|
|
provider: String(result.provider || ''),
|
|
model_name: String(result.model_name || ''),
|
|
image_path: String(result.image_path || ''),
|
|
thumb_path: String(result.thumb_path || ''),
|
|
settings: String(result.settings || ''),
|
|
strengths: String(result.strengths || ''),
|
|
weaknesses: String(result.weaknesses || ''),
|
|
best_for: String(result.best_for || ''),
|
|
score: result.score === '' || result.score == null ? null : Number(result.score),
|
|
sort_order: Number(result.sort_order ?? resultIndex),
|
|
active: Boolean(result.active),
|
|
})),
|
|
}))
|
|
}
|
|
|
|
function getFormError(errors, path) {
|
|
return errors?.[path] || ''
|
|
}
|
|
|
|
function buildLessonPayload(data) {
|
|
return {
|
|
category_id: data.category_id === '' || data.category_id == null ? null : Number(data.category_id),
|
|
title: String(data.title || ''),
|
|
slug: String(data.slug || ''),
|
|
lesson_number: data.lesson_number === '' || data.lesson_number == null ? '' : Number(data.lesson_number),
|
|
course_order: data.course_order === '' || data.course_order == null ? '' : Number(data.course_order),
|
|
course_ids: Array.isArray(data.course_ids) ? data.course_ids.map((courseId) => Number(courseId)).filter((courseId) => Number.isInteger(courseId) && courseId > 0) : [],
|
|
series_name: String(data.series_name || ''),
|
|
excerpt: String(data.excerpt || ''),
|
|
content: String(data.content || ''),
|
|
content_markdown: String(data.content_markdown || ''),
|
|
content_source: String(data.content_source || 'html'),
|
|
difficulty: String(data.difficulty || ''),
|
|
access_level: String(data.access_level || ''),
|
|
lesson_type: String(data.lesson_type || ''),
|
|
cover_image: String(data.cover_image || ''),
|
|
article_cover_image: String(data.article_cover_image || ''),
|
|
tags: String(data.tags || ''),
|
|
video_url: String(data.video_url || ''),
|
|
reading_minutes: data.reading_minutes === '' || data.reading_minutes == null ? '' : Number(data.reading_minutes),
|
|
published_at: data.published_at || '',
|
|
seo_title: String(data.seo_title || ''),
|
|
seo_description: String(data.seo_description || ''),
|
|
featured: Boolean(data.featured),
|
|
active: Boolean(data.active),
|
|
blocks: serializeComparisonBlocks(data.blocks),
|
|
}
|
|
}
|
|
|
|
function parseLessonImport(rawText, categoryOptions) {
|
|
let parsed
|
|
|
|
try {
|
|
parsed = JSON.parse(String(rawText || ''))
|
|
} catch {
|
|
throw new Error('Could not parse JSON.')
|
|
}
|
|
|
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
|
throw new Error('Import JSON must be an object.')
|
|
}
|
|
|
|
const next = {}
|
|
const applied = []
|
|
|
|
const apply = (key, value) => {
|
|
next[key] = value
|
|
applied.push(key)
|
|
}
|
|
|
|
if (parsed.title != null) apply('title', String(parsed.title))
|
|
if (parsed.slug != null) apply('slug', String(parsed.slug))
|
|
if (parsed.lesson_number != null) apply('lesson_number', String(parsed.lesson_number))
|
|
if (parsed.course_order != null) apply('course_order', String(parsed.course_order))
|
|
if (parsed.series_name != null) apply('series_name', String(parsed.series_name))
|
|
if (parsed.excerpt != null) apply('excerpt', String(parsed.excerpt))
|
|
if (parsed.content_markdown != null) apply('content_markdown', String(parsed.content_markdown))
|
|
if (parsed.markdown != null && parsed.content_markdown == null) apply('content_markdown', String(parsed.markdown))
|
|
if (parsed.md != null && parsed.content_markdown == null && parsed.markdown == null) apply('content_markdown', String(parsed.md))
|
|
if (parsed.content != null) apply('content', String(parsed.content))
|
|
if (parsed.body != null && parsed.content == null) apply('content', String(parsed.body))
|
|
if (parsed.html != null && parsed.content == null && parsed.body == null) apply('content', String(parsed.html))
|
|
if (parsed.difficulty != null) apply('difficulty', String(parsed.difficulty))
|
|
if (parsed.access_level != null) apply('access_level', String(parsed.access_level))
|
|
if (parsed.access != null && parsed.access_level == null) apply('access_level', String(parsed.access))
|
|
if (parsed.lesson_type != null) apply('lesson_type', String(parsed.lesson_type))
|
|
if (parsed.type != null && parsed.lesson_type == null) apply('lesson_type', String(parsed.type))
|
|
if (parsed.cover_image != null) apply('cover_image', String(parsed.cover_image))
|
|
if (parsed.cover != null && parsed.cover_image == null) apply('cover_image', String(parsed.cover))
|
|
if (parsed.cover_url != null && parsed.cover_image == null && parsed.cover == null) apply('cover_image', String(parsed.cover_url))
|
|
if (parsed.article_cover_image != null) apply('article_cover_image', String(parsed.article_cover_image))
|
|
if (parsed.article_cover != null && parsed.article_cover_image == null) apply('article_cover_image', String(parsed.article_cover))
|
|
if (parsed.article_cover_url != null && parsed.article_cover_image == null && parsed.article_cover == null) apply('article_cover_image', String(parsed.article_cover_url))
|
|
if (parsed.tags != null) apply('tags', Array.isArray(parsed.tags) ? parsed.tags.join(', ') : String(parsed.tags))
|
|
if (parsed.video_url != null) apply('video_url', String(parsed.video_url))
|
|
if (parsed.reading_minutes != null) apply('reading_minutes', String(parsed.reading_minutes))
|
|
if (parsed.published_at != null) apply('published_at', String(parsed.published_at))
|
|
if (parsed.seo_title != null) apply('seo_title', String(parsed.seo_title))
|
|
if (parsed.seo_description != null) apply('seo_description', String(parsed.seo_description))
|
|
if (parsed.featured != null) apply('featured', normalizeBoolean(parsed.featured))
|
|
if (parsed.active != null) apply('active', normalizeBoolean(parsed.active, true))
|
|
|
|
if (parsed.category_id != null || parsed.category_slug != null || parsed.category != null) {
|
|
const requested = String(parsed.category_id ?? parsed.category_slug ?? parsed.category).trim().toLowerCase()
|
|
const match = (Array.isArray(categoryOptions) ? categoryOptions : []).find((option) => [option.id, option.value, option.slug, option.name, option.label]
|
|
.filter((candidate) => candidate != null)
|
|
.map((candidate) => String(candidate).trim().toLowerCase())
|
|
.includes(requested))
|
|
|
|
if (match) {
|
|
apply('category_id', String(match.id ?? match.value ?? ''))
|
|
}
|
|
}
|
|
|
|
if (applied.length === 0) {
|
|
throw new Error('The JSON did not contain any recognized lesson fields.')
|
|
}
|
|
|
|
return { next, applied }
|
|
}
|
|
|
|
function JsonImportDialog({ open, value, error, onChange, onClose, onApply }) {
|
|
const backdropRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
if (!open) return undefined
|
|
|
|
const handleKeyDown = (event) => {
|
|
if (event.key === 'Escape') {
|
|
onClose?.()
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [onClose, open])
|
|
|
|
if (!open) return null
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={backdropRef}
|
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
|
|
onClick={(event) => {
|
|
if (event.target === backdropRef.current) {
|
|
onClose?.()
|
|
}
|
|
}}
|
|
role="presentation"
|
|
>
|
|
<div className="w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
|
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Structured Import</p>
|
|
<h3 className="mt-2 text-lg font-semibold text-white">Paste lesson JSON</h3>
|
|
<p className="mt-2 text-sm leading-6 text-white/65">Use this to seed the lesson form with structured content before you refine it in the editor.</p>
|
|
</div>
|
|
|
|
<div className="grid gap-5 px-6 py-5 xl:grid-cols-[minmax(0,1fr)_280px]">
|
|
<div className="grid gap-3">
|
|
<textarea
|
|
value={value}
|
|
onChange={(event) => onChange?.(event.target.value)}
|
|
rows={16}
|
|
placeholder={'{\n "title": "Prompt engineering for cleaner scene direction",\n "excerpt": "Short summary...",\n "content": "<p>Rich HTML body...</p>",\n "category": "Prompting",\n "difficulty": "beginner"\n}'}
|
|
className="rounded-[24px] border border-white/10 bg-slate-950/80 px-4 py-4 font-mono text-sm leading-6 text-slate-100 outline-none"
|
|
/>
|
|
{error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
|
</div>
|
|
|
|
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Accepted keys</div>
|
|
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
|
<p>title, slug, excerpt</p>
|
|
<p>lesson_number, course_order, series_name</p>
|
|
<p>content_markdown, markdown, md</p>
|
|
<p>content, body, html</p>
|
|
<p>category_id, category_slug, category</p>
|
|
<p>difficulty, access_level, lesson_type</p>
|
|
<p>cover_image, cover, cover_url</p>
|
|
<p>article_cover_image, article_cover, article_cover_url</p>
|
|
<p>tags</p>
|
|
<p>video_url</p>
|
|
<p>reading_minutes, published_at</p>
|
|
<p>seo_title, seo_description, featured, active</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
|
<button type="button" onClick={() => onClose?.()} className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white">Cancel</button>
|
|
<button type="button" onClick={() => onApply?.()} className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110">Apply JSON</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|
|
|
|
function MarkdownImportDialog({ open, value, error, onChange, onClose, onApply }) {
|
|
const backdropRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
if (!open) return undefined
|
|
|
|
const handleKeyDown = (event) => {
|
|
if (event.key === 'Escape') {
|
|
onClose?.()
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [onClose, open])
|
|
|
|
if (!open) return null
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={backdropRef}
|
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
|
|
onClick={(event) => {
|
|
if (event.target === backdropRef.current) {
|
|
onClose?.()
|
|
}
|
|
}}
|
|
role="presentation"
|
|
>
|
|
<div className="w-full max-w-4xl overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
|
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Markdown Import</p>
|
|
<h3 className="mt-2 text-lg font-semibold text-white">Paste lesson Markdown</h3>
|
|
<p className="mt-2 text-sm leading-6 text-white/65">Paste Markdown here and apply it to regenerate the lesson HTML. This overwrites the current article body.</p>
|
|
</div>
|
|
|
|
<div className="grid gap-5 px-6 py-5 xl:grid-cols-[minmax(0,1fr)_280px]">
|
|
<div className="grid gap-3">
|
|
<textarea
|
|
value={value}
|
|
onChange={(event) => onChange?.(event.target.value)}
|
|
rows={18}
|
|
placeholder={'## Introduction\n\nPaste Markdown here to regenerate the lesson body.'}
|
|
spellCheck={false}
|
|
className="rounded-[24px] border border-white/10 bg-slate-950/80 px-4 py-4 font-mono text-sm leading-6 text-slate-100 outline-none"
|
|
/>
|
|
{error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
|
</div>
|
|
|
|
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Result</div>
|
|
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
|
<p>The Markdown is stored in <span className="font-mono text-slate-200">content_markdown</span>.</p>
|
|
<p>The HTML editor is regenerated from it.</p>
|
|
<p>The visual lesson preview updates immediately.</p>
|
|
<p>Use the HTML button inside the editor for source-level HTML edits.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
|
<button type="button" onClick={() => onClose?.()} className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white">Cancel</button>
|
|
<button type="button" onClick={() => onApply?.()} className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110">Apply Markdown</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|
|
|
|
export default function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext = {} }) {
|
|
const recordContentMarkdown = String(record.content_markdown || '')
|
|
const recordContentHtml = String(record.content || '')
|
|
const cdnBaseUrl = editorContext.coverCdnBaseUrl || ''
|
|
const initialContentMarkdown = recordContentMarkdown.trim() !== ''
|
|
? recordContentMarkdown
|
|
: ''
|
|
const form = useForm({
|
|
...record,
|
|
content: String(record.content || convertLessonMarkdownToHtml(initialContentMarkdown) || ''),
|
|
content_markdown: initialContentMarkdown,
|
|
content_source: recordContentMarkdown.trim() !== '' ? 'markdown' : 'html',
|
|
lesson_number: record.lesson_number === '' || record.lesson_number == null ? '' : String(record.lesson_number),
|
|
course_order: record.course_order === '' || record.course_order == null ? '' : String(record.course_order),
|
|
series_name: String(record.series_name || ''),
|
|
article_cover_image: String(record.article_cover_image || ''),
|
|
course_ids: Array.isArray(record.course_ids) ? record.course_ids.map((courseId) => String(courseId)) : [],
|
|
tags: String(record.tags || ''),
|
|
category_id: normalizeCategoryValue(record.category_id),
|
|
blocks: normalizeComparisonBlocks(record.blocks, cdnBaseUrl),
|
|
})
|
|
const [coverPreviewUrl, setCoverPreviewUrl] = useState(record.cover_image_url || normalizeCoverPreview(record.cover_image, editorContext.coverCdnBaseUrl))
|
|
const [stagedCoverPath, setStagedCoverPath] = useState('')
|
|
const [articleCoverPreviewUrl, setArticleCoverPreviewUrl] = useState(record.article_cover_image_url || normalizeCoverPreview(record.article_cover_image, editorContext.coverCdnBaseUrl))
|
|
const [stagedArticleCoverPath, setStagedArticleCoverPath] = useState('')
|
|
const [categories, setCategories] = useState(Array.isArray(editorContext.categories) ? editorContext.categories : [])
|
|
const [jsonImportOpen, setJsonImportOpen] = useState(false)
|
|
const [jsonImportValue, setJsonImportValue] = useState('')
|
|
const [jsonImportError, setJsonImportError] = useState('')
|
|
const [markdownImportOpen, setMarkdownImportOpen] = useState(false)
|
|
const [markdownImportValue, setMarkdownImportValue] = useState('')
|
|
const [markdownImportError, setMarkdownImportError] = useState('')
|
|
const [categoryError, setCategoryError] = useState('')
|
|
const [categorySaving, setCategorySaving] = useState(false)
|
|
const [isBrowserFullscreen, setIsBrowserFullscreen] = useState(() => typeof document !== 'undefined' && Boolean(document.fullscreenElement))
|
|
const [isEditorFullHeight, setIsEditorFullHeight] = useState(false)
|
|
const [activeTab, setActiveTab] = useState('write')
|
|
const [categoryDraft, setCategoryDraft] = useState({ type: 'lesson', name: '', slug: '', description: '', order_num: 0, active: true })
|
|
const slugTouchedRef = useRef(Boolean(String(record.slug || '').trim()))
|
|
const lessonNumberAutofillRef = useRef(false)
|
|
const courseOrderAutofillRef = useRef(false)
|
|
const difficultyField = useMemo(() => getField(fields, 'difficulty'), [fields])
|
|
const accessField = useMemo(() => getField(fields, 'access_level'), [fields])
|
|
const bodyWordCount = useMemo(() => countWords(form.data.content_markdown || form.data.content), [form.data.content, form.data.content_markdown])
|
|
const estimatedReadingMinutes = useMemo(() => Math.max(1, Math.ceil(bodyWordCount / 180)), [bodyWordCount])
|
|
const excerptLength = String(form.data.excerpt || '').length
|
|
const deferredArticlePreviewHtml = useDeferredValue(form.data.content || '')
|
|
const tabErrorCounts = useMemo(() => lessonTabErrorCounts(form.errors), [form.errors])
|
|
const numberingContext = useMemo(() => editorContext.numbering || {}, [editorContext.numbering])
|
|
const courseOptions = useMemo(() => Array.isArray(editorContext.courses) ? editorContext.courses : [], [editorContext.courses])
|
|
const selectedCourses = useMemo(() => {
|
|
const selectedIds = new Set((Array.isArray(form.data.course_ids) ? form.data.course_ids : []).map((courseId) => String(courseId)))
|
|
return courseOptions.filter((course) => selectedIds.has(String(course.value)))
|
|
}, [courseOptions, form.data.course_ids])
|
|
const currentLessonId = useMemo(() => Number(editorContext.currentLessonId || 0), [editorContext.currentLessonId])
|
|
const [courseManagerDrafts, setCourseManagerDrafts] = useState({})
|
|
const [draggedCourseLesson, setDraggedCourseLesson] = useState(null)
|
|
const [courseSaveProcessing, setCourseSaveProcessing] = useState({})
|
|
const revisions = useMemo(() => Array.isArray(editorContext.revisions) ? editorContext.revisions : [], [editorContext.revisions])
|
|
const [revisionFieldSelections, setRevisionFieldSelections] = useState({})
|
|
const csrfToken = useMemo(() => {
|
|
if (typeof document === 'undefined') return ''
|
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
}, [])
|
|
|
|
const handleMarkdownContentChange = (nextMarkdown) => {
|
|
const nextHtml = convertLessonMarkdownToHtml(nextMarkdown)
|
|
|
|
startTransition(() => {
|
|
form.setData('content_source', 'markdown')
|
|
form.setData('content_markdown', nextMarkdown)
|
|
form.setData('content', nextHtml)
|
|
})
|
|
}
|
|
|
|
const handleRichContentChange = (nextHtml) => {
|
|
startTransition(() => {
|
|
form.setData('content', nextHtml)
|
|
if (form.data.content_source === 'markdown') {
|
|
form.setData('content_markdown', convertLessonHtmlToMarkdown(nextHtml))
|
|
return
|
|
}
|
|
|
|
form.setData('content_markdown', '')
|
|
})
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadLessonMarkdownTurndown()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (slugTouchedRef.current) return
|
|
form.setData('slug', slugifyLessonTitle(form.data.title))
|
|
}, [form.data.title])
|
|
|
|
useEffect(() => {
|
|
if (typeof document === 'undefined') return undefined
|
|
|
|
const handleFullscreenChange = () => {
|
|
setIsBrowserFullscreen(Boolean(document.fullscreenElement))
|
|
}
|
|
|
|
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
|
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (typeof document === 'undefined') return undefined
|
|
|
|
const previousOverflow = document.body.style.overflow
|
|
|
|
if (isEditorFullHeight) {
|
|
document.body.style.overflow = 'hidden'
|
|
}
|
|
|
|
return () => {
|
|
document.body.style.overflow = previousOverflow
|
|
}
|
|
}, [isEditorFullHeight])
|
|
|
|
useEffect(() => {
|
|
const nextTab = firstLessonErrorTab(form.errors)
|
|
if (!nextTab) return
|
|
setActiveTab(nextTab)
|
|
}, [form.errors])
|
|
|
|
useEffect(() => {
|
|
setCourseManagerDrafts(Object.fromEntries(selectedCourses.map((course) => [String(course.value), normalizeCourseManagerLessons(course.lessons)])))
|
|
}, [selectedCourses])
|
|
|
|
const categoryOptions = useMemo(() => {
|
|
const next = categories.map((category) => ({ value: String(category.id), label: category.name }))
|
|
return [{ value: '', label: 'No category' }, ...next]
|
|
}, [categories])
|
|
|
|
useEffect(() => {
|
|
if (method !== 'post' || lessonNumberAutofillRef.current) return
|
|
if (String(form.data.lesson_number || '').trim() !== '') return
|
|
|
|
const suggested = Number(numberingContext?.lesson_number?.suggested || 0)
|
|
if (!Number.isInteger(suggested) || suggested < 1) return
|
|
|
|
lessonNumberAutofillRef.current = true
|
|
form.setData('lesson_number', String(suggested))
|
|
}, [form, form.data.lesson_number, method, numberingContext])
|
|
|
|
useEffect(() => {
|
|
if (method !== 'post' || courseOrderAutofillRef.current) return
|
|
if (String(form.data.course_order || '').trim() !== '') return
|
|
|
|
const suggested = Number(numberingContext?.course_order?.suggested || 0)
|
|
if (!Number.isInteger(suggested) || suggested < 1) return
|
|
|
|
courseOrderAutofillRef.current = true
|
|
form.setData('course_order', String(suggested))
|
|
}, [form, form.data.course_order, method, numberingContext])
|
|
|
|
useEffect(() => {
|
|
const nextValue = String(estimatedReadingMinutes)
|
|
if (String(form.data.reading_minutes || '') === nextValue) return
|
|
form.setData('reading_minutes', nextValue)
|
|
}, [estimatedReadingMinutes, form, form.data.reading_minutes])
|
|
|
|
const handleManualCoverChange = (nextValue) => {
|
|
form.setData('cover_image', nextValue)
|
|
setCoverPreviewUrl(normalizeCoverPreview(nextValue, editorContext.coverCdnBaseUrl))
|
|
}
|
|
|
|
const handleManualArticleCoverChange = (nextValue) => {
|
|
form.setData('article_cover_image', nextValue)
|
|
setArticleCoverPreviewUrl(normalizeCoverPreview(nextValue, editorContext.coverCdnBaseUrl))
|
|
}
|
|
|
|
const updateBlocks = (updater) => {
|
|
const currentBlocks = Array.isArray(form.data.blocks) ? form.data.blocks : []
|
|
const nextBlocks = typeof updater === 'function' ? updater(currentBlocks) : updater
|
|
form.setData('blocks', nextBlocks)
|
|
}
|
|
|
|
const updateBlock = (blockKey, updater) => {
|
|
updateBlocks((currentBlocks) => currentBlocks.map((block) => (
|
|
block.client_key === blockKey ? updater(block) : block
|
|
)))
|
|
}
|
|
|
|
const removeBlock = (blockKey) => {
|
|
updateBlocks((currentBlocks) => currentBlocks.filter((block) => block.client_key !== blockKey))
|
|
}
|
|
|
|
const addComparisonBlock = () => {
|
|
updateBlocks((currentBlocks) => ([
|
|
...currentBlocks,
|
|
createEmptyComparisonBlock(currentBlocks.length, cdnBaseUrl),
|
|
]))
|
|
}
|
|
|
|
const addComparisonResult = (blockKey) => {
|
|
updateBlock(blockKey, (block) => ({
|
|
...block,
|
|
comparison_results: [
|
|
...(Array.isArray(block.comparison_results) ? block.comparison_results : []),
|
|
createEmptyComparisonResult(Array.isArray(block.comparison_results) ? block.comparison_results.length : 0),
|
|
],
|
|
}))
|
|
}
|
|
|
|
const updateComparisonResult = (blockKey, resultKey, updater) => {
|
|
updateBlock(blockKey, (block) => ({
|
|
...block,
|
|
comparison_results: (Array.isArray(block.comparison_results) ? block.comparison_results : []).map((result) => (
|
|
result.client_key === resultKey ? updater(result) : result
|
|
)),
|
|
}))
|
|
}
|
|
|
|
const removeComparisonResult = (blockKey, resultKey) => {
|
|
updateBlock(blockKey, (block) => ({
|
|
...block,
|
|
comparison_results: (Array.isArray(block.comparison_results) ? block.comparison_results : []).filter((result) => result.client_key !== resultKey),
|
|
}))
|
|
}
|
|
|
|
const submit = (event) => {
|
|
event.preventDefault()
|
|
const payload = buildLessonPayload(form.data)
|
|
form.transform(() => payload)
|
|
|
|
if (method === 'patch') {
|
|
form.patch(submitUrl)
|
|
return
|
|
}
|
|
|
|
form.post(submitUrl)
|
|
}
|
|
|
|
const deleteLesson = () => {
|
|
if (!destroyUrl) return
|
|
if (!window.confirm('Delete this lesson?')) return
|
|
router.delete(destroyUrl)
|
|
}
|
|
|
|
const updateCourseDraft = (courseId, nextLessons) => {
|
|
setCourseManagerDrafts((current) => ({
|
|
...current,
|
|
[String(courseId)]: normalizeCourseManagerLessons(nextLessons),
|
|
}))
|
|
}
|
|
|
|
const attachLessonToCourseNow = (course) => {
|
|
if (!currentLessonId) return
|
|
|
|
router.post(course.attach_url, {
|
|
lesson_id: currentLessonId,
|
|
order_num: Number(course.next_order_num || 0),
|
|
is_required: true,
|
|
}, {
|
|
preserveScroll: true,
|
|
onSuccess: () => form.setData('course_ids', Array.from(new Set([...(Array.isArray(form.data.course_ids) ? form.data.course_ids : []), String(course.value)]))),
|
|
})
|
|
}
|
|
|
|
const detachLessonFromCourseNow = (course, courseLesson) => {
|
|
router.delete(courseLesson.destroy_url, {
|
|
preserveScroll: true,
|
|
onSuccess: () => form.setData('course_ids', (Array.isArray(form.data.course_ids) ? form.data.course_ids : []).filter((courseId) => String(courseId) !== String(course.value))),
|
|
})
|
|
}
|
|
|
|
const saveCourseDraftOrder = (course) => {
|
|
const nextLessons = normalizeCourseManagerLessons(courseManagerDrafts[String(course.value)] || course.lessons)
|
|
|
|
setCourseSaveProcessing((current) => ({ ...current, [String(course.value)]: true }))
|
|
router.patch(course.reorder_url, {
|
|
sections: [],
|
|
lessons: nextLessons.map((lesson) => ({
|
|
id: lesson.id,
|
|
order_num: lesson.order_num,
|
|
section_id: lesson.section_id,
|
|
})),
|
|
}, {
|
|
preserveScroll: true,
|
|
onFinish: () => setCourseSaveProcessing((current) => ({ ...current, [String(course.value)]: false })),
|
|
})
|
|
}
|
|
|
|
const applyJsonImport = () => {
|
|
try {
|
|
const parsed = parseLessonImport(jsonImportValue, categories)
|
|
|
|
Object.entries(parsed.next).forEach(([key, value]) => {
|
|
if (key === 'content_markdown') {
|
|
handleMarkdownContentChange(String(value || ''))
|
|
return
|
|
}
|
|
|
|
if (key === 'content') {
|
|
handleRichContentChange(String(value || ''))
|
|
return
|
|
}
|
|
|
|
form.setData(key, value)
|
|
})
|
|
|
|
if (parsed.next.cover_image != null) {
|
|
handleManualCoverChange(String(parsed.next.cover_image || ''))
|
|
}
|
|
|
|
if (parsed.next.slug != null) {
|
|
slugTouchedRef.current = true
|
|
}
|
|
|
|
setJsonImportError('')
|
|
setJsonImportOpen(false)
|
|
} catch (error) {
|
|
setJsonImportError(error instanceof Error ? error.message : 'Could not parse JSON.')
|
|
}
|
|
}
|
|
|
|
const openMarkdownImport = () => {
|
|
setMarkdownImportValue(String(form.data.content_markdown || ''))
|
|
setMarkdownImportError('')
|
|
setMarkdownImportOpen(true)
|
|
}
|
|
|
|
const applyMarkdownImport = () => {
|
|
const nextMarkdown = String(markdownImportValue || '').trim()
|
|
|
|
if (nextMarkdown === '') {
|
|
setMarkdownImportError('Paste Markdown before applying it to the lesson body.')
|
|
return
|
|
}
|
|
|
|
handleMarkdownContentChange(nextMarkdown)
|
|
setMarkdownImportError('')
|
|
setMarkdownImportOpen(false)
|
|
}
|
|
|
|
const createCategory = async () => {
|
|
setCategorySaving(true)
|
|
setCategoryError('')
|
|
|
|
try {
|
|
const response = await fetch(editorContext.categoryStoreUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
Accept: 'application/json',
|
|
},
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({
|
|
...categoryDraft,
|
|
order_num: Number(categoryDraft.order_num || 0),
|
|
slug: categoryDraft.slug || slugifyLessonTitle(categoryDraft.name),
|
|
}),
|
|
})
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
const firstError = payload?.errors ? Object.values(payload.errors)[0]?.[0] : null
|
|
throw new Error(firstError || payload?.message || 'Could not create category.')
|
|
}
|
|
|
|
const nextCategory = payload?.category
|
|
if (!nextCategory?.id) {
|
|
throw new Error('Category response was incomplete.')
|
|
}
|
|
|
|
setCategories((current) => [...current, nextCategory].sort((left, right) => {
|
|
if (left.order_num !== right.order_num) return Number(left.order_num || 0) - Number(right.order_num || 0)
|
|
return String(left.name || '').localeCompare(String(right.name || ''))
|
|
}))
|
|
form.setData('category_id', String(nextCategory.id))
|
|
setCategoryDraft({ type: 'lesson', name: '', slug: '', description: '', order_num: 0, active: true })
|
|
} catch (error) {
|
|
setCategoryError(error instanceof Error ? error.message : 'Could not create category.')
|
|
} finally {
|
|
setCategorySaving(false)
|
|
}
|
|
}
|
|
|
|
const comparisonBlocks = Array.isArray(form.data.blocks) ? form.data.blocks : []
|
|
|
|
const toggleBrowserFullscreen = async () => {
|
|
if (typeof document === 'undefined') return
|
|
|
|
try {
|
|
if (document.fullscreenElement) {
|
|
await document.exitFullscreen()
|
|
return
|
|
}
|
|
|
|
await document.documentElement.requestFullscreen()
|
|
} catch {
|
|
// Ignore browsers that deny fullscreen from this context.
|
|
}
|
|
}
|
|
|
|
const bodyEditorActions = (
|
|
<>
|
|
<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.14em] text-white">{bodyWordCount.toLocaleString()} words</div>
|
|
<button
|
|
type="button"
|
|
onClick={openMarkdownImport}
|
|
className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]"
|
|
>
|
|
Import Markdown
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsEditorFullHeight((current) => !current)}
|
|
className={`rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] transition ${isEditorFullHeight ? 'border-sky-300/35 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]'}`}
|
|
>
|
|
{isEditorFullHeight ? 'Normal height' : 'Full-height editor'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void toggleBrowserFullscreen()}
|
|
className={`rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] transition ${isBrowserFullscreen ? 'border-[#ff9e8c]/35 bg-[#ff9e8c]/12 text-[#ffd5cd]' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]'}`}
|
|
>
|
|
{isBrowserFullscreen ? 'Exit browser fullscreen' : 'Browser fullscreen'}
|
|
</button>
|
|
</>
|
|
)
|
|
|
|
const visibleSections = useMemo(() => new Set((LESSON_EDITOR_TABS.find((tab) => tab.id === activeTab)?.sections) || []), [activeTab])
|
|
const isSectionVisible = (sectionId) => visibleSections.has(sectionId)
|
|
const activeTabMeta = useMemo(() => LESSON_EDITOR_TABS.find((tab) => tab.id === activeTab) || LESSON_EDITOR_TABS[0], [activeTab])
|
|
const sectionClassName = (sectionId, className = '') => `${isSectionVisible(sectionId) ? '' : 'hidden'} ${className}`.trim()
|
|
const showWriteCompanion = activeTab === 'write' && !isEditorFullHeight
|
|
const showSupportRail = showWriteCompanion || isSectionVisible('lesson-preview')
|
|
const lessonStatusItems = [
|
|
{ label: 'Title', ready: String(form.data.title || '').trim() !== '' },
|
|
{ label: 'Excerpt', ready: String(form.data.excerpt || '').trim() !== '' },
|
|
{ label: 'Body', ready: String(form.data.content || form.data.content_markdown || '').trim() !== '' },
|
|
{ label: 'Slug', ready: String(form.data.slug || '').trim() !== '' },
|
|
]
|
|
const lessonPathPreview = form.data.slug ? `/academy/${form.data.slug}` : '/academy/lesson-slug'
|
|
|
|
const restoreLessonRevision = (revision, field = '') => {
|
|
if (!revision?.restore_url) return
|
|
|
|
const message = field
|
|
? `Restore ${LESSON_REVISION_FIELD_OPTIONS.find((option) => option.value === field)?.label || field} from revision #${revision.id}? A new safety revision will be created first.`
|
|
: `Restore the full lesson from revision #${revision.id}? A new safety revision will be created first.`
|
|
|
|
if (!window.confirm(message)) return
|
|
|
|
router.post(revision.restore_url, field ? { field } : {}, { preserveScroll: true })
|
|
}
|
|
|
|
return (
|
|
<AdminLayout title={title} subtitle={subtitle}>
|
|
<Head title={`Admin · ${title}`} />
|
|
|
|
{isEditorFullHeight ? <div className="fixed inset-0 z-[110] bg-[#02040add]/90 backdrop-blur-md" aria-hidden="true" /> : null}
|
|
|
|
<form onSubmit={submit} className="space-y-6 pb-16">
|
|
<section className="overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur">
|
|
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
|
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]">Back to lessons</Link>
|
|
<span>{destroyUrl ? 'Edit lesson' : 'New lesson'}</span>
|
|
</div>
|
|
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy lesson'}</h1>
|
|
<p className="mt-2 max-w-3xl text-sm leading-7 text-slate-300">Use the same richer writing flow as the newsroom: drag in the cover, shape the article with the rich editor, and keep publishing details in the same place.</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-3">
|
|
<button type="button" onClick={() => setJsonImportOpen(true)} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Import JSON</button>
|
|
<button type="submit" disabled={form.processing} className="rounded-2xl border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save lesson'}</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<EditorWorkspaceTabs tabs={LESSON_EDITOR_TABS} activeTab={activeTab} onChange={setActiveTab} errorCounts={tabErrorCounts} />
|
|
|
|
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.14)]">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Current workspace</p>
|
|
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{activeTabMeta.label}</h2>
|
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">{activeTabMeta.description}</p>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Words</div>
|
|
<div className="mt-1 text-lg font-semibold text-white">{bodyWordCount.toLocaleString()}</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Excerpt</div>
|
|
<div className="mt-1 text-lg font-semibold text-white">{excerptLength}/800</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Errors</div>
|
|
<div className="mt-1 text-lg font-semibold text-white">{Object.keys(form.errors || {}).length}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div className={showSupportRail ? 'grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start' : 'grid gap-6'}>
|
|
<div className="min-w-0 space-y-6" role="tabpanel" id={`lesson-editor-panel-${activeTab}`} aria-labelledby={`lesson-editor-tab-${activeTab}`}>
|
|
{activeTab === 'preview' ? (
|
|
<SectionCard eyebrow="Preview mode" title="Rendered lesson review" description="Use this tab to scan the public-facing lesson card, article imagery, and rendered article body without the rest of the form in the way." tone="feature">
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Hero image</p>
|
|
<p className="mt-2 text-sm leading-6 text-slate-400">{coverPreviewUrl ? 'Ready' : 'Missing'} hero artwork for lesson cards and social previews.</p>
|
|
</div>
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Article image</p>
|
|
<p className="mt-2 text-sm leading-6 text-slate-400">{articleCoverPreviewUrl ? 'Ready' : 'Missing'} inline article cover shown before the lesson body.</p>
|
|
</div>
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Body length</p>
|
|
<p className="mt-2 text-sm leading-6 text-slate-400">{bodyWordCount.toLocaleString()} words currently in the lesson body.</p>
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
) : null}
|
|
|
|
<SectionCard id="lesson-story-setup" eyebrow="Story setup" title="Headline and framing" description="Start with the lesson identity and summary, then move into the full article body." tone="feature" className={sectionClassName('lesson-story-setup')}>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<TextField
|
|
label="Title"
|
|
value={form.data.title}
|
|
onChange={(event) => form.setData('title', event.target.value)}
|
|
error={form.errors.title}
|
|
maxLength={180}
|
|
placeholder="Prompt engineering for cleaner scene direction"
|
|
/>
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
|
<span>Slug</span>
|
|
<button type="button" onClick={() => {
|
|
slugTouchedRef.current = false
|
|
form.setData('slug', slugifyLessonTitle(form.data.title))
|
|
}} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold text-white">Sync</button>
|
|
</span>
|
|
<input
|
|
value={form.data.slug}
|
|
onChange={(event) => {
|
|
slugTouchedRef.current = String(event.target.value).trim() !== ''
|
|
form.setData('slug', event.target.value)
|
|
}}
|
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
|
placeholder="prompt-engineering-for-cleaner-scene-direction"
|
|
maxLength={180}
|
|
/>
|
|
<span className="text-xs leading-5 text-slate-500">The slug follows the title until you override it manually.</span>
|
|
<FieldError message={form.errors.slug} />
|
|
</label>
|
|
</div>
|
|
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
|
<span>Excerpt</span>
|
|
<span>{excerptLength}/800</span>
|
|
</span>
|
|
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={5} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" placeholder="Summarize what the lesson teaches, why it matters, and what a creator will walk away with." />
|
|
<span className="text-xs leading-5 text-slate-500">This is the short summary used in cards, internal lists, and metadata surfaces.</span>
|
|
<FieldError message={form.errors.excerpt} />
|
|
</label>
|
|
</SectionCard>
|
|
|
|
<SectionCard
|
|
id="lesson-body-editor"
|
|
eyebrow="Main article"
|
|
title="Lesson body editor"
|
|
description="Write the tutorial in the same richer editing surface used for newsroom articles."
|
|
actions={bodyEditorActions}
|
|
className={sectionClassName('lesson-body-editor', isEditorFullHeight ? 'fixed inset-4 z-[120] flex min-h-0 flex-col overflow-hidden border-sky-300/20 shadow-[0_32px_100px_rgba(2,6,23,0.72)]' : '')}
|
|
contentClassName={isEditorFullHeight ? 'flex min-h-0 flex-1 flex-col' : ''}
|
|
>
|
|
<div className={`grid min-w-0 gap-3 text-sm text-slate-300 ${isEditorFullHeight ? 'min-h-0 flex-1' : ''}`.trim()}>
|
|
<RichTextEditor
|
|
content={form.data.content}
|
|
onChange={handleRichContentChange}
|
|
placeholder="Open with the problem, explain the workflow step by step, and use headings, media, and links where the lesson needs structure."
|
|
error={form.errors.content}
|
|
minHeight={isEditorFullHeight ? 30 : 24}
|
|
maxHeightRem={isEditorFullHeight ? 72 : 42}
|
|
autofocus={false}
|
|
advancedNews
|
|
mediaSupport={{
|
|
uploadUrl: editorContext.bodyMediaUploadUrl,
|
|
deleteUrl: editorContext.bodyMediaDeleteUrl,
|
|
assetsUrl: editorContext.bodyMediaAssetsUrl,
|
|
slot: 'body',
|
|
}}
|
|
/>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard
|
|
id="lesson-ai-comparisons"
|
|
eyebrow="Structured blocks"
|
|
title="AI model comparisons"
|
|
description="Add reusable same-prompt comparison blocks without burying the data inside the lesson HTML body."
|
|
className={sectionClassName('lesson-ai-comparisons')}
|
|
actions={<button type="button" onClick={addComparisonBlock} className="rounded-full border border-[#ff9e8c]/25 bg-[#ff9e8c]/12 px-4 py-2 text-sm font-semibold text-[#ffd5cd]">+ Add AI Comparison</button>}
|
|
>
|
|
<div className="space-y-5">
|
|
{comparisonBlocks.length === 0 ? (
|
|
<div className="rounded-[24px] border border-dashed border-white/10 bg-black/20 px-5 py-6 text-sm leading-7 text-slate-400">
|
|
No comparison blocks yet. Add one when a lesson needs the same prompt analyzed across multiple AI image tools.
|
|
</div>
|
|
) : comparisonBlocks.map((block, blockIndex) => (
|
|
<div key={block.client_key} className="rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(255,158,140,0.12),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.82),rgba(6,10,18,0.95))] p-5 shadow-[0_24px_60px_rgba(2,6,23,0.22)]">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-white/10 pb-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-[#ffc0b4]">AI Model Comparison</p>
|
|
<h3 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{block.payload?.title || block.title || DEFAULT_AI_COMPARISON_TITLE}</h3>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button type="button" onClick={() => removeBlock(block.client_key)} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-3 py-1.5 text-xs font-semibold text-rose-100">Remove block</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
|
<TextField
|
|
label="Block title"
|
|
value={block.title}
|
|
onChange={(event) => updateBlock(block.client_key, (current) => ({
|
|
...current,
|
|
title: event.target.value,
|
|
payload: { ...current.payload, title: event.target.value },
|
|
}))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.title`) || getFormError(form.errors, `blocks.${blockIndex}.payload.title`)}
|
|
placeholder={DEFAULT_AI_COMPARISON_TITLE}
|
|
/>
|
|
<TextField
|
|
label="Aspect ratio"
|
|
value={block.payload?.aspect_ratio}
|
|
onChange={(event) => updateBlock(block.client_key, (current) => ({ ...current, payload: { ...current.payload, aspect_ratio: event.target.value } }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.payload.aspect_ratio`)}
|
|
placeholder="16:9"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-4">
|
|
<TextAreaField
|
|
label="Intro"
|
|
value={block.payload?.intro}
|
|
onChange={(event) => updateBlock(block.client_key, (current) => ({ ...current, payload: { ...current.payload, intro: event.target.value } }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.payload.intro`)}
|
|
rows={3}
|
|
/>
|
|
<TextAreaField
|
|
label="Prompt"
|
|
value={block.payload?.prompt}
|
|
onChange={(event) => updateBlock(block.client_key, (current) => ({ ...current, payload: { ...current.payload, prompt: event.target.value } }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.payload.prompt`)}
|
|
rows={5}
|
|
/>
|
|
<TextAreaField
|
|
label="Negative prompt"
|
|
value={block.payload?.negative_prompt}
|
|
onChange={(event) => updateBlock(block.client_key, (current) => ({ ...current, payload: { ...current.payload, negative_prompt: event.target.value } }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.payload.negative_prompt`)}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_200px_200px]">
|
|
<TextAreaField
|
|
label="Criteria (one per line)"
|
|
value={(block.payload?.criteria || []).join('\n')}
|
|
onChange={(event) => updateBlock(block.client_key, (current) => ({
|
|
...current,
|
|
payload: { ...current.payload, criteria: normalizeCriteria(String(event.target.value || '').split(/\r?\n/)) },
|
|
}))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.payload.criteria`) || getFormError(form.errors, `blocks.${blockIndex}.payload.criteria.0`)}
|
|
rows={6}
|
|
hint="Composition, lighting, wallpaper quality, and similar criteria work well here."
|
|
/>
|
|
<TextField
|
|
label="Sort order"
|
|
value={block.sort_order}
|
|
onChange={(event) => updateBlock(block.client_key, (current) => ({ ...current, sort_order: event.target.value }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.sort_order`)}
|
|
type="number"
|
|
min="0"
|
|
/>
|
|
<ToggleField
|
|
label="Active"
|
|
checked={Boolean(block.active)}
|
|
onChange={(event) => updateBlock(block.client_key, (current) => ({ ...current, active: event.target.checked }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.active`)}
|
|
help="Inactive comparison blocks stay stored but are hidden on the public lesson page."
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-6 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Results</p>
|
|
<p className="mt-1 text-sm text-slate-400">Add one card per tool or model you want to compare.</p>
|
|
</div>
|
|
<button type="button" onClick={() => addComparisonResult(block.client_key)} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100">+ Add model result</button>
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-4">
|
|
{(block.comparison_results || []).length === 0 ? (
|
|
<div className="rounded-[22px] border border-dashed border-white/10 bg-white/[0.03] px-4 py-5 text-sm text-slate-500">No model results yet.</div>
|
|
) : (block.comparison_results || []).map((result, resultIndex) => (
|
|
<div key={result.client_key} className="rounded-[24px] border border-white/10 bg-slate-950/70 p-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm font-semibold text-white">{result.model_name || result.provider || `Model result ${resultIndex + 1}`}</div>
|
|
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">Comparison card</div>
|
|
</div>
|
|
<button type="button" onClick={() => removeComparisonResult(block.client_key, result.client_key)} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-3 py-1.5 text-xs font-semibold text-rose-100">Remove</button>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
|
<WorldMediaUploadField
|
|
label="Generated image"
|
|
slot="body"
|
|
value={result.image_path}
|
|
previewUrl={result.image_url}
|
|
emptyLabel="Generated image"
|
|
helperText="Upload the main comparison image using the same Academy lesson media pipeline."
|
|
uploadUrl={editorContext.bodyMediaUploadUrl}
|
|
deleteUrl={editorContext.bodyMediaDeleteUrl}
|
|
isTemporaryValue={Boolean(result.image_temp_path) && result.image_temp_path === result.image_path}
|
|
onChange={({ path, url }) => updateComparisonResult(block.client_key, result.client_key, (current) => ({
|
|
...current,
|
|
image_path: path || '',
|
|
image_url: url || normalizeCoverPreview(path || '', cdnBaseUrl),
|
|
image_temp_path: path || '',
|
|
}))}
|
|
/>
|
|
<WorldMediaUploadField
|
|
label="Thumbnail image"
|
|
slot="body"
|
|
value={result.thumb_path}
|
|
previewUrl={result.thumb_url}
|
|
emptyLabel="Thumbnail"
|
|
helperText="Optional smaller variant. Leave empty to reuse the main image on the public lesson page."
|
|
uploadUrl={editorContext.bodyMediaUploadUrl}
|
|
deleteUrl={editorContext.bodyMediaDeleteUrl}
|
|
isTemporaryValue={Boolean(result.thumb_temp_path) && result.thumb_temp_path === result.thumb_path}
|
|
onChange={({ path, url }) => updateComparisonResult(block.client_key, result.client_key, (current) => ({
|
|
...current,
|
|
thumb_path: path || '',
|
|
thumb_url: url || normalizeCoverPreview(path || '', cdnBaseUrl),
|
|
thumb_temp_path: path || '',
|
|
}))}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
|
|
<TextField
|
|
label="Provider"
|
|
value={result.provider}
|
|
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, provider: event.target.value }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.provider`)}
|
|
placeholder="OpenAI"
|
|
/>
|
|
<TextField
|
|
label="Model"
|
|
value={result.model_name}
|
|
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, model_name: event.target.value }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.model_name`)}
|
|
placeholder="ChatGPT Images"
|
|
/>
|
|
<TextField
|
|
label="Score"
|
|
value={result.score}
|
|
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, score: event.target.value }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.score`)}
|
|
type="number"
|
|
min="1"
|
|
max="10"
|
|
/>
|
|
<TextField
|
|
label="Sort order"
|
|
value={result.sort_order}
|
|
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, sort_order: event.target.value }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.sort_order`)}
|
|
type="number"
|
|
min="0"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
|
<TextAreaField
|
|
label="Settings"
|
|
value={result.settings}
|
|
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, settings: event.target.value }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.settings`)}
|
|
rows={3}
|
|
/>
|
|
<TextAreaField
|
|
label="Strengths"
|
|
value={result.strengths}
|
|
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, strengths: event.target.value }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.strengths`)}
|
|
rows={3}
|
|
/>
|
|
<TextAreaField
|
|
label="Weaknesses"
|
|
value={result.weaknesses}
|
|
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, weaknesses: event.target.value }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.weaknesses`)}
|
|
rows={3}
|
|
/>
|
|
<TextAreaField
|
|
label="Best for"
|
|
value={result.best_for}
|
|
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, best_for: event.target.value }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.best_for`)}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
|
<TextField
|
|
label="Image path override"
|
|
value={result.image_path}
|
|
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({
|
|
...current,
|
|
image_path: event.target.value,
|
|
image_url: normalizeCoverPreview(event.target.value, cdnBaseUrl),
|
|
image_temp_path: '',
|
|
}))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.image_path`)}
|
|
placeholder="academy/lessons/body/..."
|
|
/>
|
|
<TextField
|
|
label="Thumbnail path override"
|
|
value={result.thumb_path}
|
|
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({
|
|
...current,
|
|
thumb_path: event.target.value,
|
|
thumb_url: normalizeCoverPreview(event.target.value, cdnBaseUrl),
|
|
thumb_temp_path: '',
|
|
}))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.thumb_path`)}
|
|
placeholder="Optional academy/lessons/body/..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<ToggleField
|
|
label="Result active"
|
|
checked={Boolean(result.active)}
|
|
onChange={(event) => updateComparisonResult(block.client_key, result.client_key, (current) => ({ ...current, active: event.target.checked }))}
|
|
error={getFormError(form.errors, `blocks.${blockIndex}.comparison_results.${resultIndex}.active`)}
|
|
help="Inactive results stay saved but do not render on the public lesson page."
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard id="lesson-course-numbering" eyebrow="Course placement" title="Courses and numbering" description="Keep series numbering and course placement in one workspace so guided lesson management stays separate from publishing." className={sectionClassName('lesson-course-numbering')}>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="grid gap-2 text-sm text-slate-300">
|
|
<TextField label="Series name" value={form.data.series_name} onChange={(event) => form.setData('series_name', event.target.value)} error={form.errors.series_name} maxLength={120} placeholder="AI Art Basics" hint="Shown before the lesson number on public pages. Leave empty if this lesson is not part of a named series." />
|
|
</div>
|
|
<div className="grid gap-2 text-sm text-slate-300">
|
|
<TextField label="Reading minutes" value={form.data.reading_minutes} error={form.errors.reading_minutes} type="number" min="1" max="999" readOnly hint={`Auto-calculated from the lesson body. Current estimate: ${estimatedReadingMinutes} min for ${bodyWordCount.toLocaleString()} words.`} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<div className="grid gap-2">
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
|
<span>Lesson number</span>
|
|
<button type="button" onClick={() => form.setData('lesson_number', String(numberingContext?.lesson_number?.suggested || ''))} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold text-white">Use {numberingContext?.lesson_number?.suggested || 'next'}</button>
|
|
</span>
|
|
<input value={form.data.lesson_number ?? ''} onChange={(event) => form.setData('lesson_number', event.target.value)} type="number" min="1" placeholder={String(numberingContext?.lesson_number?.suggested || 3)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
<span className="text-xs leading-5 text-slate-500">Visible public numbering like Lesson 03. Suggested next slot: {numberingContext?.lesson_number?.suggested || 1}. Missing numbers: {formatMissingNumbers(numberingContext?.lesson_number?.missing)}.</span>
|
|
<FieldError message={form.errors.lesson_number} />
|
|
</label>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="flex items-center justify-between gap-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
|
<span>Course order</span>
|
|
<button type="button" onClick={() => form.setData('course_order', String(numberingContext?.course_order?.suggested || ''))} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold text-white">Use {numberingContext?.course_order?.suggested || 'next'}</button>
|
|
</span>
|
|
<input value={form.data.course_order ?? ''} onChange={(event) => form.setData('course_order', event.target.value)} type="number" min="1" placeholder={String(numberingContext?.course_order?.suggested || 3)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
<span className="text-xs leading-5 text-slate-500">Controls standalone lesson list sorting and previous or next lesson navigation. Suggested next slot: {numberingContext?.course_order?.suggested || 1}. Missing numbers: {formatMissingNumbers(numberingContext?.course_order?.missing)}.</span>
|
|
<FieldError message={form.errors.course_order} />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Lesson numbering</p>
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold text-sky-100">Suggested {numberingContext?.lesson_number?.suggested || 1}</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold text-slate-200">{numberingContext?.lesson_number?.used_count || 0} used</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold text-slate-200">Highest {numberingContext?.lesson_number?.highest || 0}</span>
|
|
</div>
|
|
<p className="mt-3 text-sm leading-6 text-slate-400">Missing lesson numbers: {formatMissingNumbers(numberingContext?.lesson_number?.missing)}</p>
|
|
</div>
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Course order guidance</p>
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
<span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold text-amber-100">Suggested {numberingContext?.course_order?.suggested || 1}</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold text-slate-200">{numberingContext?.course_order?.used_count || 0} used</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold text-slate-200">Highest {numberingContext?.course_order?.highest || 0}</span>
|
|
</div>
|
|
<p className="mt-3 text-sm leading-6 text-slate-400">Missing course-order slots: {formatMissingNumbers(numberingContext?.course_order?.missing)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2 text-sm text-slate-300">
|
|
<NovaSelect
|
|
multi
|
|
label="Courses"
|
|
value={form.data.course_ids || []}
|
|
onChange={(nextValue) => form.setData('course_ids', Array.isArray(nextValue) ? nextValue.map((courseId) => String(courseId)) : [])}
|
|
options={courseOptions}
|
|
className="bg-black/20"
|
|
error={form.errors.course_ids}
|
|
/>
|
|
<p className="text-xs leading-6 text-slate-400">Attach this lesson to one or more existing courses. You can also attach or remove the current lesson immediately from the course manager cards below.</p>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard id="lesson-course-manager" eyebrow="Course manager" title="Manage course placement" description="Attach the current lesson, drag to reorder it within each selected course, and save the updated path without leaving this lesson page." className={sectionClassName('lesson-course-manager')}>
|
|
<div className="grid gap-4">
|
|
{selectedCourses.length === 0 ? (
|
|
<div className="rounded-[24px] border border-dashed border-white/10 bg-black/20 px-5 py-5 text-sm leading-7 text-slate-400">Select one or more courses above to manage where this lesson sits in guided learning paths.</div>
|
|
) : selectedCourses.map((course) => {
|
|
const currentCourseLesson = Array.isArray(course.lessons) ? course.lessons.find((lesson) => lesson.is_current) : null
|
|
const nextStepLabel = formatCourseStep(course.next_order_num)
|
|
const draftLessons = normalizeCourseManagerLessons(courseManagerDrafts[String(course.value)] || course.lessons)
|
|
const draftIsDirty = courseManagerSignature(draftLessons) !== courseManagerSignature(course.lessons)
|
|
const courseIsSaving = Boolean(courseSaveProcessing[String(course.value)])
|
|
|
|
return (
|
|
<div key={course.value} className="rounded-[26px] border border-white/10 bg-black/20 p-5">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<p className="text-lg font-semibold tracking-[-0.03em] text-white">{course.label}</p>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{course.status}</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{course.lesson_count} lessons</span>
|
|
{draftIsDirty ? <span className="rounded-full border border-amber-300/20 bg-amber-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Unsaved order</span> : null}
|
|
</div>
|
|
<p className="mt-2 text-sm leading-6 text-slate-400">
|
|
{currentCourseLesson ? `${currentCourseLesson.title} is currently attached in ${formatCourseStep(currentCourseLesson.order_num) || `slot ${currentCourseLesson.display_order}`}.` : `This lesson is not attached yet. Add it now at ${nextStepLabel || `slot ${Number(course.next_order_num || 0) + 1}`}.`}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{currentLessonId > 0 && !currentCourseLesson ? <button type="button" onClick={() => attachLessonToCourseNow(course)} className="rounded-full border border-[#f39a24]/25 bg-[#f39a24]/12 px-3 py-1.5 text-xs font-semibold text-[#ffd5cd]">Add this lesson now</button> : null}
|
|
{currentCourseLesson ? <button type="button" onClick={() => detachLessonFromCourseNow(course, currentCourseLesson)} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-3 py-1.5 text-xs font-semibold text-rose-100">Remove from course</button> : null}
|
|
<button type="button" onClick={() => updateCourseDraft(course.value, course.lessons)} disabled={!draftIsDirty || courseIsSaving} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-40">Reset order</button>
|
|
<button type="button" onClick={() => saveCourseDraftOrder(course)} disabled={!draftIsDirty || courseIsSaving} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-3 py-1.5 text-xs font-semibold text-sky-100 disabled:opacity-40">{courseIsSaving ? 'Saving...' : 'Save order'}</button>
|
|
<a href={course.builder_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">Open builder</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-2">
|
|
{draftLessons.length === 0 ? (
|
|
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-500">No lessons are attached to this course yet.</div>
|
|
) : draftLessons.map((lesson, lessonIndex) => (
|
|
<div
|
|
key={lesson.id}
|
|
draggable
|
|
onDragStart={() => setDraggedCourseLesson({ courseId: String(course.value), lessonId: lesson.id })}
|
|
onDragEnd={() => setDraggedCourseLesson(null)}
|
|
onDragOver={(event) => event.preventDefault()}
|
|
onDrop={(event) => {
|
|
event.preventDefault()
|
|
if (!draggedCourseLesson || draggedCourseLesson.courseId !== String(course.value)) return
|
|
updateCourseDraft(course.value, reorderCourseManagerLessons(draftLessons, draggedCourseLesson.lessonId, lesson.id))
|
|
setDraggedCourseLesson(null)
|
|
}}
|
|
className={`flex flex-wrap items-center justify-between gap-3 rounded-2xl border px-4 py-3 transition ${lesson.is_current ? 'border-[#f39a24]/30 bg-[#f39a24]/10' : 'border-white/10 bg-white/[0.03]'} ${draggedCourseLesson?.courseId === String(course.value) && Number(draggedCourseLesson.lessonId) === Number(lesson.id) ? 'opacity-60' : ''}`}
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{formatCourseStep(lesson.order_num) || `#${lesson.display_order}`}</span>
|
|
{lesson.formatted_lesson_number ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{lesson.formatted_lesson_number}</span> : null}
|
|
{lesson.section_title ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-300">{lesson.section_title}</span> : null}
|
|
{lesson.is_current ? <span className="rounded-full border border-[#f39a24]/25 bg-[#f39a24]/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#ffd5cd]">This lesson</span> : null}
|
|
</div>
|
|
<p className="mt-2 truncate text-sm font-semibold text-white">{lesson.title}</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button type="button" onClick={() => updateCourseDraft(course.value, moveCourseManagerLesson(draftLessons, lesson.id, -1))} disabled={lessonIndex === 0} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-40"><i className="fa-solid fa-arrow-up" /></button>
|
|
<button type="button" onClick={() => updateCourseDraft(course.value, moveCourseManagerLesson(draftLessons, lesson.id, 1))} disabled={lessonIndex === draftLessons.length - 1} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-40"><i className="fa-solid fa-arrow-down" /></button>
|
|
{lesson.edit_url ? <a href={lesson.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">Open lesson</a> : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard id="lesson-publishing" eyebrow="Publishing" title="Placement and visibility" description="Set the lesson metadata, schedule, and discovery fields before it goes live." className={sectionClassName('lesson-publishing')}>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="grid gap-2 text-sm text-slate-300">
|
|
<NovaSelect label="Difficulty" value={form.data.difficulty || ''} onChange={(nextValue) => form.setData('difficulty', String(nextValue || ''))} options={difficultyField?.options || []} searchable={false} className="bg-black/20" error={form.errors.difficulty} />
|
|
</div>
|
|
<div className="grid gap-2 text-sm text-slate-300">
|
|
<NovaSelect label="Access" value={form.data.access_level || ''} onChange={(nextValue) => form.setData('access_level', String(nextValue || ''))} options={accessField?.options || []} searchable={false} className="bg-black/20" error={form.errors.access_level} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<TextField label="Lesson type" value={form.data.lesson_type} onChange={(event) => form.setData('lesson_type', event.target.value)} error={form.errors.lesson_type} placeholder="article, video, walkthrough" />
|
|
<div className="grid gap-2">
|
|
<TextField label="Microtags" value={form.data.tags} onChange={(event) => form.setData('tags', event.target.value)} error={form.errors.tags} placeholder="workflow, cleanup, publishing" />
|
|
<p className="text-xs leading-6 text-slate-400">Comma-separated short tags for the public lesson page and article discovery context.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="grid gap-2 text-sm text-slate-300">
|
|
<NovaSelect label="Category" value={form.data.category_id || ''} onChange={(nextValue) => form.setData('category_id', String(nextValue || ''))} options={categoryOptions} searchable={false} className="bg-black/20" error={form.errors.category_id} />
|
|
</div>
|
|
<div className="grid gap-2 text-sm text-slate-300">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</span>
|
|
<DateTimePicker value={form.data.published_at || ''} onChange={(nextValue) => form.setData('published_at', nextValue || '')} clearable className="bg-black/20" />
|
|
<FieldError message={form.errors.published_at} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<ToggleField label="Featured" checked={Boolean(form.data.featured)} onChange={(event) => form.setData('featured', event.target.checked)} help="Highlight this lesson in featured academy surfaces." error={form.errors.featured} />
|
|
<ToggleField label="Active" checked={Boolean(form.data.active)} onChange={(event) => form.setData('active', event.target.checked)} help="Keep inactive lessons hidden until the draft is ready." error={form.errors.active} />
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard id="lesson-seo" eyebrow="SEO" title="Search metadata" description="Keep the lesson search-ready without stuffing the headline." className={sectionClassName('lesson-seo')}>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<TextField label="SEO title" value={form.data.seo_title} onChange={(event) => form.setData('seo_title', event.target.value)} error={form.errors.seo_title} maxLength={180} placeholder="Optional search title" />
|
|
<TextField label="Video URL" value={form.data.video_url} onChange={(event) => form.setData('video_url', event.target.value)} error={form.errors.video_url} placeholder="Optional lesson video URL" />
|
|
</div>
|
|
<TextAreaField label="SEO description" value={form.data.seo_description} onChange={(event) => form.setData('seo_description', event.target.value)} error={form.errors.seo_description} rows={4} hint="Keep this tighter than the excerpt and focused on search intent." />
|
|
</SectionCard>
|
|
|
|
<SectionCard id="lesson-categories" eyebrow="Lesson categories" title="Create category inline" description="Add lesson categories without leaving the writing flow." className={sectionClassName('lesson-categories')} actions={<a href={editorContext.categoryManageUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Manage all categories</a>}>
|
|
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
|
<div className="grid gap-3">
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<input value={categoryDraft.name} onChange={(event) => setCategoryDraft((current) => ({ ...current, name: event.target.value }))} placeholder="Category name" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
<input value={categoryDraft.slug} onChange={(event) => setCategoryDraft((current) => ({ ...current, slug: event.target.value }))} placeholder="Optional slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
</div>
|
|
<textarea value={categoryDraft.description} onChange={(event) => setCategoryDraft((current) => ({ ...current, description: event.target.value }))} rows={3} placeholder="Description" className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<input type="number" value={categoryDraft.order_num} min="0" onChange={(event) => setCategoryDraft((current) => ({ ...current, order_num: event.target.value }))} className="w-28 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
<div className="min-w-[260px] flex-1">
|
|
<ToggleField label="Category active" checked={categoryDraft.active} onChange={(event) => setCategoryDraft((current) => ({ ...current, active: event.target.checked }))} help="Inactive categories stay available for cleanup but disappear from regular lesson assignment." />
|
|
</div>
|
|
<button type="button" onClick={() => void createCategory()} disabled={categorySaving} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">{categorySaving ? 'Creating...' : 'Create category'}</button>
|
|
</div>
|
|
{categoryError ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{categoryError}</div> : null}
|
|
</div>
|
|
|
|
<div className="grid gap-3">
|
|
{(categories || []).length === 0 ? <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-500">No lesson categories yet.</div> : categories.map((category) => (
|
|
<div key={category.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm font-semibold text-white">{category.name}</div>
|
|
<div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{category.slug}</div>
|
|
{category.description ? <p className="mt-2 text-sm leading-6 text-slate-400">{category.description}</p> : null}
|
|
</div>
|
|
<a href={category.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">Edit</a>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard id="lesson-cover" eyebrow="Cover image" title="Hero asset" description="Use drag and drop for the lesson image, or paste a direct URL when you already have one." className={sectionClassName('lesson-cover')}>
|
|
<div className="grid gap-4">
|
|
<WorldMediaUploadField
|
|
label="Lesson cover"
|
|
slot="cover"
|
|
value={form.data.cover_image}
|
|
previewUrl={coverPreviewUrl}
|
|
emptyLabel="Drop a lesson cover"
|
|
helperText="Upload the hero image directly to object storage. A wide landscape image works best for academy cards, previews, and social sharing."
|
|
uploadUrl={editorContext.coverUploadUrl}
|
|
deleteUrl={editorContext.coverDeleteUrl}
|
|
onChange={({ path, url }) => {
|
|
setStagedCoverPath(path || '')
|
|
form.setData('cover_image', path || '')
|
|
setCoverPreviewUrl(url || '')
|
|
}}
|
|
isTemporaryValue={Boolean(stagedCoverPath) && form.data.cover_image === stagedCoverPath}
|
|
/>
|
|
<FieldError message={form.errors.cover_image} />
|
|
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced cover path or URL</span>
|
|
<input value={form.data.cover_image} onChange={(event) => handleManualCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
<span className="text-xs leading-5 text-slate-500">Keep this for migrations, imported lessons, or when you already know the exact asset path to use.</span>
|
|
</label>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard id="lesson-article-cover" eyebrow="Article cover" title="Inline article image" description="This image is rendered just before the lesson content begins." className={sectionClassName('lesson-article-cover')}>
|
|
<div className="grid gap-4">
|
|
<WorldMediaUploadField
|
|
label="Article cover"
|
|
slot="cover"
|
|
value={form.data.article_cover_image}
|
|
previewUrl={articleCoverPreviewUrl}
|
|
emptyLabel="Drop an article cover"
|
|
helperText="Upload the image that appears above the lesson body. Use a strong wide image that still reads well inside the article column."
|
|
uploadUrl={editorContext.coverUploadUrl}
|
|
deleteUrl={editorContext.coverDeleteUrl}
|
|
onChange={({ path, url }) => {
|
|
setStagedArticleCoverPath(path || '')
|
|
form.setData('article_cover_image', path || '')
|
|
setArticleCoverPreviewUrl(url || '')
|
|
}}
|
|
isTemporaryValue={Boolean(stagedArticleCoverPath) && form.data.article_cover_image === stagedArticleCoverPath}
|
|
/>
|
|
<FieldError message={form.errors.article_cover_image} />
|
|
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced article cover path or URL</span>
|
|
<input value={form.data.article_cover_image} onChange={(event) => handleManualArticleCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
<span className="text-xs leading-5 text-slate-500">Use this when the article image already exists in storage or needs to point to an external source.</span>
|
|
</label>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard id="lesson-revisions" eyebrow="Safety net" title="Revision history" description="Each lesson update now saves the previous state first. Restore the full lesson or a single field when something goes wrong." className={sectionClassName('lesson-revisions')}>
|
|
<div className="space-y-4">
|
|
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4 text-sm leading-6 text-slate-300">
|
|
Restoring from a revision creates another revision first, so you can undo the restore if needed.
|
|
</div>
|
|
|
|
{revisions.length === 0 ? (
|
|
<div className="rounded-[24px] border border-dashed border-white/10 bg-black/20 px-4 py-5 text-sm leading-7 text-slate-400">No revisions yet. The first saved update will capture the current lesson state.</div>
|
|
) : revisions.map((revision) => {
|
|
const selectedField = String(revisionFieldSelections[revision.id] || 'content')
|
|
|
|
return (
|
|
<div key={revision.id} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Revision #{revision.id}</p>
|
|
<p className="mt-2 text-sm font-semibold text-white">{revision.created_label || 'Recently saved'} by {revision.actor_name || 'Staff'}</p>
|
|
{revision.change_note ? <p className="mt-2 text-xs leading-5 text-slate-400">{revision.change_note}</p> : null}
|
|
</div>
|
|
<button type="button" onClick={() => restoreLessonRevision(revision)} className="rounded-full border border-[#f39a24]/25 bg-[#f39a24]/12 px-3 py-1.5 text-xs font-semibold text-[#ffd5cd]">Restore full lesson</button>
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-[20px] border border-white/10 bg-white/[0.03] p-3">
|
|
<p className="text-sm font-semibold text-white">{revision.snapshot?.title || 'Untitled lesson snapshot'}</p>
|
|
{revision.snapshot?.excerpt ? <p className="mt-2 text-xs leading-5 text-slate-400">{revision.snapshot.excerpt}</p> : null}
|
|
{revision.snapshot?.content_preview ? <p className="mt-2 text-xs leading-5 text-slate-500">{revision.snapshot.content_preview}</p> : null}
|
|
<div className="mt-3 flex flex-wrap gap-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
|
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">{revision.snapshot?.course_count || 0} courses</span>
|
|
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1">{revision.snapshot?.block_count || 0} blocks</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-3">
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Restore single field</span>
|
|
<select value={selectedField} onChange={(event) => setRevisionFieldSelections((current) => ({ ...current, [revision.id]: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none">
|
|
{LESSON_REVISION_FIELD_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
</select>
|
|
</label>
|
|
<button type="button" onClick={() => restoreLessonRevision(revision, selectedField)} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Restore selected field</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</SectionCard>
|
|
</div>
|
|
|
|
{showSupportRail ? (
|
|
<div className="space-y-6 xl:sticky xl:top-6 xl:self-start">
|
|
{showWriteCompanion ? (
|
|
<SectionCard eyebrow="Writing flow" title="Author companion" description="Keep the lesson opening tight, then expand through headings and examples. This panel stays compact so the editor remains the focus.">
|
|
<div className="grid gap-4">
|
|
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Public path</p>
|
|
<p className="mt-2 break-all text-sm font-semibold text-white">{lessonPathPreview}</p>
|
|
<p className="mt-2 text-sm leading-6 text-slate-400">Keep the headline specific enough that the slug reads clearly in search results and internal links.</p>
|
|
</div>
|
|
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Writing checklist</p>
|
|
<div className="mt-3 grid gap-2">
|
|
{lessonStatusItems.map((item) => (
|
|
<div key={item.label} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-slate-300">
|
|
<span>{item.label}</span>
|
|
<span className={`rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.14em] ${item.ready ? 'border border-emerald-300/20 bg-emerald-300/10 text-emerald-100' : 'border border-amber-300/20 bg-amber-300/10 text-amber-100'}`}>
|
|
{item.ready ? 'Ready' : 'Missing'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Article rhythm</p>
|
|
<div className="mt-3 space-y-3 text-sm leading-6 text-slate-400">
|
|
<p>Lead with the problem in the first paragraph, then break the workflow into headline-sized steps.</p>
|
|
<p>Use Markdown import when you already have a draft, then switch back to the visual editor for structure, media, and cleanup.</p>
|
|
<p>Open full-height mode once the outline is stable so the body editor takes the entire screen.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
) : null}
|
|
|
|
<SectionCard id="lesson-preview" eyebrow="Preview" title="Lesson snapshot" description="A quick view of what editors and visitors will scan first." className={sectionClassName('lesson-preview')}>
|
|
<div className="grid gap-4">
|
|
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/30">
|
|
{coverPreviewUrl ? (
|
|
<img src={coverPreviewUrl} alt="Lesson cover preview" className="h-56 w-full object-cover" />
|
|
) : (
|
|
<div className="flex h-56 items-center justify-center px-6 text-center text-sm text-slate-500">No hero cover image selected yet.</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/30">
|
|
{articleCoverPreviewUrl ? (
|
|
<img src={articleCoverPreviewUrl} alt="Lesson article cover preview" className="h-56 w-full object-cover" />
|
|
) : (
|
|
<div className="flex h-56 items-center justify-center px-6 text-center text-sm text-slate-500">No article cover image selected yet.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Lesson summary</p>
|
|
<h3 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white">{form.data.title || 'Untitled lesson'}</h3>
|
|
<p className="mt-2 text-sm leading-7 text-slate-400">{form.data.excerpt || 'Add a concise excerpt to frame the lesson before someone opens it.'}</p>
|
|
<dl className="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-400">
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Difficulty</dt><dd className="mt-1 text-sm text-white">{form.data.difficulty || '—'}</dd></div>
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Access</dt><dd className="mt-1 text-sm text-white">{form.data.access_level || '—'}</dd></div>
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Reading</dt><dd className="mt-1 text-sm text-white">{form.data.reading_minutes || '—'} min</dd></div>
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Body</dt><dd className="mt-1 text-sm text-white">{bodyWordCount.toLocaleString()} words</dd></div>
|
|
</dl>
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Article preview</p>
|
|
{deferredArticlePreviewHtml ? (
|
|
<div
|
|
className="prose prose-invert mt-4 max-w-none prose-headings:text-white prose-p:text-slate-300 prose-p:leading-7 prose-li:text-slate-300 prose-strong:text-white prose-code:text-amber-300 prose-pre:border prose-pre:border-white/10 prose-pre:bg-slate-950/70 prose-blockquote:border-sky-300/30 prose-a:text-sky-300"
|
|
dangerouslySetInnerHTML={{ __html: deferredArticlePreviewHtml }}
|
|
/>
|
|
) : (
|
|
<p className="mt-3 text-sm leading-7 text-slate-500">Add lesson body content to see the rendered article preview here.</p>
|
|
)}
|
|
</div>
|
|
</SectionCard>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
|
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save lesson'}</button>
|
|
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back</Link>
|
|
{destroyUrl ? <button type="button" onClick={deleteLesson} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete</button> : null}
|
|
</div>
|
|
</form>
|
|
|
|
<JsonImportDialog
|
|
open={jsonImportOpen}
|
|
value={jsonImportValue}
|
|
error={jsonImportError}
|
|
onChange={(nextValue) => {
|
|
setJsonImportValue(nextValue)
|
|
if (jsonImportError) {
|
|
setJsonImportError('')
|
|
}
|
|
}}
|
|
onClose={() => {
|
|
setJsonImportOpen(false)
|
|
setJsonImportError('')
|
|
}}
|
|
onApply={applyJsonImport}
|
|
/>
|
|
|
|
<MarkdownImportDialog
|
|
open={markdownImportOpen}
|
|
value={markdownImportValue}
|
|
error={markdownImportError}
|
|
onChange={(nextValue) => {
|
|
setMarkdownImportValue(nextValue)
|
|
if (markdownImportError) {
|
|
setMarkdownImportError('')
|
|
}
|
|
}}
|
|
onClose={() => {
|
|
setMarkdownImportOpen(false)
|
|
setMarkdownImportError('')
|
|
}}
|
|
onApply={applyMarkdownImport}
|
|
/>
|
|
</AdminLayout>
|
|
)
|
|
} |