2528 lines
140 KiB
JavaScript
2528 lines
140 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'
|
|
import ShareToast from '../../../components/ui/ShareToast'
|
|
|
|
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: 'Hero cover, article cover, and lesson categories.',
|
|
icon: 'fa-images',
|
|
sections: ['lesson-cover', 'lesson-article-cover', 'lesson-categories'],
|
|
},
|
|
{
|
|
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 CopyablePromptCard({ eyebrow, title, description, prompt, onCopy }) {
|
|
return (
|
|
<div 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">{eyebrow}</p>
|
|
<h3 className="mt-1 text-base font-semibold text-white">{title}</h3>
|
|
{description ? <p className="mt-2 text-sm leading-6 text-slate-400">{description}</p> : null}
|
|
</div>
|
|
<button type="button" onClick={onCopy} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]">Copy prompt</button>
|
|
</div>
|
|
|
|
<textarea readOnly value={prompt} rows={10} spellCheck={false} className="mt-4 w-full rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300 outline-none" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 firstErrorMessage(errors, fallback = 'Please correct the highlighted fields and try again.') {
|
|
const queue = [errors]
|
|
|
|
while (queue.length > 0) {
|
|
const current = queue.shift()
|
|
|
|
if (typeof current === 'string') {
|
|
const message = current.trim()
|
|
|
|
if (message) {
|
|
return message
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
if (Array.isArray(current)) {
|
|
queue.push(...current)
|
|
continue
|
|
}
|
|
|
|
if (current && typeof current === 'object') {
|
|
queue.push(...Object.values(current))
|
|
}
|
|
}
|
|
|
|
return fallback
|
|
}
|
|
|
|
function 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 buildLessonImportExample({ title, excerpt, difficulty, accessLevel, lessonType, categoryName }) {
|
|
const nextTitle = String(title || '').trim() || 'How to Build Cleaner Prompt References'
|
|
const nextExcerpt = String(excerpt || '').trim() || 'Build a lesson draft with a clear promise, practical steps, and reusable examples.'
|
|
|
|
return JSON.stringify({
|
|
title: nextTitle,
|
|
slug: slugifyLessonTitle(nextTitle),
|
|
excerpt: nextExcerpt,
|
|
category: String(categoryName || '').trim() || 'Prompting',
|
|
difficulty: String(difficulty || '').trim() || 'beginner',
|
|
access_level: String(accessLevel || '').trim() || 'free',
|
|
lesson_type: String(lessonType || '').trim() || 'article',
|
|
tags: ['prompting', 'workflow', 'editing'],
|
|
content_markdown: [
|
|
'# Why this lesson matters',
|
|
'',
|
|
'Open with the promise of the lesson and the result the reader should get.',
|
|
'',
|
|
'## Core workflow',
|
|
'',
|
|
'- Step 1: Define the goal clearly.',
|
|
'- Step 2: Show the pattern or framework.',
|
|
'- Step 3: Add one concrete example.',
|
|
'',
|
|
'## Wrap up',
|
|
'',
|
|
'Close with the next action or checklist the reader should follow.',
|
|
].join('\n'),
|
|
reading_minutes: 8,
|
|
seo_title: nextTitle,
|
|
seo_description: nextExcerpt,
|
|
active: false,
|
|
}, null, 2)
|
|
}
|
|
|
|
function buildLessonImportPrompt({ title, difficulty, accessLevel, lessonType, categoryName }) {
|
|
return [
|
|
'Create valid JSON only for a Skinbase Academy lesson import.',
|
|
'Do not wrap the answer in markdown fences.',
|
|
'Return one object with this shape:',
|
|
'{',
|
|
' "title": "Lesson title",',
|
|
' "slug": "lesson-title",',
|
|
' "excerpt": "One short summary sentence.",',
|
|
` "category": "${String(categoryName || 'Prompting')}",`,
|
|
` "difficulty": "${String(difficulty || 'beginner')}",`,
|
|
` "access_level": "${String(accessLevel || 'free')}",`,
|
|
` "lesson_type": "${String(lessonType || 'article')}",`,
|
|
' "tags": ["tag-one", "tag-two"],',
|
|
' "content_markdown": "# Heading\\n\\nWrite the lesson body in Markdown.",',
|
|
' "reading_minutes": 8,',
|
|
' "seo_title": "Optional SEO title",',
|
|
' "seo_description": "Optional SEO description",',
|
|
' "active": false',
|
|
'}',
|
|
'Requirements:',
|
|
'- Keep the response as valid JSON only.',
|
|
'- Prefer content_markdown over HTML unless HTML is explicitly requested.',
|
|
'- Keep excerpt concise and specific.',
|
|
'- Keep tags short and relevant.',
|
|
'- Use lowercase hyphenated slugs.',
|
|
'- Do not invent image URLs unless source assets are provided.',
|
|
`Current lesson title: ${String(title || 'Untitled lesson')}`,
|
|
].join('\n')
|
|
}
|
|
|
|
function buildLessonHeroPrompt({ title, excerpt, categoryName, tags = [] }) {
|
|
return [
|
|
'Create a wide hero cover image for a Skinbase Academy lesson.',
|
|
`Lesson title: ${String(title || 'Untitled lesson')}`,
|
|
`Lesson summary: ${String(excerpt || 'No summary added yet.')}`,
|
|
`Category: ${String(categoryName || 'Uncategorized')}`,
|
|
`Tags: ${tags.length > 0 ? tags.join(', ') : 'none'}`,
|
|
'',
|
|
'Aspect ratio: 16:9 landscape.',
|
|
'Style: cinematic editorial artwork with premium lighting, a strong focal point, and a clean composition that still reads well when cropped into cards and previews.',
|
|
'Text rules: no added text, no captions, no logos, no watermarks, and no visible UI.',
|
|
'Composition: keep the center readable and leave safe space for future cropping.',
|
|
'Output: a single final image prompt, not a report.',
|
|
].join('\n')
|
|
}
|
|
|
|
function buildLessonArticleCoverPrompt({ courseName, lessonNumber, title, excerpt, categoryName, tags = [], aspectRatio = '3:2', mainVisualSubject, previewImageDescription }) {
|
|
return [
|
|
'Create a premium Skinbase Academy inline article cover image.',
|
|
'',
|
|
`Course name: ${String(courseName || 'Unassigned')}`,
|
|
`Lesson number: ${String(lessonNumber || '1')}`,
|
|
`Lesson title: ${String(title || 'Untitled lesson')}`,
|
|
`Lesson summary: ${String(excerpt || 'No summary added yet.')}`,
|
|
`Category: ${String(categoryName || 'Uncategorized')}`,
|
|
`Tags: ${tags.length > 0 ? tags.join(', ') : 'none'}`,
|
|
'',
|
|
`Aspect ratio: ${String(aspectRatio || '3:2')}, landscape article-cover format.`,
|
|
'',
|
|
'Visual direction:',
|
|
'Design a polished dark editorial academy cover inspired by a modern creative-tech learning interface. The layout should feel like a premium lesson card for an online academy article.',
|
|
'',
|
|
'Composition:',
|
|
'Use a strong two-column layout.',
|
|
'Left side: large lesson-title area, lesson badge, short summary area, and a row of small educational icon blocks.',
|
|
'Right side: a large cinematic preview image inside a rounded rectangular frame, showing the lesson concept visually.',
|
|
'Below or near the preview image: add a subtle prompt/workflow card with abstract lines and interface-like blocks.',
|
|
'Bottom area: add a clean row of small learning-step modules or icon cards.',
|
|
'',
|
|
'Main visual subject:',
|
|
String(mainVisualSubject || `A premium editorial visual focused on ${String(title || 'this lesson')}`),
|
|
'',
|
|
'The right preview image should show:',
|
|
String(previewImageDescription || `A cinematic article-cover scene that clearly supports ${String(title || 'the lesson topic')} and feels premium at thumbnail size.`),
|
|
'',
|
|
'Educational UI details:',
|
|
'Include subtle composition guide lines, crop guides, small abstract icons, prompt-card shapes, clean rounded panels, soft glows, and thin purple outlines. Make the design feel structured, modern, and readable.',
|
|
'',
|
|
'Style:',
|
|
'Dark modern Skinbase Academy aesthetic, polished editorial design, premium creative-tech interface, cinematic digital art, clean hierarchy, soft shadows, rounded cards, subtle grid background, elegant purple/cyan accents, high-end course-platform look.',
|
|
'',
|
|
'Color palette:',
|
|
'Deep navy, black, dark violet, purple gradients, muted cyan highlights, soft white typography areas, warm cinematic orange/gold highlights inside the preview artwork.',
|
|
'',
|
|
'Text handling:',
|
|
'Use clean title-like placeholder text areas only. Do not create messy fake text. Keep typography areas visually readable and leave enough space for real text to be added later. Avoid small unreadable paragraphs.',
|
|
'',
|
|
'Important:',
|
|
'No logos, no watermarks, no brand marks, no fake signatures, no cluttered UI, no distorted icons, no random letters, no overcrowded composition. The cover must work as an inline article image and still be clear at thumbnail size.',
|
|
].join('\n')
|
|
}
|
|
|
|
function JsonImportDialog({ open, value, error, exampleValue, promptValue, onChange, onClose, onApply, onCopyExample, onCopyPrompt }) {
|
|
const backdropRef = useRef(null)
|
|
const [activeReferenceTab, setActiveReferenceTab] = useState('structure')
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setActiveReferenceTab('structure')
|
|
}
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
if (!open) return undefined
|
|
|
|
const handleKeyDown = (event) => {
|
|
if (event.key === 'Escape') {
|
|
onClose?.()
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [onClose, open])
|
|
|
|
if (!open) return null
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={backdropRef}
|
|
className="fixed inset-0 z-[9999] flex items-start justify-center overflow-y-auto bg-[#04070dcc] px-4 py-4 backdrop-blur-md sm:items-center sm:px-6 sm:py-6"
|
|
onClick={(event) => {
|
|
if (event.target === backdropRef.current) {
|
|
onClose?.()
|
|
}
|
|
}}
|
|
role="presentation"
|
|
>
|
|
<div className="flex max-h-[calc(100vh-2rem)] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)] sm:max-h-[calc(100vh-3rem)]">
|
|
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">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="min-h-0 flex-1 overflow-y-auto">
|
|
<div className="grid gap-5 px-6 py-5 xl:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)]">
|
|
<div className="grid gap-3">
|
|
<textarea
|
|
value={value}
|
|
onChange={(event) => onChange?.(event.target.value)}
|
|
rows={16}
|
|
placeholder={exampleValue}
|
|
className="min-h-[320px] rounded-[24px] border border-white/10 bg-slate-950/80 px-4 py-4 font-mono text-sm leading-6 text-slate-100 outline-none"
|
|
/>
|
|
{error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
|
</div>
|
|
|
|
<div className="grid content-start gap-4">
|
|
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-2">
|
|
<div className="flex flex-wrap gap-2" role="tablist" aria-label="Lesson import reference panels">
|
|
{[
|
|
{ id: 'structure', label: 'Structure', icon: 'fa-brackets-curly' },
|
|
{ id: 'fields', label: 'Fields', icon: 'fa-table-columns' },
|
|
{ id: 'prompt', label: 'Prompt', icon: 'fa-wand-magic-sparkles' },
|
|
{ id: 'notes', label: 'Notes', icon: 'fa-list-check' },
|
|
].map((tab) => {
|
|
const isActive = tab.id === activeReferenceTab
|
|
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
onClick={() => setActiveReferenceTab(tab.id)}
|
|
className={[
|
|
'inline-flex items-center gap-2 rounded-2xl border px-3.5 py-2 text-xs font-semibold uppercase tracking-[0.14em] transition',
|
|
isActive
|
|
? 'border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20'
|
|
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white',
|
|
].join(' ')}
|
|
>
|
|
<i className={`fa-solid ${tab.icon} text-[10px]`} />
|
|
<span>{tab.label}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div className="mt-3 rounded-[20px] border border-white/10 bg-slate-950/50 p-4 text-sm text-slate-300">
|
|
{activeReferenceTab === 'structure' ? (
|
|
<div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Accepted structure</div>
|
|
<button type="button" onClick={onCopyExample} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]">Copy example</button>
|
|
</div>
|
|
<pre className="mt-3 max-h-[360px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300">{exampleValue}</pre>
|
|
</div>
|
|
) : null}
|
|
|
|
{activeReferenceTab === 'fields' ? (
|
|
<div>
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Accepted keys</div>
|
|
<div className="mt-3 grid gap-3 text-slate-400 sm:grid-cols-2">
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-3">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Core</p>
|
|
<p className="mt-2 text-xs leading-6">title, slug, excerpt</p>
|
|
<p className="text-xs leading-6">lesson_number, course_order, series_name</p>
|
|
<p className="text-xs leading-6">difficulty, access_level, lesson_type</p>
|
|
<p className="text-xs leading-6">reading_minutes, published_at</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-3">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Body</p>
|
|
<p className="mt-2 text-xs leading-6">content_markdown, markdown, md</p>
|
|
<p className="text-xs leading-6">content, body, html</p>
|
|
<p className="text-xs leading-6">tags, video_url</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-3">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Taxonomy</p>
|
|
<p className="mt-2 text-xs leading-6">category_id, category_slug, category</p>
|
|
<p className="text-xs leading-6">featured, active</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-3">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Media + SEO</p>
|
|
<p className="mt-2 text-xs leading-6">cover_image, cover, cover_url</p>
|
|
<p className="text-xs leading-6">article_cover_image, article_cover, article_cover_url</p>
|
|
<p className="text-xs leading-6">seo_title, seo_description</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{activeReferenceTab === 'prompt' ? (
|
|
<div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">ChatGPT helper prompt</div>
|
|
<button type="button" onClick={onCopyPrompt} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]">Copy prompt</button>
|
|
</div>
|
|
<pre className="mt-3 max-h-[360px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300 whitespace-pre-wrap">{promptValue}</pre>
|
|
</div>
|
|
) : null}
|
|
|
|
{activeReferenceTab === 'notes' ? (
|
|
<div>
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">What gets applied</div>
|
|
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
|
<p>The JSON updates only recognized lesson fields already supported by the editor.</p>
|
|
<p>Markdown import updates both the Markdown source and rendered HTML body.</p>
|
|
<p>Category values can match by id, slug, or visible category name.</p>
|
|
<p>Imported values become editable immediately before you save the lesson.</p>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
|
<button type="button" onClick={() => onClose?.()} className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white">Cancel</button>
|
|
<button type="button" onClick={() => onApply?.()} className="inline-flex items-center 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 [toast, setToast] = useState({ id: 0, visible: false, message: '', variant: 'success' })
|
|
const csrfToken = useMemo(() => {
|
|
if (typeof document === 'undefined') return ''
|
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
}, [])
|
|
const showToast = (message, variant = 'error') => {
|
|
setToast({
|
|
id: Date.now() + Math.random(),
|
|
visible: true,
|
|
message,
|
|
variant,
|
|
})
|
|
}
|
|
|
|
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') {
|
|
if (!lessonMarkdownTurndown) {
|
|
form.setData('content_source', 'html')
|
|
form.setData('content_markdown', '')
|
|
return
|
|
}
|
|
|
|
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])
|
|
const selectedCategoryName = useMemo(() => {
|
|
const selectedId = String(form.data.category_id || '').trim()
|
|
if (!selectedId) return ''
|
|
|
|
const match = categories.find((category) => String(category.id) === selectedId)
|
|
return match ? String(match.name || '') : ''
|
|
}, [categories, form.data.category_id])
|
|
const jsonImportExampleValue = useMemo(() => buildLessonImportExample({
|
|
title: form.data.title,
|
|
excerpt: form.data.excerpt,
|
|
difficulty: form.data.difficulty,
|
|
accessLevel: form.data.access_level,
|
|
lessonType: form.data.lesson_type,
|
|
categoryName: selectedCategoryName,
|
|
}), [form.data.access_level, form.data.difficulty, form.data.excerpt, form.data.lesson_type, form.data.title, selectedCategoryName])
|
|
const jsonImportPromptValue = useMemo(() => buildLessonImportPrompt({
|
|
title: form.data.title,
|
|
difficulty: form.data.difficulty,
|
|
accessLevel: form.data.access_level,
|
|
lessonType: form.data.lesson_type,
|
|
categoryName: selectedCategoryName,
|
|
}), [form.data.access_level, form.data.difficulty, form.data.lesson_type, form.data.title, selectedCategoryName])
|
|
const selectedCourseName = useMemo(() => selectedCourses[0]?.label || 'Unassigned', [selectedCourses])
|
|
const lessonNumberValue = useMemo(() => {
|
|
const numeric = Number(form.data.lesson_number)
|
|
if (Number.isFinite(numeric) && numeric > 0) return String(numeric)
|
|
|
|
const suggested = Number(numberingContext?.lesson_number?.suggested || 0)
|
|
if (Number.isFinite(suggested) && suggested > 0) return String(suggested)
|
|
|
|
return '1'
|
|
}, [form.data.lesson_number, numberingContext])
|
|
const lessonHeroPromptValue = useMemo(() => buildLessonHeroPrompt({
|
|
title: form.data.title,
|
|
excerpt: form.data.excerpt,
|
|
categoryName: selectedCategoryName,
|
|
tags: String(form.data.tags || '').split(',').map((tag) => tag.trim()).filter(Boolean),
|
|
}), [form.data.excerpt, form.data.tags, form.data.title, selectedCategoryName])
|
|
const lessonArticleCoverPromptValue = useMemo(() => buildLessonArticleCoverPrompt({
|
|
courseName: selectedCourseName,
|
|
lessonNumber: lessonNumberValue,
|
|
title: form.data.title,
|
|
excerpt: form.data.excerpt,
|
|
categoryName: selectedCategoryName,
|
|
tags: String(form.data.tags || '').split(',').map((tag) => tag.trim()).filter(Boolean),
|
|
aspectRatio: '3:2',
|
|
mainVisualSubject: `A premium editorial visual focused on ${String(form.data.title || 'this lesson')}`,
|
|
previewImageDescription: `A cinematic article-cover scene that clearly supports ${String(form.data.title || 'the lesson topic')} and feels premium at thumbnail size.`,
|
|
}), [form.data.excerpt, form.data.tags, form.data.title, lessonNumberValue, selectedCategoryName, selectedCourseName])
|
|
const lessonHeaderNumberLabel = useMemo(() => {
|
|
const numeric = Number(form.data.lesson_number)
|
|
|
|
if (!Number.isFinite(numeric) || numeric < 1) {
|
|
return 'Unnumbered'
|
|
}
|
|
|
|
return `Lesson ${String(numeric).padStart(2, '0')}`
|
|
}, [form.data.lesson_number])
|
|
|
|
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)
|
|
|
|
const submitOptions = {
|
|
preserveScroll: true,
|
|
onError: (errors) => {
|
|
const nextTab = firstLessonErrorTab(errors)
|
|
|
|
if (nextTab) {
|
|
setActiveTab(nextTab)
|
|
}
|
|
|
|
showToast(firstErrorMessage(errors), 'error')
|
|
},
|
|
onFinish: () => form.transform((data) => data),
|
|
}
|
|
|
|
if (method === 'patch') {
|
|
form.patch(submitUrl, submitOptions)
|
|
return
|
|
}
|
|
|
|
form.post(submitUrl, submitOptions)
|
|
}
|
|
|
|
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 copyImportHelperText = async (text, successMessage) => {
|
|
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
|
showToast('Clipboard copy is not available in this browser.', 'error')
|
|
return
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(String(text || ''))
|
|
showToast(successMessage, 'success')
|
|
} catch {
|
|
showToast('Could not copy import helper text.', 'error')
|
|
}
|
|
}
|
|
|
|
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>
|
|
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-sky-100">{lessonHeaderNumberLabel}</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-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-5 lg:grid-cols-2 lg:items-start">
|
|
<div className="grid gap-4">
|
|
<WorldMediaUploadField
|
|
label="Hero cover"
|
|
slot="cover"
|
|
value={form.data.cover_image}
|
|
previewUrl={coverPreviewUrl}
|
|
emptyLabel="Drop a hero cover"
|
|
helperText="Upload a wide landscape image for academy cards, previews, and social sharing. Keep it cinematic, readable at small sizes, and free of embedded text."
|
|
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 hero 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">Use this for migrations, imported lessons, or when you already know the exact asset path to use.</span>
|
|
</label>
|
|
</div>
|
|
|
|
<CopyablePromptCard
|
|
eyebrow="ChatGPT prompt"
|
|
title="Copy this for the hero cover"
|
|
description="Paste this into ChatGPT when you want a new hero image for the lesson."
|
|
prompt={lessonHeroPromptValue}
|
|
onCopy={() => {
|
|
void copyImportHelperText(lessonHeroPromptValue, 'Hero cover prompt copied.')
|
|
}}
|
|
/>
|
|
</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-5 lg:grid-cols-2 lg:items-start">
|
|
<div className="grid gap-4">
|
|
<WorldMediaUploadField
|
|
label="Inline article cover"
|
|
slot="cover"
|
|
value={form.data.article_cover_image}
|
|
previewUrl={articleCoverPreviewUrl}
|
|
emptyLabel="Drop an inline article cover"
|
|
helperText="Upload the image that appears above the lesson body. Use a strong landscape 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 inline 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>
|
|
|
|
<CopyablePromptCard
|
|
eyebrow="ChatGPT prompt"
|
|
title="Copy this for the inline article image"
|
|
description="Paste this into ChatGPT when you want a cleaner image that sits above the lesson body."
|
|
prompt={lessonArticleCoverPromptValue}
|
|
onCopy={() => {
|
|
void copyImportHelperText(lessonArticleCoverPromptValue, 'Article cover prompt copied.')
|
|
}}
|
|
/>
|
|
</div>
|
|
</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-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}
|
|
exampleValue={jsonImportExampleValue}
|
|
promptValue={jsonImportPromptValue}
|
|
onChange={(nextValue) => {
|
|
setJsonImportValue(nextValue)
|
|
if (jsonImportError) {
|
|
setJsonImportError('')
|
|
}
|
|
}}
|
|
onCopyExample={() => {
|
|
void copyImportHelperText(jsonImportExampleValue, 'Lesson JSON example copied.')
|
|
}}
|
|
onCopyPrompt={() => {
|
|
void copyImportHelperText(jsonImportPromptValue, 'Lesson import prompt copied.')
|
|
}}
|
|
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}
|
|
/>
|
|
|
|
<ShareToast
|
|
key={toast.id}
|
|
message={toast.message}
|
|
visible={toast.visible}
|
|
variant={toast.variant}
|
|
duration={toast.variant === 'error' ? 3200 : 2200}
|
|
onHide={() => setToast((current) => ({ ...current, visible: false }))}
|
|
/>
|
|
</AdminLayout>
|
|
)
|
|
} |