Implement academy analytics, billing, and web stories updates
This commit is contained in:
@@ -7,6 +7,7 @@ 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
|
||||
@@ -74,9 +75,9 @@ const LESSON_EDITOR_TABS = [
|
||||
{
|
||||
id: 'assets',
|
||||
label: 'Assets',
|
||||
description: 'Categories, hero media, and article imagery.',
|
||||
description: 'Hero cover, article cover, and lesson categories.',
|
||||
icon: 'fa-images',
|
||||
sections: ['lesson-categories', 'lesson-cover', 'lesson-article-cover'],
|
||||
sections: ['lesson-cover', 'lesson-article-cover', 'lesson-categories'],
|
||||
},
|
||||
{
|
||||
id: 'revisions',
|
||||
@@ -157,6 +158,23 @@ function FieldError({ message }) {
|
||||
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)]'
|
||||
@@ -241,6 +259,35 @@ function lessonTabErrorCounts(errors) {
|
||||
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">
|
||||
@@ -652,8 +699,143 @@ function parseLessonImport(rawText, categoryOptions) {
|
||||
return { next, applied }
|
||||
}
|
||||
|
||||
function JsonImportDialog({ open, value, error, onChange, onClose, onApply }) {
|
||||
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
|
||||
@@ -673,7 +855,7 @@ function JsonImportDialog({ open, value, error, onChange, onClose, onApply }) {
|
||||
return createPortal(
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
|
||||
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?.()
|
||||
@@ -681,40 +863,124 @@ function JsonImportDialog({ open, value, error, onChange, onClose, onApply }) {
|
||||
}}
|
||||
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="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="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="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="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Accepted keys</div>
|
||||
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
||||
<p>title, slug, excerpt</p>
|
||||
<p>lesson_number, course_order, series_name</p>
|
||||
<p>content_markdown, markdown, md</p>
|
||||
<p>content, body, html</p>
|
||||
<p>category_id, category_slug, category</p>
|
||||
<p>difficulty, access_level, lesson_type</p>
|
||||
<p>cover_image, cover, cover_url</p>
|
||||
<p>article_cover_image, article_cover, article_cover_url</p>
|
||||
<p>tags</p>
|
||||
<p>video_url</p>
|
||||
<p>reading_minutes, published_at</p>
|
||||
<p>seo_title, seo_description, featured, active</p>
|
||||
<div 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>
|
||||
@@ -859,10 +1125,19 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
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)
|
||||
@@ -878,6 +1153,12 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
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
|
||||
}
|
||||
@@ -934,6 +1215,64 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
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
|
||||
@@ -1027,12 +1366,26 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
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)
|
||||
form.patch(submitUrl, submitOptions)
|
||||
return
|
||||
}
|
||||
|
||||
form.post(submitUrl)
|
||||
form.post(submitUrl, submitOptions)
|
||||
}
|
||||
|
||||
const deleteLesson = () => {
|
||||
@@ -1137,6 +1490,20 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
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('')
|
||||
@@ -1264,6 +1631,7 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
<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>
|
||||
@@ -1851,6 +2219,86 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
<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">
|
||||
@@ -1886,62 +2334,6 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="lesson-cover" eyebrow="Cover image" title="Hero asset" description="Use drag and drop for the lesson image, or paste a direct URL when you already have one." className={sectionClassName('lesson-cover')}>
|
||||
<div className="grid gap-4">
|
||||
<WorldMediaUploadField
|
||||
label="Lesson cover"
|
||||
slot="cover"
|
||||
value={form.data.cover_image}
|
||||
previewUrl={coverPreviewUrl}
|
||||
emptyLabel="Drop a lesson cover"
|
||||
helperText="Upload the hero image directly to object storage. A wide landscape image works best for academy cards, previews, and social sharing."
|
||||
uploadUrl={editorContext.coverUploadUrl}
|
||||
deleteUrl={editorContext.coverDeleteUrl}
|
||||
onChange={({ path, url }) => {
|
||||
setStagedCoverPath(path || '')
|
||||
form.setData('cover_image', path || '')
|
||||
setCoverPreviewUrl(url || '')
|
||||
}}
|
||||
isTemporaryValue={Boolean(stagedCoverPath) && form.data.cover_image === stagedCoverPath}
|
||||
/>
|
||||
<FieldError message={form.errors.cover_image} />
|
||||
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced cover path or URL</span>
|
||||
<input value={form.data.cover_image} onChange={(event) => handleManualCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<span className="text-xs leading-5 text-slate-500">Keep this for migrations, imported lessons, or when you already know the exact asset path to use.</span>
|
||||
</label>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="lesson-article-cover" eyebrow="Article cover" title="Inline article image" description="This image is rendered just before the lesson content begins." className={sectionClassName('lesson-article-cover')}>
|
||||
<div className="grid gap-4">
|
||||
<WorldMediaUploadField
|
||||
label="Article cover"
|
||||
slot="cover"
|
||||
value={form.data.article_cover_image}
|
||||
previewUrl={articleCoverPreviewUrl}
|
||||
emptyLabel="Drop an article cover"
|
||||
helperText="Upload the image that appears above the lesson body. Use a strong wide image that still reads well inside the article column."
|
||||
uploadUrl={editorContext.coverUploadUrl}
|
||||
deleteUrl={editorContext.coverDeleteUrl}
|
||||
onChange={({ path, url }) => {
|
||||
setStagedArticleCoverPath(path || '')
|
||||
form.setData('article_cover_image', path || '')
|
||||
setArticleCoverPreviewUrl(url || '')
|
||||
}}
|
||||
isTemporaryValue={Boolean(stagedArticleCoverPath) && form.data.article_cover_image === stagedArticleCoverPath}
|
||||
/>
|
||||
<FieldError message={form.errors.article_cover_image} />
|
||||
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced article cover path or URL</span>
|
||||
<input value={form.data.article_cover_image} onChange={(event) => handleManualArticleCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<span className="text-xs leading-5 text-slate-500">Use this when the article image already exists in storage or needs to point to an external source.</span>
|
||||
</label>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="lesson-revisions" eyebrow="Safety net" title="Revision history" description="Each lesson update now saves the previous state first. Restore the full lesson or a single field when something goes wrong." className={sectionClassName('lesson-revisions')}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4 text-sm leading-6 text-slate-300">
|
||||
@@ -2085,12 +2477,20 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
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('')
|
||||
@@ -2114,6 +2514,15 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
}}
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user