Minimum-viable port of the user-facing Learning Hub at /app/learning.
Sidebar nav flipped to available in the same commit.
client/src/pages/Learning.tsx
Three-screen flow: search + category pills drive a feed grid; clicking
a card opens the viewer; viewer shows body + progress + quiz (if any).
Feed: one query key per filter — ['learning-feed'], ['learning-category',
slug], or ['learning-search', q] — so React Query caches each view
independently and flicking between categories is instant after the first
load. Search hits /api/learning/search; category filter hits
/api/learning/category/:slug; default hits /api/learning/feed?limit=30.
Viewer: body rendered as pre-wrap text intentionally. The vanilla tree
uses DOMPurify (CDN-loaded) to render HTML bodies; adding that dep to
the client bundle is a follow-up. Authored content is still clinical
info, so plain-text preservation is acceptable for this commit — no
content is lost, just unstyled. Presentations (content_type === 'presentation')
link to the legacy viewer at /#learning/:slug — Marp slide rendering is
its own port.
Quiz: supports single-choice, multi-select, and true/false. Answers
tracked via { optionId?, optionIds: Set<number> } per question so the
same state shape drives both radio and checkbox rendering. Submit POSTs
to /api/learning/submit-quiz; results screen shows per-question verdict
with correct answer + why-incorrect + general explanation — same fields
the vanilla showQuizResults renders. Retake wipes the answer map;
Back-to-Feed returns to the list.
Progress list reads content.progress[] directly from the content response
— last 5 attempts, color-coded green/amber at 70%.
shared/types.ts + client/src/shared/types.ts — additive:
LearningCategory/LearningCategoriesOk, LearningFeedRow/LearningFeedListOk,
LearningOption/LearningQuestion/LearningProgressEntry/LearningContentFull/
LearningContentOk, QuizAnswer/QuizResultEntry/QuizSubmitOk. Keys match
the wire shape server routes return (snake_case for DB columns).
e2e/tests/learning-react.spec.js — three smoke tests:
shell renders, feed shows items OR empty-state (no crash on empty DB),
typing into search fires /api/learning/search.
Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Bundle 435.44 kB / 123.81 kB gzipped (+10 kB over Settings complete).
115 lines
3.6 KiB
TypeScript
115 lines
3.6 KiB
TypeScript
// ============================================================
|
|
// LAYOUT — sidebar + main content shell shared across every page.
|
|
// Structure mirrors the vanilla app so a user moving between the two
|
|
// trees during migration sees consistent navigation.
|
|
// ============================================================
|
|
|
|
import { NavLink, Outlet } from 'react-router-dom';
|
|
import type { ReactNode } from 'react';
|
|
|
|
interface NavItem {
|
|
to: string;
|
|
label: string;
|
|
available?: boolean; // false = rendered as "coming soon" stub
|
|
}
|
|
|
|
interface NavGroup {
|
|
label: string;
|
|
items: NavItem[];
|
|
}
|
|
|
|
const NAV: NavGroup[] = [
|
|
{
|
|
label: 'Encounters',
|
|
items: [
|
|
{ to: '/encounter', label: 'Encounter HPI', available: true },
|
|
{ to: '/dictation', label: 'Dictation HPI', available: true },
|
|
],
|
|
},
|
|
{
|
|
label: 'Notes',
|
|
items: [
|
|
{ to: '/hospital', label: 'Hospital Course', available: true },
|
|
{ to: '/chart', label: 'Chart Review', available: true },
|
|
{ to: '/soap', label: 'SOAP Note', available: true },
|
|
{ to: '/wellvisit', label: 'Well Visit', available: true },
|
|
{ to: '/sickvisit', label: 'Sick Visit', available: true },
|
|
],
|
|
},
|
|
{
|
|
label: 'Clinical Tools',
|
|
items: [
|
|
{ to: '/vaxschedule', label: 'Vaccine Schedule', available: true },
|
|
{ to: '/catchup', label: 'Catch-Up Schedule', available: true },
|
|
{ to: '/peguide', label: 'Physical Exam Guide', available: false },
|
|
{ to: '/bedside', label: 'Bedside', available: false },
|
|
{ to: '/calculators', label: 'Calculators', available: false },
|
|
{ to: '/extensions', label: 'Pagers & Extensions', available: true },
|
|
{ to: '/learning', label: 'Learning Hub', available: true },
|
|
],
|
|
},
|
|
{
|
|
label: 'Account',
|
|
items: [
|
|
{ to: '/settings', label: 'Settings', available: true },
|
|
{ to: '/faq', label: 'FAQ', available: true },
|
|
],
|
|
},
|
|
];
|
|
|
|
function SidebarLink({ item }: { item: NavItem }) {
|
|
if (!item.available) {
|
|
return (
|
|
<div
|
|
className="px-3 py-2 text-sm rounded-md text-muted-foreground italic cursor-not-allowed opacity-60"
|
|
title="Not yet ported to React — still available in the vanilla app at /"
|
|
>
|
|
{item.label} <span className="text-[10px]">· pending</span>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<NavLink
|
|
to={item.to}
|
|
className={({ isActive }) =>
|
|
'block px-3 py-2 text-sm rounded-md transition-colors ' +
|
|
(isActive
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'hover:bg-muted text-foreground')
|
|
}
|
|
>
|
|
{item.label}
|
|
</NavLink>
|
|
);
|
|
}
|
|
|
|
export default function Layout({ children }: { children?: ReactNode }) {
|
|
return (
|
|
<div className="min-h-screen bg-background text-foreground flex">
|
|
{/* Sidebar */}
|
|
<aside className="w-64 border-r border-border bg-muted/30 flex-shrink-0 p-3 space-y-4 sticky top-0 h-screen overflow-y-auto">
|
|
<div className="px-2 py-1 border-b border-border pb-3">
|
|
<div className="font-semibold">Pediatric AI Scribe</div>
|
|
<a href="/" className="text-[11px] text-muted-foreground underline">
|
|
← back to legacy app
|
|
</a>
|
|
</div>
|
|
{NAV.map((group) => (
|
|
<div key={group.label} className="space-y-1">
|
|
<div className="px-3 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{group.label}
|
|
</div>
|
|
{group.items.map((item) => (
|
|
<SidebarLink key={item.to} item={item} />
|
|
))}
|
|
</div>
|
|
))}
|
|
</aside>
|
|
|
|
{/* Main */}
|
|
<main className="flex-1 min-w-0">
|
|
{children ?? <Outlet />}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|