Compare commits

...

2 Commits

Author SHA1 Message Date
449b595df0 more UI adjustment 2026-04-07 09:12:57 -07:00
45c385e5fa Add Human Language sub-module page with Lesson/Practice tabs.
Dedicated routes under /content/human-language/.../sub-module/:id for lesson videos and practice cards (aligned with existing sub-course styling); confirm removals via Dialog on hierarchy page; wire add-practice and questions back navigation for HL paths.

Made-with: Cursor
2026-04-07 09:01:59 -07:00
5 changed files with 1386 additions and 63 deletions

View File

@ -32,6 +32,7 @@ import { PracticeMembersPage } from "../pages/content-management/PracticeMembers
import { QuestionsPage } from "../pages/content-management/QuestionsPage" import { QuestionsPage } from "../pages/content-management/QuestionsPage"
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage" import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage" import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage"
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage"
import { UserLogPage } from "../pages/user-log/UserLogPage" import { UserLogPage } from "../pages/user-log/UserLogPage"
import { IssuesPage } from "../pages/issues/IssuesPage" import { IssuesPage } from "../pages/issues/IssuesPage"
import { ProfilePage } from "../pages/ProfilePage" import { ProfilePage } from "../pages/ProfilePage"
@ -78,6 +79,18 @@ export function AppRoutes() {
<Route path="courses" element={<AllCoursesPage />} /> <Route path="courses" element={<AllCoursesPage />} />
<Route path="flows" element={<CourseFlowBuilderPage />} /> <Route path="flows" element={<CourseFlowBuilderPage />} />
<Route path="human-language" element={<HumanLanguagePage />} /> <Route path="human-language" element={<HumanLanguagePage />} />
<Route
path="human-language/:categoryId/:courseId/sub-module/:subCourseId/add-practice"
element={<AddNewPracticePage />}
/>
<Route
path="human-language/:categoryId/:courseId/sub-module/:subCourseId/practices/:practiceId/questions"
element={<PracticeQuestionsPage />}
/>
<Route
path="human-language/:categoryId/:courseId/sub-module/:subCourseId"
element={<HumanLanguageSubModulePage />}
/>
<Route path="category/:categoryId" element={<ContentOverviewPage />} /> <Route path="category/:categoryId" element={<ContentOverviewPage />} />
<Route path="category/:categoryId/courses" element={<CoursesPage />} /> <Route path="category/:categoryId/courses" element={<CoursesPage />} />
{/* Course → Sub-course → Video/Practice */} {/* Course → Sub-course → Video/Practice */}

View File

@ -100,9 +100,12 @@ export function AddNewPracticePage() {
const searchParams = new URLSearchParams(location.search) const searchParams = new URLSearchParams(location.search)
const source = searchParams.get("source") const source = searchParams.get("source")
const backTo = useMemo(() => { const backTo = useMemo(() => {
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subCourseId}`
}
if (source === "human-language") return "/content/human-language" if (source === "human-language") return "/content/human-language"
return `/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}` return `/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`
}, [source, categoryId, courseId, subCourseId]) }, [location.pathname, source, categoryId, courseId, subCourseId])
const [currentStep, setCurrentStep] = useState<Step>(1) const [currentStep, setCurrentStep] = useState<Step>(1)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)

View File

@ -1,16 +1,62 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { BookOpen, ChevronDown, ChevronRight, Languages, Loader2, Plus, Search, Trash2 } from "lucide-react" import { ChevronDown, ChevronRight, Languages, Loader2, Plus, Search, Trash2 } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { SpinnerIcon } from "../../components/ui/spinner-icon" import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { createCourse, createCourseCategory, createHumanLanguageLesson, deleteSubCourse, getHumanLanguageHierarchy } from "../../api/courses.api" import { createCourse, createCourseCategory, createHumanLanguageLesson, deleteSubCourse, getHumanLanguageHierarchy } from "../../api/courses.api"
import type { HumanLanguageCourseTree, HumanLanguageSubCategoryTree } from "../../types/course.types" import { Badge } from "../../components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
import type {
HumanLanguageCourseTree,
HumanLanguageSubCategoryTree,
LearningPathPractice,
LearningPathVideo,
} from "../../types/course.types"
import { toast } from "sonner" import { toast } from "sonner"
const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const
type SubModulePanelTab = "lessons" | "practices"
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 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"
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"
}
type CefrLevel = (typeof CEFR_LEVELS)[number] type CefrLevel = (typeof CEFR_LEVELS)[number]
type PendingRemove = {
ids: number[]
key: string
successMessage: string
title: string
description: string
}
export function HumanLanguagePage() { export function HumanLanguagePage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [categoryId, setCategoryId] = useState<number | null>(null) const [categoryId, setCategoryId] = useState<number | null>(null)
@ -27,6 +73,9 @@ export function HumanLanguagePage() {
const [deletingKey, setDeletingKey] = useState<string | null>(null) const [deletingKey, setDeletingKey] = useState<string | null>(null)
/** Course IDs whose path body is collapsed (headers stay visible). */ /** Course IDs whose path body is collapsed (headers stay visible). */
const [collapsedPathIds, setCollapsedPathIds] = useState<number[]>([]) const [collapsedPathIds, setCollapsedPathIds] = useState<number[]>([])
const [pendingRemove, setPendingRemove] = useState<PendingRemove | null>(null)
/** Per sub-module panel tab (lessons table vs practices table). */
const [subModulePanelTab, setSubModulePanelTab] = useState<Record<string, SubModulePanelTab>>({})
const loadHierarchy = async () => { const loadHierarchy = async () => {
setLoading(true) setLoading(true)
@ -188,10 +237,15 @@ export function HumanLanguagePage() {
} }
} }
const handleDeleteSubModules = async (ids: number[], key: string, successMessage: string) => { const requestRemove = (payload: PendingRemove) => {
if (ids.length === 0) return if (payload.ids.length === 0) return
const proceed = window.confirm("This action will permanently delete selected item(s). Continue?") setPendingRemove(payload)
if (!proceed) return }
const executePendingRemove = async () => {
if (!pendingRemove) return
const { ids, key, successMessage } = pendingRemove
setPendingRemove(null)
setDeletingKey(key) setDeletingKey(key)
try { try {
for (const id of ids) { for (const id of ids) {
@ -427,13 +481,6 @@ export function HumanLanguagePage() {
<span className="text-base font-semibold text-brand-700">{course.course_name}</span> <span className="text-base font-semibold text-brand-700">{course.course_name}</span>
</button> </button>
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
{categoryId ? (
<Link to={`/content/category/${categoryId}/courses/${course.course_id}/sub-courses`}>
<Button type="button" variant="outline" size="sm" className="shrink-0">
Open detailed management
</Button>
</Link>
) : null}
<Button <Button
type="button" type="button"
size="sm" size="sm"
@ -485,7 +532,13 @@ export function HumanLanguagePage() {
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" 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}`} disabled={!canRemoveLevel || deletingKey === `level-${course.course_id}-${level}`}
onClick={() => onClick={() =>
handleDeleteSubModules(levelRemoveIds, `level-${course.course_id}-${level}`, `Level ${level} removed`) 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 /> <Trash2 className="h-3 w-3.5" aria-hidden />
@ -539,11 +592,14 @@ export function HumanLanguagePage() {
className="h-8 gap-1 border-red-200/90 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50" 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}`} disabled={deletingKey === `module-${module.id}`}
onClick={() => onClick={() =>
handleDeleteSubModules( requestRemove({
module.sub_modules.map((s) => s.id), ids: module.sub_modules.map((s) => s.id),
`module-${module.id}`, key: `module-${module.id}`,
`Module ${module.title} removed`, 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 /> <Trash2 className="h-3 w-3.5" aria-hidden />
@ -551,53 +607,205 @@ export function HumanLanguagePage() {
</Button> </Button>
</div> </div>
</div> </div>
{module.sub_modules.map((subModule) => ( {module.sub_modules.map((subModule) => {
<div key={subModule.id} className="mt-2 rounded-md border border-grayScale-100 bg-white p-2"> const smKey = `${course.course_id}-${subModule.id}`
<div className="flex flex-wrap items-center justify-between gap-2"> const panelTab = subModulePanelTab[smKey] ?? "lessons"
<p className="text-xs font-semibold text-grayScale-700">Sub-module: {subModule.title}</p> const lessonRows: LearningPathVideo[] = [...subModule.videos].sort(
{categoryId ? ( (a, b) => a.display_order - b.display_order,
<div className="flex gap-2"> )
<Link to={`/content/category/${categoryId}/courses/${course.course_id}/sub-courses/${subModule.id}`}> const practiceRows: LearningPathPractice[] = [...subModule.practices].sort(
<Button size="sm" variant="outline">Manage lesson videos/audio</Button> (a, b) => (a.display_order ?? 0) - (b.display_order ?? 0),
</Link> )
<Link to={`/content/category/${categoryId}/courses/${course.course_id}/sub-courses/${subModule.id}/add-practice?source=human-language`}> return (
<Button size="sm">Add practice/audio questions</Button> <div
</Link> key={subModule.id}
<Button 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 gap-6">
<button
type="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={() => onClick={() =>
handleDeleteSubModules( setSubModulePanelTab((prev) => ({ ...prev, [smKey]: "lessons" }))
[subModule.id],
`submodule-${subModule.id}`,
`Sub-module ${subModule.title} removed`,
)
} }
className={`relative pb-3 pt-2 text-sm font-semibold transition-colors ${
panelTab === "lessons"
? "text-brand-600"
: "text-grayScale-400 hover:text-grayScale-700"
}`}
> >
<Trash2 className="h-3 w-3.5" aria-hidden /> Lessons
Remove {panelTab === "lessons" ? (
</Button> <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> </div>
) : null} </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>
) : (
<Table>
<TableHeader>
<TableRow className="border-grayScale-100 hover:bg-transparent">
<TableHead className="w-10 text-grayScale-500">#</TableHead>
<TableHead>Title</TableHead>
<TableHead className="whitespace-nowrap">Duration</TableHead>
<TableHead className="whitespace-nowrap">Order</TableHead>
<TableHead>Video URL</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lessonRows.map((v, idx) => (
<TableRow key={v.id} className="border-grayScale-100">
<TableCell className="text-xs text-grayScale-500">{idx + 1}</TableCell>
<TableCell className="font-medium text-grayScale-900">{v.title}</TableCell>
<TableCell className="tabular-nums text-grayScale-600">
{formatDurationSeconds(v.duration ?? 0)}
</TableCell>
<TableCell className="tabular-nums text-grayScale-600">
{v.display_order}
</TableCell>
<TableCell>
{v.video_url ? (
<a
href={v.video_url}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-brand-600 hover:underline"
title={v.video_url}
>
{truncateMiddle(v.video_url, 48)}
</a>
) : (
<span className="text-xs text-grayScale-400"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
) : 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>
) : (
<Table>
<TableHeader>
<TableRow className="border-grayScale-100 hover:bg-transparent">
<TableHead className="w-10 text-grayScale-500">#</TableHead>
<TableHead>Title</TableHead>
<TableHead className="whitespace-nowrap">Status</TableHead>
<TableHead className="whitespace-nowrap text-right">Questions</TableHead>
<TableHead className="whitespace-nowrap">Order</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{practiceRows.map((p, idx) => (
<TableRow key={p.id} className="border-grayScale-100">
<TableCell className="text-xs text-grayScale-500">{idx + 1}</TableCell>
<TableCell className="max-w-[220px]">
<p className="truncate font-medium text-grayScale-900" title={p.title}>
{p.title}
</p>
</TableCell>
<TableCell>
<Badge
className={`rounded-full px-2 py-0.5 text-[11px] font-semibold capitalize ${practiceStatusStyle(p.status)}`}
>
{(p.status ?? "—").replace(/_/g, " ").toLowerCase()}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums text-grayScale-700">
{p.question_count}
</TableCell>
<TableCell className="tabular-nums text-grayScale-600">
{p.display_order ?? "—"}
</TableCell>
<TableCell className="text-right">
{categoryId ? (
<Link
to={`/content/human-language/${categoryId}/${course.course_id}/sub-module/${subModule.id}/practices/${p.id}/questions`}
className="text-xs font-semibold text-brand-600 hover:text-brand-700 hover:underline"
>
Questions
</Link>
) : (
<span className="text-xs text-grayScale-400"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div> </div>
<div className="mt-2 flex flex-wrap gap-2"> )
{subModule.videos.map((video) => ( })}
<div key={video.id} className="inline-flex items-center gap-2 rounded-md bg-grayScale-50 px-2 py-1 text-xs text-grayScale-700">
<BookOpen className="h-3.5 w-3.5" />
{video.title}
</div>
))}
{subModule.practices.map((practice) => (
<div key={practice.id} className="rounded-md bg-brand-50 px-2 py-1 text-xs text-brand-700">
Practice: {practice.title} ({practice.question_count} audio question(s))
</div>
))}
</div>
</div>
))}
</div> </div>
)) ))
)} )}
@ -615,6 +823,23 @@ export function HumanLanguagePage() {
: null} : null}
</div> </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>
</div> </div>
) )
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { Link, useParams } from "react-router-dom" import { Link, useLocation, useParams } from "react-router-dom"
import { ArrowLeft, Plus, Edit, Trash2, X, Check, ChevronDown, ChevronUp, SlidersHorizontal, ArrowUpDown } from "lucide-react" import { ArrowLeft, Plus, Edit, Trash2, X, Check, ChevronDown, ChevronUp, SlidersHorizontal, ArrowUpDown } from "lucide-react"
import practiceSrc from "../../assets/Practice.svg" import practiceSrc from "../../assets/Practice.svg"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
@ -59,6 +59,7 @@ const typeColors: Record<QuestionType, string> = {
export function PracticeQuestionsPage() { export function PracticeQuestionsPage() {
const { categoryId, courseId, subCourseId, practiceId } = useParams() const { categoryId, courseId, subCourseId, practiceId } = useParams()
const location = useLocation()
const [questions, setQuestions] = useState<PracticeQuestion[]>([]) const [questions, setQuestions] = useState<PracticeQuestion[]>([])
const [practiceTitle, setPracticeTitle] = useState("Practice Questions") const [practiceTitle, setPracticeTitle] = useState("Practice Questions")
@ -100,7 +101,12 @@ export function PracticeQuestionsPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null) const [saveError, setSaveError] = useState<string | null>(null)
const backLink = `/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}` const backLink = useMemo(() => {
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subCourseId}`
}
return `/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`
}, [location.pathname, categoryId, courseId, subCourseId])
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => { const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
if (type === "TRUE_FALSE") { if (type === "TRUE_FALSE") {