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
165 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
}
|