Yimaru-Admin/src/pages/content-management/PracticeDetailsPage.tsx
2026-04-25 02:48:52 -07:00

1146 lines
42 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { BookOpen, GraduationCap, Layers, PlayCircle, RefreshCw, Pencil, Search, Trash2 } from "lucide-react"
import { useSearchParams } from "react-router-dom"
import { toast } from "sonner"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Select } from "../../components/ui/select"
import { Badge } from "../../components/ui/badge"
import {
deleteParentLinkedPractice,
getLearningPrograms,
getModuleLessons,
getPracticesByParentCourse,
getPracticesByParentLesson,
getPracticesByParentModule,
getProgramCourses,
getTopLevelCourseModules,
updateParentLinkedPractice,
} from "../../api/courses.api"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import type {
LearningProgramListItem,
ParentContextPractice,
ProgramCourseListItem,
TopLevelCourseModuleItem,
TopLevelModuleLessonItem,
UpdateParentLinkedPracticeRequest,
} from "../../types/course.types"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { CreatePracticeWizard } from "./components/CreatePracticeWizard"
import type { PracticeParentKind } from "../../types/course.types"
import { cn } from "../../lib/utils"
type ParentTab = "course" | "module" | "lesson"
type FlowMode = "view" | "create"
function toApiParentKind(tab: ParentTab): PracticeParentKind {
if (tab === "course") return "COURSE"
if (tab === "module") return "MODULE"
return "LESSON"
}
function parseParentTab(s: string | null): ParentTab {
if (s === "module" || s === "lesson" || s === "course") return s
return "course"
}
function parseQuickTipParts(text: string): string[] {
return text
.split(",")
.map((p) => p.trim())
.filter(Boolean)
}
function formatDate(iso: string): string {
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString()
}
const parentTabCopy: Record<ParentTab, { label: string; hint: string; icon: typeof BookOpen }> = {
course: { label: "Course", hint: "Pick a program, then the course the practice is attached to.", icon: BookOpen },
module: { label: "Module", hint: "Pick program → course → module.", icon: Layers },
lesson: { label: "Lesson", hint: "Pick program → course → module → video lesson.", icon: PlayCircle },
}
const LIST_LIMIT = 200
const sortBySortOrder = <T extends { sort_order?: number }>(items: T[]): T[] =>
[...items].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
function courseLabel(c: ProgramCourseListItem) {
return c.name?.trim() || `Course #${c.id}`
}
function moduleLabel(m: TopLevelCourseModuleItem) {
return m.name?.trim() || `Module #${m.id}`
}
function lessonLabel(l: TopLevelModuleLessonItem) {
return l.title?.trim() || `Lesson #${l.id}`
}
function programLabel(p: LearningProgramListItem) {
return p.name?.trim() || `Program #${p.id}`
}
function parseIntParam(s: string | null): number | null {
if (!s?.trim() || !/^\d+$/.test(s.trim())) return null
const n = Number(s)
return Number.isFinite(n) && n > 0 ? n : null
}
/** When URL matches what is already on screen (after "Load"), skip network to fill dropdowns. */
function selectionMatchesUrl(
type: ParentTab,
leafIdParam: string,
sp: URLSearchParams,
pId: string,
cId: string,
mId: string,
lId: string,
): boolean {
const pro = sp.get("program")
const cou = sp.get("course")
const mod = sp.get("module")
if (type === "course") {
if (!pId || cId !== leafIdParam) return false
return pro === pId
}
if (type === "module") {
if (!pId || !cId || mId !== leafIdParam) return false
return pro === pId && cou === cId
}
if (type === "lesson") {
if (!pId || !cId || !mId || lId !== leafIdParam) return false
return pro === pId && cou === cId && mod === mId
}
return false
}
export function PracticeDetailsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [parentTab, setParentTab] = useState<ParentTab>(() => parseParentTab(searchParams.get("type")))
const [programs, setPrograms] = useState<LearningProgramListItem[]>([])
const [courses, setCourses] = useState<ProgramCourseListItem[]>([])
const [modules, setModules] = useState<TopLevelCourseModuleItem[]>([])
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([])
const [programId, setProgramId] = useState("")
const [courseId, setCourseId] = useState("")
const [moduleId, setModuleId] = useState("")
const [lessonId, setLessonId] = useState("")
const [programsLoading, setProgramsLoading] = useState(true)
const [coursesLoading, setCoursesLoading] = useState(false)
const [modulesLoading, setModulesLoading] = useState(false)
const [lessonsLoading, setLessonsLoading] = useState(false)
const [locationRestoring, setLocationRestoring] = useState(false)
const programsForRestoreRef = useRef<LearningProgramListItem[] | null>(null)
useEffect(() => {
if (programs.length) programsForRestoreRef.current = programs
}, [programs])
const [loading, setLoading] = useState(false)
const [selectionError, setSelectionError] = useState<string | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [practice, setPractice] = useState<ParentContextPractice | null>(null)
const [totalCount, setTotalCount] = useState(0)
const [flowMode, setFlowMode] = useState<FlowMode>("view")
const [editOpen, setEditOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [savePracticeLoading, setSavePracticeLoading] = useState(false)
const [deletePracticeLoading, setDeletePracticeLoading] = useState(false)
const [editTitle, setEditTitle] = useState("")
const [editStory, setEditStory] = useState("")
const [editImage, setEditImage] = useState("")
const [editQuestionSetId, setEditQuestionSetId] = useState("")
const [editQuickTips, setEditQuickTips] = useState("")
const [editPersonaId, setEditPersonaId] = useState("")
const syncTabFromUrl = useRef(false)
useEffect(() => {
if (syncTabFromUrl.current) return
setParentTab(parseParentTab(searchParams.get("type")))
}, [searchParams])
useEffect(() => {
if (editOpen && practice) {
setEditTitle(practice.title)
setEditStory(practice.story_description)
setEditImage(practice.story_image)
setEditQuestionSetId(String(practice.question_set_id))
setEditQuickTips(practice.quick_tips ?? "")
setEditPersonaId(practice.persona_id != null ? String(practice.persona_id) : "")
}
}, [editOpen, practice])
const loadPrograms = useCallback(async () => {
setProgramsLoading(true)
try {
const res = await getLearningPrograms({ limit: LIST_LIMIT, offset: 0 })
const list = res.data?.data?.programs ?? []
setPrograms(sortBySortOrder(list))
} catch {
setPrograms([])
} finally {
setProgramsLoading(false)
}
}, [])
useEffect(() => {
loadPrograms()
}, [loadPrograms])
const loadCourses = useCallback(async (pid: number) => {
setCoursesLoading(true)
try {
const res = await getProgramCourses(pid, { limit: LIST_LIMIT, offset: 0 })
const list = res.data?.data?.courses ?? []
setCourses(sortBySortOrder(list))
} catch {
setCourses([])
} finally {
setCoursesLoading(false)
}
}, [])
const loadModules = useCallback(async (cid: number) => {
setModulesLoading(true)
try {
const res = await getTopLevelCourseModules(cid, { limit: LIST_LIMIT, offset: 0 })
const list = res.data?.data?.modules ?? []
setModules(sortBySortOrder(list))
} catch {
setModules([])
} finally {
setModulesLoading(false)
}
}, [])
const loadLessons = useCallback(async (mid: number) => {
setLessonsLoading(true)
try {
const res = await getModuleLessons(mid, { limit: LIST_LIMIT, offset: 0 })
const list = res.data?.data?.lessons ?? []
setLessons(sortBySortOrder(list))
} catch {
setLessons([])
} finally {
setLessonsLoading(false)
}
}, [])
const onProgramChange = (value: string) => {
setSelectionError(null)
setProgramId(value)
setCourseId("")
setModuleId("")
setLessonId("")
setCourses([])
setModules([])
setLessons([])
if (value) {
loadCourses(Number(value))
}
}
const onCourseChange = (value: string) => {
setSelectionError(null)
setCourseId(value)
setModuleId("")
setLessonId("")
setModules([])
setLessons([])
if (value && (parentTab === "module" || parentTab === "lesson")) {
loadModules(Number(value))
}
}
const onModuleChange = (value: string) => {
setSelectionError(null)
setModuleId(value)
setLessonId("")
setLessons([])
if (value && parentTab === "lesson") {
loadLessons(Number(value))
}
}
const onParentTabChange = (next: ParentTab) => {
setSelectionError(null)
setParentTab(next)
setModuleId("")
setLessonId("")
setModules([])
setLessons([])
if (next === "course") {
// module/lesson not needed; keep program + course if already chosen
} else if (next === "module" && programId && courseId) {
loadModules(Number(courseId))
} else if (next === "lesson" && programId && courseId) {
loadModules(Number(courseId))
if (moduleId) {
loadLessons(Number(moduleId))
}
}
}
const getLeafId = useCallback((): number | null => {
if (parentTab === "course") {
if (!courseId) return null
const n = Number(courseId)
return Number.isFinite(n) && n > 0 ? n : null
}
if (parentTab === "module") {
if (!moduleId) return null
const n = Number(moduleId)
return Number.isFinite(n) && n > 0 ? n : null
}
if (!lessonId) return null
const n = Number(lessonId)
return Number.isFinite(n) && n > 0 ? n : null
}, [parentTab, courseId, moduleId, lessonId])
const runFetch = useCallback(async (tab: ParentTab, rawId: string) => {
const id = Number(String(rawId).trim())
if (!rawId.trim() || !Number.isFinite(id) || id < 1) {
setLoadError("Invalid selection (missing or invalid id).")
setPractice(null)
setTotalCount(0)
return
}
setLoading(true)
setLoadError(null)
setSelectionError(null)
setPractice(null)
try {
const params = { limit: 20, offset: 0 }
const res =
tab === "course"
? await getPracticesByParentCourse(id, params)
: tab === "module"
? await getPracticesByParentModule(id, params)
: await getPracticesByParentLesson(id, params)
const data = res.data?.data
const list = data?.practices ?? []
setTotalCount(data?.total_count ?? list.length)
setPractice(list[0] ?? null)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } }
setLoadError(err.response?.data?.message || "Failed to load practices for this parent.")
setPractice(null)
setTotalCount(0)
} finally {
setLoading(false)
}
}, [])
const restoreFromUrl = useCallback(
async (type: ParentTab, targetId: number, sp: URLSearchParams) => {
setLocationRestoring(true)
try {
const pHint = parseIntParam(sp.get("program"))
const cHint = parseIntParam(sp.get("course"))
const mHint = parseIntParam(sp.get("module"))
/* ---- Fast path: hints in URL = a few parallel calls ---- */
if (type === "course" && pHint != null) {
const [progRes, cRes] = await Promise.all([
getLearningPrograms({ limit: LIST_LIMIT, offset: 0 }),
getProgramCourses(pHint, { limit: LIST_LIMIT, offset: 0 }),
])
const programList = sortBySortOrder(progRes.data?.data?.programs ?? [])
setPrograms(programList)
const cl = sortBySortOrder(cRes.data?.data?.courses ?? [])
const c = cl.find((x) => x.id === targetId)
if (c) {
setProgramId(String(pHint))
setCourses(cl)
setCourseId(String(c.id))
return
}
}
if (type === "module" && pHint != null && cHint != null) {
const [progRes, cRes, mRes] = await Promise.all([
getLearningPrograms({ limit: LIST_LIMIT, offset: 0 }),
getProgramCourses(pHint, { limit: LIST_LIMIT, offset: 0 }),
getTopLevelCourseModules(cHint, { limit: LIST_LIMIT, offset: 0 }),
])
setPrograms(sortBySortOrder(progRes.data?.data?.programs ?? []))
setCourses(sortBySortOrder(cRes.data?.data?.courses ?? []))
setProgramId(String(pHint))
setCourseId(String(cHint))
const ml = sortBySortOrder(mRes.data?.data?.modules ?? [])
const m = ml.find((x) => x.id === targetId)
if (m) {
setModules(ml)
setModuleId(String(m.id))
return
}
}
if (type === "lesson" && pHint != null && cHint != null && mHint != null) {
const [progRes, cRes, mRes, lRes] = await Promise.all([
getLearningPrograms({ limit: LIST_LIMIT, offset: 0 }),
getProgramCourses(pHint, { limit: LIST_LIMIT, offset: 0 }),
getTopLevelCourseModules(cHint, { limit: LIST_LIMIT, offset: 0 }),
getModuleLessons(mHint, { limit: LIST_LIMIT, offset: 0 }),
])
setPrograms(sortBySortOrder(progRes.data?.data?.programs ?? []))
setCourses(sortBySortOrder(cRes.data?.data?.courses ?? []))
setModules(sortBySortOrder(mRes.data?.data?.modules ?? []))
setProgramId(String(pHint))
setCourseId(String(cHint))
setModuleId(String(mHint))
const ll = sortBySortOrder(lRes.data?.data?.lessons ?? [])
const lesson = ll.find((x) => x.id === targetId)
if (lesson) {
setLessons(ll)
setLessonId(String(lesson.id))
return
}
}
/* ---- Legacy / wrong hints: programs list, then search ---- */
let programList: LearningProgramListItem[] = programsForRestoreRef.current ?? []
if (programList.length === 0) {
const progRes = await getLearningPrograms({ limit: LIST_LIMIT, offset: 0 })
programList = sortBySortOrder(progRes.data?.data?.programs ?? [])
}
setPrograms(programList)
if (type === "course") {
const courseResults = await Promise.all(
programList.map((p) => getProgramCourses(p.id, { limit: LIST_LIMIT, offset: 0 })),
)
for (let i = 0; i < programList.length; i++) {
const cl = sortBySortOrder(courseResults[i].data?.data?.courses ?? [])
const c = cl.find((x) => x.id === targetId)
if (c) {
setProgramId(String(programList[i].id))
setCourses(cl)
setCourseId(String(c.id))
return
}
}
return
}
if (type === "module") {
for (const p of programList) {
const cRes = await getProgramCourses(p.id, { limit: LIST_LIMIT, offset: 0 })
const cl = sortBySortOrder(cRes.data?.data?.courses ?? [])
const modBatches = await Promise.all(
cl.map((c) => getTopLevelCourseModules(c.id, { limit: LIST_LIMIT, offset: 0 })),
)
for (let j = 0; j < cl.length; j++) {
const ml = sortBySortOrder(modBatches[j].data?.data?.modules ?? [])
const m = ml.find((x) => x.id === targetId)
if (m) {
setProgramId(String(p.id))
setCourses(cl)
setCourseId(String(cl[j].id))
setModules(ml)
setModuleId(String(m.id))
return
}
}
}
return
}
if (type === "lesson") {
for (const p of programList) {
const cRes = await getProgramCourses(p.id, { limit: LIST_LIMIT, offset: 0 })
const cl = sortBySortOrder(cRes.data?.data?.courses ?? [])
for (const c of cl) {
const mRes = await getTopLevelCourseModules(c.id, { limit: LIST_LIMIT, offset: 0 })
const ml = sortBySortOrder(mRes.data?.data?.modules ?? [])
const lessonBatches = await Promise.all(
ml.map((m) => getModuleLessons(m.id, { limit: LIST_LIMIT, offset: 0 })),
)
for (let k = 0; k < ml.length; k++) {
const ll = sortBySortOrder(lessonBatches[k].data?.data?.lessons ?? [])
const lesson = ll.find((x) => x.id === targetId)
if (lesson) {
setProgramId(String(p.id))
setCourses(cl)
setCourseId(String(c.id))
setModules(ml)
setModuleId(String(ml[k].id))
setLessons(ll)
setLessonId(String(lesson.id))
return
}
}
}
}
}
} finally {
setLocationRestoring(false)
}
},
[],
)
/** Only re-run when the query string actually changes (not when the user only edits a dropdown). */
const lastUrlKeyProcessedRef = useRef<string | null>(null)
const selectionUrlSyncedRef = useRef<string | null>(null)
useEffect(() => {
const idParam = searchParams.get("id")
if (!idParam?.trim() || !/^\d+$/.test(idParam.trim())) {
lastUrlKeyProcessedRef.current = null
selectionUrlSyncedRef.current = null
return
}
const type = parseParentTab(searchParams.get("type"))
const id = Number(idParam)
if (!Number.isFinite(id) || id < 1) return
const urlKey = searchParams.toString()
if (urlKey === lastUrlKeyProcessedRef.current) {
return
}
lastUrlKeyProcessedRef.current = urlKey
if (selectionMatchesUrl(type, idParam, searchParams, programId, courseId, moduleId, lessonId)) {
if (selectionUrlSyncedRef.current === urlKey) return
selectionUrlSyncedRef.current = urlKey
syncTabFromUrl.current = true
setParentTab(type)
queueMicrotask(() => {
syncTabFromUrl.current = false
})
return
}
selectionUrlSyncedRef.current = null
syncTabFromUrl.current = true
setParentTab(type)
queueMicrotask(() => {
syncTabFromUrl.current = false
})
void restoreFromUrl(type, id, searchParams)
}, [searchParams, restoreFromUrl, programId, courseId, moduleId, lessonId])
useEffect(() => {
const id = searchParams.get("id")
const type = parseParentTab(searchParams.get("type"))
if (!id?.trim() || !/^\d+$/.test(id.trim())) {
return
}
runFetch(type, id)
}, [searchParams, runFetch])
const handleLoad = () => {
const leaf = getLeafId()
if (leaf == null) {
setSelectionError("Choose all required items from the lists before loading.")
return
}
setSelectionError(null)
setLoadError(null)
const next: Record<string, string> = {
type: parentTab,
id: String(leaf),
}
if (programId) next.program = programId
if (courseId) next.course = courseId
if (moduleId) next.module = moduleId
setSearchParams(next)
}
const savePracticeEdit = async () => {
if (!practice) return
const qid = Number(editQuestionSetId.trim())
if (!Number.isFinite(qid) || qid < 1) {
toast.error("Enter a valid question set id")
return
}
if (!editTitle.trim() || !editStory.trim() || !editImage.trim()) {
toast.error("Title, story description, and story image are required")
return
}
const payload: UpdateParentLinkedPracticeRequest = {
title: editTitle.trim(),
story_description: editStory.trim(),
story_image: editImage.trim(),
question_set_id: qid,
quick_tips: editQuickTips.trim(),
}
const p = editPersonaId.trim()
if (p) {
const n = Number(p)
if (!Number.isFinite(n) || n < 1) {
toast.error("Persona id must be a positive number or empty")
return
}
payload.persona_id = n
} else {
payload.persona_id = null
}
setSavePracticeLoading(true)
try {
const res = await updateParentLinkedPractice(practice.id, payload)
const updated = res.data?.data
if (updated) {
setPractice(updated)
} else {
setPractice({
...practice,
...payload,
id: practice.id,
parent_kind: practice.parent_kind,
parent_id: practice.parent_id,
created_at: practice.created_at,
})
}
toast.success("Practice updated")
setEditOpen(false)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } }
toast.error(err.response?.data?.message || "Failed to update practice")
} finally {
setSavePracticeLoading(false)
}
}
const confirmDeletePractice = async () => {
if (!practice) return
setDeletePracticeLoading(true)
try {
await deleteParentLinkedPractice(practice.id)
toast.success("Practice deleted")
setDeleteOpen(false)
const id = searchParams.get("id")
const t = parseParentTab(searchParams.get("type"))
if (id?.trim() && /^\d+$/.test(id.trim())) {
await runFetch(t, id)
} else {
setPractice(null)
setTotalCount(0)
}
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } }
toast.error(err.response?.data?.message || "Failed to delete practice")
} finally {
setDeletePracticeLoading(false)
}
}
const quickTipParts = useMemo(
() => (practice?.quick_tips ? parseQuickTipParts(practice.quick_tips) : []),
[practice?.quick_tips],
)
const canLoad =
parentTab === "course"
? Boolean(programId && courseId)
: parentTab === "module"
? Boolean(programId && courseId && moduleId)
: Boolean(programId && courseId && moduleId && lessonId)
const createWizardParent = useMemo((): { kind: PracticeParentKind; id: number } | null => {
const leaf = getLeafId()
if (leaf == null) return null
return { kind: toApiParentKind(parentTab), id: leaf }
}, [parentTab, getLeafId])
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Practice Management</h1>
<p className="mt-1 text-sm text-grayScale-400">
View the practice linked to a course, module, or lesson. Each parent has at most one
practice.
</p>
</div>
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<div
className="flex rounded-2xl border border-grayScale-200 bg-grayScale-50/80 p-1 shadow-sm"
role="tablist"
aria-label="View or create practice"
>
<button
type="button"
role="tab"
aria-selected={flowMode === "view"}
onClick={() => setFlowMode("view")}
className={cn(
"inline-flex items-center justify-center gap-1.5 rounded-xl px-4 py-2 text-sm font-semibold transition",
flowMode === "view"
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-500 hover:text-grayScale-800",
)}
>
<Search className="h-4 w-4" />
View
</button>
<button
type="button"
role="tab"
aria-selected={flowMode === "create"}
onClick={() => setFlowMode("create")}
className={cn(
"inline-flex items-center justify-center gap-1.5 rounded-xl px-4 py-2 text-sm font-semibold transition",
flowMode === "create"
? "bg-white text-brand-600 shadow-sm"
: "text-grayScale-500 hover:text-grayScale-800",
)}
>
<Pencil className="h-4 w-4" />
Create
</button>
</div>
<Button
variant="outline"
onClick={() => {
const id = searchParams.get("id")
if (id?.trim() && /^\d+$/.test(id.trim())) {
runFetch(parseParentTab(searchParams.get("type")), id)
}
}}
disabled={loading}
className="shrink-0"
>
{loading ? <SpinnerIcon className="h-4 w-4" /> : <RefreshCw className="h-4 w-4" />}
Refresh
</Button>
</div>
</div>
<Card className="shadow-soft">
<CardHeader className="border-b border-grayScale-200 pb-4">
<CardTitle className="text-base font-semibold text-grayScale-600">Look up practice</CardTitle>
<p className="text-sm font-normal text-grayScale-500">
Choose a program and narrow down to the {parentTabCopy[parentTab].label.toLowerCase()} you
need, then load. The URL includes program / course / module so shared links and refreshes
load quickly.
</p>
</CardHeader>
<CardContent className="pt-5">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Practice is attached to
</p>
<Select
value={parentTab}
onChange={(e) => onParentTabChange(parseParentTab(e.target.value))}
className="w-full"
>
<option value="course">Course</option>
<option value="module">Module</option>
<option value="lesson">Lesson (video)</option>
</Select>
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Program
</p>
<Select
value={programId}
onChange={(e) => onProgramChange(e.target.value)}
className="w-full"
disabled={programsLoading}
>
<option value="">{programsLoading ? "Loading programs…" : "Select a program"}</option>
{programs.map((p) => (
<option key={p.id} value={p.id}>
{programLabel(p)}
</option>
))}
</Select>
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Course
</p>
<Select
value={courseId}
onChange={(e) => onCourseChange(e.target.value)}
className="w-full"
disabled={!programId || coursesLoading}
>
<option value="">
{!programId
? "Select a program first"
: coursesLoading
? "Loading courses…"
: "Select a course"}
</option>
{courses.map((c) => (
<option key={c.id} value={c.id}>
{courseLabel(c)}
</option>
))}
</Select>
</div>
{parentTab !== "course" && (
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Module
</p>
<Select
value={moduleId}
onChange={(e) => onModuleChange(e.target.value)}
className="w-full"
disabled={!courseId || modulesLoading}
>
<option value="">
{!courseId
? "Select a course first"
: modulesLoading
? "Loading modules…"
: "Select a module"}
</option>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{moduleLabel(m)}
</option>
))}
</Select>
</div>
)}
{parentTab === "lesson" && (
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Lesson
</p>
<Select
value={lessonId}
onChange={(e) => {
setSelectionError(null)
setLessonId(e.target.value)
}}
className="w-full"
disabled={!moduleId || lessonsLoading}
>
<option value="">
{!moduleId
? "Select a module first"
: lessonsLoading
? "Loading lessons…"
: "Select a lesson"}
</option>
{lessons.map((l) => (
<option key={l.id} value={l.id}>
{lessonLabel(l)}
</option>
))}
</Select>
</div>
)}
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button
type="button"
onClick={handleLoad}
disabled={loading || !canLoad}
>
{loading ? <SpinnerIcon className="h-4 w-4" /> : null}
Load practice
</Button>
{selectionError && <p className="text-sm text-amber-700">{selectionError}</p>}
{locationRestoring && (
<p className="text-xs text-grayScale-500 flex items-center gap-2">
<SpinnerIcon className="h-3.5 w-3.5" />
Resolving your link
</p>
)}
</div>
<p className="mt-3 flex items-start gap-2 text-xs text-grayScale-500">
{(() => {
const meta = parentTabCopy[parentTab]
const Icon = meta.icon
return (
<>
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-grayScale-400" />
{meta.hint}
</>
)
})()}
</p>
</CardContent>
</Card>
{flowMode === "create" && (
<CreatePracticeWizard
parent={createWizardParent}
onCreated={() => {
setFlowMode("view")
if (canLoad) {
handleLoad()
}
}}
/>
)}
{flowMode === "view" && (
<Card className="shadow-soft overflow-hidden">
<CardHeader className="border-b border-grayScale-200 bg-grayScale-50/80 pb-4">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="text-base font-semibold text-grayScale-600">Practice</CardTitle>
{searchParams.get("id") && (
<span className="text-xs text-grayScale-500">
{totalCount === 0
? "No results"
: totalCount === 1
? "1 result"
: `${totalCount} results (showing first)`}
</span>
)}
</div>
</CardHeader>
<CardContent className="p-0">
{loading && (
<div className="flex flex-col items-center gap-3 py-16">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
<span className="text-sm text-grayScale-500">Loading practice</span>
</div>
)}
{!loading && loadError && searchParams.get("id") && (
<div className="px-6 py-10 text-center">
<p className="text-sm text-red-600">{loadError}</p>
</div>
)}
{!loading && !loadError && searchParams.get("id") && !practice && (
<div className="px-6 py-14 text-center">
<GraduationCap className="mx-auto h-10 w-10 text-grayScale-300" />
<p className="mt-3 text-sm font-medium text-grayScale-600">No practice for this parent</p>
<p className="mt-1 max-w-md mx-auto text-sm text-grayScale-500">
There is no practice attached to this {parentTabCopy[parentTab].label.toLowerCase()}{" "}
yet, or the selection may be wrong.
</p>
</div>
)}
{!loading && !loadError && practice && (
<div className="grid gap-0 md:grid-cols-[minmax(0,380px),1fr]">
<div className="relative aspect-[4/3] w-full overflow-hidden bg-grayScale-900 md:aspect-auto md:min-h-[280px]">
{practice.story_image?.trim() ? (
<img
src={practice.story_image}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="flex h-full min-h-[200px] items-center justify-center bg-gradient-to-br from-grayScale-800 to-grayScale-900 text-grayScale-500">
<span className="text-sm">No banner image</span>
</div>
)}
</div>
<div className="flex flex-col gap-5 p-6 sm:p-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="font-mono text-[10px] uppercase tracking-wider">
{practice.parent_kind}
</Badge>
<span className="text-xs text-grayScale-500">
parent #{practice.parent_id} · practice #{practice.id}
</span>
</div>
{flowMode === "view" && (
<div className="flex shrink-0 flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="font-semibold"
onClick={() => setEditOpen(true)}
>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="font-semibold text-red-600 hover:text-red-700"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete
</Button>
</div>
)}
</div>
<h2 className="text-xl font-bold leading-tight text-grayScale-800 sm:text-2xl">
{practice.title}
</h2>
<div>
<p className="text-xs font-bold uppercase tracking-wider text-grayScale-500">Story</p>
<p className="mt-1.5 whitespace-pre-wrap text-sm leading-relaxed text-grayScale-700">
{practice.story_description?.trim() || "—"}
</p>
</div>
{quickTipParts.length > 0 && (
<div>
<p className="text-xs font-bold uppercase tracking-wider text-grayScale-500">
Quick tips
</p>
<ul className="mt-2 list-disc space-y-1.5 pl-5 text-sm text-grayScale-700">
{quickTipParts.map((tip, i) => (
<li key={i} className="leading-relaxed">
{tip}
</li>
))}
</ul>
</div>
)}
<div className="mt-auto flex flex-col gap-2 border-t border-grayScale-100 pt-5 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-grayScale-500">
<span className="text-gray-400">Question set</span>{" "}
<span className="font-mono font-medium text-grayScale-700">
#{practice.question_set_id}
</span>
</div>
<p className="text-xs text-grayScale-500">Created {formatDate(practice.created_at)}</p>
</div>
</div>
</div>
)}
{!loading && !loadError && !searchParams.get("id") && (
<div className="px-6 py-14 text-center text-sm text-grayScale-500">
Choose a program{parentTab !== "course" ? ", course" : ""}
{parentTab === "module" || parentTab === "lesson" ? ", module" : ""}
{parentTab === "lesson" ? ", lesson" : ""} above, then <strong>Load practice</strong>.
</div>
)}
</CardContent>
</Card>
)}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-h-[min(90vh,720px)] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit practice</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Title
</p>
<Input value={editTitle} onChange={(e) => setEditTitle(e.target.value)} />
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Story description
</p>
<Textarea
value={editStory}
onChange={(e) => setEditStory(e.target.value)}
rows={4}
className="min-h-[100px]"
/>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Story image URL
</p>
<Input value={editImage} onChange={(e) => setEditImage(e.target.value)} />
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Question set id
</p>
<Input
value={editQuestionSetId}
onChange={(e) => setEditQuestionSetId(e.target.value)}
inputMode="numeric"
className="font-mono"
/>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Quick tips
</p>
<Textarea
value={editQuickTips}
onChange={(e) => setEditQuickTips(e.target.value)}
rows={2}
placeholder="Optional"
/>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
Persona id
</p>
<Input
value={editPersonaId}
onChange={(e) => setEditPersonaId(e.target.value)}
inputMode="numeric"
className="font-mono"
placeholder="Optional"
/>
</div>
<p className="text-xs text-grayScale-500">
Uses <span className="font-mono">PUT /practices/&#123;id&#125;</span> with the fields above.
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={() => setEditOpen(false)} disabled={savePracticeLoading}>
Cancel
</Button>
<Button type="button" onClick={savePracticeEdit} disabled={savePracticeLoading}>
{savePracticeLoading ? <SpinnerIcon className="h-4 w-4" /> : null}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete this practice?</DialogTitle>
</DialogHeader>
<p className="text-sm text-grayScale-600">
This will call <span className="font-mono">DELETE /practices/&#123;id&#125;</span> and remove the practice
for this {parentTabCopy[parentTab].label.toLowerCase()}. The question set is not deleted unless your API
cascades.
</p>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deletePracticeLoading}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={confirmDeletePractice}
disabled={deletePracticeLoading}
>
{deletePracticeLoading ? <SpinnerIcon className="h-4 w-4" /> : null}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}