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)
|
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 {
|
function createEmptyQuestion(id: string): Question {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -230,6 +277,11 @@ export function AddNewPracticePage() {
|
||||||
return null
|
return null
|
||||||
}, [introVideoUrl])
|
}, [introVideoUrl])
|
||||||
|
|
||||||
|
const descriptionPreviewHtml = useMemo(
|
||||||
|
() => formatDescriptionForPreview(practiceDescription),
|
||||||
|
[practiceDescription],
|
||||||
|
)
|
||||||
|
|
||||||
const addQuestion = () => {
|
const addQuestion = () => {
|
||||||
setQuestions([...questions, createEmptyQuestion(String(Date.now()))])
|
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"
|
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}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
Supports plain text and formatted HTML (for headings, lists, italics, and emphasis).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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 text-grayScale-500">Title</span>
|
||||||
<span className="text-sm font-medium text-grayScale-900">{practiceTitle || "Untitled Practice"}</span>
|
<span className="text-sm font-medium text-grayScale-900">{practiceTitle || "Untitled Practice"}</span>
|
||||||
</div>
|
</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="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>
|
||||||
<div className="flex justify-between px-6 py-3.5">
|
<div className="flex justify-between px-6 py-3.5">
|
||||||
<span className="text-sm text-grayScale-500">Intro video URL</span>
|
<span className="text-sm text-grayScale-500">Intro video URL</span>
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import type {
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
|
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
|
||||||
import {
|
import {
|
||||||
createEmptyPracticeQuestionDraft,
|
createEmptyPracticeQuestionDraft,
|
||||||
PracticeQuestionEditorFields,
|
PracticeQuestionEditorFields,
|
||||||
|
|
@ -184,6 +185,121 @@ type PendingRemove = {
|
||||||
description: string
|
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() {
|
export function HumanLanguagePage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
@ -231,74 +347,7 @@ export function HumanLanguagePage() {
|
||||||
hint?: "audio" | "video" | "image",
|
hint?: "audio" | "video" | "image",
|
||||||
className = "mt-2",
|
className = "mt-2",
|
||||||
label?: string,
|
label?: string,
|
||||||
) => {
|
) => <MediaPreviewCard urlRaw={urlRaw} hint={hint} className={className} label={label} />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadHierarchy = async () => {
|
const loadHierarchy = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -1715,6 +1764,16 @@ export function HumanLanguagePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user