more UI adjustment

This commit is contained in:
Yared Yemane 2026-04-07 09:31:22 -07:00
parent 3c4b0c4cd3
commit 1a3f974e6a

View File

@ -28,6 +28,7 @@ import {
createHumanLanguageLesson, createHumanLanguageLesson,
deleteSubCourse, deleteSubCourse,
getHumanLanguageHierarchy, getHumanLanguageHierarchy,
getPracticeQuestions,
getPracticeQuestionsByPractice, getPracticeQuestionsByPractice,
} from "../../api/courses.api" } from "../../api/courses.api"
import { Badge } from "../../components/ui/badge" import { Badge } from "../../components/ui/badge"
@ -48,7 +49,7 @@ type SubModuleCardSelection = { lessonId: number | null; practiceId: number | nu
type PracticeQuestionsFetchState = type PracticeQuestionsFetchState =
| { status: "idle" } | { status: "idle" }
| { status: "loading" } | { status: "loading"; startedAt: number }
| { status: "ok"; questions: QuestionSetQuestion[]; totalCount: number } | { status: "ok"; questions: QuestionSetQuestion[]; totalCount: number }
| { status: "error"; message: string } | { 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" 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" 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 CefrLevel = (typeof CEFR_LEVELS)[number]
type PendingRemove = { type PendingRemove = {
@ -99,6 +142,41 @@ export function HumanLanguagePage() {
const [subModuleCardSelection, setSubModuleCardSelection] = useState<Record<string, SubModuleCardSelection>>({}) const [subModuleCardSelection, setSubModuleCardSelection] = useState<Record<string, SubModuleCardSelection>>({})
const [practiceQuestionsState, setPracticeQuestionsState] = useState<Record<number, PracticeQuestionsFetchState>>({}) 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 () => { const loadHierarchy = async () => {
setLoading(true) setLoading(true)
try { try {
@ -352,18 +430,31 @@ export function HumanLanguagePage() {
let skipFetch = false let skipFetch = false
setPracticeQuestionsState((prev) => { setPracticeQuestionsState((prev) => {
const ex = prev[practiceId] const ex = prev[practiceId]
if (ex?.status === "ok" || ex?.status === "loading") { if (ex?.status === "ok") {
skipFetch = true skipFetch = true
return prev 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 if (skipFetch) return
try { try {
const res = await getPracticeQuestionsByPractice(practiceId, { limit: 200, offset: 0 }) let questions: QuestionSetQuestion[] = []
const payload = res.data?.data let totalCount = 0
const questions = payload?.questions ?? [] try {
const totalCount = payload?.total_count ?? questions.length 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) => ({ setPracticeQuestionsState((prev) => ({
...prev, ...prev,
[practiceId]: { status: "ok", questions, totalCount }, [practiceId]: { status: "ok", questions, totalCount },
@ -860,6 +951,9 @@ export function HumanLanguagePage() {
No video URL set use Open editor to add one. No video URL set use Open editor to add one.
</span> </span>
)} )}
{selectedLesson.video_url
? renderMediaPreview(selectedLesson.video_url, "video", "mt-2")
: null}
</dd> </dd>
</div> </div>
</dl> </dl>
@ -948,7 +1042,17 @@ export function HumanLanguagePage() {
Loading questions Loading questions
</div> </div>
) : practiceFetch.status === "error" ? ( ) : 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 ? ( ) : practiceFetch.questions.length === 0 ? (
<p className="mt-3 text-sm text-grayScale-500"> <p className="mt-3 text-sm text-grayScale-500">
No questions yet. Add them via{" "} No questions yet. Add them via{" "}
@ -986,12 +1090,20 @@ export function HumanLanguagePage() {
<p className="mt-1.5 text-sm text-grayScale-800"> <p className="mt-1.5 text-sm text-grayScale-800">
{q.question_text || "—"} {q.question_text || "—"}
</p> </p>
{extractUrls(q.question_text || "").map((u) => (
<div key={u}>{renderMediaPreview(u)}</div>
))}
{q.tips ? ( {q.tips ? (
<p className="mt-1 text-xs text-grayScale-500"> <p className="mt-1 text-xs text-grayScale-500">
<span className="font-medium text-grayScale-600">Tip: </span> <span className="font-medium text-grayScale-600">Tip: </span>
{q.tips} {q.tips}
</p> </p>
) : null} ) : 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> </li>
))} ))}
</ul> </ul>