pediatric-ai-scribe-v3/public/app/assets/ChartReview-D6HIVum0.js
Daniel db85440325 feat(client): Learning Hub — sanitized HTML body + Marp slide viewer
Closes the last two Learning Hub follow-ups flagged in the earlier
commit body: rich-HTML body rendering (instead of pre-wrap plain text)
and in-React Marp slide playback (instead of the legacy-viewer link).

client/src/lib/sanitize.ts (new)
  Inline HTML sanitizer. Parses via DOMParser (sandboxed — no scripts
  run), walks the tree and:
    • Removes script / style / iframe / object / embed / link / meta /
      base / form / input / button / select / textarea.
    • Drops every on* event-handler attribute.
    • Drops href / src / xlink:href values starting with javascript:
      or data:text/html.
    • Drops any attribute whose value contains "javascript:".
    • Falls back to entity-escaping the raw string if parsing throws.
  Admin-authored Learning Hub content is the trust model here —
  essentially CMS content. A full DOMPurify dep would be strictly
  better but adding a package requires a network install; the inline
  sanitizer covers the realistic XSS vectors without the dep bump.

client/src/pages/Learning.tsx
  • Body rendering (non-presentation content_type) now runs through
    sanitizeHtml + dangerouslySetInnerHTML with a `prose prose-sm`
    Tailwind-typography class. Markdown/HTML formatting from admin
    content now appears correctly (headings, lists, code, bold,
    italics, links) instead of raw text.
  • SlideViewer component (new) replaces the "Open in legacy viewer"
    button for content_type === 'presentation'. Fetches
    /api/learning/content/:slug/slides (returns { css, slides[] } —
    server-side Marp output), sanitizes each slide's HTML + the CSS
    block, renders one slide at a time with:
      - prev/next buttons
      - keyboard ←/→ and PageUp/PageDown navigation
      - slide counter (N/total)
      - fullscreen toggle (Escape exits)
    Marp's own CSS is injected scoped-ish via sanitizer so slide
    theming survives.

shared/types.ts + client/src/shared/types.ts — additive:
  LearningSlidesOk { css: string; slides: string[] }

Client tsc + vite build clean. Initial bundle unchanged
(342.86 kB / 106.03 kB gz) since Learning.tsx was already lazy-loaded;
the sanitizer + SlideViewer roll into its chunk.
2026-04-24 02:30:44 +02:00

1 line
No EOL
4.9 KiB
JavaScript

