feat(cms): port Learning Hub Content Manager (admin/moderator only)

Closes the largest remaining gap from the vanilla→React migration.
Vanilla had ~308 lines of HTML in cms.html plus ~1000 lines of CMS
logic in learningHub.js, all gone since the vanilla deletion at
be14578. Server endpoints under /api/learning-admin survived but
had no React UI.

New page at /cms (sidebar nav adminOnly):

  client/src/pages/Cms.tsx                — shell, list/edit views
  client/src/pages/cms/StatsBar.tsx       — 6-cell metrics
  client/src/pages/cms/CategoriesPanel.tsx — list + add + delete +
                                             status & category filters
  client/src/pages/cms/ContentList.tsx    — table with toolbar
                                             (new article/quiz/pearl/
                                             presentation), search,
                                             publish toggle, delete
  client/src/pages/cms/ContentEditor.tsx  — title/category/type/
                                             subject/body/published,
                                             embeds QuestionsEditor
                                             when type=quiz
  client/src/pages/cms/QuestionsEditor.tsx — Q+options builder
                                             (mcq/multi/true_false)
                                             with per-option
                                             explanation
  client/src/pages/cms/cms-types.ts       — shared CMS types

Destructive actions (delete category, delete content, delete
question) all use ConfirmModal — Daniel's no-native-dialog rule.

Intentionally NOT in this first cut (each is a follow-up if used):
  • AI generation panel (vanilla lh-ai-panel)
  • WebDAV file picker for AI sources
  • Drag-and-drop file upload for AI ingest
  • Rich-text body editor — body is a textarea
  • Slide editor for presentations — body holds JSON
This commit is contained in:
Daniel 2026-04-24 04:41:33 +02:00
parent 83f3ac70d5
commit 1beabe40e4
9 changed files with 871 additions and 0 deletions

View file

