more UI adjustment
This commit is contained in:
parent
449b595df0
commit
3c4b0c4cd3
|
|
@ -1,6 +1,16 @@
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { ChevronDown, ChevronRight, Languages, Loader2, Plus, Search, Trash2 } from "lucide-react"
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
ClipboardList,
|
||||||
|
Languages,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Video,
|
||||||
|
} from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import {
|
import {
|
||||||
|
|
@ -12,20 +22,36 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../../components/ui/dialog"
|
} from "../../components/ui/dialog"
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
import { createCourse, createCourseCategory, createHumanLanguageLesson, deleteSubCourse, getHumanLanguageHierarchy } from "../../api/courses.api"
|
import {
|
||||||
|
createCourse,
|
||||||
|
createCourseCategory,
|
||||||
|
createHumanLanguageLesson,
|
||||||
|
deleteSubCourse,
|
||||||
|
getHumanLanguageHierarchy,
|
||||||
|
getPracticeQuestionsByPractice,
|
||||||
|
} from "../../api/courses.api"
|
||||||
import { Badge } from "../../components/ui/badge"
|
import { Badge } from "../../components/ui/badge"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
|
||||||
import type {
|
import type {
|
||||||
HumanLanguageCourseTree,
|
HumanLanguageCourseTree,
|
||||||
HumanLanguageSubCategoryTree,
|
HumanLanguageSubCategoryTree,
|
||||||
LearningPathPractice,
|
LearningPathPractice,
|
||||||
LearningPathVideo,
|
LearningPathVideo,
|
||||||
|
QuestionSetQuestion,
|
||||||
} from "../../types/course.types"
|
} from "../../types/course.types"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const
|
const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const
|
||||||
type SubModulePanelTab = "lessons" | "practices"
|
type SubModulePanelTab = "lessons" | "practices"
|
||||||
|
|
||||||
|
type SubModuleCardSelection = { lessonId: number | null; practiceId: number | null }
|
||||||
|
|
||||||
|
type PracticeQuestionsFetchState =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "ok"; questions: QuestionSetQuestion[]; totalCount: number }
|
||||||
|
| { status: "error"; message: string }
|
||||||
|
|
||||||
function formatDurationSeconds(total: number): string {
|
function formatDurationSeconds(total: number): string {
|
||||||
const s = Math.max(0, Math.floor(total))
|
const s = Math.max(0, Math.floor(total))
|
||||||
const m = Math.floor(s / 60)
|
const m = Math.floor(s / 60)
|
||||||
|
|
@ -33,13 +59,6 @@ function formatDurationSeconds(total: number): string {
|
||||||
return `${m}:${r.toString().padStart(2, "0")}`
|
return `${m}:${r.toString().padStart(2, "0")}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateMiddle(str: string, max = 42): string {
|
|
||||||
const t = str.trim()
|
|
||||||
if (t.length <= max) return t
|
|
||||||
const half = Math.floor((max - 3) / 2)
|
|
||||||
return `${t.slice(0, half)}…${t.slice(-half)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function practiceStatusStyle(status: string): string {
|
function practiceStatusStyle(status: string): string {
|
||||||
const u = status.toUpperCase()
|
const u = status.toUpperCase()
|
||||||
if (u === "PUBLISHED") return "bg-green-50 text-green-700 ring-1 ring-inset ring-green-200"
|
if (u === "PUBLISHED") return "bg-green-50 text-green-700 ring-1 ring-inset ring-green-200"
|
||||||
|
|
@ -74,8 +93,11 @@ export function HumanLanguagePage() {
|
||||||
/** Course IDs whose path body is collapsed (headers stay visible). */
|
/** Course IDs whose path body is collapsed (headers stay visible). */
|
||||||
const [collapsedPathIds, setCollapsedPathIds] = useState<number[]>([])
|
const [collapsedPathIds, setCollapsedPathIds] = useState<number[]>([])
|
||||||
const [pendingRemove, setPendingRemove] = useState<PendingRemove | null>(null)
|
const [pendingRemove, setPendingRemove] = useState<PendingRemove | null>(null)
|
||||||
/** Per sub-module panel tab (lessons table vs practices table). */
|
/** Per sub-module panel tab (lessons vs practices). */
|
||||||
const [subModulePanelTab, setSubModulePanelTab] = useState<Record<string, SubModulePanelTab>>({})
|
const [subModulePanelTab, setSubModulePanelTab] = useState<Record<string, SubModulePanelTab>>({})
|
||||||
|
/** Selected lesson / practice card per sub-module (for inline detail panel). */
|
||||||
|
const [subModuleCardSelection, setSubModuleCardSelection] = useState<Record<string, SubModuleCardSelection>>({})
|
||||||
|
const [practiceQuestionsState, setPracticeQuestionsState] = useState<Record<number, PracticeQuestionsFetchState>>({})
|
||||||
|
|
||||||
const loadHierarchy = async () => {
|
const loadHierarchy = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -326,6 +348,57 @@ export function HumanLanguagePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadPracticeQuestionsIfNeeded = async (practiceId: number) => {
|
||||||
|
let skipFetch = false
|
||||||
|
setPracticeQuestionsState((prev) => {
|
||||||
|
const ex = prev[practiceId]
|
||||||
|
if (ex?.status === "ok" || ex?.status === "loading") {
|
||||||
|
skipFetch = true
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
return { ...prev, [practiceId]: { status: "loading" } }
|
||||||
|
})
|
||||||
|
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
|
||||||
|
setPracticeQuestionsState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[practiceId]: { status: "ok", questions, totalCount },
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load practice questions:", error)
|
||||||
|
setPracticeQuestionsState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[practiceId]: { status: "error", message: "Could not load questions" },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSubModuleSelection = (smKey: string): SubModuleCardSelection =>
|
||||||
|
subModuleCardSelection[smKey] ?? { lessonId: null, practiceId: null }
|
||||||
|
|
||||||
|
const toggleLessonCard = (smKey: string, lessonId: number) => {
|
||||||
|
setSubModuleCardSelection((prev) => {
|
||||||
|
const cur = prev[smKey] ?? { lessonId: null, practiceId: null }
|
||||||
|
const nextLessonId = cur.lessonId === lessonId ? null : lessonId
|
||||||
|
return { ...prev, [smKey]: { ...cur, lessonId: nextLessonId } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePracticeCard = (smKey: string, practiceId: number) => {
|
||||||
|
let openedPracticeId: number | null = null
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="rounded-2xl border border-grayScale-200 bg-gradient-to-r from-white to-brand-50/30 p-5 shadow-sm">
|
<div className="rounded-2xl border border-grayScale-200 bg-gradient-to-r from-white to-brand-50/30 p-5 shadow-sm">
|
||||||
|
|
@ -610,12 +683,23 @@ export function HumanLanguagePage() {
|
||||||
{module.sub_modules.map((subModule) => {
|
{module.sub_modules.map((subModule) => {
|
||||||
const smKey = `${course.course_id}-${subModule.id}`
|
const smKey = `${course.course_id}-${subModule.id}`
|
||||||
const panelTab = subModulePanelTab[smKey] ?? "lessons"
|
const panelTab = subModulePanelTab[smKey] ?? "lessons"
|
||||||
|
const cardSel = getSubModuleSelection(smKey)
|
||||||
const lessonRows: LearningPathVideo[] = [...subModule.videos].sort(
|
const lessonRows: LearningPathVideo[] = [...subModule.videos].sort(
|
||||||
(a, b) => a.display_order - b.display_order,
|
(a, b) => a.display_order - b.display_order,
|
||||||
)
|
)
|
||||||
const practiceRows: LearningPathPractice[] = [...subModule.practices].sort(
|
const practiceRows: LearningPathPractice[] = [...subModule.practices].sort(
|
||||||
(a, b) => (a.display_order ?? 0) - (b.display_order ?? 0),
|
(a, b) => (a.display_order ?? 0) - (b.display_order ?? 0),
|
||||||
)
|
)
|
||||||
|
const selectedLesson =
|
||||||
|
cardSel.lessonId !== null
|
||||||
|
? lessonRows.find((v) => v.id === cardSel.lessonId) ?? null
|
||||||
|
: null
|
||||||
|
const selectedPracticeMeta =
|
||||||
|
cardSel.practiceId !== null
|
||||||
|
? practiceRows.find((p) => p.id === cardSel.practiceId) ?? null
|
||||||
|
: null
|
||||||
|
const practiceFetch =
|
||||||
|
cardSel.practiceId !== null ? practiceQuestionsState[cardSel.practiceId] : undefined
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={subModule.id}
|
key={subModule.id}
|
||||||
|
|
@ -704,46 +788,88 @@ export function HumanLanguagePage() {
|
||||||
videos.
|
videos.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<div className="space-y-3">
|
||||||
<TableHeader>
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<TableRow className="border-grayScale-100 hover:bg-transparent">
|
{lessonRows.map((v, idx) => {
|
||||||
<TableHead className="w-10 text-grayScale-500">#</TableHead>
|
const isActive = cardSel.lessonId === v.id
|
||||||
<TableHead>Title</TableHead>
|
return (
|
||||||
<TableHead className="whitespace-nowrap">Duration</TableHead>
|
<button
|
||||||
<TableHead className="whitespace-nowrap">Order</TableHead>
|
key={v.id}
|
||||||
<TableHead>Video URL</TableHead>
|
type="button"
|
||||||
</TableRow>
|
onClick={() => toggleLessonCard(smKey, v.id)}
|
||||||
</TableHeader>
|
className={cn(
|
||||||
<TableBody>
|
"flex flex-col gap-1.5 rounded-xl border bg-white p-3 text-left shadow-sm transition-all hover:border-brand-200 hover:shadow-md",
|
||||||
{lessonRows.map((v, idx) => (
|
isActive
|
||||||
<TableRow key={v.id} className="border-grayScale-100">
|
? "border-brand-400 ring-2 ring-brand-400/30"
|
||||||
<TableCell className="text-xs text-grayScale-500">{idx + 1}</TableCell>
|
: "border-grayScale-100",
|
||||||
<TableCell className="font-medium text-grayScale-900">{v.title}</TableCell>
|
)}
|
||||||
<TableCell className="tabular-nums text-grayScale-600">
|
>
|
||||||
{formatDurationSeconds(v.duration ?? 0)}
|
<div className="flex items-start gap-2">
|
||||||
</TableCell>
|
<div className="rounded-lg bg-brand-50 p-1.5 text-brand-600">
|
||||||
<TableCell className="tabular-nums text-grayScale-600">
|
<Video className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="line-clamp-2 text-sm font-semibold text-grayScale-900">
|
||||||
|
{v.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-grayScale-500">
|
||||||
|
Lesson {idx + 1} · {formatDurationSeconds(v.duration ?? 0)} · Order{" "}
|
||||||
{v.display_order}
|
{v.display_order}
|
||||||
</TableCell>
|
</p>
|
||||||
<TableCell>
|
</div>
|
||||||
{v.video_url ? (
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{selectedLesson ? (
|
||||||
|
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/60 p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Lesson content
|
||||||
|
</p>
|
||||||
|
<h4 className="mt-1 text-base font-semibold text-grayScale-900">
|
||||||
|
{selectedLesson.title}
|
||||||
|
</h4>
|
||||||
|
<dl className="mt-3 grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-grayScale-500">Display order</dt>
|
||||||
|
<dd className="font-medium text-grayScale-800">
|
||||||
|
{selectedLesson.display_order}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-grayScale-500">Duration</dt>
|
||||||
|
<dd className="tabular-nums font-medium text-grayScale-800">
|
||||||
|
{formatDurationSeconds(selectedLesson.duration ?? 0)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<dt className="text-xs text-grayScale-500">Video</dt>
|
||||||
|
<dd className="mt-0.5 break-all">
|
||||||
|
{selectedLesson.video_url ? (
|
||||||
<a
|
<a
|
||||||
href={v.video_url}
|
href={selectedLesson.video_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-mono text-xs text-brand-600 hover:underline"
|
className="text-sm font-medium text-brand-600 hover:underline"
|
||||||
title={v.video_url}
|
|
||||||
>
|
>
|
||||||
{truncateMiddle(v.video_url, 48)}
|
{selectedLesson.video_url}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-grayScale-400">—</span>
|
<span className="text-sm text-grayScale-400">
|
||||||
|
No video URL set — use Open editor to add one.
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</dd>
|
||||||
</TableRow>
|
</div>
|
||||||
))}
|
</dl>
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
) : (
|
||||||
|
<p className="text-center text-xs text-grayScale-400">
|
||||||
|
Select a lesson card to view full content.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
) : practiceRows.length === 0 ? (
|
) : practiceRows.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/40 px-4 py-8 text-center text-sm text-grayScale-500">
|
<div className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/40 px-4 py-8 text-center text-sm text-grayScale-500">
|
||||||
|
|
@ -752,55 +878,138 @@ export function HumanLanguagePage() {
|
||||||
practice.
|
practice.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<div className="space-y-3">
|
||||||
<TableHeader>
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<TableRow className="border-grayScale-100 hover:bg-transparent">
|
{practiceRows.map((p, pIdx) => {
|
||||||
<TableHead className="w-10 text-grayScale-500">#</TableHead>
|
const isActive = cardSel.practiceId === p.id
|
||||||
<TableHead>Title</TableHead>
|
return (
|
||||||
<TableHead className="whitespace-nowrap">Status</TableHead>
|
<button
|
||||||
<TableHead className="whitespace-nowrap text-right">Questions</TableHead>
|
key={p.id}
|
||||||
<TableHead className="whitespace-nowrap">Order</TableHead>
|
type="button"
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
onClick={() => togglePracticeCard(smKey, p.id)}
|
||||||
</TableRow>
|
className={cn(
|
||||||
</TableHeader>
|
"flex flex-col gap-1.5 rounded-xl border bg-white p-3 text-left shadow-sm transition-all hover:border-brand-200 hover:shadow-md",
|
||||||
<TableBody>
|
isActive
|
||||||
{practiceRows.map((p, idx) => (
|
? "border-brand-400 ring-2 ring-brand-400/30"
|
||||||
<TableRow key={p.id} className="border-grayScale-100">
|
: "border-grayScale-100",
|
||||||
<TableCell className="text-xs text-grayScale-500">{idx + 1}</TableCell>
|
)}
|
||||||
<TableCell className="max-w-[220px]">
|
>
|
||||||
<p className="truncate font-medium text-grayScale-900" title={p.title}>
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="rounded-lg bg-violet-50 p-1.5 text-violet-600">
|
||||||
|
<ClipboardList className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="line-clamp-2 text-sm font-semibold text-grayScale-900">
|
||||||
{p.title}
|
{p.title}
|
||||||
</p>
|
</p>
|
||||||
</TableCell>
|
<p className="mt-0.5 text-[11px] text-grayScale-500">
|
||||||
<TableCell>
|
Practice {pIdx + 1}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
<Badge
|
<Badge
|
||||||
className={`rounded-full px-2 py-0.5 text-[11px] font-semibold capitalize ${practiceStatusStyle(p.status)}`}
|
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold capitalize ${practiceStatusStyle(p.status)}`}
|
||||||
>
|
>
|
||||||
{(p.status ?? "—").replace(/_/g, " ").toLowerCase()}
|
{(p.status ?? "—").replace(/_/g, " ").toLowerCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
<span className="text-[11px] text-grayScale-500">
|
||||||
<TableCell className="text-right tabular-nums text-grayScale-700">
|
{p.question_count} Q · order {p.display_order ?? "—"}
|
||||||
{p.question_count}
|
</span>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="tabular-nums text-grayScale-600">
|
</div>
|
||||||
{p.display_order ?? "—"}
|
</div>
|
||||||
</TableCell>
|
</button>
|
||||||
<TableCell className="text-right">
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{cardSel.practiceId !== null && selectedPracticeMeta ? (
|
||||||
|
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/60 p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Practice questions
|
||||||
|
</p>
|
||||||
|
<h4 className="mt-1 text-base font-semibold text-grayScale-900">
|
||||||
|
{selectedPracticeMeta.title}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
</div>
|
||||||
{categoryId ? (
|
{categoryId ? (
|
||||||
<Link
|
<Link
|
||||||
to={`/content/human-language/${categoryId}/${course.course_id}/sub-module/${subModule.id}/practices/${p.id}/questions`}
|
to={`/content/human-language/${categoryId}/${course.course_id}/sub-module/${subModule.id}/practices/${selectedPracticeMeta.id}/questions`}
|
||||||
className="text-xs font-semibold text-brand-600 hover:text-brand-700 hover:underline"
|
className="shrink-0 text-xs font-semibold text-brand-600 hover:underline"
|
||||||
>
|
>
|
||||||
Questions
|
Edit in full view
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{!practiceFetch || practiceFetch.status === "loading" ? (
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-2 py-8 text-sm text-grayScale-500">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||||
|
Loading questions…
|
||||||
|
</div>
|
||||||
|
) : practiceFetch.status === "error" ? (
|
||||||
|
<p className="mt-3 text-sm text-red-600">{practiceFetch.message}</p>
|
||||||
|
) : practiceFetch.questions.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-grayScale-500">
|
||||||
|
No questions yet. 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>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-grayScale-400">—</span>
|
<ul className="mt-3 max-h-72 space-y-2 overflow-y-auto pr-1">
|
||||||
)}
|
{practiceFetch.questions.map((q, qIdx) => (
|
||||||
</TableCell>
|
<li
|
||||||
</TableRow>
|
key={q.question_id ?? q.id}
|
||||||
|
className="rounded-lg border border-grayScale-100 bg-white px-3 py-2.5 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[11px] font-semibold tabular-nums text-grayScale-400">
|
||||||
|
#{qIdx + 1}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="h-5 rounded-md px-1.5 text-[10px] font-semibold uppercase"
|
||||||
|
>
|
||||||
|
{String(q.question_type ?? "—").replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
{q.points != null ? (
|
||||||
|
<span className="text-[11px] text-grayScale-500">
|
||||||
|
{q.points} pts
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{q.difficulty_level ? (
|
||||||
|
<span className="text-[11px] text-grayScale-500">
|
||||||
|
{q.difficulty_level}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-sm text-grayScale-800">
|
||||||
|
{q.question_text || "—"}
|
||||||
|
</p>
|
||||||
|
{q.tips ? (
|
||||||
|
<p className="mt-1 text-xs text-grayScale-500">
|
||||||
|
<span className="font-medium text-grayScale-600">Tip: </span>
|
||||||
|
{q.tips}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</ul>
|
||||||
</Table>
|
)}
|
||||||
|
{practiceFetch?.status === "ok" &&
|
||||||
|
practiceFetch.totalCount > practiceFetch.questions.length ? (
|
||||||
|
<p className="mt-2 text-xs text-grayScale-500">
|
||||||
|
Showing {practiceFetch.questions.length} of {practiceFetch.totalCount}{" "}
|
||||||
|
questions. Open full editor to see or edit the rest.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-xs text-grayScale-400">
|
||||||
|
Select a practice card to view its questions.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user