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
185 lines
8 KiB
TypeScript
185 lines
8 KiB
TypeScript
// 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>
|
||
);
|
||
}
|