@ -26,6 +26,7 @@ const PeGuide = lazy(() => import('@/pages/PeGuide'));
const Bedside = lazy(() => import('@/pages/Bedside'));
const Calculators = lazy(() => import('@/pages/Calculators'));
const Admin = lazy(() => import('@/pages/Admin'));
const Cms = lazy(() => import('@/pages/Cms'));
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
@ -87,6 +88,7 @@ export default function App() {
<Route path="/bedside" element={<Bedside />} />
<Route path="/calculators" element={<Calculators />} />
<Route path="/admin" element={<Admin />} />
<Route path="/cms" element={<Cms />} />
<Route path="/faq" element={<Faq />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>

View file

@ -63,6 +63,7 @@ const NAV: NavGroup[] = [
label: 'Admin',
items: [
{ to: '/admin', label: 'Admin Panel', available: true, adminOnly: true },
{ to: '/cms', label: 'Content Manager', available: true, adminOnly: true },
],
},
];

76
client/src/pages/Cms.tsx Normal file
View file

@ -0,0 +1,76 @@
// ============================================================
// CMS — Learning Hub Content Manager. Faithful port of vanilla
// public/components/cms.html + the loadCms / loadCmsContent / etc.
// section of public/js/learningHub.js (@be14578).
//
// Server gates this with moderatorMiddleware (admin OR moderator),
// so the route is mounted unconditionally and the server returns
// 403 to non-moderators. The sidebar nav link is gated by
// `me.user.role` to keep it hidden from clinicians.
//
// Sub-components live under src/pages/cms/:
// StatsBar — 6-cell metrics summary
// CategoriesPanel — category list + add/delete + filters
// ContentList — table + toolbar (new article/quiz/pearl/presentation)
// ContentEditor — title/category/type/body + per-quiz QuestionsEditor
//
// What's intentionally NOT in this first cut (each of these is a
// follow-up commit if Daniel actually starts using them):
// • AI generation panel (vanilla `lh-ai-panel`)
// • WebDAV file picker for AI sources
// • Drag-and-drop file upload for AI ingest
// • Rich-text body editor (Quill toolbar) — body is a textarea
// • Slide editor for presentations — body holds JSON for now
// ============================================================
import { useState } from 'react';
import StatsBar from './cms/StatsBar';
import CategoriesPanel from './cms/CategoriesPanel';
import ContentList from './cms/ContentList';
import ContentEditor from './cms/ContentEditor';
import type { ContentType } from './cms/cms-types';
type View = { kind: 'list' } | { kind: 'edit'; id: number | null; type: ContentType };
export default function Cms() {
const [statusFilter, setStatusFilter] = useState<'all' | 'published' | 'draft'>('all');
const [categoryFilter, setCategoryFilter] = useState<number | 'all'>('all');
const [view, setView] = useState<View>({ kind: 'list' });
return (
<div className="max-w-6xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Content Manager</h1>
<p className="text-sm text-muted-foreground">
Create and manage Learning Hub content, quizzes, and categories.
</p>
</header>
<StatsBar />
<div className="flex flex-col lg:flex-row gap-4">
<CategoriesPanel
statusFilter={statusFilter}
onStatusFilter={setStatusFilter}
categoryFilter={categoryFilter}
onCategoryFilter={setCategoryFilter}
/>
{view.kind === 'list' ? (
<ContentList
statusFilter={statusFilter}
categoryFilter={categoryFilter}
onEdit={(id) => setView({ kind: 'edit', id, type: 'article' })}
onCreate={(type) => setView({ kind: 'edit', id: null, type })}
/>
) : (
<ContentEditor
id={view.id}
initialType={view.type}
onClose={() => setView({ kind: 'list' })}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,146 @@
// CMS categories sidebar — list, add, delete, plus the status +
// category filters that drive the content list. Mirrors the vanilla
// cms-sidebar / cms-add-cat / cms-filter-status / cms-filter-category
// from public/components/cms.html.
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import ConfirmModal from '@/components/ConfirmModal';
import type { CmsCategory } from './cms-types';
interface CategoriesOk { success: true; categories: CmsCategory[] }
interface Props {
statusFilter: 'all' | 'published' | 'draft';
onStatusFilter: (s: 'all' | 'published' | 'draft') => void;
categoryFilter: number | 'all';
onCategoryFilter: (id: number | 'all') => void;
}
const sm = 'rounded-md border border-input bg-background px-2 py-1 text-xs';
export default function CategoriesPanel(props: Props) {
const qc = useQueryClient();
const [name, setName] = useState('');
const [err, setErr] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<CmsCategory | null>(null);
const { data } = useQuery<CategoriesOk>({
queryKey: ['cms-categories'],
queryFn: () => api.get<CategoriesOk>('/api/learning-admin/categories'),
});
const addCat = useMutation<{ success: true; id: number }, Error, string>({
mutationFn: (n) => api.post('/api/learning-admin/categories', { name: n }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cms-categories'] });
qc.invalidateQueries({ queryKey: ['cms-stats'] });
setName('');
},
onError: (e) => setErr((e as ApiError).message || 'Failed'),
});
const delCat = useMutation<{ success: true }, Error, number>({
mutationFn: (id) => api.delete('/api/learning-admin/categories/' + id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cms-categories'] });
qc.invalidateQueries({ queryKey: ['cms-content'] });
qc.invalidateQueries({ queryKey: ['cms-stats'] });
},
});
function submit(e: React.FormEvent) {
e.preventDefault();
setErr(null);
if (!name.trim()) return;
addCat.mutate(name.trim());
}
const cats = data?.categories || [];
return (
<aside className="w-64 shrink-0 space-y-3" data-testid="cms-sidebar">
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Categories</div>
<ul className="space-y-1 max-h-64 overflow-auto">
<li>
<button
type="button"
onClick={() => props.onCategoryFilter('all')}
className={'w-full text-left text-sm px-2 py-1 rounded ' +
(props.categoryFilter === 'all' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')
}
>All categories</button>
</li>
{cats.map((c) => (
<li key={c.id} className="flex items-center gap-1">
<button
type="button"
onClick={() => props.onCategoryFilter(c.id)}
className={'flex-1 text-left text-sm px-2 py-1 rounded ' +
(props.categoryFilter === c.id ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')
}
data-testid={'cms-cat-' + c.id}
>
{c.name}
{typeof c.content_count === 'number' && (
<span className="ml-1 text-[10px] text-muted-foreground">({c.content_count})</span>
)}
</button>
<button
type="button"
onClick={() => setPendingDelete(c)}
className="text-xs text-destructive hover:text-red-700 px-1"
title="Delete category"
>×</button>
</li>
))}
</ul>
<form onSubmit={submit} className="flex gap-1">
<input
type="text"
placeholder="New category…"
value={name}
onChange={(e) => setName(e.target.value)}
className={sm + ' flex-1'}
data-testid="cms-new-cat-name"
/>
<button type="submit" disabled={addCat.isPending} className="rounded-md bg-primary text-primary-foreground px-2 py-1 text-xs">
+
</button>
</form>
{err && <div className="text-xs text-destructive">{err}</div>}
</div>
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Filter</div>
<select
className={sm + ' w-full'}
value={props.statusFilter}
onChange={(e) => props.onStatusFilter(e.target.value as 'all' | 'published' | 'draft')}
data-testid="cms-filter-status"
>
<option value="all">All status</option>
<option value="published">Published</option>
<option value="draft">Drafts</option>
</select>
</div>
<ConfirmModal
open={!!pendingDelete}
title="Delete category?"
body={pendingDelete ? 'Delete "' + pendingDelete.name + '"? Its content moves to uncategorized.' : ''}
confirmText="Delete"
danger
busy={delCat.isPending}
onCancel={() => setPendingDelete(null)}
onConfirm={() => {
if (pendingDelete) {
delCat.mutate(pendingDelete.id, { onSettled: () => setPendingDelete(null) });
}
}}
/>
</aside>
);
}

View file

@ -0,0 +1,207 @@
// 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 type { CmsCategory, CmsContentDetail, CmsQuestion, ContentType } from './cms-types';
import QuestionsEditor from './QuestionsEditor';
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 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>
<button type="button" onClick={onClose} className={btn}> Back to list</button>
</div>
<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>
<label className="block">
<span className="block text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-1">
Body {contentType === 'presentation' && <span className="normal-case font-normal">(JSON slide array)</span>}
</span>
<textarea
className={input + ' w-full min-h-[280px] font-mono text-sm'}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={contentType === 'presentation'
? '[ { "title": "Intro", "body": "..." }, ... ]'
: 'Markdown / HTML body'
}
data-testid="cms-edit-body"
/>
</label>
<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>
);
}

View file

@ -0,0 +1,165 @@
// CMS content list — table of articles/pearls/quizzes/presentations
// with toolbar (new article / new quiz / new pearl / new presentation
// + search), per-row publish-toggle / edit / delete. Mirrors the
// vanilla #lh-cms-content-list table.
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import ConfirmModal from '@/components/ConfirmModal';
import type { CmsContentRow, ContentType } from './cms-types';
interface ContentListOk { success: true; content: CmsContentRow[] }
interface Props {
statusFilter: 'all' | 'published' | 'draft';
categoryFilter: number | 'all';
onEdit: (id: number) => void;
onCreate: (type: ContentType) => void;
}
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';
const tag = 'text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border';
const TYPE_TAG: Record<ContentType, string> = {
article: 'bg-blue-100 text-blue-700 border-blue-300',
quiz: 'bg-amber-100 text-amber-800 border-amber-300',
pearl: 'bg-purple-100 text-purple-700 border-purple-300',
presentation: 'bg-emerald-100 text-emerald-800 border-emerald-300',
};
export default function ContentList(props: Props) {
const qc = useQueryClient();
const [search, setSearch] = useState('');
const [pendingDelete, setPendingDelete] = useState<CmsContentRow | null>(null);
const { data, isLoading } = useQuery<ContentListOk>({
queryKey: ['cms-content'],
queryFn: () => api.get<ContentListOk>('/api/learning-admin/content'),
});
const togglePublish = useMutation<{ success: true }, Error, { id: number; published: boolean }>({
mutationFn: ({ id, published }) => api.put('/api/learning-admin/content/' + id, { published }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cms-content'] });
qc.invalidateQueries({ queryKey: ['cms-stats'] });
},
});
const del = useMutation<{ success: true }, Error, number>({
mutationFn: (id) => api.delete('/api/learning-admin/content/' + id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cms-content'] });
qc.invalidateQueries({ queryKey: ['cms-stats'] });
},
});
const filtered = useMemo(() => {
const rows = data?.content || [];
return rows.filter((r) => {
if (props.statusFilter === 'published' && !r.published) return false;
if (props.statusFilter === 'draft' && r.published) return false;
if (props.categoryFilter !== 'all' && r.category_id !== props.categoryFilter) return false;
if (search.trim()) {
const q = search.trim().toLowerCase();
const hay = (r.title + ' ' + (r.subject || '') + ' ' + (r.category_name || '')).toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}, [data, props.statusFilter, props.categoryFilter, search]);
return (
<div className="flex-1 space-y-3 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={() => props.onCreate('article')} className={btnPrimary} data-testid="cms-new-article">
+ Article
</button>
<button type="button" onClick={() => props.onCreate('quiz')} className={btn} data-testid="cms-new-quiz">
+ Quiz
</button>
<button type="button" onClick={() => props.onCreate('pearl')} className={btn} data-testid="cms-new-pearl">
+ Pearl
</button>
<button type="button" onClick={() => props.onCreate('presentation')} className={btn} data-testid="cms-new-presentation">
+ Presentation
</button>
<div className="ml-auto">
<input
type="search"
placeholder="Search content…"
className="rounded-md border border-input bg-background px-3 py-1.5 text-xs min-w-[220px]"
value={search}
onChange={(e) => setSearch(e.target.value)}
data-testid="cms-search"
/>
</div>
</div>
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="grid grid-cols-[1fr_120px_90px_90px_120px_120px] gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border">
<span>Title</span>
<span>Category</span>
<span>Type</span>
<span>Status</span>
<span>Updated</span>
<span className="text-right">Actions</span>
</div>
{isLoading && <div className="p-6 text-sm text-muted-foreground text-center">Loading</div>}
{!isLoading && filtered.length === 0 && (
<div className="p-6 text-sm text-muted-foreground italic text-center">No content matches.</div>
)}
{filtered.map((r) => (
<div
key={r.id}
className="grid grid-cols-[1fr_120px_90px_90px_120px_120px] gap-2 px-3 py-2 text-sm items-center border-b border-border last:border-0 hover:bg-muted/30"
data-testid={'cms-row-' + r.id}
>
<button type="button" onClick={() => props.onEdit(r.id)} className="text-left truncate hover:underline">
<strong>{r.title}</strong>
{r.subject && <span className="ml-2 text-xs text-muted-foreground truncate">· {r.subject}</span>}
{r.content_type === 'quiz' && typeof r.question_count === 'number' && (
<span className="ml-2 text-[10px] text-muted-foreground">{r.question_count} Q</span>
)}
</button>
<span className="text-xs text-muted-foreground truncate">{r.category_name || '—'}</span>
<span className={tag + ' ' + TYPE_TAG[r.content_type]}>{r.content_type}</span>
<button
type="button"
onClick={() => togglePublish.mutate({ id: r.id, published: !r.published })}
className={tag + ' ' + (r.published ? 'bg-green-100 text-green-700 border-green-300' : 'bg-muted text-muted-foreground border-border')}
title="Toggle published"
>
{r.published ? 'Published' : 'Draft'}
</button>
<span className="text-xs text-muted-foreground">{new Date(r.updated_at).toLocaleDateString()}</span>
<div className="flex justify-end gap-1">
<button type="button" onClick={() => props.onEdit(r.id)} className={btn}>Edit</button>
<button
type="button"
onClick={() => setPendingDelete(r)}
className={btn + ' text-destructive'}
>Del</button>
</div>
</div>
))}
</div>
<ConfirmModal
open={!!pendingDelete}
title="Delete content?"
body={pendingDelete ? 'Delete "' + pendingDelete.title + '"? This cannot be undone.' : ''}
confirmText="Delete"
danger
busy={del.isPending}
onCancel={() => setPendingDelete(null)}
onConfirm={() => {
if (pendingDelete) {
del.mutate(pendingDelete.id, { onSettled: () => setPendingDelete(null) });
}
}}
/>
</div>
);
}

View file

@ -0,0 +1,179 @@
// 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 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>
<textarea
className={input + ' w-full min-h-[60px]'}
placeholder="Question text"
value={q.question_text}
onChange={(e) => patchQ(qIdx, { question_text: e.target.value })}
/>
<div className="space-y-1">
{(q.options || []).map((o, optIdx) => (
<div key={optIdx} className="flex items-center 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 };
}));
}
}}
/>
<input
type="text"
className={input + ' flex-1'}
placeholder={'Option ' + (optIdx + 1)}
value={o.option_text}
onChange={(e) => patchOpt(qIdx, optIdx, { option_text: e.target.value })}
/>
<input
type="text"
className={input + ' w-48 text-xs'}
placeholder="Per-option explanation"
value={o.explanation || ''}
onChange={(e) => patchOpt(qIdx, optIdx, { explanation: e.target.value })}
/>
<button type="button" onClick={() => removeOpt(qIdx, optIdx)} className="text-xs text-destructive">×</button>
</div>
))}
<button type="button" onClick={() => addOpt(qIdx)} className={btn}>+ Option</button>
</div>
<textarea
className={input + ' w-full min-h-[40px] text-xs'}
placeholder="Question explanation (shown after answering)"
value={q.explanation || ''}
onChange={(e) => patchQ(qIdx, { explanation: e.target.value })}
/>
</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>
);
}

