Compare commits

...

2 Commits

Author SHA1 Message Date
51a14ad975 practice questions fix 2026-04-07 09:35:14 -07:00
1a3f974e6a more UI adjustment 2026-04-07 09:31:22 -07:00

View File

@ -28,6 +28,7 @@ import {
createHumanLanguageLesson,
deleteSubCourse,
getHumanLanguageHierarchy,
getPracticeQuestions,
getPracticeQuestionsByPractice,
} from "../../api/courses.api"
import { Badge } from "../../components/ui/badge"
@ -48,7 +49,7 @@ type SubModuleCardSelection = { lessonId: number | null; practiceId: number | nu
type PracticeQuestionsFetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "loading"; startedAt: number }
| { status: "ok"; questions: QuestionSetQuestion[]; totalCount: number }
| { status: "error"; message: string }
@ -66,6 +67,48 @@ function practiceStatusStyle(status: string): string {
if (u === "ARCHIVED") return "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200"
return "bg-grayScale-50 text-grayScale-600 ring-1 ring-inset ring-grayScale-200"
}
const URL_REGEX = /(https?:\/\/[^\s<>"')\]]+)/gi
function extractUrls(text: string): string[] {
const out = text.match(URL_REGEX) ?? []
return [...new Set(out)]
}
function normalizeUrl(raw: string): string {
return raw.trim().replace(/[),.;!?]+$/, "")
}
function getVimeoEmbedUrl(url: string): string | null {
const m = url.match(/vimeo\.com\/(?:video\/)?(\d+)/i)
return m?.[1] ? `https://player.vimeo.com/video/${m[1]}` : null
}
function detectMediaType(url: string, hint?: "audio" | "video" | "image"): "audio" | "video" | "image" | "unknown" {
if (hint) return hint
const vimeo = getVimeoEmbedUrl(url)
if (vimeo) return "video"
const clean = url.split("?")[0].toLowerCase()
if (/\.(png|jpe?g|gif|webp|svg|avif|bmp)$/.test(clean)) return "image"
if (/\.(mp4|webm|ogg|mov|m4v)$/.test(clean)) return "video"
if (/\.(mp3|wav|m4a|aac|ogg|webm)$/.test(clean)) return "audio"
return "unknown"
}
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("Request timed out")), ms)
promise
.then((value) => {
clearTimeout(timer)
resolve(value)
})
.catch((err) => {
clearTimeout(timer)
reject(err)
})
})
}
type CefrLevel = (typeof CEFR_LEVELS)[number]
type PendingRemove = {
@ -99,6 +142,41 @@ export function HumanLanguagePage() {
const [subModuleCardSelection, setSubModuleCardSelection] = useState<Record<string, SubModuleCardSelection>>({})
const [practiceQuestionsState, setPracticeQuestionsState] = useState<Record<number, PracticeQuestionsFetchState>>({})
const renderMediaPreview = (
urlRaw: string,
hint?: "audio" | "video" | "image",
className = "mt-2",
) => {
const url = normalizeUrl(urlRaw)
if (!url) return null
const mediaType = detectMediaType(url, hint)
const vimeoEmbed = getVimeoEmbedUrl(url)
return (
<div className={className}>
{mediaType === "image" ? (
<img src={url} alt="preview" className="max-h-48 rounded-md border border-grayScale-200 object-contain" />
) : mediaType === "video" ? (
vimeoEmbed ? (
<iframe
src={vimeoEmbed}
title="Vimeo preview"
className="h-48 w-full rounded-md border border-grayScale-200"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
) : (
<video controls className="max-h-56 w-full rounded-md border border-grayScale-200" src={url} />
)
) : mediaType === "audio" ? (
<audio controls className="w-full" src={url} />
) : null}
<a href={url} target="_blank" rel="noopener noreferrer" className="mt-1 inline-block text-xs text-brand-600 hover:underline">
Open media URL
</a>
</div>
)
}
const loadHierarchy = async () => {
setLoading(true)
try {
@ -352,18 +430,31 @@ export function HumanLanguagePage() {
let skipFetch = false
setPracticeQuestionsState((prev) => {
const ex = prev[practiceId]
if (ex?.status === "ok" || ex?.status === "loading") {
if (ex?.status === "ok") {
skipFetch = true
return prev
}
return { ...prev, [practiceId]: { status: "loading" } }
if (ex?.status === "loading" && Date.now() - ex.startedAt < 15000) {
skipFetch = true
return prev
}
return { ...prev, [practiceId]: { status: "loading", startedAt: Date.now() } }
})
if (skipFetch) return
try {
const res = await getPracticeQuestionsByPractice(practiceId, { limit: 200, offset: 0 })
const payload = res.data?.data
const questions = payload?.questions ?? []
const totalCount = payload?.total_count ?? questions.length
let questions: QuestionSetQuestion[] = []
let totalCount = 0
try {
const res = await withTimeout(getPracticeQuestionsByPractice(practiceId, { limit: 200, offset: 0 }), 12000)
const payload = res.data?.data
questions = payload?.questions ?? []
totalCount = payload?.total_count ?? questions.length
} catch {
// Fallback endpoint for environments where /practices/:id/questions can hang.
const fallback = await withTimeout(getPracticeQuestions(practiceId), 12000)
questions = fallback.data?.data ?? []
totalCount = questions.length
}
setPracticeQuestionsState((prev) => ({
...prev,
[practiceId]: { status: "ok", questions, totalCount },
@ -389,14 +480,13 @@ export function HumanLanguagePage() {
}
const togglePracticeCard = (smKey: string, practiceId: number) => {
let openedPracticeId: number | null = null
const currentPracticeId = subModuleCardSelection[smKey]?.practiceId ?? null
const nextPracticeId = currentPracticeId === practiceId ? null : practiceId
setSubModuleCardSelection((prev) => {
const cur = prev[smKey] ?? { lessonId: null, practiceId: null }
const nextPracticeId = cur.practiceId === practiceId ? null : practiceId
if (nextPracticeId !== null) openedPracticeId = nextPracticeId
return { ...prev, [smKey]: { ...cur, practiceId: nextPracticeId } }
})
if (openedPracticeId !== null) void loadPracticeQuestionsIfNeeded(openedPracticeId)
if (nextPracticeId !== null) void loadPracticeQuestionsIfNeeded(nextPracticeId)
}
return (
@ -860,6 +950,9 @@ export function HumanLanguagePage() {
No video URL set use Open editor to add one.
</span>
)}
{selectedLesson.video_url
? renderMediaPreview(selectedLesson.video_url, "video", "mt-2")
: null}
</dd>
</div>
</dl>
@ -948,7 +1041,17 @@ export function HumanLanguagePage() {
Loading questions
</div>
) : practiceFetch.status === "error" ? (
<p className="mt-3 text-sm text-red-600">{practiceFetch.message}</p>
<div className="mt-3 space-y-2">
<p className="text-sm text-red-600">{practiceFetch.message}</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void loadPracticeQuestionsIfNeeded(selectedPracticeMeta.id)}
>
Retry
</Button>
</div>
) : practiceFetch.questions.length === 0 ? (
<p className="mt-3 text-sm text-grayScale-500">
No questions yet. Add them via{" "}
@ -986,12 +1089,20 @@ export function HumanLanguagePage() {
<p className="mt-1.5 text-sm text-grayScale-800">
{q.question_text || "—"}
</p>
{extractUrls(q.question_text || "").map((u) => (
<div key={u}>{renderMediaPreview(u)}</div>
))}
{q.tips ? (
<p className="mt-1 text-xs text-grayScale-500">
<span className="font-medium text-grayScale-600">Tip: </span>
{q.tips}
</p>
) : null}
{q.image_url ? renderMediaPreview(q.image_url, "image") : null}
{q.voice_prompt ? renderMediaPreview(q.voice_prompt, "audio") : null}
{q.sample_answer_voice_prompt
? renderMediaPreview(q.sample_answer_voice_prompt, "audio")
: null}
</li>
))}
</ul>