From 3c4b0c4cd39f716dd260d3f1f4fcb6f2115fe223 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 7 Apr 2026 09:23:05 -0700 Subject: [PATCH] more UI adjustment --- .../content-management/HumanLanguagePage.tsx | 405 +++++++++++++----- 1 file changed, 307 insertions(+), 98 deletions(-) diff --git a/src/pages/content-management/HumanLanguagePage.tsx b/src/pages/content-management/HumanLanguagePage.tsx index ec54e04..fad29ba 100644 --- a/src/pages/content-management/HumanLanguagePage.tsx +++ b/src/pages/content-management/HumanLanguagePage.tsx @@ -1,6 +1,16 @@ import { useEffect, useMemo, useState } from "react" import { Link } from "react-router-dom" -import { ChevronDown, ChevronRight, Languages, Loader2, Plus, Search, Trash2 } from "lucide-react" +import { + ChevronDown, + ChevronRight, + ClipboardList, + Languages, + Loader2, + Plus, + Search, + Trash2, + Video, +} from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Button } from "../../components/ui/button" import { @@ -12,20 +22,36 @@ import { DialogTitle, } from "../../components/ui/dialog" import { SpinnerIcon } from "../../components/ui/spinner-icon" -import { createCourse, createCourseCategory, createHumanLanguageLesson, deleteSubCourse, getHumanLanguageHierarchy } from "../../api/courses.api" +import { + createCourse, + createCourseCategory, + createHumanLanguageLesson, + deleteSubCourse, + getHumanLanguageHierarchy, + getPracticeQuestionsByPractice, +} from "../../api/courses.api" import { Badge } from "../../components/ui/badge" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table" import type { HumanLanguageCourseTree, HumanLanguageSubCategoryTree, LearningPathPractice, LearningPathVideo, + 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" } + | { status: "ok"; questions: QuestionSetQuestion[]; totalCount: number } + | { status: "error"; message: string } + function formatDurationSeconds(total: number): string { const s = Math.max(0, Math.floor(total)) const m = Math.floor(s / 60) @@ -33,13 +59,6 @@ function formatDurationSeconds(total: number): string { return `${m}:${r.toString().padStart(2, "0")}` } -function truncateMiddle(str: string, max = 42): string { - const t = str.trim() - if (t.length <= max) return t - const half = Math.floor((max - 3) / 2) - return `${t.slice(0, half)}…${t.slice(-half)}` -} - function practiceStatusStyle(status: string): string { const u = status.toUpperCase() if (u === "PUBLISHED") return "bg-green-50 text-green-700 ring-1 ring-inset ring-green-200" @@ -74,8 +93,11 @@ export function HumanLanguagePage() { /** Course IDs whose path body is collapsed (headers stay visible). */ const [collapsedPathIds, setCollapsedPathIds] = useState([]) const [pendingRemove, setPendingRemove] = useState(null) - /** Per sub-module panel tab (lessons table vs practices table). */ + /** Per sub-module panel tab (lessons vs practices). */ const [subModulePanelTab, setSubModulePanelTab] = useState>({}) + /** Selected lesson / practice card per sub-module (for inline detail panel). */ + const [subModuleCardSelection, setSubModuleCardSelection] = useState>({}) + const [practiceQuestionsState, setPracticeQuestionsState] = useState>({}) const loadHierarchy = async () => { setLoading(true) @@ -326,6 +348,57 @@ export function HumanLanguagePage() { } } + const loadPracticeQuestionsIfNeeded = async (practiceId: number) => { + let skipFetch = false + setPracticeQuestionsState((prev) => { + const ex = prev[practiceId] + if (ex?.status === "ok" || ex?.status === "loading") { + skipFetch = true + return prev + } + return { ...prev, [practiceId]: { status: "loading" } } + }) + if (skipFetch) return + try { + const res = await getPracticeQuestionsByPractice(practiceId, { limit: 200, offset: 0 }) + const payload = res.data?.data + const questions = payload?.questions ?? [] + const totalCount = payload?.total_count ?? questions.length + setPracticeQuestionsState((prev) => ({ + ...prev, + [practiceId]: { status: "ok", questions, totalCount }, + })) + } catch (error) { + console.error("Failed to load practice questions:", error) + setPracticeQuestionsState((prev) => ({ + ...prev, + [practiceId]: { status: "error", message: "Could not load questions" }, + })) + } + } + + const getSubModuleSelection = (smKey: string): SubModuleCardSelection => + subModuleCardSelection[smKey] ?? { lessonId: null, practiceId: null } + + const toggleLessonCard = (smKey: string, lessonId: number) => { + setSubModuleCardSelection((prev) => { + const cur = prev[smKey] ?? { lessonId: null, practiceId: null } + const nextLessonId = cur.lessonId === lessonId ? null : lessonId + return { ...prev, [smKey]: { ...cur, lessonId: nextLessonId } } + }) + } + + const togglePracticeCard = (smKey: string, practiceId: number) => { + let openedPracticeId: number | null = null + setSubModuleCardSelection((prev) => { + const cur = prev[smKey] ?? { lessonId: null, practiceId: null } + const nextPracticeId = cur.practiceId === practiceId ? null : practiceId + if (nextPracticeId !== null) openedPracticeId = nextPracticeId + return { ...prev, [smKey]: { ...cur, practiceId: nextPracticeId } } + }) + if (openedPracticeId !== null) void loadPracticeQuestionsIfNeeded(openedPracticeId) + } + return (
@@ -610,12 +683,23 @@ export function HumanLanguagePage() { {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 (
) : ( - - - - # - Title - Duration - Order - Video URL - - - - {lessonRows.map((v, idx) => ( - - {idx + 1} - {v.title} - - {formatDurationSeconds(v.duration ?? 0)} - - - {v.display_order} - - - {v.video_url ? ( - - {truncateMiddle(v.video_url, 48)} - - ) : ( - +
+
+ {lessonRows.map((v, idx) => { + const isActive = cardSel.lessonId === v.id + return ( +
+ > +
+
+
+
+

+ {v.title} +

+

+ Lesson {idx + 1} · {formatDurationSeconds(v.duration ?? 0)} · Order{" "} + {v.display_order} +

+
+
+ + ) + })} +
+ {selectedLesson ? ( +
+

+ Lesson content +

+

+ {selectedLesson.title} +

+
+
+
Display order
+
+ {selectedLesson.display_order} +
+
+
+
Duration
+
+ {formatDurationSeconds(selectedLesson.duration ?? 0)} +
+
+
+
Video
+
+ {selectedLesson.video_url ? ( + + {selectedLesson.video_url} + + ) : ( + + No video URL set — use Open editor to add one. + + )} +
+
+
+
+ ) : ( +

+ Select a lesson card to view full content. +

+ )} +
) ) : practiceRows.length === 0 ? (
@@ -752,55 +878,138 @@ export function HumanLanguagePage() { practice.
) : ( - - - - # - Title - Status - Questions - Order - Actions - - - - {practiceRows.map((p, idx) => ( - - {idx + 1} - -

- {p.title} -

-
- - - {(p.status ?? "—").replace(/_/g, " ").toLowerCase()} - - - - {p.question_count} - - - {p.display_order ?? "—"} - - - {categoryId ? ( - - Questions - - ) : ( - +
+
+ {practiceRows.map((p, pIdx) => { + const isActive = cardSel.practiceId === p.id + return ( +
+ > +
+
+ +
+
+

+ {p.title} +

+

+ Practice {pIdx + 1} +

+
+ + {(p.status ?? "—").replace(/_/g, " ").toLowerCase()} + + + {p.question_count} Q · order {p.display_order ?? "—"} + +
+
+
+ + ) + })} +
+ {cardSel.practiceId !== null && selectedPracticeMeta ? ( +
+
+
+

+ Practice questions +

+

+ {selectedPracticeMeta.title} +

+ +
+ {categoryId ? ( + + Edit in full view + + ) : null} +
+ {!practiceFetch || practiceFetch.status === "loading" ? ( +
+ + Loading questions… +
+ ) : practiceFetch.status === "error" ? ( +

{practiceFetch.message}

+ ) : practiceFetch.questions.length === 0 ? ( +

+ No questions yet. Add them via{" "} + Open editor or{" "} + Edit in full view. +

+ ) : ( +
    + {practiceFetch.questions.map((q, qIdx) => ( +
  • +
    + + #{qIdx + 1} + + + {String(q.question_type ?? "—").replace(/_/g, " ")} + + {q.points != null ? ( + + {q.points} pts + + ) : null} + {q.difficulty_level ? ( + + {q.difficulty_level} + + ) : null} +
    +

    + {q.question_text || "—"} +

    + {q.tips ? ( +

    + Tip: + {q.tips} +

    + ) : null} +
  • + ))} +
+ )} + {practiceFetch?.status === "ok" && + practiceFetch.totalCount > practiceFetch.questions.length ? ( +

+ Showing {practiceFetch.questions.length} of {practiceFetch.totalCount}{" "} + questions. Open full editor to see or edit the rest. +

+ ) : null} +
+ ) : ( +

+ Select a practice card to view its questions. +

+ )} + )}