Compare commits
2 Commits
679568e51c
...
449b595df0
| Author | SHA1 | Date | |
|---|---|---|---|
| 449b595df0 | |||
| 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,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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