// CMS content editor — title / category / type / subject / body / // published toggle. For quizzes, also embeds QuestionsEditor. // // New content: passes `null` id and POSTs on save. Existing content: // passes id and PUTs. Both refresh the content list query. // // Body is a plain textarea — no rich editor in this first React port. // (Vanilla used a Quill-ish toolbar; that lands as a follow-up if // users actually start using it.) import { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api, ApiError } from '@/lib/api'; import RichTextEditor from '@/components/RichTextEditor'; import type { CmsCategory, CmsContentDetail, CmsQuestion, ContentType } from './cms-types'; import QuestionsEditor from './QuestionsEditor'; import SlideEditor from './SlideEditor'; import AiGenerator, { type GeneratedPayload } from './AiGenerator'; interface CategoriesOk { success: true; categories: CmsCategory[] } interface ContentDetailOk { success: true; content: CmsContentDetail } interface Props { id: number | null; // null = creating new initialType: ContentType; // for creates — pre-selects the type onClose: () => void; } const input = 'rounded-md border border-input bg-background px-3 py-2 text-sm'; const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50'; const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50'; export default function ContentEditor({ id, initialType, onClose }: Props) { const qc = useQueryClient(); const isNew = id == null; const [title, setTitle] = useState(''); const [subject, setSubject] = useState(''); const [body, setBody] = useState(''); const [categoryId, setCategoryId] = useState(''); const [contentType, setContentType] = useState(initialType); const [published, setPublished] = useState(false); const [questions, setQuestions] = useState([]); const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null); const [aiOpen, setAiOpen] = useState(false); function applyAi(payload: GeneratedPayload, ctype: ContentType) { setContentType(ctype); // Presentation → body is the Marp markdown (becomes the slide-editor // source after split by \n---\n). if (ctype === 'presentation' && payload.marpMarkdown) { setBody(payload.marpMarkdown); // Extract title from first # heading so the editor can save immediately. const titleMatch = payload.marpMarkdown.match(/^#\s+(.+)$/m); if (titleMatch) setTitle(titleMatch[1]); } else { if (payload.title) setTitle(payload.title); if (payload.subject !== undefined) setSubject(payload.subject); if (payload.body !== undefined) setBody(payload.body); } if (payload.questions && payload.questions.length) { setQuestions(payload.questions); } setAiOpen(false); setMsg({ kind: 'ok', text: 'Content generated — review, then save.' }); } const cats = useQuery({ queryKey: ['cms-categories'], queryFn: () => api.get('/api/learning-admin/categories'), }); const detail = useQuery({ queryKey: ['cms-content-detail', id], queryFn: () => api.get('/api/learning-admin/content/' + id), enabled: !isNew, }); // Hydrate state from server when editing an existing item. useEffect(() => { if (!detail.data?.content) return; const c = detail.data.content; setTitle(c.title); setSubject(c.subject || ''); setBody(c.body || ''); setCategoryId(c.category_id ?? ''); setContentType(c.content_type); setPublished(!!c.published); setQuestions(c.questions || []); }, [detail.data]); const save = useMutation<{ success: true; id: number }, Error, void>({ mutationFn: async () => { const body_ = { title, subject, body, category_id: categoryId === '' ? null : categoryId, content_type: contentType, published, }; if (isNew) { return api.post<{ success: true; id: number }>('/api/learning-admin/content', body_); } await api.put<{ success: true }>('/api/learning-admin/content/' + id, body_); return { success: true as const, id: id! }; }, onSuccess: (d) => { qc.invalidateQueries({ queryKey: ['cms-content'] }); qc.invalidateQueries({ queryKey: ['cms-stats'] }); qc.invalidateQueries({ queryKey: ['cms-content-detail', d.id] }); setMsg({ kind: 'ok', text: isNew ? 'Created — switch to the list to add questions' : 'Saved' }); }, onError: (e) => setMsg({ kind: 'err', text: (e as ApiError).message || 'Save failed' }), }); return (

{isNew ? 'New ' + contentType : 'Edit ' + contentType}

{aiOpen && ( setAiOpen(false)} /> )}
Body {contentType === 'presentation' ? ( ) : ( )}
{msg && ( {msg.text} )}
{!isNew && contentType === 'quiz' && (
)}
); }