import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{a as r,i}from"./index-DTb5Y4u0.js";var a=e(t(),1),o=n();function s(){return{date:``,content:``,labs:``}}function c(){let[e,t]=(0,a.useState)(`outpatient`),[n,c]=(0,a.useState)(``),[l,u]=(0,a.useState)(``),[d,f]=(0,a.useState)(``),[p,m]=(0,a.useState)([s()]),[h,g]=(0,a.useState)(``),[_,v]=(0,a.useState)(null),y=r({mutationFn:e=>i.post(`/api/generate-chart-review`,e),onSuccess:e=>v(e.review)});function b(e,t){m(n=>n.map((n,r)=>r===e?{...n,...t}:n))}function x(t){t.preventDefault(),v(null);let r=p.filter(e=>e.content.trim());y.mutate({type:e,patientAge:n,patientGender:l,pmh:d,visits:e===`outpatient`?r:void 0,subspecialty:e===`subspecialty`?r:void 0,edVisits:e===`ed`?r:void 0,additionalInstructions:h||void 0})}let S=`w-full rounded-md border border-input bg-background px-3 py-2 text-sm`;return(0,o.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,o.jsxs)(`header`,{children:[(0,o.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Chart Review`}),(0,o.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Past visits → summary for pre-charting.`})]}),(0,o.jsxs)(`form`,{onSubmit:x,className:`space-y-4`,children:[(0,o.jsxs)(`div`,{className:`grid grid-cols-4 gap-3`,children:[(0,o.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,o.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Review type`}),(0,o.jsxs)(`select`,{className:S,value:e,onChange:e=>t(e.target.value),children:[(0,o.jsx)(`option`,{value:`outpatient`,children:`Outpatient`}),(0,o.jsx)(`option`,{value:`subspecialty`,children:`Subspecialty`}),(0,o.jsx)(`option`,{value:`ed`,children:`ED`})]})]}),(0,o.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,o.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Age`}),(0,o.jsx)(`input`,{className:S,value:n,onChange:e=>c(e.target.value)})]}),(0,o.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,o.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Gender`}),(0,o.jsxs)(`select`,{className:S,value:l,onChange:e=>u(e.target.value),children:[(0,o.jsx)(`option`,{value:``,children:`Select`}),(0,o.jsx)(`option`,{children:`Male`}),(0,o.jsx)(`option`,{children:`Female`})]})]}),(0,o.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,o.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`PMH`}),(0,o.jsx)(`input`,{className:S,value:d,onChange:e=>f(e.target.value)})]})]}),(0,o.jsxs)(`div`,{className:`space-y-3`,children:[(0,o.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,o.jsx)(`span`,{className:`text-sm font-semibold`,children:`Visits`}),(0,o.jsx)(`button`,{type:`button`,onClick:()=>m(e=>[...e,s()]),className:`text-xs rounded-md border border-border px-2 py-1`,children:`+ Add visit`})]}),p.map((e,t)=>(0,o.jsxs)(`div`,{className:`rounded-lg border border-border p-3 space-y-2 bg-card`,children:[(0,o.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,o.jsx)(`input`,{type:`date`,className:S+` max-w-xs`,value:e.date,onChange:e=>b(t,{date:e.target.value})}),p.length>1&&(0,o.jsx)(`button`,{type:`button`,onClick:()=>m(e=>e.filter((e,n)=>n!==t)),className:`text-xs text-destructive`,children:`Remove`})]}),(0,o.jsx)(`textarea`,{className:S+` min-h-[100px] font-mono text-sm`,placeholder:`Visit note content — paste here.`,value:e.content,onChange:e=>b(t,{content:e.target.value})}),(0,o.jsx)(`textarea`,{className:S+` min-h-[60px] font-mono text-xs`,placeholder:`Labs from this visit (optional)`,value:e.labs,onChange:e=>b(t,{labs:e.target.value})})]},t))]}),(0,o.jsxs)(`label`,{className:`flex flex-col gap-1`,children:[(0,o.jsx)(`span`,{className:`text-xs font-semibold uppercase tracking-wider text-muted-foreground`,children:`Additional instructions`}),(0,o.jsx)(`textarea`,{className:S+` min-h-[60px] text-sm`,placeholder:`e.g. 'Focus on thyroid management', 'Highlight medication changes'`,value:h,onChange:e=>g(e.target.value)})]}),y.error&&(0,o.jsx)(`div`,{className:`text-sm text-destructive`,children:y.error.message}),(0,o.jsx)(`button`,{type:`submit`,disabled:y.isPending||!p.some(e=>e.content.trim()),className:`rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50`,children:y.isPending?`Generating…`:`Generate Chart Review`})]}),_&&(0,o.jsxs)(`section`,{className:`rounded-lg border border-border bg-card`,children:[(0,o.jsxs)(`header`,{className:`px-4 py-2 border-b border-border flex items-center justify-between bg-muted/40`,children:[(0,o.jsx)(`h2`,{className:`text-sm font-semibold`,children:`Chart Review`}),(0,o.jsx)(`button`,{onClick:()=>navigator.clipboard.writeText(_),className:`text-xs text-muted-foreground underline`,children:`Copy`})]}),(0,o.jsx)(`div`,{className:`p-4 whitespace-pre-wrap text-sm`,children:_})]})]})}export{c as default};