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
248 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
}
|