import React, { useEffect, useMemo, useRef, useState } from 'react' import { Head, Link, router, useForm } from '@inertiajs/react' import AdminLayout from '../../../Layouts/AdminLayout' import DateTimePicker from '../../../components/ui/DateTimePicker' import NovaSelect from '../../../components/ui/NovaSelect' 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} ) } 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} form.setData(field.name, event.target.value)} rows={field.rows || 6} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-white outline-none" /> {form.errors[field.name] ? {form.errors[field.name]} : null} ) } 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 }) { const form = useForm({ ...record, preview_image_file: null }) 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 previewUrl = form.data.preview_image_url || '' const submit = (event) => { event.preventDefault() const payload = normalizePayload(fields, form.data) form.transform(() => payload) if (method === 'patch') { form.patch(submitUrl) return } form.post(submitUrl) } const tagCount = String(form.data.tags || '') .split(/[,\n]/) .map((item) => item.trim()) .filter(Boolean).length return ( {categoryField ? form.setData('category_id', nextValue ?? '')} options={categoryField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.category_id} /> : null} {difficultyField ? form.setData('difficulty', nextValue ?? '')} options={difficultyField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.difficulty} /> : null} {accessField ? form.setData('access_level', nextValue ?? '')} options={accessField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.access_level} /> : null} form.setData('aspect_ratio', event.target.value)} error={form.errors.aspect_ratio} placeholder="1:1, 16:9, 3:2" /> form.setData('title', event.target.value)} error={form.errors.title} maxLength={180} /> form.setData('slug', event.target.value)} error={form.errors.slug} maxLength={180} placeholder="prompt-template-slug" /> form.setData('excerpt', event.target.value)} error={form.errors.excerpt} rows={4} hint="Short summary shown in the library and preview cards." /> form.setData('tags', event.target.value)} error={form.errors.tags} placeholder="wallpaper, cinematic, neon, portrait" /> form.setData('prompt', event.target.value)} error={form.errors.prompt} rows={10} hint="This is the main model instruction used by creators." /> form.setData('negative_prompt', event.target.value)} error={form.errors.negative_prompt} rows={5} hint="Optional exclusions, artifacts, or anti-patterns to avoid." /> form.setData('usage_notes', event.target.value)} error={form.errors.usage_notes} rows={5} hint="Explain how to apply the prompt in a practical workflow." /> form.setData('workflow_notes', event.target.value)} error={form.errors.workflow_notes} rows={5} hint="Internal editorial notes, camera settings, or prompt variants." /> {publishedAtField ? form.setData('published_at', nextValue || '')} error={form.errors.published_at} clearable className="bg-black/20" /> : null} form.setData('seo_title', event.target.value)} error={form.errors.seo_title} maxLength={180} /> {seoDescriptionField ? form.setData('seo_description', event.target.value)} error={form.errors.seo_description} rows={4} /> : null} {featuredField ? form.setData('featured', event.target.checked)} help="Highlight this prompt in featured rails." error={form.errors.featured} /> : null} {promptOfWeekField ? form.setData('prompt_of_week', event.target.checked)} help="Promote this prompt as the current weekly pick." error={form.errors.prompt_of_week} /> : null} {activeField ? form.setData('active', event.target.checked)} help="Keep draft prompts hidden until they are ready." error={form.errors.active} /> : null} {previewUrl || form.data.preview_image ? ( ) : ( No preview image selected yet. )} Prompt summary {form.data.title || 'Untitled prompt'} {form.data.excerpt || 'Add a concise excerpt to give the prompt some context in the library.'} Difficulty{form.data.difficulty || '—'} Access{form.data.access_level || '—'} Aspect{form.data.aspect_ratio || '—'} Tags{tagCount} Uploaded images are converted to WebP and stored on the Contabo S3-backed CDN before the record is saved. {form.processing ? 'Saving...' : 'Save prompt'} Back {destroyUrl ? { if (!window.confirm('Delete this record?')) return; router.delete(destroyUrl) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete : null} ) } function GenericEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method }) { const form = useForm(record) 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 ( {fields.map((field) => ( ))} {form.processing ? 'Saving...' : 'Save'} Back {destroyUrl ? { if (!window.confirm('Delete this record?')) return; router.delete(destroyUrl) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete : null} ) } export default function AcademyCrudForm({ resource, title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext }) { if (resource === 'lessons') { return ( ) } if (resource === 'prompts') { return ( ) } return ( ) }
{eyebrow}
{description}
{error}
{form.errors[field.name]}
{form.errors.preview_image_file}
Prompt summary
{form.data.excerpt || 'Add a concise excerpt to give the prompt some context in the library.'}
Uploaded images are converted to WebP and stored on the Contabo S3-backed CDN before the record is saved.