import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Head, Link, router, useForm } from '@inertiajs/react'
import { createPortal } from 'react-dom'
import AdminLayout from '../../../Layouts/AdminLayout'
import DateTimePicker from '../../../components/ui/DateTimePicker'
import NovaSelect from '../../../components/ui/NovaSelect'
import CourseEditor from './CourseEditor'
import LessonEditor from './LessonEditor'
function normalizePayload(fields, data) {
const payload = { ...data }
fields.forEach((field) => {
if (field.type === 'csv') {
payload[field.name] = String(payload[field.name] || '')
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean)
}
if (field.type === 'json') {
try {
payload[field.name] = payload[field.name] ? JSON.parse(payload[field.name]) : {}
} catch {
payload[field.name] = {}
}
}
})
return payload
}
function getField(fields, name) {
return fields.find((field) => field.name === name) || null
}
function SectionCard({ eyebrow, title, description, children, className = '' }) {
return (
{eyebrow ?
{eyebrow}
: null}
{title}
{description ?
{description}
: null}
{children}
)
}
function TextField({ label, value, onChange, error, ...rest }) {
return (
{label}
{error ? {error}
: null}
)
}
function TextAreaField({ label, value, onChange, error, rows = 6, hint }) {
return (
{label}
{hint ? {hint} : null}
{error ? {error}
: null}
)
}
function ToggleField({ label, checked, onChange, help, error }) {
return (
{label}
{help ? {help} : null}
{error ? {error} : null}
)
}
const PROMPT_EDITOR_TABS = [
{
id: 'overview',
label: 'Overview',
description: 'Set the prompt identity, category, access level, and the short summary shown in the library.',
icon: 'fa-compass-drafting',
sections: ['prompt-identity'],
},
{
id: 'prompt',
label: 'Prompt Body',
description: 'Write the main prompt, exclusions, usage notes, and workflow direction without crowding the rest of the form.',
icon: 'fa-wand-magic-sparkles',
sections: ['prompt-body'],
},
{
id: 'comparisons',
label: 'AI Model Comparisons',
description: 'Compare how different AI models or providers behave on the same prompt so editors can keep the guidance reusable.',
icon: 'fa-scale-balanced',
sections: ['prompt-comparisons'],
},
{
id: 'media',
label: 'Media',
description: 'Upload the preview image used across the prompt library and public prompt detail page.',
icon: 'fa-image',
sections: ['prompt-media'],
},
{
id: 'publish',
label: 'Publish',
description: 'Control timing, SEO, and promotion state without showing every publishing option at once.',
icon: 'fa-rocket-launch',
sections: ['prompt-publishing', 'prompt-preview'],
},
]
const PROMPT_FIELD_TAB_MAP = {
category_id: 'overview',
title: 'overview',
slug: 'overview',
excerpt: 'overview',
difficulty: 'overview',
access_level: 'overview',
aspect_ratio: 'overview',
tags: 'overview',
prompt: 'prompt',
negative_prompt: 'prompt',
usage_notes: 'prompt',
workflow_notes: 'prompt',
preview_image: 'media',
preview_image_file: 'media',
published_at: 'publish',
seo_title: 'publish',
seo_description: 'publish',
featured: 'publish',
prompt_of_week: 'publish',
active: 'publish',
}
function slugifyPromptTitle(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 180)
}
function stripPlainText(value) {
return String(value || '').replace(/\s+/g, ' ').trim()
}
function countPlainWords(value) {
const text = stripPlainText(value)
return text ? text.split(/\s+/).length : 0
}
function emptyPromptComparison() {
return {
client_key: `comparison-${Math.random().toString(36).slice(2, 10)}`,
provider: '',
model_name: '',
notes: '',
strengths: '',
weaknesses: '',
best_for: '',
image_path: '',
image_url: '',
thumb_path: '',
thumb_url: '',
settings: '',
score: '',
active: true,
}
}
function sanitizePromptComparison(value) {
if (!value || typeof value !== 'object') {
return emptyPromptComparison()
}
return {
client_key: String(value.client_key || emptyPromptComparison().client_key),
provider: String(value.provider || '').trim(),
model_name: String(value.model_name || '').trim(),
notes: String(value.notes || '').trim(),
strengths: String(value.strengths || '').trim(),
weaknesses: String(value.weaknesses || '').trim(),
best_for: String(value.best_for || '').trim(),
image_path: String(value.image_path || '').trim(),
image_url: String(value.image_url || '').trim(),
thumb_path: String(value.thumb_path || '').trim(),
thumb_url: String(value.thumb_url || '').trim(),
settings: String(value.settings || '').trim(),
score: value.score === '' || value.score === null || typeof value.score === 'undefined' ? '' : String(value.score).trim(),
active: typeof value.active === 'boolean' ? value.active : true,
}
}
function normalizePromptComparisons(value, { preserveEmpty = false } = {}) {
if (!Array.isArray(value)) return []
return value
.map((item) => {
if (typeof item === 'string') {
const normalized = { ...emptyPromptComparison(), notes: item.trim() }
return normalized.notes || preserveEmpty ? normalized : null
}
if (!item || typeof item !== 'object') return preserveEmpty ? emptyPromptComparison() : null
const normalized = sanitizePromptComparison(item)
const hasContent = [
normalized.provider,
normalized.model_name,
normalized.notes,
normalized.strengths,
normalized.weaknesses,
normalized.best_for,
normalized.image_path,
normalized.thumb_path,
normalized.settings,
normalized.score,
].some(Boolean)
return hasContent || preserveEmpty ? normalized : null
})
.filter(Boolean)
}
function serializePromptComparisons(value) {
return normalizePromptComparisons(value)
.map((comparison) => ({
provider: comparison.provider,
model_name: comparison.model_name,
notes: comparison.notes,
strengths: comparison.strengths,
weaknesses: comparison.weaknesses,
best_for: comparison.best_for,
image_path: comparison.image_path,
thumb_path: comparison.thumb_path,
settings: comparison.settings,
score: comparison.score === '' ? null : Number(comparison.score),
active: Boolean(comparison.active),
}))
}
function normalizeCodeList(values) {
return Array.from(new Set((Array.isArray(values) ? values : [])
.map((value) => String(value || '').trim())
.filter(Boolean)))
}
function loadCodeList(storageKey) {
if (typeof window === 'undefined') return []
try {
return normalizeCodeList(JSON.parse(window.localStorage.getItem(storageKey) || '[]'))
} catch {
return []
}
}
function saveCodeList(storageKey, values) {
if (typeof window === 'undefined') return
window.localStorage.setItem(storageKey, JSON.stringify(normalizeCodeList(values)))
}
function parsePromptImport(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.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.aspect_ratio != null) apply('aspect_ratio', String(parsed.aspect_ratio))
if (parsed.tags != null) apply('tags', Array.isArray(parsed.tags) ? parsed.tags.map((tag) => typeof tag === 'string' ? tag : (tag?.name || tag?.label || tag?.title || tag?.slug || '')).filter(Boolean).join(', ') : String(parsed.tags))
if (parsed.prompt != null) apply('prompt', String(parsed.prompt))
if (parsed.negative_prompt != null) apply('negative_prompt', String(parsed.negative_prompt))
if (parsed.usage_notes != null) apply('usage_notes', String(parsed.usage_notes))
if (parsed.workflow_notes != null) apply('workflow_notes', String(parsed.workflow_notes))
if (parsed.preview_image != null) apply('preview_image', String(parsed.preview_image))
if (parsed.preview_image_url != null && parsed.preview_image == null) apply('preview_image', String(parsed.preview_image_url))
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', Boolean(parsed.featured))
if (parsed.prompt_of_week != null) apply('prompt_of_week', Boolean(parsed.prompt_of_week))
if (parsed.active != null) apply('active', Boolean(parsed.active))
if (parsed.tool_notes != null || parsed.comparisons != null) {
const comparisonSource = Array.isArray(parsed.tool_notes) ? parsed.tool_notes : (Array.isArray(parsed.comparisons) ? parsed.comparisons : [])
apply('tool_notes', normalizePromptComparisons(comparisonSource, { preserveEmpty: 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 prompt fields.')
}
return { next, applied }
}
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
async function uploadPromptComparisonMedia(uploadUrl, file) {
const formData = new FormData()
formData.append('slot', 'body')
formData.append('image', file)
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
Accept: 'application/json',
},
credentials: 'same-origin',
body: formData,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || 'Could not upload comparison image right now.')
}
return payload
}
async function deletePromptComparisonMedia(deleteUrl, path) {
if (!deleteUrl || !path) return
await fetch(deleteUrl, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
Accept: 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({ path }),
})
}
function CodeListEditor({ title, description, items, customItems, draftValue, setDraftValue, onAdd, onRemove }) {
return (
{title}
{description}
{items.map((item) => {
const removable = customItems.includes(item)
return (
{item}
{removable ? onRemove(item)} className="text-slate-300 transition hover:text-rose-200"> : null}
)
})}
setDraftValue(event.target.value)} className="min-w-[220px] flex-1 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-white outline-none" placeholder={`Add ${title.toLowerCase().slice(0, -1)}`} />
Add
)
}
function PromptJsonImportDialog({ open, value, error, onChange, onClose, onApply }) {
const backdropRef = useRef(null)
const [activeImportTab, setActiveImportTab] = useState('input')
const [copyFeedback, setCopyFeedback] = useState('')
const importTabs = [
{ id: 'input', label: 'Input', description: 'Paste structured prompt JSON and apply it.' },
{ id: 'structure', label: 'Structure example', description: 'Reference payload for prompt templates.' },
{ id: 'docs', label: 'Documentation', description: 'Field rules and import notes.' },
{ id: 'prompts', label: 'AI prompts', description: 'Prompt templates to generate valid JSON.' },
]
const structureExample = {
title: 'Peaceful Fantasy Forest Wallpaper',
slug: 'peaceful-fantasy-forest-wallpaper',
excerpt: 'Create a calm fantasy forest wallpaper with glowing flowers, soft morning light, and gentle mist.',
category: 'Academy',
difficulty: 'beginner',
access_level: 'free',
aspect_ratio: '16:9',
tags: ['wallpaper', 'fantasy', 'forest', 'glowing flowers', 'morning light'],
prompt: 'Create a calm fantasy forest wallpaper with glowing flowers, soft morning light, gentle mist, and a peaceful magical atmosphere.',
negative_prompt: 'blurry, muddy lighting, distorted tree trunks, low detail, oversaturated highlights',
usage_notes: 'Start with the base prompt, then increase atmosphere and foliage density gradually.',
workflow_notes: 'Good candidate for comparison across ChatGPT, Gemini, and Leonardo image models.',
preview_image: 'https://files.skinbase.org/prompts/peaceful-fantasy-forest.webp',
featured: false,
prompt_of_week: false,
active: true,
tool_notes: [
{
provider: 'ChatGPT',
model_name: '4o Image',
settings: 'High detail, cinematic natural light, 16:9',
notes: 'Strong mood and color harmony, slightly idealized lighting.',
strengths: 'Atmosphere, foliage glow, readable composition.',
weaknesses: 'Can over-soften foreground detail.',
best_for: 'Wallpaper-style fantasy environments.',
score: 9,
active: true,
},
],
}
const promptJsonSchemaSummary = `You are generating a Skinbase Academy prompt template JSON object.
Return only valid JSON. No markdown, no commentary, no code fences.
Recommended fields:
- title: string
- slug: SEO-friendly slug
- excerpt: concise summary for cards and search results
- category_id or category/category_slug
- difficulty: beginner|intermediate|advanced|pro
- access_level: free|creator|pro|admin
- aspect_ratio: string like 1:1, 16:9, 3:2
- tags: array of strings or objects with name/title/label/slug
- prompt: main prompt text
- negative_prompt: optional exclusions
- usage_notes: practical usage guidance
- workflow_notes: internal/editorial workflow notes
- preview_image: path or URL
- featured: boolean
- prompt_of_week: boolean
- active: boolean
- published_at: YYYY-MM-DD HH:MM:SS when known
- seo_title, seo_description
- tool_notes: array of model comparison objects
tool_notes object fields:
- provider
- model_name
- settings
- notes
- strengths
- weaknesses
- best_for
- image_path or image_url when available
- score (1-10)
- active boolean
Rules:
- Return one JSON object only.
- Keep excerpt concise and readable in cards.
- Keep tags relevant and production-usable.
- If you include tool_notes, keep them normalized and consistent.`
const aiPromptExamples = [
{
title: 'Prompt template generator',
prompt: `${promptJsonSchemaSummary}
Create a Skinbase Academy prompt template JSON object from the following creative brief.
- Keep the title concise and catalog-friendly.
- Write a prompt that is immediately usable.
- Write an excerpt that works in cards and search results.
- Add 5 to 12 focused tags.
- Include 2 to 4 tool_notes comparisons when the brief mentions multiple AI providers.
Creative brief:
{{CREATIVE_BRIEF}}`,
},
{
title: 'Provider comparison generator',
prompt: `${promptJsonSchemaSummary}
Generate a prompt template JSON object for Skinbase Academy.
- Focus on the same core prompt being tested across multiple AI image providers.
- Include tool_notes entries for each provider.
- Each tool_notes item should explain settings, strengths, weaknesses, and best_for in plain production language.
- Return JSON only.
Source notes:
{{MODEL_COMPARISON_NOTES}}`,
},
{
title: 'Prompt migration import',
prompt: `${promptJsonSchemaSummary}
Convert the following source prompt page into structured Skinbase Academy prompt JSON.
- Preserve the core instruction intent.
- Normalize tags and metadata.
- Convert provider reviews into tool_notes.
- Use category/category_slug when category_id is unknown.
- Return JSON only.
Source content:
{{SOURCE_PROMPT_PAGE}}`,
},
]
function tabButtonClass(active) {
return `flex-1 rounded-2xl border px-4 py-3 text-left transition ${active ? 'border-sky-300/25 bg-sky-400/10 text-white' : 'border-white/10 bg-white/[0.03] text-slate-400 hover:border-white/20 hover:bg-white/[0.05] hover:text-slate-200'}`
}
const copyText = async (text, label) => {
try {
await copyTextToClipboard(String(text))
setCopyFeedback(`${label} copied`)
window.setTimeout(() => setCopyFeedback(''), 1800)
} catch {
setCopyFeedback('Copy failed')
window.setTimeout(() => setCopyFeedback(''), 1800)
}
}
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(
{
if (event.target === backdropRef.current) {
onClose?.()
}
}}
role="presentation"
>
Structured import
Paste prompt JSON
Use this to seed the prompt form from AI output, documentation drafts, or migrated prompt library content.
{importTabs.map((tab) => (
setActiveImportTab(tab.id)} className={tabButtonClass(activeImportTab === tab.id)}>
{tab.label}
{tab.description}
))}
{activeImportTab === 'input' ? (
Recognized keys
title, slug, excerpt
category_id, category, category_slug
difficulty, access_level, aspect_ratio
tags
prompt, negative_prompt
usage_notes, workflow_notes
preview_image, preview_image_url
published_at, seo_title, seo_description
featured, prompt_of_week, active
tool_notes, comparisons
) : null}
{activeImportTab === 'structure' ? (
Structure example
copyText(JSON.stringify(structureExample, null, 2), 'Structure example')} 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
{JSON.stringify(structureExample, null, 2)}
Notes
`tool_notes` can be an array of comparison objects or a simpler array under `comparisons`.
`tags` can be strings or objects with `name`, `label`, `title`, or `slug`.
`preview_image` accepts either a stored path or an external URL.
) : null}
{activeImportTab === 'docs' ? (
Field guide
title - public prompt name used in the library and detail page.
excerpt - short summary for cards, preview blocks, and search results.
prompt - the main instruction body shown to creators.
negative_prompt - exclusions, defects, or anti-patterns.
tool_notes - structured comparison notes for provider/model variants.
preview_image - existing asset URL or stored path. File upload still happens separately.
category_id is preferred when known. `category` or `category_slug` are used for best-effort matching.
Import rules
Unknown keys are ignored, so broader AI output is safe to paste.
Use JSON booleans for featured, prompt_of_week, and active.
Use `YYYY-MM-DD HH:MM:SS` for `published_at` when scheduling is needed.
Keep comparison rows normalized so provider/model names remain consistent in the frontend.
) : null}
{activeImportTab === 'prompts' ? (
{aiPromptExamples.map((example) => (
{example.title}
copyText(example.prompt, example.title)} 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
{example.prompt}
))}
Prompt tips
Tell the model to return JSON only, with no explanation text.
Ask for `tool_notes` when you want provider-by-provider comparison output.
Tell the model to keep titles and tags production-ready, not overly verbose.
) : null}
{copyFeedback ?
{copyFeedback}
: null}
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
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
,
document.body,
)
}
function firstPromptErrorTab(errors) {
const firstKey = Object.keys(errors || {})[0]
if (!firstKey) return null
return PROMPT_FIELD_TAB_MAP[firstKey] || null
}
function promptTabErrorCounts(errors) {
const counts = {}
Object.keys(errors || {}).forEach((key) => {
const tabId = PROMPT_FIELD_TAB_MAP[key]
if (!tabId) return
counts[tabId] = Number(counts[tabId] || 0) + 1
})
return counts
}
function PromptEditorTabs({ activeTab, onChange, errorCounts }) {
const activeMeta = PROMPT_EDITOR_TABS.find((tab) => tab.id === activeTab) || PROMPT_EDITOR_TABS[0]
return (
{PROMPT_EDITOR_TABS.map((tab) => {
const isActive = tab.id === activeTab
const errorCount = Number(errorCounts?.[tab.id] || 0)
return (
onChange(tab.id)}
className={[
'inline-flex items-center gap-2 rounded-2xl border px-4 py-2.5 text-sm font-semibold transition',
isActive
? 'border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20'
: 'border-white/10 bg-white/[0.03] text-white/80 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white',
].join(' ')}
>
{tab.label}
{errorCount > 0 ? {errorCount} : null}
)
})}
{activeMeta.description}
{activeMeta.sections.map((section) => (
{section.replace('prompt-', '').replace(/-/g, ' ')}
))}
)
}
function PromptComparisonEditor({ comparisons, setComparisons, editorContext }) {
const [busyIndex, setBusyIndex] = useState(null)
const [uploadError, setUploadError] = useState('')
const [draftProvider, setDraftProvider] = useState('')
const [draftModel, setDraftModel] = useState('')
const [customProviders, setCustomProviders] = useState([])
const [customModels, setCustomModels] = useState([])
const providerStorageKey = 'academy.prompt-comparison.providers'
const modelStorageKey = 'academy.prompt-comparison.models'
const defaultProviders = normalizeCodeList(editorContext?.comparisonCodeLists?.providers || [])
const defaultModels = normalizeCodeList(editorContext?.comparisonCodeLists?.models || [])
const providerOptions = normalizeCodeList([...defaultProviders, ...customProviders, ...comparisons.map((comparison) => comparison.provider)])
.map((value) => ({ value, label: value }))
const modelOptions = normalizeCodeList([...defaultModels, ...customModels, ...comparisons.map((comparison) => comparison.model_name)])
.map((value) => ({ value, label: value }))
useEffect(() => {
setCustomProviders(loadCodeList(providerStorageKey))
setCustomModels(loadCodeList(modelStorageKey))
}, [])
const replaceComparison = (index, nextComparison) => {
setComparisons(comparisons.map((comparison, currentIndex) => currentIndex === index ? sanitizePromptComparison(nextComparison) : comparison))
}
const updateComparison = (index, field, value) => {
replaceComparison(index, { ...comparisons[index], [field]: value })
}
const removeStoredMedia = async (comparison) => {
const deleteUrl = editorContext?.comparisonMediaDeleteUrl || ''
const imagePaths = [comparison?.image_path, comparison?.thumb_path].filter(Boolean)
await Promise.all(imagePaths.map((path) => deletePromptComparisonMedia(deleteUrl, path)))
}
const removeComparison = async (index) => {
const comparison = comparisons[index]
setComparisons(comparisons.filter((_, currentIndex) => currentIndex !== index))
try {
await removeStoredMedia(comparison)
} catch {
// Ignore cleanup failures; the saved payload still controls final media retention.
}
}
const moveComparison = (index, direction) => {
const nextIndex = index + direction
if (nextIndex < 0 || nextIndex >= comparisons.length) return
const nextComparisons = [...comparisons]
const [entry] = nextComparisons.splice(index, 1)
nextComparisons.splice(nextIndex, 0, entry)
setComparisons(nextComparisons)
}
const resolvePreviewUrl = (comparison) => comparison.image_url || comparison.thumb_url || ''
const addCustomProvider = () => {
const nextValue = String(draftProvider || '').trim()
if (!nextValue) return
const nextItems = normalizeCodeList([...customProviders, nextValue])
setCustomProviders(nextItems)
saveCodeList(providerStorageKey, nextItems)
setDraftProvider('')
}
const addCustomModel = () => {
const nextValue = String(draftModel || '').trim()
if (!nextValue) return
const nextItems = normalizeCodeList([...customModels, nextValue])
setCustomModels(nextItems)
saveCodeList(modelStorageKey, nextItems)
setDraftModel('')
}
const removeCustomProvider = (value) => {
const nextItems = customProviders.filter((item) => item !== value)
setCustomProviders(nextItems)
saveCodeList(providerStorageKey, nextItems)
}
const removeCustomModel = (value) => {
const nextItems = customModels.filter((item) => item !== value)
setCustomModels(nextItems)
saveCodeList(modelStorageKey, nextItems)
}
const handleUpload = async (index, file) => {
const uploadUrl = editorContext?.comparisonMediaUploadUrl || ''
if (!uploadUrl || !file) return
setBusyIndex(index)
setUploadError('')
const previous = comparisons[index]
try {
const uploaded = await uploadPromptComparisonMedia(uploadUrl, file)
replaceComparison(index, {
...previous,
image_path: uploaded.path || '',
image_url: uploaded.url || '',
thumb_path: previous?.thumb_path || '',
thumb_url: previous?.thumb_url || '',
})
if (previous?.image_path && previous.image_path !== uploaded.path) {
await deletePromptComparisonMedia(editorContext?.comparisonMediaDeleteUrl || '', previous.image_path)
}
if (previous?.thumb_path && previous.thumb_path !== uploaded.path) {
await deletePromptComparisonMedia(editorContext?.comparisonMediaDeleteUrl || '', previous.thumb_path)
}
} catch (error) {
setUploadError(error instanceof Error ? error.message : 'Could not upload comparison image.')
} finally {
setBusyIndex(null)
}
}
const clearMedia = async (index) => {
const comparison = comparisons[index]
replaceComparison(index, {
...comparison,
image_path: '',
image_url: '',
thumb_path: '',
thumb_url: '',
})
try {
await removeStoredMedia(comparison)
} catch {
// Ignore cleanup failures; backend cleanup also runs on save.
}
}
return (
Structured blocks
Upload the generated output for each provider, then document what it does well, where it fails, and which workflow it fits best.
setComparisons([...comparisons, emptyPromptComparison()])} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-4 py-2.5 text-sm font-semibold text-amber-100">+ Add AI Comparison
{uploadError ?
{uploadError}
: null}
option.value)}
customItems={customProviders}
draftValue={draftProvider}
setDraftValue={setDraftProvider}
onAdd={addCustomProvider}
onRemove={removeCustomProvider}
/>
option.value)}
customItems={customModels}
draftValue={draftModel}
setDraftValue={setDraftModel}
onAdd={addCustomModel}
onRemove={removeCustomModel}
/>
{comparisons.length ? comparisons.map((comparison, index) => (
AI model comparison
{comparison.model_name || `Comparison ${String(index + 1).padStart(2, '0')}`}
Document how this model handles the same prompt so creators can choose the right tool faster.
moveComparison(index, -1)} disabled={index === 0} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40">
moveComparison(index, 1)} disabled={index === comparisons.length - 1} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40">
removeComparison(index)} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-3 py-2 text-xs font-semibold text-rose-100">Remove
{resolvePreviewUrl(comparison) ? (
) : (
Upload generated output from this provider
)}
{
const file = event.target.files?.[0] || null
if (file) {
handleUpload(index, file)
}
event.target.value = ''
}}
/>
{busyIndex === index ? 'Uploading...' : (resolvePreviewUrl(comparison) ? 'Replace image' : 'Upload image')}
{comparison.image_path ? clearMedia(index)} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-slate-200 transition hover:bg-white/[0.08]">Clear image : null}
Stored asset
{comparison.image_path || 'No uploaded comparison image yet.'}
updateComparison(index, 'provider', String(nextValue || ''))} options={providerOptions} searchable className="rounded-2xl bg-black/20" />
updateComparison(index, 'model_name', String(nextValue || ''))} options={modelOptions} searchable className="rounded-2xl bg-black/20" />
updateComparison(index, 'settings', event.target.value)} rows={4} hint="Mention where it was generated, model mode, aspect ratio, or special settings." />
updateComparison(index, 'notes', event.target.value)} rows={4} hint="How does this provider interpret the prompt overall?" />
updateComparison(index, 'best_for', event.target.value)} rows={4} hint="What type of creator or output is this model the best fit for?" />
updateComparison(index, 'score', event.target.value)} placeholder="1-10" />
updateComparison(index, 'active', event.target.checked)} help="Turn this off to keep the comparison saved but hidden publicly." />
updateComparison(index, 'strengths', event.target.value)} rows={4} hint="What this model consistently does well with the prompt." />
updateComparison(index, 'weaknesses', event.target.value)} rows={4} hint="What tends to fail or need correction in post-processing." />
)) :
No comparison blocks yet. Add one when the same prompt needs model-specific guidance.
}
)
}
function Field({ field, form }) {
const value = form.data[field.name]
if (field.type === 'checkbox') {
return (
form.setData(field.name, event.target.checked)} />
{field.label}
)
}
if (field.type === 'datetime-local') {
return (
form.setData(field.name, nextValue || '')}
error={form.errors[field.name]}
clearable
className="bg-black/20"
/>
)
}
if (field.type === 'textarea') {
return (
{field.label}
)
}
if (field.type === 'select') {
return (
form.setData(field.name, nextValue ?? '')}
options={field.options || []}
searchable={false}
className="rounded-2xl bg-black/20"
error={form.errors[field.name]}
/>
)
}
if (field.type === 'multiselect') {
return (
form.setData(field.name, Array.isArray(nextValue) ? nextValue : [])}
options={field.options || []}
className="rounded-2xl bg-black/20"
error={form.errors[field.name]}
/>
)
}
return (
{field.label}
form.setData(field.name, event.target.value)}
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none"
/>
{form.errors[field.name] ? {form.errors[field.name]}
: null}
)
}
function PromptPreviewDropzone({ form, previewUrl }) {
const inputRef = useRef(null)
const [dragging, setDragging] = useState(false)
const [localPreviewUrl, setLocalPreviewUrl] = useState('')
const [selectedFileName, setSelectedFileName] = useState('')
const previewSrc = localPreviewUrl || previewUrl || form.data.preview_image || ''
useEffect(() => () => {
if (localPreviewUrl.startsWith('blob:')) {
URL.revokeObjectURL(localPreviewUrl)
}
}, [localPreviewUrl])
const setSelectedFile = (file) => {
if (localPreviewUrl.startsWith('blob:')) {
URL.revokeObjectURL(localPreviewUrl)
}
if (!file) {
setLocalPreviewUrl('')
setSelectedFileName('')
form.setData('preview_image_file', null)
form.clearErrors('preview_image_file')
return
}
const nextPreviewUrl = URL.createObjectURL(file)
setLocalPreviewUrl(nextPreviewUrl)
setSelectedFileName(file.name)
form.setData('preview_image_file', file)
form.clearErrors('preview_image_file')
}
const clearSelection = () => {
if (localPreviewUrl.startsWith('blob:')) {
URL.revokeObjectURL(localPreviewUrl)
}
setLocalPreviewUrl('')
setSelectedFileName('')
form.setData('preview_image_file', null)
form.clearErrors('preview_image_file')
if (inputRef.current) {
inputRef.current.value = ''
}
}
return (
inputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
inputRef.current?.click()
}
}}
onDragOver={(event) => {
event.preventDefault()
setDragging(true)
}}
onDragEnter={(event) => {
event.preventDefault()
setDragging(true)
}}
onDragLeave={(event) => {
event.preventDefault()
setDragging(false)
}}
onDrop={(event) => {
event.preventDefault()
setDragging(false)
setSelectedFile(event.dataTransfer?.files?.[0] || null)
}}
className={[
'w-full min-w-0 rounded-[28px] border border-dashed p-5 outline-none transition',
dragging ? 'border-sky-300/50 bg-sky-400/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]',
].join(' ')}
>
Drop a preview image or browse
JPG, PNG, or WEBP. The server re-encodes the final asset to WebP before uploading it to the CDN.
JPG
PNG
WEBP
Max 5 MB
{previewSrc ? (
) : (
No preview image selected
)}
inputRef.current?.click()} className="flex-1 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Browse
{selectedFileName || localPreviewUrl ? Clear : null}
{
setSelectedFile(event.target.files?.[0] || null)
event.target.value = ''
}}
/>
form.setData('preview_image', event.target.value)}
error={form.errors.preview_image}
placeholder="Paste a URL or leave empty if you upload a file"
/>
Stored value
{form.data.preview_image_file?.name || form.data.preview_image || previewUrl || 'None yet'}
{form.errors.preview_image_file ?
{form.errors.preview_image_file}
: null}
)
}
function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext }) {
const form = useForm({ ...record, new_category_name: '', preview_image_file: null, tool_notes: normalizePromptComparisons(record.tool_notes, { preserveEmpty: true }) })
const categoryField = useMemo(() => getField(fields, 'category_id'), [fields])
const difficultyField = useMemo(() => getField(fields, 'difficulty'), [fields])
const accessField = useMemo(() => getField(fields, 'access_level'), [fields])
const publishedAtField = useMemo(() => getField(fields, 'published_at'), [fields])
const featuredField = useMemo(() => getField(fields, 'featured'), [fields])
const promptOfWeekField = useMemo(() => getField(fields, 'prompt_of_week'), [fields])
const activeField = useMemo(() => getField(fields, 'active'), [fields])
const seoDescriptionField = useMemo(() => getField(fields, 'seo_description'), [fields])
const slugTouchedRef = useRef(Boolean(String(record.slug || '').trim()))
const [activeTab, setActiveTab] = useState('overview')
const [jsonImportOpen, setJsonImportOpen] = useState(false)
const [jsonImportValue, setJsonImportValue] = useState('')
const [jsonImportError, setJsonImportError] = useState('')
const previewUrl = form.data.preview_image_url || ''
const tagCount = String(form.data.tags || '')
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean).length
const promptWordCount = useMemo(() => countPlainWords(form.data.prompt), [form.data.prompt])
const negativePromptWordCount = useMemo(() => countPlainWords(form.data.negative_prompt), [form.data.negative_prompt])
const comparisonCount = Array.isArray(form.data.tool_notes) ? form.data.tool_notes.length : 0
const tabErrorCounts = useMemo(() => promptTabErrorCounts(form.errors), [form.errors])
const activeTabMeta = useMemo(() => PROMPT_EDITOR_TABS.find((tab) => tab.id === activeTab) || PROMPT_EDITOR_TABS[0], [activeTab])
const visibleSections = useMemo(() => new Set(activeTabMeta.sections), [activeTabMeta])
const sectionClassName = (sectionId, className = '') => `${visibleSections.has(sectionId) ? '' : 'hidden'} ${className}`.trim()
const editorLinks = editorContext?.links || {}
useEffect(() => {
if (slugTouchedRef.current) return
form.setData('slug', slugifyPromptTitle(form.data.title))
}, [form, form.data.title])
useEffect(() => {
const nextTab = firstPromptErrorTab(form.errors)
if (!nextTab) return
setActiveTab(nextTab)
}, [form.errors])
const applyJsonImport = () => {
try {
const categoryOptions = Array.isArray(categoryField?.options) ? categoryField.options : []
const parsed = parsePromptImport(jsonImportValue, categoryOptions)
Object.entries(parsed.next).forEach(([key, value]) => {
form.setData(key, value)
})
if (parsed.next.slug != null) {
slugTouchedRef.current = true
}
setJsonImportError('')
setJsonImportOpen(false)
} catch (error) {
setJsonImportError(error instanceof Error ? error.message : 'Could not parse JSON.')
}
}
const submit = (event) => {
event.preventDefault()
const payload = normalizePayload(fields, {
...form.data,
tool_notes: serializePromptComparisons(form.data.tool_notes),
})
form.transform(() => payload)
if (method === 'patch') {
form.patch(submitUrl)
return
}
form.post(submitUrl)
}
return (
setJsonImportOpen(false)}
onApply={applyJsonImport}
/>
)
}
function GenericEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext }) {
const form = useForm(record)
const editorLinks = editorContext?.links || {}
const submit = (event) => {
event.preventDefault()
const payload = normalizePayload(fields, form.data)
form.transform(() => payload)
if (method === 'patch') {
form.patch(submitUrl)
return
}
form.post(submitUrl)
}
return (
{(editorLinks.builder || editorLinks.preview) ? (
{editorLinks.builder ? Open builder : null}
{editorLinks.preview ? Preview public page : null}
) : null}
)
}
export default function AcademyCrudForm({ resource, title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext }) {
if (resource === 'courses') {
return (
)
}
if (resource === 'lessons') {
return (
)
}
if (resource === 'prompts') {
return (
)
}
return (
)
}