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:
parent
83f3ac70d5
commit
1beabe40e4
9 changed files with 871 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
76
client/src/pages/Cms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
client/src/pages/cms/CategoriesPanel.tsx
Normal file
146
client/src/pages/cms/CategoriesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
207
client/src/pages/cms/ContentEditor.tsx
Normal file
207
client/src/pages/cms/ContentEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
client/src/pages/cms/ContentList.tsx
Normal file
165
client/src/pages/cms/ContentList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
client/src/pages/cms/QuestionsEditor.tsx
Normal file
179
client/src/pages/cms/QuestionsEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
client/src/pages/cms/StatsBar.tsx
Normal file
34
client/src/pages/cms/StatsBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
client/src/pages/cms/cms-types.ts
Normal file
61
client/src/pages/cms/cms-types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue