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
This commit is contained in:
Yared Yemane 2026-04-07 09:01:59 -07:00
parent 679568e51c
commit 45c385e5fa
5 changed files with 1178 additions and 45 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,8 +1,16 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { 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 type { HumanLanguageCourseTree, HumanLanguageSubCategoryTree } from "../../types/course.types"
@ -11,6 +19,14 @@ 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 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 +43,7 @@ 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)
const loadHierarchy = async () => { const loadHierarchy = async () => {
setLoading(true) setLoading(true)
@ -188,10 +205,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 +449,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 +500,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 +560,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 />
@ -556,12 +580,13 @@ export function HumanLanguagePage() {
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-semibold text-grayScale-700">Sub-module: {subModule.title}</p> <p className="text-xs font-semibold text-grayScale-700">Sub-module: {subModule.title}</p>
{categoryId ? ( {categoryId ? (
<div className="flex gap-2"> <div className="flex flex-wrap items-center gap-2">
<Link to={`/content/category/${categoryId}/courses/${course.course_id}/sub-courses/${subModule.id}`}> <Link
<Button size="sm" variant="outline">Manage lesson videos/audio</Button> to={`/content/human-language/${categoryId}/${course.course_id}/sub-module/${subModule.id}`}
</Link> >
<Link to={`/content/category/${categoryId}/courses/${course.course_id}/sub-courses/${subModule.id}/add-practice?source=human-language`}> <Button size="sm" className="bg-brand-500 hover:bg-brand-600">
<Button size="sm">Add practice/audio questions</Button> Manage lesson & practice
</Button>
</Link> </Link>
<Button <Button
type="button" type="button"
@ -570,11 +595,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 === `submodule-${subModule.id}`} disabled={deletingKey === `submodule-${subModule.id}`}
onClick={() => onClick={() =>
handleDeleteSubModules( requestRemove({
[subModule.id], ids: [subModule.id],
`submodule-${subModule.id}`, key: `submodule-${subModule.id}`,
`Sub-module ${subModule.title} removed`, 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 /> <Trash2 className="h-3 w-3.5" aria-hidden />
@ -583,19 +611,9 @@ export function HumanLanguagePage() {
</div> </div>
) : null} ) : null}
</div> </div>
<div className="mt-2 flex flex-wrap gap-2"> <p className="mt-2 text-xs text-grayScale-500">
{subModule.videos.map((video) => ( {subModule.videos.length} lesson video(s) · {subModule.practices.length} practice(s)
<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"> </p>
<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> </div>
@ -615,6 +633,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") {