From 0dc7aa81bac71408d481c67f14da210bc4c02f69 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Wed, 8 Apr 2026 01:33:52 -0700 Subject: [PATCH] fix practice review rendering and playable audio previews Improve Step 4 Basic Information description rendering with formatted-text preview, and resolve media object keys so practice audio prompts are playable in Human Language question cards while showing sample answer text. Made-with: Cursor --- .../content-management/AddNewPracticePage.tsx | 66 +++++- .../content-management/HumanLanguagePage.tsx | 195 ++++++++++++------ 2 files changed, 191 insertions(+), 70 deletions(-) diff --git a/src/pages/content-management/AddNewPracticePage.tsx b/src/pages/content-management/AddNewPracticePage.tsx index bdb8ce0..3160d1a 100644 --- a/src/pages/content-management/AddNewPracticePage.tsx +++ b/src/pages/content-management/AddNewPracticePage.tsx @@ -96,6 +96,53 @@ function isDirectVideoFile(url: string): boolean { return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean) } +function escapeHtml(raw: string): string { + return raw + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} + +function sanitizeAdminRichTextHtml(input: string): string { + if (!input.trim()) return "" + try { + const parser = new DOMParser() + const doc = parser.parseFromString(input, "text/html") + const blockedTags = new Set(["script", "style", "iframe", "object", "embed", "link", "meta"]) + doc.body.querySelectorAll("*").forEach((el) => { + const tagName = el.tagName.toLowerCase() + if (blockedTags.has(tagName)) { + el.remove() + return + } + const attrs = [...el.attributes] + attrs.forEach((attr) => { + const name = attr.name.toLowerCase() + const value = attr.value.trim().toLowerCase() + if (name.startsWith("on")) { + el.removeAttribute(attr.name) + return + } + if ((name === "href" || name === "src") && value.startsWith("javascript:")) { + el.removeAttribute(attr.name) + } + }) + }) + return doc.body.innerHTML + } catch { + return escapeHtml(input).replace(/\r?\n/g, "
") + } +} + +function formatDescriptionForPreview(raw: string): string { + if (!raw.trim()) return "" + const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw) + if (hasHtml) return sanitizeAdminRichTextHtml(raw) + return escapeHtml(raw).replace(/\r?\n/g, "
") +} + function createEmptyQuestion(id: string): Question { return { id, @@ -230,6 +277,11 @@ export function AddNewPracticePage() { return null }, [introVideoUrl]) + const descriptionPreviewHtml = useMemo( + () => formatDescriptionForPreview(practiceDescription), + [practiceDescription], + ) + const addQuestion = () => { setQuestions([...questions, createEmptyQuestion(String(Date.now()))]) } @@ -430,6 +482,9 @@ export function AddNewPracticePage() { className="min-h-[88px] w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" rows={3} /> +

+ Supports plain text and formatted HTML (for headings, lists, italics, and emphasis). +

@@ -777,9 +832,16 @@ export function AddNewPracticePage() { Title {practiceTitle || "Untitled Practice"}
-
+
Description - {practiceDescription || "—"} + {descriptionPreviewHtml ? ( +
+ ) : ( +

+ )}
Intro video URL diff --git a/src/pages/content-management/HumanLanguagePage.tsx b/src/pages/content-management/HumanLanguagePage.tsx index b189dce..2bc70f0 100644 --- a/src/pages/content-management/HumanLanguagePage.tsx +++ b/src/pages/content-management/HumanLanguagePage.tsx @@ -58,6 +58,7 @@ import type { import { cn } from "../../lib/utils" import { toast } from "sonner" import { Input } from "../../components/ui/input" +import { resolveMediaPreviewUrl } from "../../lib/practiceMedia" import { createEmptyPracticeQuestionDraft, PracticeQuestionEditorFields, @@ -184,6 +185,121 @@ type PendingRemove = { description: string } +function MediaPreviewCard({ + urlRaw, + hint, + className = "mt-2", + label, +}: { + urlRaw: string + hint?: "audio" | "video" | "image" + className?: string + label?: string +}) { + const normalized = normalizeUrl(urlRaw) + const [resolvedUrl, setResolvedUrl] = useState(normalized) + const [resolving, setResolving] = useState(false) + + useEffect(() => { + let cancelled = false + const run = async () => { + if (!normalized) { + setResolvedUrl("") + return + } + if (/^https?:\/\//i.test(normalized)) { + setResolvedUrl(normalized) + return + } + setResolving(true) + try { + const url = await resolveMediaPreviewUrl(normalized) + if (!cancelled) setResolvedUrl(url || normalized) + } catch { + if (!cancelled) setResolvedUrl(normalized) + } finally { + if (!cancelled) setResolving(false) + } + } + void run() + return () => { + cancelled = true + } + }, [normalized]) + + if (!normalized) return null + const previewUrl = resolvedUrl || normalized + const mediaType = detectMediaType(previewUrl, hint) + const vimeoEmbed = getVimeoEmbedUrl(previewUrl) + const showPlayer = mediaType === "image" || mediaType === "video" || mediaType === "audio" + + return ( +
+ {label ? ( +

+ {hint === "image" ? ( + + ) : hint === "audio" ? ( + + ) : hint === "video" ? ( +

+ ) : null} + {resolving ? ( +
+ + Resolving media URL... +
+ ) : mediaType === "image" ? ( + + ) : mediaType === "video" ? ( + vimeoEmbed ? ( +