pediatric-ai-scribe-v3/client/src/pages/cms/QuestionsEditor.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

185 lines
8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Questions editor — used inside ContentEditor when content_type === 'quiz'.
// Manages the local list of questions + options for a content item; on save
// the parent diffs against server state via add/update/delete endpoints.
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import ConfirmModal from '@/components/ConfirmModal';
import RichTextEditor from '@/components/RichTextEditor';
import type { CmsQuestion } from './cms-types';
interface Props {
contentId: number;
questions: CmsQuestion[];
onChange: (next: CmsQuestion[]) => void;
}
const input = 'rounded-md border border-input bg-background px-2 py-1 text-sm';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 py-1 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-2 py-1 text-xs font-medium disabled:opacity-50';
function emptyQ(): CmsQuestion {
return { question_text: '', question_type: 'mcq', explanation: '', options: [
{ option_text: '', is_correct: true },
{ option_text: '', is_correct: false },
] };
}
export default function QuestionsEditor(props: Props) {
const qc = useQueryClient();
const [pendingDeleteIdx, setPendingDeleteIdx] = useState<number | null>(null);
const createQ = useMutation<{ success: true; id: number }, Error, CmsQuestion>({
mutationFn: (q) => api.post('/api/learning-admin/content/' + props.contentId + '/questions', q),
onSuccess: () => qc.invalidateQueries({ queryKey: ['cms-content-detail', props.contentId] }),
});
const updateQ = useMutation<{ success: true }, Error, CmsQuestion>({
mutationFn: (q) => api.put('/api/learning-admin/questions/' + q.id, q),
onSuccess: () => qc.invalidateQueries({ queryKey: ['cms-content-detail', props.contentId] }),
});
const deleteQ = useMutation<{ success: true }, Error, number>({
mutationFn: (id) => api.delete('/api/learning-admin/questions/' + id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['cms-content-detail', props.contentId] }),
});
function patchQ(idx: number, patch: Partial<CmsQuestion>) {
props.onChange(props.questions.map((q, i) => (i === idx ? { ...q, ...patch } : q)));
}
function patchOpt(qIdx: number, optIdx: number, patch: Partial<CmsQuestion['options'] extends (infer T)[] | undefined ? T : never>) {
props.onChange(props.questions.map((q, i) => {
if (i !== qIdx) return q;
const opts = (q.options || []).map((o, j) => (j === optIdx ? { ...o, ...patch } : o));
return { ...q, options: opts };
}));
}
function addQ() { props.onChange([...props.questions, emptyQ()]); }
function addOpt(qIdx: number) {
props.onChange(props.questions.map((q, i) => {
if (i !== qIdx) return q;
return { ...q, options: [...(q.options || []), { option_text: '', is_correct: false }] };
}));
}
function removeOpt(qIdx: number, optIdx: number) {
props.onChange(props.questions.map((q, i) => {
if (i !== qIdx) return q;
return { ...q, options: (q.options || []).filter((_, j) => j !== optIdx) };
}));
}
function saveQ(idx: number) {
const q = props.questions[idx];
if (q.id) updateQ.mutate(q);
else createQ.mutate(q);
}
function handleDelete(idx: number) {
const q = props.questions[idx];
if (q.id) deleteQ.mutate(q.id);
props.onChange(props.questions.filter((_, i) => i !== idx));
setPendingDeleteIdx(null);
}
return (
<div className="space-y-3" data-testid="cms-questions-editor">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">Questions ({props.questions.length})</h4>
<button type="button" onClick={addQ} className={btnPrimary}>+ Add question</button>
</div>
{props.questions.length === 0 && (
<div className="text-sm text-muted-foreground italic">No questions yet.</div>
)}
{props.questions.map((q, qIdx) => (
<div key={qIdx} className="rounded-lg border border-border bg-muted/20 p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground">Q{qIdx + 1}</span>
<select
className={input + ' text-xs'}
value={q.question_type}
onChange={(e) => patchQ(qIdx, { question_type: e.target.value as CmsQuestion['question_type'] })}
>
<option value="mcq">MCQ (single answer)</option>
<option value="multi">Multi-select</option>
<option value="true_false">True/False</option>
</select>
<div className="ml-auto flex gap-1">
<button type="button" onClick={() => saveQ(qIdx)} className={btnPrimary} disabled={createQ.isPending || updateQ.isPending}>
Save Q
</button>
<button type="button" onClick={() => setPendingDeleteIdx(qIdx)} className={btn + ' text-destructive'}>
Del
</button>
</div>
</div>
<RichTextEditor
value={q.question_text}
onChange={(html) => patchQ(qIdx, { question_text: html })}
variant="mini"
minHeight="min-h-[60px]"
placeholder="Question text"
/>
<div className="space-y-1">
{(q.options || []).map((o, optIdx) => (
<div key={optIdx} className="flex items-start gap-2">
<input
type={q.question_type === 'multi' ? 'checkbox' : 'radio'}
name={'q-' + qIdx + '-correct'}
checked={o.is_correct}
onChange={(e) => {
if (q.question_type === 'multi') {
patchOpt(qIdx, optIdx, { is_correct: e.target.checked });
} else {
props.onChange(props.questions.map((qq, i) => {
if (i !== qIdx) return qq;
const opts = (qq.options || []).map((oo, j) => ({ ...oo, is_correct: j === optIdx }));
return { ...qq, options: opts };
}));
}
}}
className="mt-2"
/>
<div className="flex-1 space-y-1">
<RichTextEditor
value={o.option_text}
onChange={(html) => patchOpt(qIdx, optIdx, { option_text: html })}
variant="option"
minHeight="min-h-[40px]"
placeholder={'Option ' + (optIdx + 1)}
/>
<RichTextEditor
value={o.explanation || ''}
onChange={(html) => patchOpt(qIdx, optIdx, { explanation: html })}
variant="option"
minHeight="min-h-[32px]"
placeholder="Per-option explanation (optional)"
/>
</div>
<button type="button" onClick={() => removeOpt(qIdx, optIdx)} className="text-xs text-destructive mt-2">×</button>
</div>
))}
<button type="button" onClick={() => addOpt(qIdx)} className={btn}>+ Option</button>
</div>
<RichTextEditor
value={q.explanation || ''}
onChange={(html) => patchQ(qIdx, { explanation: html })}
variant="mini"
minHeight="min-h-[40px]"
placeholder="Question explanation (shown after answering)"
/>
</div>
))}
<ConfirmModal
open={pendingDeleteIdx !== null}
title="Delete question?"
body="The question and its options will be removed."
confirmText="Delete"
danger
busy={deleteQ.isPending}
onCancel={() => setPendingDeleteIdx(null)}
onConfirm={() => { if (pendingDeleteIdx !== null) handleDelete(pendingDeleteIdx); }}
/>
</div>
);
}