Compare commits
No commits in common. "bf0dabbf05fc3dc91b5696273075a06bd2e8b800" and "ee2dbc57926d3276b31cde59f91566a8355dd5e9" have entirely different histories.
bf0dabbf05
...
ee2dbc5792
|
|
@ -1,5 +1,5 @@
|
||||||
import { useMemo, useRef, useState, type ChangeEvent } from "react"
|
import { useRef, useState, type ChangeEvent } from "react"
|
||||||
import { Link, useLocation, useParams, useNavigate } from "react-router-dom"
|
import { Link, useParams, useNavigate } from "react-router-dom"
|
||||||
import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, X, Edit, Rocket, Loader2, Upload } from "lucide-react"
|
import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, X, Edit, Rocket, Loader2, Upload } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Card } from "../../components/ui/card"
|
import { Card } from "../../components/ui/card"
|
||||||
|
|
@ -12,7 +12,7 @@ import type { QuestionOption } from "../../types/course.types"
|
||||||
|
|
||||||
type Step = 1 | 2 | 3 | 4 | 5
|
type Step = 1 | 2 | 3 | 4 | 5
|
||||||
type ResultStatus = "success" | "error"
|
type ResultStatus = "success" | "error"
|
||||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT"
|
||||||
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
||||||
|
|
||||||
interface Persona {
|
interface Persona {
|
||||||
|
|
@ -37,7 +37,6 @@ interface Question {
|
||||||
options: MCQOption[]
|
options: MCQOption[]
|
||||||
voicePrompt: string
|
voicePrompt: string
|
||||||
sampleAnswerVoicePrompt: string
|
sampleAnswerVoicePrompt: string
|
||||||
audioCorrectAnswerText: string
|
|
||||||
shortAnswers: string[]
|
shortAnswers: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,21 +87,13 @@ function createEmptyQuestion(id: string): Question {
|
||||||
],
|
],
|
||||||
voicePrompt: "",
|
voicePrompt: "",
|
||||||
sampleAnswerVoicePrompt: "",
|
sampleAnswerVoicePrompt: "",
|
||||||
audioCorrectAnswerText: "",
|
|
||||||
shortAnswers: [],
|
shortAnswers: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddNewPracticePage() {
|
export function AddNewPracticePage() {
|
||||||
const { categoryId, courseId, subCourseId } = useParams()
|
const { categoryId, courseId, subCourseId } = useParams()
|
||||||
const location = useLocation()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const searchParams = new URLSearchParams(location.search)
|
|
||||||
const source = searchParams.get("source")
|
|
||||||
const backTo = useMemo(() => {
|
|
||||||
if (source === "human-language") return "/content/human-language"
|
|
||||||
return `/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`
|
|
||||||
}, [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)
|
||||||
|
|
@ -143,7 +134,7 @@ export function AddNewPracticePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
navigate(backTo)
|
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleIntroVideoFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
const handleIntroVideoFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -256,7 +247,6 @@ export function AddNewPracticePage() {
|
||||||
options: options.length > 0 ? options : undefined,
|
options: options.length > 0 ? options : undefined,
|
||||||
voice_prompt: q.voicePrompt || undefined,
|
voice_prompt: q.voicePrompt || undefined,
|
||||||
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
|
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
|
||||||
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
|
|
||||||
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
|
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -307,7 +297,7 @@ export function AddNewPracticePage() {
|
||||||
<>
|
<>
|
||||||
{/* Back Link */}
|
{/* Back Link */}
|
||||||
<Link
|
<Link
|
||||||
to={backTo}
|
to={`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`}
|
||||||
className="group inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-grayScale-600 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
className="group inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-grayScale-600 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
||||||
|
|
@ -598,7 +588,7 @@ export function AddNewPracticePage() {
|
||||||
<div className="rounded-2xl border border-grayScale-200/80 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 shadow-sm sm:px-8 sm:py-6">
|
<div className="rounded-2xl border border-grayScale-200/80 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 shadow-sm sm:px-8 sm:py-6">
|
||||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Questions</h2>
|
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Questions</h2>
|
||||||
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
|
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
|
||||||
Add MCQ, True/False, Short Answer, or Audio items. Use the full width for stems and options.
|
Add MCQ, True/False, or Short Answer items. Use the full width for stems and options.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -646,7 +636,6 @@ export function AddNewPracticePage() {
|
||||||
<option value="MCQ">Multiple Choice</option>
|
<option value="MCQ">Multiple Choice</option>
|
||||||
<option value="TRUE_FALSE">True/False</option>
|
<option value="TRUE_FALSE">True/False</option>
|
||||||
<option value="SHORT">Short Answer</option>
|
<option value="SHORT">Short Answer</option>
|
||||||
<option value="AUDIO">Audio</option>
|
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -811,19 +800,6 @@ export function AddNewPracticePage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{question.questionType === "AUDIO" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
|
||||||
Audio Correct Answer Text
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={question.audioCorrectAnswerText}
|
|
||||||
onChange={(e) => updateQuestion(question.id, { audioCorrectAnswerText: e.target.value })}
|
|
||||||
placeholder="Expected correct answer text for audio response"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
@ -949,13 +925,7 @@ export function AddNewPracticePage() {
|
||||||
<p className="text-sm font-medium leading-relaxed text-grayScale-900">{question.questionText}</p>
|
<p className="text-sm font-medium leading-relaxed text-grayScale-900">{question.questionText}</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="rounded-md bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600">
|
<span className="rounded-md bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600">
|
||||||
{question.questionType === "MCQ"
|
{question.questionType === "MCQ" ? "Multiple Choice" : question.questionType === "TRUE_FALSE" ? "True/False" : "Short Answer"}
|
||||||
? "Multiple Choice"
|
|
||||||
: question.questionType === "TRUE_FALSE"
|
|
||||||
? "True/False"
|
|
||||||
: question.questionType === "AUDIO"
|
|
||||||
? "Audio"
|
|
||||||
: "Short Answer"}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600">
|
<span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600">
|
||||||
{question.difficultyLevel}
|
{question.difficultyLevel}
|
||||||
|
|
@ -1031,7 +1001,7 @@ export function AddNewPracticePage() {
|
||||||
<div className="mt-10 flex w-full max-w-sm flex-col gap-3">
|
<div className="mt-10 flex w-full max-w-sm flex-col gap-3">
|
||||||
<Button
|
<Button
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600"
|
className="w-full bg-brand-500 hover:bg-brand-600"
|
||||||
onClick={() => navigate(backTo)}
|
onClick={() => navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)}
|
||||||
>
|
>
|
||||||
Go back to Course
|
Go back to Course
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
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 { BookOpen, ChevronDown, ChevronRight, Languages, Loader2, Plus } 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 { SpinnerIcon } from "../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
import { createCourse, createCourseCategory, createHumanLanguageLesson, deleteSubCourse, getHumanLanguageHierarchy } from "../../api/courses.api"
|
import { createHumanLanguageLesson, getHumanLanguageHierarchy } from "../../api/courses.api"
|
||||||
import type { HumanLanguageCourseTree, HumanLanguageSubCategoryTree } from "../../types/course.types"
|
import type { HumanLanguageCourseTree, HumanLanguageSubCategoryTree } from "../../types/course.types"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
|
@ -20,11 +20,6 @@ export function HumanLanguagePage() {
|
||||||
const [selectedLevel, setSelectedLevel] = useState<CefrLevel | "ALL">("ALL")
|
const [selectedLevel, setSelectedLevel] = useState<CefrLevel | "ALL">("ALL")
|
||||||
const [collapsedLevels, setCollapsedLevels] = useState<CefrLevel[]>([])
|
const [collapsedLevels, setCollapsedLevels] = useState<CefrLevel[]>([])
|
||||||
const [creatingKey, setCreatingKey] = useState<string | null>(null)
|
const [creatingKey, setCreatingKey] = useState<string | null>(null)
|
||||||
const [quickSubCategoryName, setQuickSubCategoryName] = useState("")
|
|
||||||
const [quickCourseName, setQuickCourseName] = useState("")
|
|
||||||
const [quickSearch, setQuickSearch] = useState("")
|
|
||||||
const [quickCreating, setQuickCreating] = useState(false)
|
|
||||||
const [deletingKey, setDeletingKey] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const loadHierarchy = async () => {
|
const loadHierarchy = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -70,13 +65,6 @@ export function HumanLanguagePage() {
|
||||||
[availableCourses, selectedCourseId],
|
[availableCourses, selectedCourseId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const levelsForSelectedCourse = useMemo(() => {
|
|
||||||
if (selectedCourseId === "ALL") return [] as string[]
|
|
||||||
const course = selectedCourses.find((c) => c.course_id === selectedCourseId)
|
|
||||||
if (!course) return []
|
|
||||||
return course.levels.filter((l) => l.modules.length > 0).map((l) => l.level.toUpperCase())
|
|
||||||
}, [selectedCourses, selectedCourseId])
|
|
||||||
|
|
||||||
const toggleLevel = (level: CefrLevel) => {
|
const toggleLevel = (level: CefrLevel) => {
|
||||||
setCollapsedLevels((prev) => (prev.includes(level) ? prev.filter((l) => l !== level) : [...prev, level]))
|
setCollapsedLevels((prev) => (prev.includes(level) ? prev.filter((l) => l !== level) : [...prev, level]))
|
||||||
}
|
}
|
||||||
|
|
@ -159,89 +147,6 @@ export function HumanLanguagePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteSubModules = async (ids: number[], key: string, successMessage: string) => {
|
|
||||||
if (ids.length === 0) return
|
|
||||||
const proceed = window.confirm("This action will permanently delete selected item(s). Continue?")
|
|
||||||
if (!proceed) return
|
|
||||||
setDeletingKey(key)
|
|
||||||
try {
|
|
||||||
for (const id of ids) {
|
|
||||||
await deleteSubCourse(id)
|
|
||||||
}
|
|
||||||
toast.success(successMessage)
|
|
||||||
await loadHierarchy()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete item(s):", error)
|
|
||||||
toast.error("Failed to delete item(s)")
|
|
||||||
} finally {
|
|
||||||
setDeletingKey(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateNextLevel = async () => {
|
|
||||||
if (selectedCourseId === "ALL") {
|
|
||||||
toast.error("Select a specific course first")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const existing = new Set(levelsForSelectedCourse)
|
|
||||||
const next = CEFR_LEVELS.find((level) => !existing.has(level))
|
|
||||||
if (!next) {
|
|
||||||
toast.error("All CEFR levels are already created")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const key = `next-level-${selectedCourseId}-${next}`
|
|
||||||
setCreatingKey(key)
|
|
||||||
try {
|
|
||||||
await createHumanLanguageLesson({
|
|
||||||
course_id: selectedCourseId,
|
|
||||||
cefr_level: next,
|
|
||||||
title: "Module-1",
|
|
||||||
description: `${next} Module-1`,
|
|
||||||
})
|
|
||||||
toast.success(`${next} created with Module-1`)
|
|
||||||
await loadHierarchy()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to create next level:", error)
|
|
||||||
toast.error("Failed to create next level")
|
|
||||||
} finally {
|
|
||||||
setCreatingKey(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleQuickCreatePath = async () => {
|
|
||||||
if (!quickSubCategoryName.trim() || !quickCourseName.trim()) {
|
|
||||||
toast.error("Subcategory and course names are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setQuickCreating(true)
|
|
||||||
try {
|
|
||||||
let effectiveCategoryId = categoryId
|
|
||||||
if (!effectiveCategoryId) {
|
|
||||||
const createdCategory = await createCourseCategory({ name: "Human Language" })
|
|
||||||
effectiveCategoryId = createdCategory.data?.data?.id ?? null
|
|
||||||
setCategoryId(effectiveCategoryId)
|
|
||||||
}
|
|
||||||
if (!effectiveCategoryId) {
|
|
||||||
throw new Error("Missing human language category id")
|
|
||||||
}
|
|
||||||
const title = `${quickSubCategoryName.trim()} - ${quickCourseName.trim()}`
|
|
||||||
await createCourse({
|
|
||||||
category_id: effectiveCategoryId,
|
|
||||||
title,
|
|
||||||
description: `${quickSubCategoryName.trim()} / ${quickCourseName.trim()}`,
|
|
||||||
})
|
|
||||||
toast.success("Subcategory/course path created")
|
|
||||||
setQuickSubCategoryName("")
|
|
||||||
setQuickCourseName("")
|
|
||||||
await loadHierarchy()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to quick-create language path:", error)
|
|
||||||
toast.error("Failed to create subcategory/course path")
|
|
||||||
} finally {
|
|
||||||
setQuickCreating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="rounded-2xl border border-grayScale-200 bg-gradient-to-r from-white to-brand-50/30 p-5 shadow-sm">
|
<div className="rounded-2xl border border-grayScale-200 bg-gradient-to-r from-white to-brand-50/30 p-5 shadow-sm">
|
||||||
|
|
@ -313,17 +218,6 @@ export function HumanLanguagePage() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardContent className="pt-0">
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCreateNextLevel}
|
|
||||||
disabled={selectedCourseId === "ALL" || levelsForSelectedCourse.length >= CEFR_LEVELS.length || creatingKey?.startsWith("next-level-")}
|
|
||||||
>
|
|
||||||
{creatingKey?.startsWith("next-level-") ? "Creating level..." : "Add Next Level"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{categoryId && selectedCourseId !== "ALL" ? (
|
{categoryId && selectedCourseId !== "ALL" ? (
|
||||||
|
|
@ -341,217 +235,119 @@ export function HumanLanguagePage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{availableCourses.length === 0 ? (
|
{CEFR_LEVELS.filter((l) => selectedLevel === "ALL" || l === selectedLevel).map((level) => {
|
||||||
<Card className="overflow-hidden border-grayScale-200/80">
|
const modulesByCourse = selectedCourses
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 bg-white px-5 py-4">
|
.map((course: HumanLanguageCourseTree) => {
|
||||||
<h3 className="text-lg font-semibold text-grayScale-800">Sub-category Management</h3>
|
const levelNode = course.levels.find((item) => item.level.toUpperCase() === level)
|
||||||
<div className="relative w-full max-w-sm">
|
return {
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
course,
|
||||||
<input
|
modules: levelNode?.modules ?? [],
|
||||||
className="h-11 w-full rounded-xl border border-grayScale-200 bg-white pl-9 pr-3 text-sm"
|
}
|
||||||
placeholder="Search sub-categories..."
|
})
|
||||||
value={quickSearch}
|
return (
|
||||||
onChange={(e) => setQuickSearch(e.target.value)}
|
<Card key={level} className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||||
/>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between border-b border-grayScale-100 bg-grayScale-50/60 px-4 py-3 text-left"
|
||||||
|
onClick={() => toggleLevel(level)}
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
{collapsedLevels.includes(level) ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
<span className="text-sm font-semibold text-grayScale-900">{level}</span>
|
||||||
|
<span className="rounded-md bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">
|
||||||
|
{modulesByCourse.reduce((sum, entry) => sum + entry.modules.length, 0)} module(s)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
<CardContent className="p-5">
|
{!collapsedLevels.includes(level) ? (
|
||||||
<div className="rounded-2xl border border-dashed border-grayScale-300 bg-grayScale-50/20 px-6 py-10 text-center">
|
<CardContent className="space-y-3 p-4">
|
||||||
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-brand-100 text-brand-700">
|
{modulesByCourse.length === 0 ? (
|
||||||
<Languages className="h-6 w-6" />
|
<p className="text-sm text-grayScale-500">No lessons found for this level.</p>
|
||||||
</div>
|
) : (
|
||||||
<h4 className="text-xl font-semibold text-grayScale-800">No sub-categories yet</h4>
|
modulesByCourse.map((entry) => (
|
||||||
<p className="mt-2 text-sm text-grayScale-500">
|
<div key={entry.course.course_id} className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
|
||||||
Create your first human-language path. Level listing will appear automatically after creation.
|
<div className="flex items-center justify-between gap-2">
|
||||||
</p>
|
<p className="text-sm font-semibold text-brand-700">{entry.course.course_name}</p>
|
||||||
<div className="mx-auto mt-5 grid max-w-3xl grid-cols-1 gap-2 md:grid-cols-3">
|
<Button
|
||||||
<input
|
size="sm"
|
||||||
className="h-10 rounded-md border border-grayScale-200 bg-white px-3 text-sm"
|
variant="outline"
|
||||||
placeholder="Subcategory (e.g., English)"
|
onClick={() => handleCreateModule(entry.course.course_id, level, entry.modules)}
|
||||||
value={quickSubCategoryName}
|
disabled={creatingKey === `module-${entry.course.course_id}-${level}`}
|
||||||
onChange={(e) => setQuickSubCategoryName(e.target.value)}
|
>
|
||||||
/>
|
{creatingKey === `module-${entry.course.course_id}-${level}` ? (
|
||||||
<input
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
className="h-10 rounded-md border border-grayScale-200 bg-white px-3 text-sm"
|
) : (
|
||||||
placeholder="Course (e.g., Speaking)"
|
<Plus className="h-3.5 w-3.5" />
|
||||||
value={quickCourseName}
|
)}
|
||||||
onChange={(e) => setQuickCourseName(e.target.value)}
|
Add Module
|
||||||
/>
|
</Button>
|
||||||
<Button onClick={handleQuickCreatePath} disabled={quickCreating}>
|
</div>
|
||||||
{quickCreating ? "Creating..." : "Add your first sub-category"}
|
<div className="space-y-2">
|
||||||
</Button>
|
{entry.modules.length === 0 ? (
|
||||||
</div>
|
<p className="text-xs text-grayScale-500">No modules yet. Use “Add Module” to start.</p>
|
||||||
</div>
|
) : (
|
||||||
</CardContent>
|
entry.modules.map((module) => (
|
||||||
</Card>
|
<div key={module.id} className="rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-3">
|
||||||
) : null}
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-sm font-semibold text-grayScale-900">Module: {module.title}</p>
|
||||||
{availableCourses.length > 0
|
<Button
|
||||||
? CEFR_LEVELS.filter((l) => selectedLevel === "ALL" || l === selectedLevel).map((level) => {
|
size="sm"
|
||||||
const modulesByCourse = selectedCourses
|
variant="outline"
|
||||||
.map((course: HumanLanguageCourseTree) => {
|
onClick={() =>
|
||||||
const levelNode = course.levels.find((item) => item.level.toUpperCase() === level)
|
handleCreateSubModule(entry.course.course_id, level, module.title, module.sub_modules)
|
||||||
return {
|
}
|
||||||
course,
|
disabled={creatingKey === `submodule-${entry.course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}`}
|
||||||
modules: levelNode?.modules ?? [],
|
>
|
||||||
}
|
{creatingKey === `submodule-${entry.course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}` ? (
|
||||||
})
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
.filter((entry) => entry.modules.length > 0 || (selectedCourses.length > 0 && level === "A1"))
|
) : (
|
||||||
return (
|
<Plus className="h-3.5 w-3.5" />
|
||||||
<Card key={level} className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
)}
|
||||||
<button
|
Add Sub-module
|
||||||
type="button"
|
</Button>
|
||||||
className="flex w-full items-center justify-between border-b border-grayScale-100 bg-grayScale-50/60 px-4 py-3 text-left"
|
</div>
|
||||||
onClick={() => toggleLevel(level)}
|
{module.sub_modules.map((subModule) => (
|
||||||
>
|
<div key={subModule.id} className="mt-2 rounded-md border border-grayScale-100 bg-white p-2">
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
{collapsedLevels.includes(level) ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
<p className="text-xs font-semibold text-grayScale-700">Sub-module: {subModule.title}</p>
|
||||||
<span className="text-sm font-semibold text-grayScale-900">{level}</span>
|
{categoryId ? (
|
||||||
<span className="rounded-md bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">
|
|
||||||
{modulesByCourse.reduce((sum, entry) => sum + entry.modules.length, 0)} module(s)
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="ml-3 border-red-200 text-red-600 hover:bg-red-50"
|
|
||||||
disabled={selectedCourseId === "ALL" || deletingKey === `level-${selectedCourseId}-${level}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (selectedCourseId === "ALL") return
|
|
||||||
const courseEntry = modulesByCourse.find((entry) => entry.course.course_id === selectedCourseId)
|
|
||||||
const ids = (courseEntry?.modules ?? []).flatMap((m) => m.sub_modules.map((s) => s.id))
|
|
||||||
handleDeleteSubModules(ids, `level-${selectedCourseId}-${level}`, `Level ${level} removed`)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
Remove Level
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{!collapsedLevels.includes(level) ? (
|
|
||||||
<CardContent className="space-y-3 p-4">
|
|
||||||
{modulesByCourse.length === 0 ? (
|
|
||||||
<p className="text-sm text-grayScale-500">No lessons found for this level.</p>
|
|
||||||
) : (
|
|
||||||
modulesByCourse.map((entry) => (
|
|
||||||
<div key={entry.course.course_id} className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="text-sm font-semibold text-brand-700">{entry.course.course_name}</p>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleCreateModule(entry.course.course_id, level, entry.modules)}
|
|
||||||
disabled={creatingKey === `module-${entry.course.course_id}-${level}`}
|
|
||||||
>
|
|
||||||
{creatingKey === `module-${entry.course.course_id}-${level}` ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Add Module
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{entry.modules.length === 0 ? (
|
|
||||||
<p className="text-xs text-grayScale-500">No modules yet. Use “Add Module” to start.</p>
|
|
||||||
) : (
|
|
||||||
entry.modules.map((module) => (
|
|
||||||
<div key={module.id} className="rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="text-sm font-semibold text-grayScale-900">Module: {module.title}</p>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Link to={`/content/category/${categoryId}/courses/${entry.course.course_id}/sub-courses/${subModule.id}`}>
|
||||||
size="sm"
|
<Button size="sm" variant="outline">Manage lesson videos/audio</Button>
|
||||||
variant="outline"
|
</Link>
|
||||||
onClick={() =>
|
<Link to={`/content/category/${categoryId}/courses/${entry.course.course_id}/sub-courses/${subModule.id}/add-practice`}>
|
||||||
handleCreateSubModule(entry.course.course_id, level, module.title, module.sub_modules)
|
<Button size="sm">Add practice/audio questions</Button>
|
||||||
}
|
</Link>
|
||||||
disabled={creatingKey === `submodule-${entry.course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}`}
|
|
||||||
>
|
|
||||||
{creatingKey === `submodule-${entry.course.course_id}-${level}-${parseModuleNumber(module.title) ?? 0}` ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Add Sub-module
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="border-red-200 text-red-600 hover:bg-red-50"
|
|
||||||
disabled={deletingKey === `module-${module.id}`}
|
|
||||||
onClick={() =>
|
|
||||||
handleDeleteSubModules(
|
|
||||||
module.sub_modules.map((s) => s.id),
|
|
||||||
`module-${module.id}`,
|
|
||||||
`Module ${module.title} removed`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
Remove Module
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
{module.sub_modules.map((subModule) => (
|
</div>
|
||||||
<div key={subModule.id} className="mt-2 rounded-md border border-grayScale-100 bg-white p-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
{subModule.videos.map((video) => (
|
||||||
<p className="text-xs font-semibold text-grayScale-700">Sub-module: {subModule.title}</p>
|
<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">
|
||||||
{categoryId ? (
|
<BookOpen className="h-3.5 w-3.5" />
|
||||||
<div className="flex gap-2">
|
{video.title}
|
||||||
<Link to={`/content/category/${categoryId}/courses/${entry.course.course_id}/sub-courses/${subModule.id}`}>
|
</div>
|
||||||
<Button size="sm" variant="outline">Manage lesson videos/audio</Button>
|
))}
|
||||||
</Link>
|
{subModule.practices.map((practice) => (
|
||||||
<Link to={`/content/category/${categoryId}/courses/${entry.course.course_id}/sub-courses/${subModule.id}/add-practice?source=human-language`}>
|
<div key={practice.id} className="rounded-md bg-brand-50 px-2 py-1 text-xs text-brand-700">
|
||||||
<Button size="sm">Add practice/audio questions</Button>
|
Practice: {practice.title} ({practice.question_count} audio question(s))
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="border-red-200 text-red-600 hover:bg-red-50"
|
|
||||||
disabled={deletingKey === `submodule-${subModule.id}`}
|
|
||||||
onClick={() =>
|
|
||||||
handleDeleteSubModules(
|
|
||||||
[subModule.id],
|
|
||||||
`submodule-${subModule.id}`,
|
|
||||||
`Sub-module ${subModule.title} removed`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
Remove Sub-module
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</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>
|
</div>
|
||||||
))
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))
|
||||||
))
|
)}
|
||||||
)}
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
) : null}
|
))
|
||||||
</Card>
|
)}
|
||||||
)
|
</CardContent>
|
||||||
})
|
) : null}
|
||||||
: null}
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user