1127 lines
63 KiB
JavaScript
1127 lines
63 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { Head, Link, router, useForm } from '@inertiajs/react'
|
|
import AdminLayout from '../../../Layouts/AdminLayout'
|
|
import RichTextEditor from '../../../components/forum/RichTextEditor'
|
|
import WorldMediaUploadField from '../../../components/worlds/editor/WorldMediaUploadField'
|
|
import DateTimePicker from '../../../components/ui/DateTimePicker'
|
|
import NovaSelect from '../../../components/ui/NovaSelect'
|
|
|
|
function getField(fields, name) {
|
|
return fields.find((field) => field.name === name) || null
|
|
}
|
|
|
|
const LESSON_SECTION_NAV_ITEMS = [
|
|
{ id: 'lesson-story-setup', label: 'Story setup' },
|
|
{ id: 'lesson-body-editor', label: 'Main article' },
|
|
{ id: 'lesson-ai-comparisons', label: 'AI comparisons' },
|
|
{ id: 'lesson-publishing', label: 'Publishing' },
|
|
{ id: 'lesson-seo', label: 'SEO' },
|
|
{ id: 'lesson-categories', label: 'Categories' },
|
|
{ id: 'lesson-cover', label: 'Cover image' },
|
|
{ id: 'lesson-preview', label: 'Preview' },
|
|
]
|
|
|
|
let comparisonEditorSequence = 0
|
|
|
|
function nextComparisonEditorKey(prefix) {
|
|
comparisonEditorSequence += 1
|
|
return `${prefix}-${comparisonEditorSequence}`
|
|
}
|
|
|
|
function FieldError({ message }) {
|
|
if (!message) return null
|
|
return <p className="text-xs text-rose-300">{message}</p>
|
|
}
|
|
|
|
function SectionCard({ id, eyebrow, title, description, actions, children, tone = 'default' }) {
|
|
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}`}>
|
|
<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">{children}</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function SectionNav({ items }) {
|
|
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 items-center gap-3 overflow-x-auto pb-1 nova-scrollbar">
|
|
<span className="flex shrink-0 items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
|
<i className="fa-solid fa-compass text-[10px]" />
|
|
Sections
|
|
</span>
|
|
{items.map((item) => (
|
|
<a
|
|
key={item.id}
|
|
href={`#${item.id}`}
|
|
className="shrink-0 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-semibold text-white/80 transition hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white"
|
|
>
|
|
{item.label}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TextField({ label, value, onChange, error, hint, ...rest }) {
|
|
return (
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
|
|
<input value={value ?? ''} onChange={onChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" {...rest} />
|
|
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
|
|
<FieldError message={error} />
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function TextAreaField({ label, value, onChange, error, rows = 4, hint }) {
|
|
return (
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{label}</span>
|
|
<textarea value={value ?? ''} onChange={onChange} rows={rows} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
|
|
<FieldError message={error} />
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function ToggleField({ label, checked, onChange, help, error }) {
|
|
return (
|
|
<label className="flex items-start gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200">
|
|
<input type="checkbox" checked={Boolean(checked)} onChange={onChange} className="mt-1" />
|
|
<span>
|
|
<span className="block font-semibold text-white">{label}</span>
|
|
{help ? <span className="mt-1 block text-xs leading-5 text-slate-400">{help}</span> : null}
|
|
<FieldError message={error} />
|
|
</span>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
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 countWords(value) {
|
|
const text = 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 || ''),
|
|
excerpt: String(data.excerpt || ''),
|
|
content: String(data.content || ''),
|
|
difficulty: String(data.difficulty || ''),
|
|
access_level: String(data.access_level || ''),
|
|
lesson_type: String(data.lesson_type || ''),
|
|
cover_image: String(data.cover_image || ''),
|
|
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.excerpt != null) apply('excerpt', String(parsed.excerpt))
|
|
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.video_url != null) apply('video_url', String(parsed.video_url))
|
|
if (parsed.reading_minutes != null) apply('reading_minutes', String(parsed.reading_minutes))
|
|
if (parsed.published_at != null) apply('published_at', String(parsed.published_at))
|
|
if (parsed.seo_title != null) apply('seo_title', String(parsed.seo_title))
|
|
if (parsed.seo_description != null) apply('seo_description', String(parsed.seo_description))
|
|
if (parsed.featured != null) apply('featured', normalizeBoolean(parsed.featured))
|
|
if (parsed.active != null) apply('active', normalizeBoolean(parsed.active, true))
|
|
|
|
if (parsed.category_id != null || parsed.category_slug != null || parsed.category != null) {
|
|
const requested = String(parsed.category_id ?? parsed.category_slug ?? parsed.category).trim().toLowerCase()
|
|
const match = (Array.isArray(categoryOptions) ? categoryOptions : []).find((option) => [option.id, option.value, option.slug, option.name, option.label]
|
|
.filter((candidate) => candidate != null)
|
|
.map((candidate) => String(candidate).trim().toLowerCase())
|
|
.includes(requested))
|
|
|
|
if (match) {
|
|
apply('category_id', String(match.id ?? match.value ?? ''))
|
|
}
|
|
}
|
|
|
|
if (applied.length === 0) {
|
|
throw new Error('The JSON did not contain any recognized lesson fields.')
|
|
}
|
|
|
|
return { next, applied }
|
|
}
|
|
|
|
function JsonImportDialog({ open, value, error, onChange, onClose, onApply }) {
|
|
const backdropRef = useRef(null)
|
|
|
|
useEffect(() => {
|
|
if (!open) return undefined
|
|
|
|
const handleKeyDown = (event) => {
|
|
if (event.key === 'Escape') {
|
|
onClose?.()
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [onClose, open])
|
|
|
|
if (!open) return null
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={backdropRef}
|
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
|
|
onClick={(event) => {
|
|
if (event.target === backdropRef.current) {
|
|
onClose?.()
|
|
}
|
|
}}
|
|
role="presentation"
|
|
>
|
|
<div className="w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
|
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Structured Import</p>
|
|
<h3 className="mt-2 text-lg font-semibold text-white">Paste lesson JSON</h3>
|
|
<p className="mt-2 text-sm leading-6 text-white/65">Use this to seed the lesson form with structured content before you refine it in the editor.</p>
|
|
</div>
|
|
|
|
<div className="grid gap-5 px-6 py-5 xl:grid-cols-[minmax(0,1fr)_280px]">
|
|
<div className="grid gap-3">
|
|
<textarea
|
|
value={value}
|
|
onChange={(event) => onChange?.(event.target.value)}
|
|
rows={16}
|
|
placeholder={'{\n "title": "Prompt engineering for cleaner scene direction",\n "excerpt": "Short summary...",\n "content": "<p>Rich HTML body...</p>",\n "category": "Prompting",\n "difficulty": "beginner"\n}'}
|
|
className="rounded-[24px] border border-white/10 bg-slate-950/80 px-4 py-4 font-mono text-sm leading-6 text-slate-100 outline-none"
|
|
/>
|
|
{error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
|
</div>
|
|
|
|
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Accepted keys</div>
|
|
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
|
<p>title, slug, excerpt</p>
|
|
<p>content, body, html</p>
|
|
<p>category_id, category_slug, category</p>
|
|
<p>difficulty, access_level, lesson_type</p>
|
|
<p>cover_image, cover, cover_url, video_url</p>
|
|
<p>reading_minutes, published_at</p>
|
|
<p>seo_title, seo_description, featured, active</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
|
<button type="button" onClick={() => onClose?.()} className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white">Cancel</button>
|
|
<button type="button" onClick={() => onApply?.()} className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110">Apply JSON</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|
|
|
|
export default function LessonEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext = {} }) {
|
|
const cdnBaseUrl = editorContext.coverCdnBaseUrl || ''
|
|
const form = useForm({
|
|
...record,
|
|
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 [categories, setCategories] = useState(Array.isArray(editorContext.categories) ? editorContext.categories : [])
|
|
const [jsonImportOpen, setJsonImportOpen] = useState(false)
|
|
const [jsonImportValue, setJsonImportValue] = useState('')
|
|
const [jsonImportError, setJsonImportError] = useState('')
|
|
const [categoryError, setCategoryError] = useState('')
|
|
const [categorySaving, setCategorySaving] = useState(false)
|
|
const [categoryDraft, setCategoryDraft] = useState({ type: 'lesson', name: '', slug: '', description: '', order_num: 0, active: true })
|
|
const slugTouchedRef = useRef(Boolean(String(record.slug || '').trim()))
|
|
const difficultyField = useMemo(() => getField(fields, 'difficulty'), [fields])
|
|
const accessField = useMemo(() => getField(fields, 'access_level'), [fields])
|
|
const bodyWordCount = useMemo(() => countWords(form.data.content), [form.data.content])
|
|
const excerptLength = String(form.data.excerpt || '').length
|
|
const csrfToken = useMemo(() => {
|
|
if (typeof document === 'undefined') return ''
|
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (slugTouchedRef.current) return
|
|
form.setData('slug', slugifyLessonTitle(form.data.title))
|
|
}, [form.data.title])
|
|
|
|
const categoryOptions = useMemo(() => {
|
|
const next = categories.map((category) => ({ value: String(category.id), label: category.name }))
|
|
return [{ value: '', label: 'No category' }, ...next]
|
|
}, [categories])
|
|
|
|
const handleManualCoverChange = (nextValue) => {
|
|
form.setData('cover_image', nextValue)
|
|
setCoverPreviewUrl(normalizeCoverPreview(nextValue, editorContext.coverCdnBaseUrl))
|
|
}
|
|
|
|
const updateBlocks = (updater) => {
|
|
const currentBlocks = Array.isArray(form.data.blocks) ? form.data.blocks : []
|
|
const nextBlocks = typeof updater === 'function' ? updater(currentBlocks) : updater
|
|
form.setData('blocks', nextBlocks)
|
|
}
|
|
|
|
const updateBlock = (blockKey, updater) => {
|
|
updateBlocks((currentBlocks) => currentBlocks.map((block) => (
|
|
block.client_key === blockKey ? updater(block) : block
|
|
)))
|
|
}
|
|
|
|
const removeBlock = (blockKey) => {
|
|
updateBlocks((currentBlocks) => currentBlocks.filter((block) => block.client_key !== blockKey))
|
|
}
|
|
|
|
const addComparisonBlock = () => {
|
|
updateBlocks((currentBlocks) => ([
|
|
...currentBlocks,
|
|
createEmptyComparisonBlock(currentBlocks.length, cdnBaseUrl),
|
|
]))
|
|
}
|
|
|
|
const addComparisonResult = (blockKey) => {
|
|
updateBlock(blockKey, (block) => ({
|
|
...block,
|
|
comparison_results: [
|
|
...(Array.isArray(block.comparison_results) ? block.comparison_results : []),
|
|
createEmptyComparisonResult(Array.isArray(block.comparison_results) ? block.comparison_results.length : 0),
|
|
],
|
|
}))
|
|
}
|
|
|
|
const updateComparisonResult = (blockKey, resultKey, updater) => {
|
|
updateBlock(blockKey, (block) => ({
|
|
...block,
|
|
comparison_results: (Array.isArray(block.comparison_results) ? block.comparison_results : []).map((result) => (
|
|
result.client_key === resultKey ? updater(result) : result
|
|
)),
|
|
}))
|
|
}
|
|
|
|
const removeComparisonResult = (blockKey, resultKey) => {
|
|
updateBlock(blockKey, (block) => ({
|
|
...block,
|
|
comparison_results: (Array.isArray(block.comparison_results) ? block.comparison_results : []).filter((result) => result.client_key !== resultKey),
|
|
}))
|
|
}
|
|
|
|
const submit = (event) => {
|
|
event.preventDefault()
|
|
const payload = buildLessonPayload(form.data)
|
|
form.transform(() => payload)
|
|
|
|
if (method === 'patch') {
|
|
form.patch(submitUrl)
|
|
return
|
|
}
|
|
|
|
form.post(submitUrl)
|
|
}
|
|
|
|
const deleteLesson = () => {
|
|
if (!destroyUrl) return
|
|
if (!window.confirm('Delete this lesson?')) return
|
|
router.delete(destroyUrl)
|
|
}
|
|
|
|
const applyJsonImport = () => {
|
|
try {
|
|
const parsed = parseLessonImport(jsonImportValue, categories)
|
|
|
|
Object.entries(parsed.next).forEach(([key, value]) => {
|
|
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 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 : []
|
|
|
|
return (
|
|
<AdminLayout title={title} subtitle={subtitle}>
|
|
<Head title={`Admin · ${title}`} />
|
|
|
|
<form onSubmit={submit} className="space-y-6 pb-16">
|
|
<section className="overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur">
|
|
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
|
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]">Back to lessons</Link>
|
|
<span>{destroyUrl ? 'Edit lesson' : 'New lesson'}</span>
|
|
</div>
|
|
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy lesson'}</h1>
|
|
<p className="mt-2 max-w-3xl text-sm leading-7 text-slate-300">Use the same richer writing flow as the newsroom: drag in the cover, shape the article with the rich editor, and keep publishing details in the same place.</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-3">
|
|
<button type="button" onClick={() => setJsonImportOpen(true)} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Import JSON</button>
|
|
<button type="submit" disabled={form.processing} className="rounded-2xl border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save lesson'}</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<SectionNav items={LESSON_SECTION_NAV_ITEMS} />
|
|
|
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start">
|
|
<div className="min-w-0 space-y-6">
|
|
<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">
|
|
<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={<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>}>
|
|
<div className="grid min-w-0 gap-3 text-sm text-slate-300">
|
|
<RichTextEditor
|
|
content={form.data.content}
|
|
onChange={(nextValue) => form.setData('content', nextValue)}
|
|
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={24}
|
|
autofocus={false}
|
|
advancedNews
|
|
mediaSupport={{
|
|
uploadUrl: editorContext.bodyMediaUploadUrl,
|
|
deleteUrl: editorContext.bodyMediaDeleteUrl,
|
|
assetsUrl: editorContext.bodyMediaAssetsUrl,
|
|
slot: 'body',
|
|
}}
|
|
/>
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-400">
|
|
Tutorial workflow suggestion: define the outcome, break the process into clear steps, call out traps or quality checks, then finish with a practical next move.
|
|
</div>
|
|
</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."
|
|
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-publishing" eyebrow="Publishing" title="Placement and visibility" description="Set the lesson metadata, schedule, and discovery fields before it goes live.">
|
|
<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" />
|
|
<TextField label="Reading minutes" value={form.data.reading_minutes} onChange={(event) => form.setData('reading_minutes', event.target.value)} error={form.errors.reading_minutes} type="number" min="1" max="999" />
|
|
</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.">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<TextField label="SEO title" value={form.data.seo_title} onChange={(event) => form.setData('seo_title', event.target.value)} error={form.errors.seo_title} maxLength={180} placeholder="Optional search title" />
|
|
<TextField label="Video URL" value={form.data.video_url} onChange={(event) => form.setData('video_url', event.target.value)} error={form.errors.video_url} placeholder="Optional lesson video URL" />
|
|
</div>
|
|
<TextAreaField label="SEO description" value={form.data.seo_description} onChange={(event) => form.setData('seo_description', event.target.value)} error={form.errors.seo_description} rows={4} hint="Keep this tighter than the excerpt and focused on search intent." />
|
|
</SectionCard>
|
|
|
|
<SectionCard id="lesson-categories" eyebrow="Lesson categories" title="Create category inline" description="Add lesson categories without leaving the writing flow." 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" />
|
|
<label className="flex items-center gap-2 text-sm text-slate-300"><input type="checkbox" checked={categoryDraft.active} onChange={(event) => setCategoryDraft((current) => ({ ...current, active: event.target.checked }))} /> Active</label>
|
|
<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>
|
|
</div>
|
|
|
|
<div className="space-y-6 xl:sticky xl:top-6 xl:self-start">
|
|
<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.">
|
|
<div className="grid gap-4">
|
|
<WorldMediaUploadField
|
|
label="Lesson cover"
|
|
slot="cover"
|
|
value={form.data.cover_image}
|
|
previewUrl={coverPreviewUrl}
|
|
emptyLabel="Drop a lesson cover"
|
|
helperText="Upload the hero image directly to object storage. A wide landscape image works best for academy cards, previews, and social sharing."
|
|
uploadUrl={editorContext.coverUploadUrl}
|
|
deleteUrl={editorContext.coverDeleteUrl}
|
|
onChange={({ path, url }) => {
|
|
setStagedCoverPath(path || '')
|
|
form.setData('cover_image', path || '')
|
|
setCoverPreviewUrl(url || '')
|
|
}}
|
|
isTemporaryValue={Boolean(stagedCoverPath) && form.data.cover_image === stagedCoverPath}
|
|
/>
|
|
<FieldError message={form.errors.cover_image} />
|
|
|
|
<label className="grid gap-2 text-sm text-slate-300">
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced cover path or URL</span>
|
|
<input value={form.data.cover_image} onChange={(event) => handleManualCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
|
<span className="text-xs leading-5 text-slate-500">Keep this for migrations, imported lessons, or when you already know the exact asset path to use.</span>
|
|
</label>
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard id="lesson-preview" eyebrow="Preview" title="Lesson snapshot" description="A quick view of what editors and visitors will scan first.">
|
|
<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 cover image selected yet.</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>
|
|
</SectionCard>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
|
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save lesson'}</button>
|
|
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back</Link>
|
|
{destroyUrl ? <button type="button" onClick={deleteLesson} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete</button> : null}
|
|
</div>
|
|
</form>
|
|
|
|
<JsonImportDialog
|
|
open={jsonImportOpen}
|
|
value={jsonImportValue}
|
|
error={jsonImportError}
|
|
onChange={(nextValue) => {
|
|
setJsonImportValue(nextValue)
|
|
if (jsonImportError) {
|
|
setJsonImportError('')
|
|
}
|
|
}}
|
|
onClose={() => {
|
|
setJsonImportOpen(false)
|
|
setJsonImportError('')
|
|
}}
|
|
onApply={applyJsonImport}
|
|
/>
|
|
</AdminLayout>
|
|
)
|
|
} |