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
This commit is contained in:
Yared Yemane 2026-04-08 01:33:52 -07:00
parent 265d94999a
commit 0dc7aa81ba
2 changed files with 191 additions and 70 deletions

View File

@ -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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
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, "<br />")
}
}
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, "<br />")
}
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}
/>
<p className="text-xs text-grayScale-500">
Supports plain text and formatted HTML (for headings, lists, italics, and emphasis).
</p>
</div>
<div className="space-y-2">
@ -777,9 +832,16 @@ export function AddNewPracticePage() {
<span className="text-sm text-grayScale-500">Title</span>
<span className="text-sm font-medium text-grayScale-900">{practiceTitle || "Untitled Practice"}</span>
</div>
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
<div className="bg-grayScale-50/50 px-6 py-4">
<span className="text-sm text-grayScale-500">Description</span>
<span className="max-w-[min(28rem,55%)] text-right text-sm leading-relaxed text-grayScale-700">{practiceDescription || "—"}</span>
{descriptionPreviewHtml ? (
<div
className="mt-2 rounded-lg border border-grayScale-200 bg-white px-4 py-3 text-sm leading-relaxed text-grayScale-800 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-2 [&_strong]:font-semibold [&_ul]:list-disc [&_ul]:pl-6"
dangerouslySetInnerHTML={{ __html: descriptionPreviewHtml }}
/>
) : (
<p className="mt-2 text-sm text-grayScale-400"></p>
)}
</div>
<div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">Intro video URL</span>

View File

@ -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 (
<div
className={cn(
"rounded-lg border border-grayScale-100 bg-white p-3 shadow-sm",
!showPlayer && "border-dashed bg-grayScale-50/50",
className,
)}
>
{label ? (
<p className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-grayScale-500">
{hint === "image" ? (
<ImageIcon className="h-3 w-3" aria-hidden />
) : hint === "audio" ? (
<Mic className="h-3 w-3" aria-hidden />
) : hint === "video" ? (
<Video className="h-3 w-3" aria-hidden />
) : (
<Link2 className="h-3 w-3" aria-hidden />
)}
{label}
</p>
) : null}
{resolving ? (
<div className="flex items-center gap-2 rounded-md border border-grayScale-100 bg-grayScale-50 px-3 py-2 text-xs text-grayScale-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Resolving media URL...
</div>
) : mediaType === "image" ? (
<img
src={previewUrl}
alt=""
className="max-h-52 w-full rounded-md border border-grayScale-200/90 bg-grayScale-50 object-contain"
/>
) : mediaType === "video" ? (
vimeoEmbed ? (
<iframe
src={vimeoEmbed}
title="Vimeo preview"
className="aspect-video h-auto min-h-[200px] w-full rounded-md border border-grayScale-200/90 bg-black/5"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
) : (
<video
controls
className="max-h-60 w-full rounded-md border border-grayScale-200/90 bg-black/5"
src={previewUrl}
/>
)
) : mediaType === "audio" ? (
<audio controls className="h-9 w-full" src={previewUrl} />
) : (
<p className="text-xs text-grayScale-500">Preview not available for this URL type.</p>
)}
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-700 hover:underline"
>
<Link2 className="h-3 w-3 shrink-0" aria-hidden />
Open link
</a>
</div>
)
}
export function HumanLanguagePage() {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
@ -231,74 +347,7 @@ export function HumanLanguagePage() {
hint?: "audio" | "video" | "image",
className = "mt-2",
label?: string,
) => {
const url = normalizeUrl(urlRaw)
if (!url) return null
const mediaType = detectMediaType(url, hint)
const vimeoEmbed = getVimeoEmbedUrl(url)
const showPlayer =
mediaType === "image" || mediaType === "video" || mediaType === "audio"
return (
<div
className={cn(
"rounded-lg border border-grayScale-100 bg-white p-3 shadow-sm",
!showPlayer && "border-dashed bg-grayScale-50/50",
className,
)}
>
{label ? (
<p className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-grayScale-500">
{hint === "image" ? (
<ImageIcon className="h-3 w-3" aria-hidden />
) : hint === "audio" ? (
<Mic className="h-3 w-3" aria-hidden />
) : hint === "video" ? (
<Video className="h-3 w-3" aria-hidden />
) : (
<Link2 className="h-3 w-3" aria-hidden />
)}
{label}
</p>
) : null}
{mediaType === "image" ? (
<img
src={url}
alt=""
className="max-h-52 w-full rounded-md border border-grayScale-200/90 bg-grayScale-50 object-contain"
/>
) : mediaType === "video" ? (
vimeoEmbed ? (
<iframe
src={vimeoEmbed}
title="Vimeo preview"
className="aspect-video h-auto min-h-[200px] w-full rounded-md border border-grayScale-200/90 bg-black/5"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
) : (
<video
controls
className="max-h-60 w-full rounded-md border border-grayScale-200/90 bg-black/5"
src={url}
/>
)
) : mediaType === "audio" ? (
<audio controls className="h-9 w-full" src={url} />
) : (
<p className="text-xs text-grayScale-500">Preview not available for this URL type.</p>
)}
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-700 hover:underline"
>
<Link2 className="h-3 w-3 shrink-0" aria-hidden />
Open link
</a>
</div>
)
}
) => <MediaPreviewCard urlRaw={urlRaw} hint={hint} className={className} label={label} />
const loadHierarchy = async () => {
setLoading(true)
@ -1715,6 +1764,16 @@ export function HumanLanguagePage() {
</div>
</div>
) : null}
{q.audio_correct_answer_text ? (
<div className="rounded-lg border border-blue-100 bg-blue-50/40 px-3 py-2.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-blue-700">
Sample answer text
</p>
<p className="mt-1 text-sm leading-relaxed text-blue-900/90">
{q.audio_correct_answer_text}
</p>
</div>
) : null}
</div>
</div>
</li>