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:
parent
265d94999a
commit
0dc7aa81ba
|
|
@ -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, "<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user