Yimaru-Admin/src/pages/content-management/HumanLanguagePage.tsx
Yared Yemane cd7d330261 Align Human Language edit-question UI with create flow
Defer opening the dialog until details load; improve API mapping for true/false and types; add audio answer field; show edit loading on the row action.

Made-with: Cursor
2026-04-07 10:29:20 -07:00

2170 lines
116 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from "react"
import { Link } from "react-router-dom"
import {
ChevronDown,
ChevronRight,
ClipboardList,
HelpCircle,
Image as ImageIcon,
Languages,
Lightbulb,
Link2,
Loader2,
Mic,
Plus,
Search,
Trash2,
Video,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import {
addQuestionToSet,
createPractice,
createQuestion,
createCourse,
createCourseCategory,
createHumanLanguageLesson,
deletePractice,
deleteQuestion,
deleteSubCourse,
getHumanLanguageHierarchy,
getQuestionById,
getPracticeQuestions,
getPracticeQuestionsByPractice,
updatePractice,
updateQuestion,
} from "../../api/courses.api"
import { Badge } from "../../components/ui/badge"
import type {
CreateQuestionRequest,
HumanLanguageCourseTree,
HumanLanguageSubCategoryTree,
LearningPathPractice,
LearningPathVideo,
QuestionDetail,
QuestionSetQuestion,
} from "../../types/course.types"
import { cn } from "../../lib/utils"
import { toast } from "sonner"
const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const
type SubModulePanelTab = "lessons" | "practices"
type SubModuleCardSelection = { lessonId: number | null; practiceId: number | null }
type PracticeQuestionsFetchState =
| { status: "idle" }
| { status: "loading"; startedAt: number }
| { status: "ok"; questions: QuestionSetQuestion[]; totalCount: number }
| { status: "error"; message: string }
type PracticeDialogState =
| { open: false }
| {
open: true
mode: "create" | "edit"
subModuleId: number
practiceId?: number
}
type QuestionDialogState =
| { open: false }
| {
open: true
mode: "create" | "edit"
practiceId: number
questionId?: number
}
function formatDurationSeconds(total: number): string {
const s = Math.max(0, Math.floor(total))
const m = Math.floor(s / 60)
const r = s % 60
return `${m}:${r.toString().padStart(2, "0")}`
}
function practiceStatusStyle(status: string): string {
const u = status.toUpperCase()
if (u === "PUBLISHED") return "bg-green-50 text-green-700 ring-1 ring-inset ring-green-200"
if (u === "DRAFT") return "bg-grayScale-50 text-grayScale-600 ring-1 ring-inset ring-grayScale-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"
}
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
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 = {
ids: number[]
key: string
successMessage: string
title: string
description: string
}
export function HumanLanguagePage() {
const [loading, setLoading] = useState(false)
const [categoryId, setCategoryId] = useState<number | null>(null)
const [subCategories, setSubCategories] = useState<HumanLanguageSubCategoryTree[]>([])
const [selectedSubCategoryId, setSelectedSubCategoryId] = useState<number | "ALL">("ALL")
const [selectedCourseId, setSelectedCourseId] = useState<number | "ALL">("ALL")
const [selectedLevel, setSelectedLevel] = useState<CefrLevel | "ALL">("ALL")
const [collapsedLevels, setCollapsedLevels] = useState<string[]>([])
const [creatingKey, setCreatingKey] = useState<string | null>(null)
const [quickSubCategoryName, setQuickSubCategoryName] = useState("")
const [quickCourseName, setQuickCourseName] = useState("")
const [quickSearch, setQuickSearch] = useState("")
const [quickCreating, setQuickCreating] = useState(false)
const [deletingKey, setDeletingKey] = useState<string | null>(null)
/** Course IDs whose path body is collapsed (headers stay visible). */
const [collapsedPathIds, setCollapsedPathIds] = useState<number[]>([])
const [pendingRemove, setPendingRemove] = useState<PendingRemove | null>(null)
/** Per sub-module panel tab (lessons vs practices). */
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 [practiceDialog, setPracticeDialog] = useState<PracticeDialogState>({ open: false })
const [questionDialog, setQuestionDialog] = useState<QuestionDialogState>({ open: false })
const [practiceForm, setPracticeForm] = useState({ title: "", description: "", persona: "" })
const [questionForm, setQuestionForm] = useState({
questionText: "",
questionType: "MCQ" as "MCQ" | "TRUE_FALSE" | "SHORT",
difficulty: "EASY",
points: 1,
tips: "",
explanation: "",
imageUrl: "",
voicePrompt: "",
sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "",
optionA: "",
optionB: "",
optionC: "",
optionD: "",
correctOption: "A" as "A" | "B" | "C" | "D",
shortAnswer: "",
})
const [questionDetailById, setQuestionDetailById] = useState<Record<number, QuestionDetail>>({})
const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null)
const [questionTargetDelete, setQuestionTargetDelete] = useState<{ id: number; practiceId: number; text: string } | null>(null)
const [savingPractice, setSavingPractice] = useState(false)
const [savingQuestion, setSavingQuestion] = useState(false)
const [deletingPractice, setDeletingPractice] = useState(false)
const [deletingQuestion, setDeletingQuestion] = useState(false)
/** While fetching full question detail before opening the edit dialog (avoids empty form flash). */
const [loadingQuestionEditId, setLoadingQuestionEditId] = useState<number | null>(null)
/** Show inline field errors only after an explicit save attempt (avoids noisy empty-state on open). */
const [practiceSubmitAttempted, setPracticeSubmitAttempted] = useState(false)
const [questionSubmitAttempted, setQuestionSubmitAttempted] = useState(false)
const [practiceFormTouched, setPracticeFormTouched] = useState(false)
const [questionFormTouched, setQuestionFormTouched] = useState(false)
const renderMediaPreview = (
urlRaw: string,
hint?: "audio" | "video" | "image",
className = "mt-2",
label?: string,
) => {
const url = normalizeUrl(urlRaw)
if (!url) return null
const mediaType = detectMediaType(url, hint)
const vimeoEmbed = getVimeoEmbedUrl(url)
const showPlayer =
mediaType === "image" || mediaType === "video" || mediaType === "audio"
return (
<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" ? (
<img
src={url}
alt=""
className="max-h-52 w-full rounded-md border border-grayScale-200/90 bg-grayScale-50 object-contain"
/>
) : mediaType === "video" ? (
vimeoEmbed ? (
<iframe
src={vimeoEmbed}
title="Vimeo preview"
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"
allowFullScreen
/>
) : (
<video
controls
className="max-h-60 w-full rounded-md border border-grayScale-200/90 bg-black/5"
src={url}
/>
)
) : mediaType === "audio" ? (
<audio controls className="h-9 w-full" src={url} />
) : (
<p className="text-xs text-grayScale-500">Preview not available for this URL type.</p>
)}
<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>
</div>
)
}
const loadHierarchy = async () => {
setLoading(true)
try {
const res = await getHumanLanguageHierarchy()
const data = res.data?.data
setCategoryId(data?.category_id ?? null)
setSubCategories(data?.sub_categories ?? [])
} finally {
setLoading(false)
}
}
useEffect(() => {
const run = async () => {
setLoading(true)
try {
await loadHierarchy()
} finally {
setLoading(false)
}
}
run().catch(() => undefined)
}, [])
const filteredSubCategories = useMemo(
() =>
selectedSubCategoryId === "ALL"
? subCategories
: subCategories.filter((s) => s.sub_category_id === selectedSubCategoryId),
[subCategories, selectedSubCategoryId],
)
const availableCourses = useMemo(() => {
return filteredSubCategories.flatMap((s) => s.courses)
}, [filteredSubCategories])
const selectedCourses = useMemo(
() =>
selectedCourseId === "ALL"
? availableCourses
: availableCourses.filter((c) => c.course_id === selectedCourseId),
[availableCourses, selectedCourseId],
)
/** A1 always; A2C3 only after that level has at least one module (incremental UI). */
const visibleCefrLevels = useMemo(() => {
if (availableCourses.length === 0) return [] as CefrLevel[]
const out: CefrLevel[] = []
for (const level of CEFR_LEVELS) {
if (level === "A1") {
out.push(level)
continue
}
const hasContent = selectedCourses.some((c) => {
const node = c.levels.find((item) => item.level.toUpperCase() === level)
return node !== undefined && (node.modules?.length ?? 0) > 0
})
if (hasContent) out.push(level)
}
return out
}, [availableCourses.length, selectedCourses])
useEffect(() => {
if (selectedLevel === "ALL") return
if (!visibleCefrLevels.includes(selectedLevel)) {
setSelectedLevel("ALL")
}
}, [selectedLevel, visibleCefrLevels])
const toggleLevel = (levelKey: string) => {
setCollapsedLevels((prev) => (prev.includes(levelKey) ? prev.filter((l) => l !== levelKey) : [...prev, levelKey]))
}
const togglePathCollapsed = (courseId: number) => {
setCollapsedPathIds((prev) =>
prev.includes(courseId) ? prev.filter((id) => id !== courseId) : [...prev, courseId],
)
}
const levelsWithContentForCourse = (course: HumanLanguageCourseTree) =>
course.levels.filter((l) => (l.modules?.length ?? 0) > 0).map((l) => l.level.toUpperCase())
const parseModuleNumber = (title: string): number | null => {
const match = title.match(/module-(\d+)/i)
if (!match) return null
const value = Number(match[1])
return Number.isFinite(value) ? value : null
}
const parseSubModuleNumber = (title: string): { module: number; sub: number } | null => {
const match = title.match(/(?:sub-)?module-(\d+)\.(\d+)/i)
if (!match) return null
const module = Number(match[1])
const sub = Number(match[2])
if (!Number.isFinite(module) || !Number.isFinite(sub)) return null
return { module, sub }
}
const handleCreateModule = async (courseId: number, level: string, modules: { title: string }[]) => {
const key = `module-${courseId}-${level}`
setCreatingKey(key)
try {
const maxExisting = modules
.map((m) => parseModuleNumber(m.title))
.filter((v): v is number => v !== null)
.reduce((acc, n) => Math.max(acc, n), 0)
const next = maxExisting + 1
const title = `Module-${next}`
await createHumanLanguageLesson({
course_id: courseId,
cefr_level: level,
title,
description: `${level} ${title}`,
})
toast.success(`${title} created`)
await loadHierarchy()
} catch (error) {
console.error("Failed to create module:", error)
toast.error("Failed to create module")
} finally {
setCreatingKey(null)
}
}
const handleCreateSubModule = async (
courseId: number,
level: string,
moduleTitle: string,
existingSubModules: { title: string }[],
) => {
const moduleNo = parseModuleNumber(moduleTitle)
if (!moduleNo) {
toast.error("Cannot derive module number from title")
return
}
const key = `submodule-${courseId}-${level}-${moduleNo}`
setCreatingKey(key)
try {
const maxExisting = existingSubModules
.map((s) => parseSubModuleNumber(s.title))
.filter((v): v is { module: number; sub: number } => v !== null && v.module === moduleNo)
.reduce((acc, item) => Math.max(acc, item.sub), 0)
const next = maxExisting + 1
const title = `Module-${moduleNo}.${next}`
await createHumanLanguageLesson({
course_id: courseId,
cefr_level: level,
title,
description: `${level} ${title}`,
})
toast.success(`Sub-module ${moduleNo}.${next} created`)
await loadHierarchy()
} catch (error) {
console.error("Failed to create sub-module:", error)
toast.error("Failed to create sub-module")
} finally {
setCreatingKey(null)
}
}
const requestRemove = (payload: PendingRemove) => {
if (payload.ids.length === 0) return
setPendingRemove(payload)
}
const executePendingRemove = async () => {
if (!pendingRemove) return
const { ids, key, successMessage } = pendingRemove
setPendingRemove(null)
setDeletingKey(key)
try {
for (const id of ids) {
await deleteSubCourse(id)
}
toast.success(successMessage)
await loadHierarchy()
} catch (error) {
console.error("Failed to delete item(s):", error)
toast.error("Failed to delete item(s)")
} finally {
setDeletingKey(null)
}
}
const handleCreateNextLevelForCourse = async (courseId: number) => {
const course = availableCourses.find((c) => c.course_id === courseId)
if (!course) {
toast.error("Course not found")
return
}
const existing = new Set(levelsWithContentForCourse(course))
const next = CEFR_LEVELS.find((level) => !existing.has(level))
if (!next) {
toast.error("All CEFR levels (A1C3) already have content for this path")
return
}
const key = `next-level-${courseId}-${next}`
setCreatingKey(key)
try {
await createHumanLanguageLesson({
course_id: courseId,
cefr_level: next,
title: "Module-1",
description: `${next} Module-1`,
})
toast.success(`${next} created with Module-1`)
await loadHierarchy()
} catch (error) {
console.error("Failed to create next level:", error)
toast.error("Failed to create next level")
} finally {
setCreatingKey(null)
}
}
const handleQuickCreatePath = async () => {
if (!quickSubCategoryName.trim() || !quickCourseName.trim()) {
toast.error("Subcategory and course names are required")
return
}
setQuickCreating(true)
try {
let effectiveCategoryId = categoryId
if (!effectiveCategoryId) {
const createdCategory = await createCourseCategory({ name: "Human Language" })
effectiveCategoryId = createdCategory.data?.data?.id ?? null
setCategoryId(effectiveCategoryId)
}
if (!effectiveCategoryId) {
throw new Error("Missing human language category id")
}
const title = `${quickSubCategoryName.trim()} - ${quickCourseName.trim()}`
await createCourse({
category_id: effectiveCategoryId,
title,
description: `${quickSubCategoryName.trim()} / ${quickCourseName.trim()}`,
})
toast.success("Subcategory/course path created")
setQuickSubCategoryName("")
setQuickCourseName("")
await loadHierarchy()
} catch (error) {
console.error("Failed to quick-create language path:", error)
toast.error("Failed to create subcategory/course path")
} finally {
setQuickCreating(false)
}
}
const loadPracticeQuestionsIfNeeded = async (practiceId: number, forceRefresh = false) => {
let skipFetch = false
setPracticeQuestionsState((prev) => {
const ex = prev[practiceId]
if (!forceRefresh && ex?.status === "ok") {
skipFetch = true
return prev
}
if (!forceRefresh && ex?.status === "loading" && Date.now() - ex.startedAt < 15000) {
skipFetch = true
return prev
}
return { ...prev, [practiceId]: { status: "loading", startedAt: Date.now() } }
})
if (skipFetch) return
try {
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 },
}))
} 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 resetPracticeForm = () => setPracticeForm({ title: "", description: "", persona: "" })
const resetQuestionForm = () =>
setQuestionForm({
questionText: "",
questionType: "MCQ",
difficulty: "EASY",
points: 1,
tips: "",
explanation: "",
imageUrl: "",
voicePrompt: "",
sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "",
optionA: "",
optionB: "",
optionC: "",
optionD: "",
correctOption: "A",
shortAnswer: "",
})
const openCreatePracticeDialog = (subModuleId: number) => {
setPracticeSubmitAttempted(false)
setPracticeFormTouched(false)
resetPracticeForm()
setPracticeDialog({ open: true, mode: "create", subModuleId })
}
const openEditPracticeDialog = (subModuleId: number, p: LearningPathPractice) => {
setPracticeSubmitAttempted(false)
setPracticeFormTouched(false)
setPracticeForm({ title: p.title ?? "", description: "", persona: "" })
setPracticeDialog({ open: true, mode: "edit", subModuleId, practiceId: p.id })
}
const practiceFieldErrors = useMemo(() => {
const title = practiceForm.title.trim()
return {
title: title ? undefined : "Title is required.",
}
}, [practiceForm.title])
const practiceCanSave = !practiceFieldErrors.title
const handleSavePractice = async () => {
if (!practiceDialog.open) return
if (!practiceCanSave) {
setPracticeSubmitAttempted(true)
return
}
setSavingPractice(true)
try {
if (practiceDialog.mode === "create") {
await createPractice({
sub_course_id: practiceDialog.subModuleId,
title: practiceForm.title.trim(),
description: practiceForm.description.trim(),
persona: practiceForm.persona.trim() || undefined,
})
toast.success("Practice created")
} else if (practiceDialog.practiceId) {
await updatePractice(practiceDialog.practiceId, {
title: practiceForm.title.trim(),
description: practiceForm.description.trim(),
persona: practiceForm.persona.trim() || undefined,
})
toast.success("Practice updated")
}
setPracticeDialog({ open: false })
setPracticeSubmitAttempted(false)
setPracticeFormTouched(false)
resetPracticeForm()
await loadHierarchy()
} catch (error) {
console.error("Failed to save practice:", error)
toast.error("Failed to save practice")
} finally {
setSavingPractice(false)
}
}
const openCreateQuestionDialog = (practiceId: number) => {
setQuestionSubmitAttempted(false)
setQuestionFormTouched(false)
resetQuestionForm()
setQuestionDialog({ open: true, mode: "create", practiceId })
}
const openEditQuestionDialog = async (practiceId: number, question: QuestionSetQuestion) => {
setQuestionSubmitAttempted(false)
setQuestionFormTouched(false)
const qid = question.question_id ?? question.id
resetQuestionForm()
setLoadingQuestionEditId(qid)
try {
const detail = questionDetailById[qid] ?? (await getQuestionById(qid)).data?.data
if (!detail) {
toast.error("Could not load question details")
return
}
setQuestionDetailById((prev) => ({ ...prev, [qid]: detail }))
const options = (detail.options ?? []).slice().sort((a, b) => a.option_order - b.option_order)
const correctOpt = options.find((o) => o.is_correct)
const correctOrder = correctOpt?.option_order ?? 1
let correctOption: "A" | "B" | "C" | "D" = "A"
if (detail.question_type === "TRUE_FALSE") {
const t = (correctOpt?.option_text ?? "").trim().toLowerCase()
if (t === "false" || correctOrder === 2) correctOption = "B"
else correctOption = "A"
} else {
correctOption =
(["A", "B", "C", "D"][Math.min(Math.max(correctOrder - 1, 0), 3)] as "A" | "B" | "C" | "D") ?? "A"
}
const shortAnswer =
Array.isArray(detail.short_answers) && detail.short_answers.length > 0
? typeof detail.short_answers[0] === "string"
? detail.short_answers[0]
: detail.short_answers[0]?.acceptable_answer ?? ""
: ""
const qt = detail.question_type
let questionType: "MCQ" | "TRUE_FALSE" | "SHORT" = "MCQ"
if (qt === "TRUE_FALSE") questionType = "TRUE_FALSE"
else if (qt === "SHORT" || qt === "SHORT_ANSWER" || qt === "AUDIO") questionType = "SHORT"
const difficultyRaw = detail.difficulty_level
const difficulty =
difficultyRaw === "EASY" || difficultyRaw === "MEDIUM" || difficultyRaw === "HARD" ? difficultyRaw : "EASY"
setQuestionForm({
questionText: detail.question_text ?? "",
questionType,
difficulty,
points: detail.points && detail.points > 0 ? detail.points : 1,
tips: detail.tips ?? "",
explanation: detail.explanation ?? "",
imageUrl: detail.image_url ?? "",
voicePrompt: detail.voice_prompt ?? "",
sampleAnswerVoicePrompt: detail.sample_answer_voice_prompt ?? "",
audioCorrectAnswerText: detail.audio_correct_answer_text ?? "",
optionA: options[0]?.option_text ?? "",
optionB: options[1]?.option_text ?? "",
optionC: options[2]?.option_text ?? "",
optionD: options[3]?.option_text ?? "",
correctOption,
shortAnswer,
})
// Open only after the same form shape as create is fully populated (no empty-state flash).
setQuestionDialog({ open: true, mode: "edit", practiceId, questionId: qid })
} catch (error) {
console.error("Failed to load question detail:", error)
toast.error("Could not load question details")
} finally {
setLoadingQuestionEditId(null)
}
}
const buildQuestionPayload = (): CreateQuestionRequest => {
const payload: CreateQuestionRequest = {
question_text: questionForm.questionText.trim(),
question_type: questionForm.questionType,
difficulty_level: questionForm.difficulty,
points: Number(questionForm.points) || 1,
tips: questionForm.tips.trim() || undefined,
explanation: questionForm.explanation.trim() || undefined,
image_url: questionForm.imageUrl.trim() || undefined,
voice_prompt: questionForm.voicePrompt.trim() || undefined,
sample_answer_voice_prompt: questionForm.sampleAnswerVoicePrompt.trim() || undefined,
audio_correct_answer_text: questionForm.audioCorrectAnswerText.trim() || undefined,
status: "PUBLISHED",
}
if (questionForm.questionType === "SHORT") {
payload.short_answers = questionForm.shortAnswer.trim()
? [
{ acceptable_answer: questionForm.shortAnswer.trim(), match_type: "EXACT" },
{ acceptable_answer: questionForm.shortAnswer.trim(), match_type: "CASE_INSENSITIVE" },
]
: undefined
return payload
}
const options =
questionForm.questionType === "TRUE_FALSE"
? [
{ option_order: 1, option_text: "True", is_correct: questionForm.correctOption === "A" },
{ option_order: 2, option_text: "False", is_correct: questionForm.correctOption === "B" },
]
: [
{ option_order: 1, option_text: questionForm.optionA.trim(), is_correct: questionForm.correctOption === "A" },
{ option_order: 2, option_text: questionForm.optionB.trim(), is_correct: questionForm.correctOption === "B" },
{ option_order: 3, option_text: questionForm.optionC.trim(), is_correct: questionForm.correctOption === "C" },
{ option_order: 4, option_text: questionForm.optionD.trim(), is_correct: questionForm.correctOption === "D" },
].filter((o) => o.option_text)
payload.options = options
return payload
}
const questionFieldErrors = useMemo(() => {
const errors: {
questionText?: string
points?: string
shortAnswer?: string
options?: string
correctOption?: string
} = {}
if (!questionForm.questionText.trim()) errors.questionText = "Question text is required."
const pts = Number(questionForm.points)
if (!Number.isFinite(pts) || pts < 1) errors.points = "Enter a valid number (minimum 1)."
if (questionForm.questionType === "SHORT" && !questionForm.shortAnswer.trim()) {
errors.shortAnswer = "Expected answer is required for short-answer questions."
}
if (questionForm.questionType === "MCQ") {
const opts = {
A: questionForm.optionA.trim(),
B: questionForm.optionB.trim(),
C: questionForm.optionC.trim(),
D: questionForm.optionD.trim(),
}
const filled = Object.values(opts).filter(Boolean).length
if (filled < 2) errors.options = "Enter at least two non-empty options."
const correct = questionForm.correctOption
if (opts[correct] === "") errors.correctOption = "The marked correct option must include text."
}
return errors
}, [questionForm])
const questionCanSave = Object.keys(questionFieldErrors).length === 0
const handleSaveQuestion = async () => {
if (!questionDialog.open) return
if (!questionCanSave) {
setQuestionSubmitAttempted(true)
return
}
setSavingQuestion(true)
try {
const payload = buildQuestionPayload()
if (questionDialog.mode === "create") {
const created = await createQuestion(payload)
const questionId = created.data?.data?.id
if (!questionId) throw new Error("Missing created question id")
await addQuestionToSet(questionDialog.practiceId, { question_id: questionId })
toast.success("Question created")
} else if (questionDialog.questionId) {
await updateQuestion(questionDialog.questionId, payload)
toast.success("Question updated")
}
setQuestionDialog({ open: false })
setQuestionSubmitAttempted(false)
setQuestionFormTouched(false)
resetQuestionForm()
await Promise.all([
loadPracticeQuestionsIfNeeded(questionDialog.practiceId, true),
loadHierarchy(),
])
} catch (error) {
console.error("Failed to save question:", error)
toast.error("Failed to save question")
} finally {
setSavingQuestion(false)
}
}
const handleDeletePracticeConfirmed = async () => {
if (!practiceTargetDelete) return
setDeletingPractice(true)
try {
await deletePractice(practiceTargetDelete.id)
toast.success("Practice deleted")
setPracticeTargetDelete(null)
await loadHierarchy()
} catch (error) {
console.error("Failed to delete practice:", error)
toast.error("Failed to delete practice")
} finally {
setDeletingPractice(false)
}
}
const handleDeleteQuestionConfirmed = async () => {
if (!questionTargetDelete) return
setDeletingQuestion(true)
try {
await deleteQuestion(questionTargetDelete.id)
toast.success("Question deleted")
await Promise.all([
loadPracticeQuestionsIfNeeded(questionTargetDelete.practiceId, true),
loadHierarchy(),
])
setQuestionTargetDelete(null)
} catch (error) {
console.error("Failed to delete question:", error)
toast.error("Failed to delete question")
} finally {
setDeletingQuestion(false)
}
}
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) => {
const currentPracticeId = subModuleCardSelection[smKey]?.practiceId ?? null
const nextPracticeId = currentPracticeId === practiceId ? null : practiceId
setSubModuleCardSelection((prev) => {
const cur = prev[smKey] ?? { lessonId: null, practiceId: null }
return { ...prev, [smKey]: { ...cur, practiceId: nextPracticeId } }
})
if (nextPracticeId !== null) void loadPracticeQuestionsIfNeeded(nextPracticeId)
}
return (
<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="flex items-start gap-3">
<div className="rounded-xl bg-brand-100 p-2 text-brand-700">
<Languages className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold text-grayScale-900">Human Language Content</h2>
<p className="mt-1 text-sm text-grayScale-500">
Dedicated management view for CEFR levels A1 to C3 with no sub-levels.
</p>
</div>
</div>
</div>
<Card className="border-grayScale-200/80 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Filters</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Subcategory</label>
<select
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
value={selectedSubCategoryId}
onChange={(e) =>
setSelectedSubCategoryId(e.target.value === "ALL" ? "ALL" : Number(e.target.value))
}
>
<option value="ALL">All subcategories</option>
{subCategories.map((subCategory) => (
<option key={subCategory.sub_category_id} value={subCategory.sub_category_id}>
{subCategory.sub_category_name}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Course</label>
<select
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
value={selectedCourseId}
onChange={(e) =>
setSelectedCourseId(e.target.value === "ALL" ? "ALL" : Number(e.target.value))
}
>
<option value="ALL">All courses</option>
{availableCourses.map((course) => (
<option key={course.course_id} value={course.course_id}>
{course.course_name}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Fetch lessons by level</label>
<select
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
value={selectedLevel}
onChange={(e) => setSelectedLevel(e.target.value as CefrLevel | "ALL")}
>
<option value="ALL">ALL LEVELS</option>
{visibleCefrLevels.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</div>
</CardContent>
</Card>
{loading ? (
<div className="flex items-center gap-2 py-8 text-sm text-grayScale-500">
<SpinnerIcon className="h-4 w-4" />
Loading human language lessons...
</div>
) : (
<div className="space-y-3">
{availableCourses.length === 0 ? (
<Card className="overflow-hidden border-grayScale-200/80">
<div className="flex items-center justify-between border-b border-grayScale-100 bg-white px-5 py-4">
<h3 className="text-lg font-semibold text-grayScale-800">Sub-category Management</h3>
<div className="relative w-full max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<input
className="h-11 w-full rounded-xl border border-grayScale-200 bg-white pl-9 pr-3 text-sm"
placeholder="Search sub-categories..."
value={quickSearch}
onChange={(e) => setQuickSearch(e.target.value)}
/>
</div>
</div>
<CardContent className="p-5">
<div className="rounded-2xl border border-dashed border-grayScale-300 bg-grayScale-50/20 px-6 py-10 text-center">
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-brand-100 text-brand-700">
<Languages className="h-6 w-6" />
</div>
<h4 className="text-xl font-semibold text-grayScale-800">No sub-categories yet</h4>
<p className="mt-2 text-sm text-grayScale-500">
Create your first human-language path. Level listing will appear automatically after creation.
</p>
<div className="mx-auto mt-5 grid max-w-3xl grid-cols-1 gap-2 md:grid-cols-3">
<input
className="h-10 rounded-md border border-grayScale-200 bg-white px-3 text-sm"
placeholder="Subcategory (e.g., English)"
value={quickSubCategoryName}
onChange={(e) => setQuickSubCategoryName(e.target.value)}
/>
<input
className="h-10 rounded-md border border-grayScale-200 bg-white px-3 text-sm"
placeholder="Course (e.g., Speaking)"
value={quickCourseName}
onChange={(e) => setQuickCourseName(e.target.value)}
/>
<Button onClick={handleQuickCreatePath} disabled={quickCreating}>
{quickCreating ? "Creating..." : "Add your first sub-category"}
</Button>
</div>
</div>
</CardContent>
</Card>
) : null}
{availableCourses.length > 0
? selectedCourses.map((course: HumanLanguageCourseTree) => {
const courseLevels = CEFR_LEVELS.filter((level) => {
if (level === "A1") return true
const node = course.levels.find((item) => item.level.toUpperCase() === level)
return (node?.modules?.length ?? 0) > 0
}).filter((level) => selectedLevel === "ALL" || selectedLevel === level)
const pathCollapsed = collapsedPathIds.includes(course.course_id)
const levelsDone = levelsWithContentForCourse(course)
const nextCefrForPath = CEFR_LEVELS.find((l) => !levelsDone.includes(l))
const pathNextLevelLoading = creatingKey?.startsWith(`next-level-${course.course_id}-`) ?? false
const pathLevelsFull = levelsDone.length >= CEFR_LEVELS.length
return (
<Card key={course.course_id} className="overflow-hidden border-grayScale-200/80 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-grayScale-100 bg-white px-4 py-3">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => togglePathCollapsed(course.course_id)}
>
{pathCollapsed ? (
<ChevronRight className="h-5 w-5 shrink-0 text-grayScale-500" aria-hidden />
) : (
<ChevronDown className="h-5 w-5 shrink-0 text-grayScale-500" aria-hidden />
)}
<span className="text-base font-semibold text-brand-700">{course.course_name}</span>
</button>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
size="sm"
className="shrink-0"
title={
pathLevelsFull
? "All CEFR levels already have content for this path"
: nextCefrForPath
? `Create ${nextCefrForPath} with Module-1`
: undefined
}
disabled={pathLevelsFull || pathNextLevelLoading}
onClick={() => handleCreateNextLevelForCourse(course.course_id)}
>
{pathNextLevelLoading ? "Creating…" : "Add next CEFR level"}
</Button>
</div>
</div>
{!pathCollapsed ? (
<CardContent className="space-y-3 p-4">
{courseLevels.length === 0 ? (
<p className="text-sm text-grayScale-500">No levels match the current level filter.</p>
) : (
courseLevels.map((level) => {
const levelNode = course.levels.find((item) => item.level.toUpperCase() === level)
const modules = levelNode?.modules ?? []
const levelKey = `${course.course_id}-${level}`
const levelRemoveIds = modules.flatMap((m) => m.sub_modules.map((s) => s.id))
const canRemoveLevel = levelRemoveIds.length > 0
return (
<div key={levelKey} className="overflow-hidden rounded-lg border border-grayScale-200/90">
<div className="flex w-full flex-wrap items-center justify-between gap-2 border-b border-grayScale-100 bg-grayScale-50/60 px-4 py-3">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => toggleLevel(levelKey)}
>
{collapsedLevels.includes(levelKey) ? <ChevronRight className="h-4 w-4 shrink-0" /> : <ChevronDown className="h-4 w-4 shrink-0" />}
<span className="text-sm font-semibold text-grayScale-900">{level}</span>
<span className="rounded-md bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">
{modules.length} module(s)
</span>
</button>
<Button
type="button"
size="sm"
variant="outline"
title={!canRemoveLevel ? "Nothing to remove at this level" : `Remove all content at ${level} for ${course.course_name}`}
className="h-8 shrink-0 gap-1 border-red-200/90 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50"
disabled={!canRemoveLevel || deletingKey === `level-${course.course_id}-${level}`}
onClick={() =>
requestRemove({
ids: levelRemoveIds,
key: `level-${course.course_id}-${level}`,
successMessage: `Level ${level} removed`,
title: `Remove level ${level}?`,
description: `This will permanently delete all modules and sub-modules under ${level} for “${course.course_name}”. This action cannot be undone.`,
})
}
>
<Trash2 className="h-3 w-3.5" aria-hidden />
Remove
</Button>
</div>
{!collapsedLevels.includes(levelKey) ? (
<div className="space-y-2 p-3">
<div className="flex items-center justify-between gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleCreateModule(course.course_id, level, modules)}
disabled={creatingKey === `module-${course.course_id}-${level}`}
>
{creatingKey === `module-${course.course_id}-${level}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
Add Module
</Button>
</div>
{modules.length === 0 ? (
<p className="text-xs text-grayScale-500">No modules yet. Use Add Module to start.</p>
) : (
modules.map((module) => (
<div key={module.id} className="rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-grayScale-900">Module: {module.title}</p>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() =>
handleCreateSubModule(course.course_id, level, module.title, module.sub_modules)
}
disabled={creatingKey === `submodule-${course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}`}
>
{creatingKey === `submodule-${course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
Add Sub-module
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 gap-1 border-red-200/90 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50"
disabled={deletingKey === `module-${module.id}`}
onClick={() =>
requestRemove({
ids: module.sub_modules.map((s) => s.id),
key: `module-${module.id}`,
successMessage: `Module ${module.title} removed`,
title: `Remove ${module.title}?`,
description:
"All sub-modules in this module will be permanently deleted. This action cannot be undone.",
})
}
>
<Trash2 className="h-3 w-3.5" aria-hidden />
Remove
</Button>
</div>
</div>
{module.sub_modules.map((subModule) => {
const smKey = `${course.course_id}-${subModule.id}`
const panelTab = subModulePanelTab[smKey] ?? "lessons"
const cardSel = getSubModuleSelection(smKey)
const lessonRows: LearningPathVideo[] = [...subModule.videos].sort(
(a, b) => a.display_order - b.display_order,
)
const practiceRows: LearningPathPractice[] = [...subModule.practices].sort(
(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 (
<div
key={subModule.id}
className="mt-2 overflow-hidden rounded-lg border border-grayScale-200/90 bg-white shadow-sm"
>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-grayScale-100 bg-grayScale-50/90 px-3 py-2.5">
<p className="text-sm font-semibold text-grayScale-800">
Sub-module: {subModule.title}
</p>
{categoryId ? (
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/content/human-language/${categoryId}/${course.course_id}/sub-module/${subModule.id}`}
>
<Button type="button" variant="outline" size="sm" className="h-8 text-xs">
Open editor
</Button>
</Link>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 gap-1 border-red-200/90 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50"
disabled={deletingKey === `submodule-${subModule.id}`}
onClick={() =>
requestRemove({
ids: [subModule.id],
key: `submodule-${subModule.id}`,
successMessage: `Sub-module ${subModule.title} removed`,
title: `Remove ${subModule.title}?`,
description:
"This sub-module will be permanently deleted. This action cannot be undone.",
})
}
>
<Trash2 className="h-3 w-3.5" aria-hidden />
Remove
</Button>
</div>
) : null}
</div>
<div className="border-b border-grayScale-100 bg-white px-3">
<div className="-mb-px flex items-center justify-between gap-4">
<div className="flex gap-6">
<button
type="button"
onClick={() =>
setSubModulePanelTab((prev) => ({ ...prev, [smKey]: "lessons" }))
}
className={`relative pb-3 pt-2 text-sm font-semibold transition-colors ${
panelTab === "lessons"
? "text-brand-600"
: "text-grayScale-400 hover:text-grayScale-700"
}`}
>
Lessons
{panelTab === "lessons" ? (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
) : null}
</button>
<button
type="button"
onClick={() =>
setSubModulePanelTab((prev) => ({ ...prev, [smKey]: "practices" }))
}
className={`relative pb-3 pt-2 text-sm font-semibold transition-colors ${
panelTab === "practices"
? "text-brand-600"
: "text-grayScale-400 hover:text-grayScale-700"
}`}
>
Practices
{panelTab === "practices" ? (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
) : null}
</button>
</div>
{panelTab === "practices" ? (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2 text-[11px]"
onClick={() => openCreatePracticeDialog(subModule.id)}
>
<Plus className="h-3.5 w-3.5" />
New practice
</Button>
) : null}
</div>
</div>
<div className="p-3">
{panelTab === "lessons" ? (
lessonRows.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">
No lesson videos yet. Use{" "}
<span className="font-medium text-grayScale-700">Open editor</span> to add
videos.
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{lessonRows.map((v, idx) => {
const isActive = cardSel.lessonId === v.id
return (
<button
key={v.id}
type="button"
onClick={() => toggleLessonCard(smKey, v.id)}
className={cn(
"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",
isActive
? "border-brand-400 ring-2 ring-brand-400/30"
: "border-grayScale-100",
)}
>
<div className="flex items-start gap-2">
<div className="rounded-lg bg-brand-50 p-1.5 text-brand-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}
</p>
</div>
</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
href={selectedLesson.video_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-brand-600 hover:underline"
>
{selectedLesson.video_url}
</a>
) : (
<span className="text-sm text-grayScale-400">
No video URL set use Open editor to add one.
</span>
)}
{selectedLesson.video_url
? renderMediaPreview(
selectedLesson.video_url,
"video",
"mt-3",
"Video preview",
)
: null}
</dd>
</div>
</dl>
</div>
) : (
<p className="text-center text-xs text-grayScale-400">
Select a lesson card to view full content.
</p>
)}
</div>
)
) : 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">
No practices yet. Use{" "}
<span className="font-medium text-grayScale-700">Open editor</span> to create a
practice.
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{practiceRows.map((p, pIdx) => {
const isActive = cardSel.practiceId === p.id
return (
<button
key={p.id}
type="button"
onClick={() => togglePracticeCard(smKey, p.id)}
className={cn(
"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",
isActive
? "border-brand-400 ring-2 ring-brand-400/30"
: "border-grayScale-100",
)}
>
<div className="flex items-start justify-between 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>
<p className="mt-0.5 text-[11px] text-grayScale-500">
Practice {pIdx + 1}
</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<Badge
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold capitalize ${practiceStatusStyle(p.status)}`}
>
{(p.status ?? "—").replace(/_/g, " ").toLowerCase()}
</Badge>
<span className="text-[11px] text-grayScale-500">
{p.question_count} Q · order {p.display_order ?? "—"}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2 text-[10px]"
onClick={(e) => {
e.stopPropagation()
openEditPracticeDialog(subModule.id, p)
}}
>
Edit
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2 text-[10px] text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={(e) => {
e.stopPropagation()
setPracticeTargetDelete({ id: p.id, title: p.title })
}}
>
Delete
</Button>
</div>
</div>
</button>
)
})}
</div>
{cardSel.practiceId !== null && selectedPracticeMeta ? (
<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="border-b border-grayScale-100 bg-white/90 px-4 py-3.5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.08em] text-grayScale-400">
Question bank
</p>
<h4 className="mt-0.5 truncate text-base font-semibold text-grayScale-900">
{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">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => openCreateQuestionDialog(selectedPracticeMeta.id)}
>
<Plus className="h-3.5 w-3.5" />
Add question
</Button>
{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>
<div className="p-4">
{!practiceFetch || practiceFetch.status === "loading" ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-sm text-grayScale-500">
<Loader2 className="h-5 w-5 animate-spin text-brand-500" aria-hidden />
Loading questions
</div>
) : practiceFetch.status === "error" ? (
<div className="rounded-lg border border-red-100 bg-red-50/50 px-4 py-3">
<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
type="button"
size="sm"
variant="outline"
className="border-red-200 text-red-700 hover:bg-red-50"
onClick={() => void loadPracticeQuestionsIfNeeded(selectedPracticeMeta.id)}
>
Retry
</Button>
</div>
</div>
</div>
) : practiceFetch.questions.length === 0 ? (
<div className="rounded-lg border border-dashed border-grayScale-200 bg-white px-4 py-10 text-center">
<ClipboardList className="mx-auto mb-2 h-8 w-8 text-grayScale-300" aria-hidden />
<p className="text-sm text-grayScale-600">
No questions in this practice yet.
</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="max-h-[min(28rem,calc(100vh-16rem))] space-y-3 overflow-y-auto pr-1 [scrollbar-gutter:stable]">
{practiceFetch.questions.map((q, qIdx) => {
const qType = String(q.question_type ?? "—")
const embeddedUrls = extractUrls(q.question_text || "")
return (
<li
key={q.question_id ?? q.id}
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 justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge
className={cn(
"h-6 rounded-md px-2 py-0 text-[11px] font-semibold",
questionTypeBadgeClass(qType),
)}
>
{formatQuestionTypeLabel(qType)}
</Badge>
{q.points != null && q.points > 0 ? (
<span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-[11px] font-medium tabular-nums text-grayScale-700">
{q.points} pts
</span>
) : null}
{q.difficulty_level ? (
<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}
</span>
) : null}
</div>
<div className="flex items-center gap-1">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-[10px]"
disabled={
loadingQuestionEditId ===
(q.question_id ?? q.id)
}
onClick={() =>
void openEditQuestionDialog(
selectedPracticeMeta.id,
q,
)
}
>
{loadingQuestionEditId ===
(q.question_id ?? q.id) ? (
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
) : null}
Edit
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2 text-[10px] text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() =>
setQuestionTargetDelete({
id: q.question_id ?? q.id,
practiceId: selectedPracticeMeta.id,
text: q.question_text || "Question",
})
}
>
Delete
</Button>
</div>
</div>
<div>
<p className="text-[13px] font-medium uppercase tracking-wide text-grayScale-400">
Prompt
</p>
<p className="mt-1 text-[15px] leading-relaxed text-grayScale-900">
{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>
))}
</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>
)}
{practiceFetch?.status === "ok" &&
practiceFetch.totalCount > practiceFetch.questions.length ? (
<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 <span className="font-semibold">{practiceFetch.questions.length}</span> of{" "}
<span className="font-semibold">{practiceFetch.totalCount}</span> questions. Open{" "}
<span className="font-medium text-grayScale-800">Edit in full view</span> for the
rest.
</div>
) : null}
</div>
</div>
) : (
<p className="text-center text-xs text-grayScale-400">
Select a practice card to view its questions.
</p>
)}
</div>
)}
</div>
</div>
)
})}
</div>
))
)}
</div>
) : null}
</div>
)
})
)}
</CardContent>
) : null}
</Card>
)
})
: null}
</div>
)}
<Dialog open={pendingRemove !== null} onOpenChange={(open) => !open && setPendingRemove(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{pendingRemove?.title ?? "Confirm removal"}</DialogTitle>
<DialogDescription>{pendingRemove?.description}</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={() => setPendingRemove(null)}>
Cancel
</Button>
<Button type="button" className="bg-red-600 hover:bg-red-700" onClick={() => void executePendingRemove()}>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={practiceDialog.open}
onOpenChange={(open) => {
if (!open) {
setPracticeDialog({ open: false })
setPracticeSubmitAttempted(false)
setPracticeFormTouched(false)
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{practiceDialog.open && practiceDialog.mode === "edit" ? "Edit Practice" : "Create Practice"}</DialogTitle>
<DialogDescription>
Manage practice metadata directly from this page.
{!practiceCanSave ? (
<span className="mt-1 block text-amber-700/90">Required fields must be completed before you can save.</span>
) : null}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Title</label>
<input
value={practiceForm.title}
onChange={(e) => {
setPracticeFormTouched(true)
setPracticeForm((p) => ({ ...p, title: e.target.value }))
}}
className={cn(
"h-10 w-full rounded-md border px-3 text-sm",
(practiceSubmitAttempted || practiceFormTouched) && practiceFieldErrors.title
? "border-red-300 ring-1 ring-red-200"
: "border-grayScale-200",
)}
placeholder="Practice title"
aria-invalid={Boolean(
(practiceSubmitAttempted || practiceFormTouched) && practiceFieldErrors.title,
)}
/>
{(practiceSubmitAttempted || practiceFormTouched) && practiceFieldErrors.title ? (
<p className="text-xs text-red-600">{practiceFieldErrors.title}</p>
) : null}
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Description</label>
<textarea
value={practiceForm.description}
onChange={(e) => {
setPracticeFormTouched(true)
setPracticeForm((p) => ({ ...p, description: e.target.value }))
}}
className="min-h-[88px] w-full rounded-md border border-grayScale-200 px-3 py-2 text-sm"
placeholder="Optional description"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Persona</label>
<input
value={practiceForm.persona}
onChange={(e) => {
setPracticeFormTouched(true)
setPracticeForm((p) => ({ ...p, persona: e.target.value }))
}}
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
placeholder="Optional persona"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPracticeDialog({ open: false })}>
Cancel
</Button>
<Button type="button" onClick={() => void handleSavePractice()} disabled={savingPractice || !practiceCanSave}>
{savingPractice ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={practiceTargetDelete !== null} onOpenChange={(open) => !open && setPracticeTargetDelete(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete practice?</DialogTitle>
<DialogDescription>
{practiceTargetDelete ? `This will permanently delete "${practiceTargetDelete.title}".` : ""}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPracticeTargetDelete(null)}>
Cancel
</Button>
<Button
type="button"
className="bg-red-600 hover:bg-red-700"
onClick={() => void handleDeletePracticeConfirmed()}
disabled={deletingPractice}
>
{deletingPractice ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={questionDialog.open}
onOpenChange={(open) => {
if (!open) {
setQuestionDialog({ open: false })
setQuestionSubmitAttempted(false)
setQuestionFormTouched(false)
}
}}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{questionDialog.open && questionDialog.mode === "edit" ? "Edit question" : "Add question"}</DialogTitle>
<DialogDescription>
Use the same fields as when creating: type, scoring, prompts, media URLs, and answer options. Changes apply to this
practice only.
{!questionCanSave ? (
<span className="mt-1 block text-amber-700/90">
Fix the highlighted fields before saving. Save stays disabled until the form is valid.
</span>
) : null}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1 sm:col-span-2">
<label className="text-xs font-medium text-grayScale-600">Question text</label>
<textarea
value={questionForm.questionText}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, questionText: e.target.value }))
}}
className={cn(
"min-h-[96px] w-full rounded-md border px-3 py-2 text-sm",
(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.questionText
? "border-red-300 ring-1 ring-red-200"
: "border-grayScale-200",
)}
placeholder="Type question"
aria-invalid={Boolean(
(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.questionText,
)}
/>
{(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.questionText ? (
<p className="text-xs text-red-600">{questionFieldErrors.questionText}</p>
) : null}
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Type</label>
<select
value={questionForm.questionType}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, questionType: e.target.value as "MCQ" | "TRUE_FALSE" | "SHORT" }))
}}
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
>
<option value="MCQ">MCQ</option>
<option value="TRUE_FALSE">True/False</option>
<option value="SHORT">Short answer</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Difficulty</label>
<select
value={questionForm.difficulty}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, difficulty: e.target.value }))
}}
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
>
<option value="EASY">EASY</option>
<option value="MEDIUM">MEDIUM</option>
<option value="HARD">HARD</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Points</label>
<input
type="number"
min={1}
value={questionForm.points}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, points: Number(e.target.value) }))
}}
className={cn(
"h-10 w-full rounded-md border px-3 text-sm",
(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.points
? "border-red-300 ring-1 ring-red-200"
: "border-grayScale-200",
)}
aria-invalid={Boolean(
(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.points,
)}
/>
{(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.points ? (
<p className="text-xs text-red-600">{questionFieldErrors.points}</p>
) : null}
</div>
{questionForm.questionType === "SHORT" ? (
<div className="space-y-1 sm:col-span-2">
<label className="text-xs font-medium text-grayScale-600">Expected short answer</label>
<input
value={questionForm.shortAnswer}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, shortAnswer: e.target.value }))
}}
className={cn(
"h-10 w-full rounded-md border px-3 text-sm",
(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.shortAnswer
? "border-red-300 ring-1 ring-red-200"
: "border-grayScale-200",
)}
aria-invalid={Boolean(
(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.shortAnswer,
)}
/>
{(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.shortAnswer ? (
<p className="text-xs text-red-600">{questionFieldErrors.shortAnswer}</p>
) : null}
</div>
) : (
<div className="space-y-2 sm:col-span-2">
<label className="text-xs font-medium text-grayScale-600">Options</label>
{questionForm.questionType === "TRUE_FALSE" ? (
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant={questionForm.correctOption === "A" ? "default" : "outline"}
onClick={() => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, correctOption: "A" }))
}}
>
True
</Button>
<Button
type="button"
variant={questionForm.correctOption === "B" ? "default" : "outline"}
onClick={() => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, correctOption: "B" }))
}}
>
False
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{(["A", "B", "C", "D"] as const).map((opt) => (
<div key={opt} className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant={questionForm.correctOption === opt ? "default" : "outline"}
onClick={() => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, correctOption: opt }))
}}
>
{opt}
</Button>
<input
value={questionForm[`option${opt}` as "optionA" | "optionB" | "optionC" | "optionD"]}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({
...f,
[`option${opt}`]: e.target.value,
}))
}}
className="h-9 w-full rounded-md border border-grayScale-200 px-3 text-sm"
placeholder={`Option ${opt}`}
/>
</div>
))}
</div>
)}
{(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.options ? (
<p className="text-xs text-red-600">{questionFieldErrors.options}</p>
) : null}
{(questionSubmitAttempted || questionFormTouched) && questionFieldErrors.correctOption ? (
<p className="text-xs text-red-600">{questionFieldErrors.correctOption}</p>
) : null}
</div>
)}
<div className="space-y-1 sm:col-span-2">
<label className="text-xs font-medium text-grayScale-600">Tips</label>
<textarea
value={questionForm.tips}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, tips: e.target.value }))
}}
className="min-h-[74px] w-full rounded-md border border-grayScale-200 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className="text-xs font-medium text-grayScale-600">Explanation / sample answer</label>
<textarea
value={questionForm.explanation}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, explanation: e.target.value }))
}}
className="min-h-[74px] w-full rounded-md border border-grayScale-200 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className="text-xs font-medium text-grayScale-600">Image URL</label>
<input
value={questionForm.imageUrl}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, imageUrl: e.target.value }))
}}
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Voice prompt URL</label>
<input
value={questionForm.voicePrompt}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, voicePrompt: e.target.value }))
}}
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Sample answer voice URL</label>
<input
value={questionForm.sampleAnswerVoicePrompt}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, sampleAnswerVoicePrompt: e.target.value }))
}}
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
/>
</div>
<div className="space-y-1 sm:col-span-2">
<label className="text-xs font-medium text-grayScale-600">Audio / spoken correct answer text</label>
<textarea
value={questionForm.audioCorrectAnswerText}
onChange={(e) => {
setQuestionFormTouched(true)
setQuestionForm((f) => ({ ...f, audioCorrectAnswerText: e.target.value }))
}}
className="min-h-[64px] w-full rounded-md border border-grayScale-200 px-3 py-2 text-sm"
placeholder="Optional; used for audio-style grading when applicable"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setQuestionDialog({ open: false })}>
Cancel
</Button>
<Button type="button" onClick={() => void handleSaveQuestion()} disabled={savingQuestion || !questionCanSave}>
{savingQuestion ? "Saving..." : "Save question"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={questionTargetDelete !== null} onOpenChange={(open) => !open && setQuestionTargetDelete(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete question?</DialogTitle>
<DialogDescription>
{questionTargetDelete ? `This will permanently delete "${questionTargetDelete.text}".` : ""}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setQuestionTargetDelete(null)}>
Cancel
</Button>
<Button
type="button"
className="bg-red-600 hover:bg-red-700"
onClick={() => void handleDeleteQuestionConfirmed()}
disabled={deletingQuestion}
>
{deletingQuestion ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}