polish human language practice detail UI

Improve question-bank presentation with clearer hierarchy, richer badges/callouts, and labeled media previews for lesson/practice content.

Made-with: Cursor
This commit is contained in:
Yared Yemane 2026-04-07 09:49:28 -07:00
parent 51a14ad975
commit 4166fe0807

View File

@ -4,8 +4,13 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
ClipboardList, ClipboardList,
HelpCircle,
Image as ImageIcon,
Languages, Languages,
Lightbulb,
Link2,
Loader2, Loader2,
Mic,
Plus, Plus,
Search, Search,
Trash2, Trash2,
@ -68,6 +73,31 @@ function practiceStatusStyle(status: string): string {
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"
} }
function questionTypeBadgeClass(questionType: string): string {
const t = questionType.toUpperCase().replace(/\s+/g, "_")
if (t === "MCQ" || t.includes("MULTIPLE")) {
return "border-transparent bg-violet-50 text-violet-800 ring-1 ring-inset ring-violet-200"
}
if (t === "TRUE_FALSE" || t.includes("TRUE")) {
return "border-transparent bg-sky-50 text-sky-800 ring-1 ring-inset ring-sky-200"
}
if (t === "SHORT" || t === "SHORT_ANSWER") {
return "border-transparent bg-emerald-50 text-emerald-800 ring-1 ring-inset ring-emerald-200"
}
if (t === "AUDIO") {
return "border-transparent bg-orange-50 text-orange-800 ring-1 ring-inset ring-orange-200"
}
return "border-transparent bg-grayScale-100 text-grayScale-700 ring-1 ring-inset ring-grayScale-200"
}
function formatQuestionTypeLabel(raw: string): string {
return String(raw ?? "—")
.replace(/_/g, " ")
.trim()
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase())
}
const URL_REGEX = /(https?:\/\/[^\s<>"')\]]+)/gi const URL_REGEX = /(https?:\/\/[^\s<>"')\]]+)/gi
function extractUrls(text: string): string[] { function extractUrls(text: string): string[] {
@ -146,32 +176,71 @@ export function HumanLanguagePage() {
urlRaw: string, urlRaw: string,
hint?: "audio" | "video" | "image", hint?: "audio" | "video" | "image",
className = "mt-2", className = "mt-2",
label?: string,
) => { ) => {
const url = normalizeUrl(urlRaw) const url = normalizeUrl(urlRaw)
if (!url) return null if (!url) return null
const mediaType = detectMediaType(url, hint) const mediaType = detectMediaType(url, hint)
const vimeoEmbed = getVimeoEmbedUrl(url) const vimeoEmbed = getVimeoEmbedUrl(url)
const showPlayer =
mediaType === "image" || mediaType === "video" || mediaType === "audio"
return ( return (
<div className={className}> <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" ? ( {mediaType === "image" ? (
<img src={url} alt="preview" className="max-h-48 rounded-md border border-grayScale-200 object-contain" /> <img
src={url}
alt=""
className="max-h-52 w-full rounded-md border border-grayScale-200/90 bg-grayScale-50 object-contain"
/>
) : mediaType === "video" ? ( ) : mediaType === "video" ? (
vimeoEmbed ? ( vimeoEmbed ? (
<iframe <iframe
src={vimeoEmbed} src={vimeoEmbed}
title="Vimeo preview" title="Vimeo preview"
className="h-48 w-full rounded-md border border-grayScale-200" 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" allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen allowFullScreen
/> />
) : ( ) : (
<video controls className="max-h-56 w-full rounded-md border border-grayScale-200" src={url} /> <video
controls
className="max-h-60 w-full rounded-md border border-grayScale-200/90 bg-black/5"
src={url}
/>
) )
) : mediaType === "audio" ? ( ) : mediaType === "audio" ? (
<audio controls className="w-full" src={url} /> <audio controls className="h-9 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"> <p className="text-xs text-grayScale-500">Preview not available for this URL type.</p>
Open media URL )}
<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> </a>
</div> </div>
) )
@ -951,7 +1020,12 @@ export function HumanLanguagePage() {
</span> </span>
)} )}
{selectedLesson.video_url {selectedLesson.video_url
? renderMediaPreview(selectedLesson.video_url, "video", "mt-2") ? renderMediaPreview(
selectedLesson.video_url,
"video",
"mt-3",
"Video preview",
)
: null} : null}
</dd> </dd>
</div> </div>
@ -1015,106 +1089,191 @@ export function HumanLanguagePage() {
})} })}
</div> </div>
{cardSel.practiceId !== null && selectedPracticeMeta ? ( {cardSel.practiceId !== null && selectedPracticeMeta ? (
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/60 p-4"> <div className="overflow-hidden rounded-xl border border-grayScale-200/90 bg-gradient-to-b from-white to-grayScale-50/80 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-2"> <div className="border-b border-grayScale-100 bg-white/90 px-4 py-3.5">
<div> <div className="flex flex-wrap items-start justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-wide text-grayScale-500"> <div className="min-w-0 flex-1">
Practice questions <p className="text-[11px] font-semibold uppercase tracking-[0.08em] text-grayScale-400">
Question bank
</p> </p>
<h4 className="mt-1 text-base font-semibold text-grayScale-900"> <h4 className="mt-0.5 truncate text-base font-semibold text-grayScale-900">
{selectedPracticeMeta.title} {selectedPracticeMeta.title}
</h4> </h4>
{practiceFetch?.status === "ok" ? (
<p className="mt-1 text-xs text-grayScale-500">
{practiceFetch.totalCount}{" "}
{practiceFetch.totalCount === 1 ? "question" : "questions"} in this
practice
</p>
) : null}
</div> </div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{practiceFetch?.status === "ok" ? (
<span className="rounded-full bg-brand-50 px-2.5 py-1 text-xs font-semibold text-brand-700 ring-1 ring-inset ring-brand-100">
{practiceFetch.questions.length} loaded
</span>
) : null}
{categoryId ? ( {categoryId ? (
<Button type="button" variant="outline" size="sm" className="h-8 text-xs" asChild>
<Link <Link
to={`/content/human-language/${categoryId}/${course.course_id}/sub-module/${subModule.id}/practices/${selectedPracticeMeta.id}/questions`} to={`/content/human-language/${categoryId}/${course.course_id}/sub-module/${subModule.id}/practices/${selectedPracticeMeta.id}/questions`}
className="shrink-0 text-xs font-semibold text-brand-600 hover:underline"
> >
Edit in full view Edit in full view
</Link> </Link>
</Button>
) : null} ) : null}
</div> </div>
</div>
</div>
<div className="p-4">
{!practiceFetch || practiceFetch.status === "loading" ? ( {!practiceFetch || practiceFetch.status === "loading" ? (
<div className="mt-4 flex items-center justify-center gap-2 py-8 text-sm text-grayScale-500"> <div className="flex flex-col items-center justify-center gap-2 py-12 text-sm text-grayScale-500">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden /> <Loader2 className="h-5 w-5 animate-spin text-brand-500" aria-hidden />
Loading questions Loading questions
</div> </div>
) : practiceFetch.status === "error" ? ( ) : practiceFetch.status === "error" ? (
<div className="mt-3 space-y-2"> <div className="rounded-lg border border-red-100 bg-red-50/50 px-4 py-3">
<p className="text-sm text-red-600">{practiceFetch.message}</p> <div className="flex items-start gap-2">
<HelpCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600" aria-hidden />
<div className="space-y-2">
<p className="text-sm font-medium text-red-800">{practiceFetch.message}</p>
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="outline" variant="outline"
className="border-red-200 text-red-700 hover:bg-red-50"
onClick={() => void loadPracticeQuestionsIfNeeded(selectedPracticeMeta.id)} onClick={() => void loadPracticeQuestionsIfNeeded(selectedPracticeMeta.id)}
> >
Retry Retry
</Button> </Button>
</div> </div>
</div>
</div>
) : practiceFetch.questions.length === 0 ? ( ) : practiceFetch.questions.length === 0 ? (
<p className="mt-3 text-sm text-grayScale-500"> <div className="rounded-lg border border-dashed border-grayScale-200 bg-white px-4 py-10 text-center">
No questions yet. Add them via{" "} <ClipboardList className="mx-auto mb-2 h-8 w-8 text-grayScale-300" aria-hidden />
<span className="font-medium text-grayScale-700">Open editor</span> or{" "} <p className="text-sm text-grayScale-600">
<span className="font-medium text-grayScale-700">Edit in full view</span>. No questions in this practice yet.
</p> </p>
<p className="mt-1 text-xs text-grayScale-500">
Add them via <span className="font-medium text-grayScale-700">Open editor</span>{" "}
or <span className="font-medium text-grayScale-700">Edit in full view</span>.
</p>
</div>
) : ( ) : (
<ul className="mt-3 max-h-72 space-y-2 overflow-y-auto pr-1"> <ul className="max-h-[min(28rem,calc(100vh-16rem))] space-y-3 overflow-y-auto pr-1 [scrollbar-gutter:stable]">
{practiceFetch.questions.map((q, qIdx) => ( {practiceFetch.questions.map((q, qIdx) => {
const qType = String(q.question_type ?? "—")
const embeddedUrls = extractUrls(q.question_text || "")
return (
<li <li
key={q.question_id ?? q.id} key={q.question_id ?? q.id}
className="rounded-lg border border-grayScale-100 bg-white px-3 py-2.5 shadow-sm" className="relative overflow-hidden rounded-xl border border-grayScale-100 bg-white shadow-sm ring-1 ring-black/[0.02] transition-shadow hover:shadow-md"
> >
<div className="absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-brand-400 to-violet-500" />
<div className="flex gap-3 px-4 py-4 pl-5">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500/15 to-violet-500/15 text-sm font-bold tabular-nums text-brand-800"
aria-hidden
>
{qIdx + 1}
</div>
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-semibold tabular-nums text-grayScale-400">
#{qIdx + 1}
</span>
<Badge <Badge
variant="secondary" className={cn(
className="h-5 rounded-md px-1.5 text-[10px] font-semibold uppercase" "h-6 rounded-md px-2 py-0 text-[11px] font-semibold",
questionTypeBadgeClass(qType),
)}
> >
{String(q.question_type ?? "—").replace(/_/g, " ")} {formatQuestionTypeLabel(qType)}
</Badge> </Badge>
{q.points != null ? ( {q.points != null && q.points > 0 ? (
<span className="text-[11px] text-grayScale-500"> <span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-[11px] font-medium tabular-nums text-grayScale-700">
{q.points} pts {q.points} pts
</span> </span>
) : null} ) : null}
{q.difficulty_level ? ( {q.difficulty_level ? (
<span className="text-[11px] text-grayScale-500"> <span className="rounded-md bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-900 ring-1 ring-inset ring-amber-100">
{q.difficulty_level} {q.difficulty_level}
</span> </span>
) : null} ) : null}
</div> </div>
<p className="mt-1.5 text-sm text-grayScale-800"> <div>
{q.question_text || "—"} <p className="text-[13px] font-medium uppercase tracking-wide text-grayScale-400">
Prompt
</p> </p>
{extractUrls(q.question_text || "").map((u) => ( <p className="mt-1 text-[15px] leading-relaxed text-grayScale-900">
<div key={u}>{renderMediaPreview(u)}</div> {q.question_text?.trim() || (
<span className="italic text-grayScale-400">No prompt text</span>
)}
</p>
</div>
{embeddedUrls.length > 0 ? (
<div className="space-y-2">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-grayScale-500">
<Link2 className="h-3 w-3" aria-hidden />
Media in prompt
</p>
<div className="grid gap-2 sm:grid-cols-2">
{embeddedUrls.map((u) => (
<div key={u}>{renderMediaPreview(u, undefined, "", "Embedded link")}</div>
))} ))}
{q.tips ? ( </div>
<p className="mt-1 text-xs text-grayScale-500"> </div>
<span className="font-medium text-grayScale-600">Tip: </span>
{q.tips}
</p>
) : null} ) : null}
{q.image_url ? renderMediaPreview(q.image_url, "image") : null} {q.tips ? (
{q.voice_prompt ? renderMediaPreview(q.voice_prompt, "audio") : null} <div className="rounded-lg border border-amber-100 bg-amber-50/40 px-3 py-2.5">
{q.sample_answer_voice_prompt <p className="flex items-center gap-1.5 text-[11px] font-semibold text-amber-900">
? renderMediaPreview(q.sample_answer_voice_prompt, "audio") <Lightbulb className="h-3.5 w-3.5" aria-hidden />
Learner tip
</p>
<p className="mt-1 text-sm leading-relaxed text-amber-950/90">{q.tips}</p>
</div>
) : null}
{q.image_url ||
q.voice_prompt ||
q.sample_answer_voice_prompt ? (
<div className="space-y-2 border-t border-grayScale-100 pt-3">
<p className="text-[11px] font-semibold uppercase tracking-wide text-grayScale-500">
Assets
</p>
<div className="grid gap-2 sm:grid-cols-2">
{q.image_url
? renderMediaPreview(q.image_url, "image", "", "Image")
: null} : null}
{q.voice_prompt
? renderMediaPreview(q.voice_prompt, "audio", "", "Voice prompt")
: null}
{q.sample_answer_voice_prompt
? renderMediaPreview(
q.sample_answer_voice_prompt,
"audio",
"",
"Sample answer (audio)",
)
: null}
</div>
</div>
) : null}
</div>
</div>
</li> </li>
))} )
})}
</ul> </ul>
)} )}
{practiceFetch?.status === "ok" && {practiceFetch?.status === "ok" &&
practiceFetch.totalCount > practiceFetch.questions.length ? ( practiceFetch.totalCount > practiceFetch.questions.length ? (
<p className="mt-2 text-xs text-grayScale-500"> <div className="mt-4 rounded-lg border border-grayScale-100 bg-white/80 px-3 py-2 text-center text-xs text-grayScale-600">
Showing {practiceFetch.questions.length} of {practiceFetch.totalCount}{" "} Showing <span className="font-semibold">{practiceFetch.questions.length}</span> of{" "}
questions. Open full editor to see or edit the rest. <span className="font-semibold">{practiceFetch.totalCount}</span> questions. Open{" "}
</p> <span className="font-medium text-grayScale-800">Edit in full view</span> for the
rest.
</div>
) : null} ) : null}
</div> </div>
</div>
) : ( ) : (
<p className="text-center text-xs text-grayScale-400"> <p className="text-center text-xs text-grayScale-400">
Select a practice card to view its questions. Select a practice card to view its questions.