diff --git a/client/src/App.tsx b/client/src/App.tsx index f979657..c6c8705 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index c615ae1..dcb721c 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -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 }, ], }, ]; diff --git a/client/src/pages/Cms.tsx b/client/src/pages/Cms.tsx new file mode 100644 index 0000000..f6a0570 --- /dev/null +++ b/client/src/pages/Cms.tsx @@ -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('all'); + const [view, setView] = useState({ kind: 'list' }); + + return ( +
+
+

Content Manager

+

+ Create and manage Learning Hub content, quizzes, and categories. +

+
+ + + +
+ + + {view.kind === 'list' ? ( + setView({ kind: 'edit', id, type: 'article' })} + onCreate={(type) => setView({ kind: 'edit', id: null, type })} + /> + ) : ( + setView({ kind: 'list' })} + /> + )} +
+
+ ); +} diff --git a/client/src/pages/cms/CategoriesPanel.tsx b/client/src/pages/cms/CategoriesPanel.tsx new file mode 100644 index 0000000..dc28b32 --- /dev/null +++ b/client/src/pages/cms/CategoriesPanel.tsx @@ -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(null); + const [pendingDelete, setPendingDelete] = useState(null); + + const { data } = useQuery({ + queryKey: ['cms-categories'], + queryFn: () => api.get('/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 ( + + ); +} diff --git a/client/src/pages/cms/ContentEditor.tsx b/client/src/pages/cms/ContentEditor.tsx new file mode 100644 index 0000000..0b75432 --- /dev/null +++ b/client/src/pages/cms/ContentEditor.tsx @@ -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(''); + const [contentType, setContentType] = useState(initialType); + const [published, setPublished] = useState(false); + const [questions, setQuestions] = useState([]); + const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null); + + const cats = useQuery({ + queryKey: ['cms-categories'], + queryFn: () => api.get('/api/learning-admin/categories'), + }); + + const detail = useQuery({ + queryKey: ['cms-content-detail', id], + queryFn: () => api.get('/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 ( +
+
+

+ {isNew ? 'New ' + contentType : 'Edit ' + contentType} +

+ +
+ +
+ + +
+ + + +
+ + + +