pediatric-ai-scribe-v3/client/src/pages/cms/ContentList.tsx
Daniel 1beabe40e4 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
2026-04-24 05:24:09 +02:00

165 lines
7.3 KiB
TypeScript

// 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>
);
}