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.
1 line
No EOL
13 KiB
JavaScript
1 line
No EOL
13 KiB
JavaScript
import{i as e,n as t,t as n}from"./jsx-runtime-ByY1xr43.js";import{a as r,i,o as a,s as o}from"./index-DTb5Y4u0.js";var s=e(t(),1),c=new Set([`script`,`style`,`iframe`,`object`,`embed`,`link`,`meta`,`base`,`form`,`input`,`button`,`select`,`textarea`]);function l(e){if(c.has(e.tagName.toLowerCase())){e.remove();return}let t=[];for(let n of Array.from(e.attributes)){let e=n.name.toLowerCase(),r=(n.value||``).trim().toLowerCase();(e.startsWith(`on`)||(e===`href`||e===`src`||e===`xlink:href`)&&(r.startsWith(`javascript:`)||r.startsWith(`data:text/html`))||r.includes(`javascript:`))&&t.push(n.name)}t.forEach(t=>e.removeAttribute(t));for(let t of Array.from(e.children))l(t)}function u(e){if(!e)return``;try{let t=new DOMParser().parseFromString(`<!DOCTYPE html><html><body>`+e+`</body></html>`,`text/html`);for(let e of Array.from(t.body.children))l(e);return t.body.innerHTML}catch{return e.replace(/[<>&"']/g,e=>({"<":`<`,">":`>`,"&":`&`,'"':`"`,"'":`'`})[e]||e)}}var d=n(),f=`rounded-lg border border-border bg-card p-5 space-y-3`,p=`px-3 py-1 rounded-full text-xs font-medium border transition-colors cursor-pointer`,m=`w-full rounded-md border border-input bg-background px-3 py-2 text-sm`,h=`rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50`,g=`rounded-md border border-border px-3 py-2 text-sm disabled:opacity-50`;function _(e){switch(e){case`quiz`:return`Quiz`;case`pearl`:return`Pearl`;case`presentation`:return`Slides`;default:return`Article`}}function v({row:e,onOpen:t}){return(0,d.jsxs)(`button`,{type:`button`,onClick:t,className:`w-full text-left rounded-lg border border-border bg-card hover:bg-muted/60 p-4 transition-colors`,"data-testid":`lh-feed-item-`+e.slug,children:[(0,d.jsxs)(`div`,{className:`flex items-center gap-2 text-xs text-muted-foreground uppercase tracking-wide mb-1`,children:[(0,d.jsx)(`span`,{className:`font-semibold`,children:_(e.content_type)}),e.category_name&&(0,d.jsxs)(`span`,{children:[`· `,e.category_name]}),e.question_count?(0,d.jsxs)(`span`,{children:[`· `,e.question_count,` Q`]}):null]}),(0,d.jsx)(`div`,{className:`text-sm font-semibold`,children:e.title}),e.subject&&(0,d.jsx)(`div`,{className:`text-xs text-muted-foreground mt-0.5 truncate`,children:e.subject})]})}function y({filter:e,query:t,onOpen:n}){let{data:r,isLoading:o,error:s}=a({queryKey:t?[`learning-search`,t]:e?[`learning-category`,e]:[`learning-feed`],queryFn:()=>t?i.get(`/api/learning/search?q=`+encodeURIComponent(t)):e?i.get(`/api/learning/category/`+encodeURIComponent(e)):i.get(`/api/learning/feed?limit=30`)});if(o)return(0,d.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`Loading…`});if(s)return(0,d.jsx)(`div`,{className:`text-sm text-destructive`,children:s.message});let c=r?.content||[];return c.length===0?(0,d.jsx)(`div`,{className:`text-sm text-muted-foreground italic py-4`,children:`No content found.`}):(0,d.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 gap-3`,"data-testid":`lh-feed`,children:c.map(e=>(0,d.jsx)(v,{row:e,onOpen:()=>n(e.slug)},e.id))})}function b(e){let t={};for(let n of e)t[n.id]={optionIds:new Set};return t}function x({content:e,onReset:t}){let n=o(),[a,c]=(0,s.useState)(()=>b(e.questions)),[l,u]=(0,s.useState)(null),[p,m]=(0,s.useState)(null),_=r({mutationFn:e=>i.post(`/api/learning/submit-quiz`,e),onSuccess:t=>{u(t),n.invalidateQueries({queryKey:[`learning-content`,e.slug]})},onError:e=>m(e.message||`Submit failed`)});function v(t){t.preventDefault(),m(null);let n=e.questions.map(e=>{let t=a[e.id];return e.question_type===`multi`?{questionId:e.id,optionIds:Array.from(t?.optionIds||[])}:{questionId:e.id,optionId:t?.optionId??null}});_.mutate({contentId:e.id,answers:n})}function y(e,t){c(n=>({...n,[e.id]:{optionId:t,optionIds:new Set}}))}function x(e,t){c(n=>{let r=new Set(n[e.id]?.optionIds||[]);return r.has(t)?r.delete(t):r.add(t),{...n,[e.id]:{optionIds:r}}})}if(l){let n=l.percentage>=80?`bg-green-600`:l.percentage>=50?`bg-amber-500`:`bg-destructive`;return(0,d.jsxs)(`section`,{className:f,"data-testid":`lh-quiz-results`,children:[(0,d.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,d.jsx)(`h3`,{className:`text-base font-semibold`,children:`Results`}),(0,d.jsxs)(`span`,{className:`px-2 py-0.5 rounded text-xs font-semibold text-white `+n,"data-testid":`lh-quiz-score`,children:[l.score,`/`,l.total,` (`,l.percentage,`%)`]})]}),(0,d.jsx)(`div`,{className:`space-y-3`,children:l.results.map((e,t)=>(0,d.jsxs)(`div`,{className:`rounded-md border border-border p-3 bg-muted/30`,children:[(0,d.jsxs)(`div`,{className:`text-sm font-medium`,children:[(0,d.jsx)(`span`,{className:e.isCorrect?`text-green-600`:`text-destructive`,children:e.isCorrect?`✓`:`✗`}),` `,`Q`,t+1,`: `,e.questionText]}),!e.isCorrect&&e.correctOptionText&&(0,d.jsxs)(`div`,{className:`text-xs text-green-700 mt-1`,children:[(0,d.jsx)(`strong`,{children:`Correct:`}),` `,e.correctOptionText]}),!e.isCorrect&&e.selectedExplanation&&(0,d.jsxs)(`div`,{className:`text-xs text-destructive mt-1`,children:[(0,d.jsx)(`strong`,{children:`Why incorrect:`}),` `,e.selectedExplanation]}),e.generalExplanation&&(0,d.jsx)(`div`,{className:`text-xs text-muted-foreground mt-1`,children:e.generalExplanation})]},e.questionId))}),(0,d.jsxs)(`div`,{className:`flex gap-2`,children:[(0,d.jsx)(`button`,{type:`button`,className:g,onClick:()=>{u(null),c(b(e.questions))},children:`Retake`}),(0,d.jsx)(`button`,{type:`button`,className:h,onClick:t,children:`Back to Feed`})]})]})}return(0,d.jsxs)(`section`,{className:f,"data-testid":`lh-quiz`,children:[(0,d.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,d.jsx)(`h3`,{className:`text-base font-semibold`,children:`Quiz`}),(0,d.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[e.questions.length,` question`,e.questions.length===1?``:`s`]})]}),(0,d.jsxs)(`form`,{onSubmit:v,className:`space-y-4`,children:[e.questions.map((e,t)=>{let n=e.question_type===`multi`,r=e.question_type===`true_false`?`True / False`:n?`Multiple Select`:`Single Choice`;return(0,d.jsxs)(`div`,{className:`rounded-md border border-border p-3 space-y-2 bg-muted/30`,children:[(0,d.jsxs)(`div`,{className:`flex items-center justify-between text-xs text-muted-foreground`,children:[(0,d.jsxs)(`span`,{className:`font-semibold`,children:[`Q`,t+1]}),(0,d.jsx)(`span`,{children:r})]}),(0,d.jsx)(`div`,{className:`text-sm font-medium`,children:e.question_text}),n&&(0,d.jsx)(`div`,{className:`text-xs text-muted-foreground italic`,children:`Select all that apply`}),(0,d.jsx)(`div`,{className:`space-y-1`,children:e.options.map(t=>{let r=a[e.id],i=n?r?.optionIds.has(t.id)===!0:r?.optionId===t.id;return(0,d.jsxs)(`label`,{className:`flex items-start gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-2 py-1`,children:[(0,d.jsx)(`input`,{type:n?`checkbox`:`radio`,name:`q-`+e.id,checked:i,onChange:()=>n?x(e,t.id):y(e,t.id),className:`mt-0.5`}),(0,d.jsx)(`span`,{children:t.option_text})]},t.id)})})]},e.id)}),p&&(0,d.jsx)(`div`,{className:`text-sm text-destructive`,children:p}),(0,d.jsx)(`button`,{type:`submit`,className:h,disabled:_.isPending,"data-testid":`btn-lh-submit-quiz`,children:_.isPending?`Submitting…`:`Submit Answers`})]})]})}function S({slug:e,title:t}){let[n,r]=(0,s.useState)(0),[o,c]=(0,s.useState)(!1),{data:l,isLoading:f,error:p}=a({queryKey:[`learning-slides`,e],queryFn:()=>i.get(`/api/learning/content/`+encodeURIComponent(e)+`/slides`)});if((0,s.useEffect)(()=>{function e(e){l&&((e.key===`ArrowRight`||e.key===`PageDown`)&&r(e=>Math.min(e+1,l.slides.length-1)),(e.key===`ArrowLeft`||e.key===`PageUp`)&&r(e=>Math.max(0,e-1)),e.key===`Escape`&&o&&c(!1))}return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[l,o]),f)return(0,d.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`Loading slides…`});if(p)return(0,d.jsxs)(`div`,{className:`text-sm text-destructive`,children:[`Failed to load slides: `,p.message]});if(!l||l.slides.length===0)return(0,d.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`No slides in this presentation.`});let m=l.css?u(`<style>`+l.css+`</style>`):``,h=u(l.slides[n]||``);return(0,d.jsxs)(`div`,{className:o?`fixed inset-0 z-50 bg-background flex flex-col`:`rounded-lg border border-border bg-white dark:bg-black flex flex-col`,"data-testid":`lh-slides`,children:[m&&(0,d.jsx)(`div`,{dangerouslySetInnerHTML:{__html:m}}),(0,d.jsxs)(`div`,{className:`flex items-center justify-between border-b border-border px-3 py-2 text-xs`,children:[(0,d.jsxs)(`span`,{className:`text-muted-foreground truncate`,children:[`📊 `,t]}),(0,d.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,d.jsxs)(`span`,{children:[n+1,` / `,l.slides.length]}),(0,d.jsx)(`button`,{type:`button`,onClick:()=>c(!o),className:`px-2 py-1 rounded bg-muted text-xs`,"data-testid":`lh-slides-fullscreen`,children:o?`Exit fullscreen`:`Fullscreen`})]})]}),(0,d.jsx)(`div`,{className:`flex-1 overflow-auto p-4 flex items-center justify-center`,style:{minHeight:o?void 0:`480px`},children:(0,d.jsx)(`div`,{dangerouslySetInnerHTML:{__html:h},"data-testid":`lh-slide-current`})}),(0,d.jsxs)(`div`,{className:`flex items-center justify-between border-t border-border px-3 py-2`,children:[(0,d.jsx)(`button`,{type:`button`,onClick:()=>r(e=>Math.max(0,e-1)),disabled:n===0,className:g,"data-testid":`lh-slides-prev`,children:`← Previous`}),(0,d.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`← → to navigate`}),(0,d.jsx)(`button`,{type:`button`,onClick:()=>r(e=>Math.min(e+1,l.slides.length-1)),disabled:n>=l.slides.length-1,className:g,"data-testid":`lh-slides-next`,children:`Next →`})]})]})}function C({slug:e,onBack:t}){let{data:n,isLoading:r,error:o}=a({queryKey:[`learning-content`,e],queryFn:()=>i.get(`/api/learning/content/`+encodeURIComponent(e))});if(r)return(0,d.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`Loading…`});if(o)return(0,d.jsx)(`div`,{className:`text-sm text-destructive`,children:o.message});if(!n)return null;let s=n.content;return(0,d.jsxs)(`div`,{className:`space-y-4`,children:[(0,d.jsx)(`button`,{type:`button`,className:g,onClick:t,"data-testid":`btn-lh-back`,children:`← Back to Feed`}),(0,d.jsxs)(`section`,{className:f,"data-testid":`lh-viewer`,children:[(0,d.jsxs)(`div`,{className:`flex items-center justify-between gap-4`,children:[(0,d.jsx)(`h2`,{className:`text-xl font-semibold`,"data-testid":`lh-viewer-title`,children:s.title}),(0,d.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[_(s.content_type),s.category_name?` · `+s.category_name:``,s.author_name?` · `+s.author_name:``]})]}),s.content_type===`presentation`?(0,d.jsx)(S,{slug:s.slug,title:s.title}):(0,d.jsx)(`div`,{className:`text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none`,"data-testid":`lh-viewer-body`,dangerouslySetInnerHTML:{__html:u(s.body||``)}})]}),s.progress&&s.progress.length>0&&(0,d.jsxs)(`section`,{className:f,children:[(0,d.jsx)(`h3`,{className:`text-base font-semibold`,children:`Your past attempts`}),(0,d.jsx)(`div`,{className:`space-y-1 text-sm`,children:s.progress.map((e,t)=>{let n=e.total>0?Math.round(e.score/e.total*100):0,r=n>=70?`text-green-600`:`text-amber-600`;return(0,d.jsxs)(`div`,{className:`flex justify-between border-b border-border py-1`,children:[(0,d.jsx)(`span`,{children:new Date(e.completed_at).toLocaleDateString()}),(0,d.jsxs)(`span`,{className:`font-semibold `+r,children:[e.score,`/`,e.total,` (`,n,`%)`]})]},t)})})]}),s.questions&&s.questions.length>0&&(0,d.jsx)(x,{content:s,onReset:t})]})}function w(){let[e,t]=(0,s.useState)(``),[n,r]=(0,s.useState)(``),[o,c]=(0,s.useState)(null),{data:l}=a({queryKey:[`learning-categories`],queryFn:()=>i.get(`/api/learning/categories`)});return o?(0,d.jsx)(`div`,{className:`max-w-4xl mx-auto p-6`,children:(0,d.jsx)(C,{slug:o,onBack:()=>c(null)})}):(0,d.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,d.jsxs)(`header`,{children:[(0,d.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Learning Hub`}),(0,d.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Pediatric education, clinical pearls, and self-assessment quizzes.`})]}),(0,d.jsx)(`div`,{className:f,children:(0,d.jsx)(`input`,{type:`search`,className:m,placeholder:`Search topics, subjects…`,value:e,onChange:e=>t(e.target.value),"data-testid":`lh-search`})}),(0,d.jsxs)(`div`,{className:`flex flex-wrap gap-2`,"data-testid":`lh-categories`,children:[(0,d.jsx)(`button`,{type:`button`,onClick:()=>r(``),className:p+(n===``?` bg-primary text-primary-foreground border-primary`:` bg-muted hover:bg-muted/80`),children:`All`}),l?.categories.map(e=>(0,d.jsx)(`button`,{type:`button`,onClick:()=>r(e.slug),className:p+(n===e.slug?` bg-primary text-primary-foreground border-primary`:` bg-muted hover:bg-muted/80`),"data-testid":`lh-cat-`+e.slug,children:e.name},e.id))]}),(0,d.jsx)(y,{filter:n,query:e.trim(),onOpen:e=>c(e)})]})}export{w as default}; |