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:
parent
51a14ad975
commit
4166fe0807
|
|
@ -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,105 +1089,190 @@ 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">
|
||||||
</p>
|
Question bank
|
||||||
<h4 className="mt-1 text-base font-semibold text-grayScale-900">
|
</p>
|
||||||
{selectedPracticeMeta.title}
|
<h4 className="mt-0.5 truncate text-base font-semibold text-grayScale-900">
|
||||||
</h4>
|
{selectedPracticeMeta.title}
|
||||||
|
</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 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 ? (
|
||||||
|
<Button type="button" variant="outline" size="sm" className="h-8 text-xs" asChild>
|
||||||
|
<Link
|
||||||
|
to={`/content/human-language/${categoryId}/${course.course_id}/sub-module/${subModule.id}/practices/${selectedPracticeMeta.id}/questions`}
|
||||||
|
>
|
||||||
|
Edit in full view
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{categoryId ? (
|
|
||||||
<Link
|
|
||||||
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
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</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">
|
||||||
<Button
|
<HelpCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600" aria-hidden />
|
||||||
type="button"
|
<div className="space-y-2">
|
||||||
size="sm"
|
<p className="text-sm font-medium text-red-800">{practiceFetch.message}</p>
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() => void loadPracticeQuestionsIfNeeded(selectedPracticeMeta.id)}
|
type="button"
|
||||||
>
|
size="sm"
|
||||||
Retry
|
variant="outline"
|
||||||
</Button>
|
className="border-red-200 text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => void loadPracticeQuestionsIfNeeded(selectedPracticeMeta.id)}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</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) => {
|
||||||
<li
|
const qType = String(q.question_type ?? "—")
|
||||||
key={q.question_id ?? q.id}
|
const embeddedUrls = extractUrls(q.question_text || "")
|
||||||
className="rounded-lg border border-grayScale-100 bg-white px-3 py-2.5 shadow-sm"
|
return (
|
||||||
>
|
<li
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
key={q.question_id ?? q.id}
|
||||||
<span className="text-[11px] font-semibold tabular-nums text-grayScale-400">
|
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"
|
||||||
#{qIdx + 1}
|
>
|
||||||
</span>
|
<div className="absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-brand-400 to-violet-500" />
|
||||||
<Badge
|
<div className="flex gap-3 px-4 py-4 pl-5">
|
||||||
variant="secondary"
|
<div
|
||||||
className="h-5 rounded-md px-1.5 text-[10px] font-semibold uppercase"
|
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
|
||||||
{String(q.question_type ?? "—").replace(/_/g, " ")}
|
>
|
||||||
</Badge>
|
{qIdx + 1}
|
||||||
{q.points != null ? (
|
</div>
|
||||||
<span className="text-[11px] text-grayScale-500">
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
{q.points} pts
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</span>
|
<Badge
|
||||||
) : null}
|
className={cn(
|
||||||
{q.difficulty_level ? (
|
"h-6 rounded-md px-2 py-0 text-[11px] font-semibold",
|
||||||
<span className="text-[11px] text-grayScale-500">
|
questionTypeBadgeClass(qType),
|
||||||
{q.difficulty_level}
|
)}
|
||||||
</span>
|
>
|
||||||
) : null}
|
{formatQuestionTypeLabel(qType)}
|
||||||
</div>
|
</Badge>
|
||||||
<p className="mt-1.5 text-sm text-grayScale-800">
|
{q.points != null && q.points > 0 ? (
|
||||||
{q.question_text || "—"}
|
<span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-[11px] font-medium tabular-nums text-grayScale-700">
|
||||||
</p>
|
{q.points} pts
|
||||||
{extractUrls(q.question_text || "").map((u) => (
|
</span>
|
||||||
<div key={u}>{renderMediaPreview(u)}</div>
|
) : null}
|
||||||
))}
|
{q.difficulty_level ? (
|
||||||
{q.tips ? (
|
<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">
|
||||||
<p className="mt-1 text-xs text-grayScale-500">
|
{q.difficulty_level}
|
||||||
<span className="font-medium text-grayScale-600">Tip: </span>
|
</span>
|
||||||
{q.tips}
|
) : null}
|
||||||
</p>
|
</div>
|
||||||
) : null}
|
<div>
|
||||||
{q.image_url ? renderMediaPreview(q.image_url, "image") : null}
|
<p className="text-[13px] font-medium uppercase tracking-wide text-grayScale-400">
|
||||||
{q.voice_prompt ? renderMediaPreview(q.voice_prompt, "audio") : null}
|
Prompt
|
||||||
{q.sample_answer_voice_prompt
|
</p>
|
||||||
? renderMediaPreview(q.sample_answer_voice_prompt, "audio")
|
<p className="mt-1 text-[15px] leading-relaxed text-grayScale-900">
|
||||||
: null}
|
{q.question_text?.trim() || (
|
||||||
</li>
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{q.tips ? (
|
||||||
|
<div className="rounded-lg border border-amber-100 bg-amber-50/40 px-3 py-2.5">
|
||||||
|
<p className="flex items-center gap-1.5 text-[11px] font-semibold text-amber-900">
|
||||||
|
<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}
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user