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:
parent
679568e51c
commit
45c385e5fa
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1076
src/pages/content-management/HumanLanguageSubModulePage.tsx
Normal file
1076
src/pages/content-management/HumanLanguageSubModulePage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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") {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user