pediatric-ai-scribe-v3/client/src/pages/cms/ContentEditor.tsx
Daniel 61749a7881 feat(cms): Tiptap rich-text editor, slide editor, AI generator, WebDAV picker
Completes the CMS port to feature parity with public/js/learningHub.js
(@be14578). Previously the React CMS had only a plain-textarea body,
no slide editor for presentations, and no AI content generation —
three large gaps from the vanilla app.

New components:
  client/src/components/RichTextEditor.tsx — Tiptap/ProseMirror editor
    with the vanilla tp-toolbar feature set: bold/italic/underline/
    strike, H2/H3, bullet/ordered/quote/codeblock lists, link-with-
    URL-bar, and clear formatting. Three variants (default/mini/
    option) match vanilla's buildTpToolbar(mini, isOption).

  client/src/pages/cms/SlideEditor.tsx — per-slide Tiptap editor +
    slide navigator (move/add/remove). Slides join back to the body
    column with the vanilla \n---\n separator so the Learning Hub
    viewer renders them unchanged.

  client/src/pages/cms/AiGenerator.tsx — generate content via three
    sources (topic / upload / Nextcloud WebDAV). POSTs multipart
    to /api/admin/learning/ai-generate, matching the vanilla
    runAiGenerate flow exactly. WebDAV file browser uses the
    existing /api/admin/learning/webdav-browse endpoint. Auto-
    hides the Nextcloud tab when the user isn't connected.

Updated:
  QuestionsEditor — question text, option text, per-option
    explanation, and question explanation are now all Tiptap-backed
    so quiz authoring matches content authoring in feel.

  ContentEditor — "Generate with AI" button opens the AI panel;
    presentation body uses SlideEditor; everything else uses
    RichTextEditor with a 320px min height.

Dependencies added:
  @tiptap/react @tiptap/pm @tiptap/starter-kit
  @tiptap/extension-link @tiptap/extension-underline
2026-04-24 05:24:09 +02:00

248 lines
9.6 KiB
TypeScript

// 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<number | ''>('');
const [contentType, setContentType] = useState<ContentType>(initialType);
const [published, setPublished] = useState(false);
const [questions, setQuestions] = useState<CmsQuestion[]>([]);
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<CategoriesOk>({
queryKey: ['cms-categories'],
queryFn: () => api.get<CategoriesOk>('/api/learning-admin/categories'),
});
const detail = useQuery<ContentDetailOk>({
queryKey: ['cms-content-detail', id],
queryFn: () => api.get<ContentDetailOk>('/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 (
<div className="flex-1 space-y-3 min-w-0" data-testid="cms-editor">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">
{isNew ? 'New ' + contentType : 'Edit ' + contentType}
</h3>
<div className="flex gap-2">
<button type="button" onClick={() => setAiOpen((v) => !v)} className={btnPrimary} data-testid="cms-open-ai">
{aiOpen ? 'Hide AI' : 'Generate with AI'}
</button>
<button type="button" onClick={onClose} className={btn}> Back to list</button>
</div>
</div>
{aiOpen && (
<AiGenerator
contentType={contentType}
onChangeType={setContentType}
onGenerated={applyAi}
onCancel={() => setAiOpen(false)}
/>
)}
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Title</span>
<input
type="text"
className={input + ' w-full'}
value={title}
onChange={(e) => setTitle(e.target.value)}
data-testid="cms-edit-title"
/>
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Type</span>
<select
className={input + ' w-full'}
value={contentType}
onChange={(e) => setContentType(e.target.value as ContentType)}
data-testid="cms-edit-type"
>
<option value="article">Article</option>
<option value="pearl">Pearl</option>
<option value="quiz">Quiz</option>
<option value="presentation">Presentation</option>
</select>
</label>
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Category</span>
<select
className={input + ' w-full'}
value={categoryId}
onChange={(e) => setCategoryId(e.target.value === '' ? '' : Number(e.target.value))}
data-testid="cms-edit-category"
>
<option value="">Uncategorized</option>
{(cats.data?.categories || []).map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</label>
<label className="flex items-end gap-2">
<input
type="checkbox"
checked={published}
onChange={(e) => setPublished(e.target.checked)}
className="h-4 w-4"
data-testid="cms-edit-published"
/>
<span className="text-sm">Published</span>
</label>
</div>
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">Subject (optional)</span>
<input
type="text"
className={input + ' w-full'}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="e.g. Asthma, Bronchiolitis"
/>
</label>
<div>
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">
Body
</span>
{contentType === 'presentation' ? (
<SlideEditor value={body} onChange={setBody} />
) : (
<RichTextEditor
value={body}
onChange={setBody}
variant="default"
minHeight="min-h-[320px]"
placeholder="Write the content body…"
testId="cms-edit-body"
/>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => save.mutate()}
disabled={save.isPending || !title.trim()}
className={btnPrimary}
data-testid="cms-save"
>
{save.isPending ? 'Saving…' : (isNew ? 'Create' : 'Save changes')}
</button>
{msg && (
<span className={'text-xs ' + (msg.kind === 'ok' ? 'text-green-600' : 'text-destructive')}>
{msg.text}
</span>
)}
</div>
</div>
{!isNew && contentType === 'quiz' && (
<div className="rounded-lg border border-border bg-card p-4">
<QuestionsEditor
contentId={id!}
questions={questions}
onChange={setQuestions}
/>
</div>
)}
</div>
);
}