View file

@ -0,0 +1,34 @@
// CMS stats bar — read-only summary across the top of the Cms page.
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type { CmsStats } from './cms-types';
interface StatsOk { success: true; stats: CmsStats }
export default function StatsBar() {
const { data } = useQuery<StatsOk>({
queryKey: ['cms-stats'],
queryFn: () => api.get<StatsOk>('/api/learning-admin/stats'),
refetchInterval: 30_000,
});
const s = data?.stats;
const cells = [
{ k: 'Published', v: s?.publishedContent ?? '' },
{ k: 'All content', v: s?.totalContent ?? '' },
{ k: 'Categories', v: s?.totalCategories ?? '' },
{ k: 'Quizzes', v: s?.totalQuizzes ?? '' },
{ k: 'Attempts', v: s?.totalAttempts ?? '' },
{ k: 'Embeddings', v: s?.embeddingsEnabled ? (s?.withEmbeddings ?? '') : 'off' },
];
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2" data-testid="cms-stats-bar">
{cells.map((c) => (
<div key={c.k} className="rounded-md border border-border bg-card px-3 py-2">
<div className="text-lg font-semibold">{c.v}</div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">{c.k}</div>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,61 @@
// Shared types for the Learning Hub CMS — match the server response
// shapes from src/routes/learningAdmin.ts. Kept narrow (only the fields
// the CMS reads/writes) so a column rename on the server breaks the
// client at compile time.
export interface CmsCategory {
id: number;
name: string;
slug: string;
description?: string | null;
sort_order?: number;
content_count?: number;
}
export type ContentType = 'article' | 'quiz' | 'pearl' | 'presentation';
export interface CmsContentRow {
id: number;
title: string;
slug?: string;
subject?: string | null;
content_type: ContentType;
published: boolean;
category_id?: number | null;
category_name?: string | null;
author_name?: string | null;
question_count?: number;
created_at: string;
updated_at: string;
}
export interface CmsOption {
id?: number;
option_text: string;
is_correct: boolean;
explanation?: string;
}
export interface CmsQuestion {
id?: number;
question_text: string;
question_type: 'mcq' | 'true_false' | 'multi';
explanation?: string;
sort_order?: number;
options?: CmsOption[];
}
export interface CmsContentDetail extends CmsContentRow {
body?: string;
questions?: CmsQuestion[];
}
export interface CmsStats {
totalContent: number;
publishedContent: number;
totalCategories: number;
totalQuizzes: number;
totalAttempts: number;
withEmbeddings: number;
embeddingsEnabled: boolean;
}