Compare commits

..

No commits in common. "61fb096aa8d256a78d91bc665e7d5203aa537a26" and "383886156cb7d421448651835d9293baf7bcdb83" have entirely different histories.

3 changed files with 45 additions and 198 deletions

View File

@ -1,4 +1,4 @@
# Yimaru Academy LMS Admin Dashboard
# Yimaru Academy Admin Dashboard
A modern, feature-rich admin dashboard for managing Yimaru Academy's educational platform. Built with React, TypeScript, and Tailwind CSS.

View File

@ -1,5 +1,5 @@
import { useMemo, useRef, useState, type ChangeEvent } from "react"
import { Link, useLocation, useParams, useNavigate } from "react-router-dom"
import { useRef, useState, type ChangeEvent } from "react"
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 { toast } from "sonner"
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 ResultStatus = "success" | "error"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT"
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
interface Persona {
@ -37,7 +37,6 @@ interface Question {
options: MCQOption[]
voicePrompt: string
sampleAnswerVoicePrompt: string
audioCorrectAnswerText: string
shortAnswers: string[]
}
@ -88,21 +87,13 @@ function createEmptyQuestion(id: string): Question {
],
voicePrompt: "",
sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "",
shortAnswers: [],
}
}
export function AddNewPracticePage() {
const { categoryId, courseId, subCourseId } = useParams()
const location = useLocation()
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 [saving, setSaving] = useState(false)
@ -143,7 +134,7 @@ export function AddNewPracticePage() {
}
const handleCancel = () => {
navigate(backTo)
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)
}
const handleIntroVideoFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
@ -256,7 +247,6 @@ export function AddNewPracticePage() {
options: options.length > 0 ? options : undefined,
voice_prompt: q.voicePrompt || undefined,
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
})
@ -307,7 +297,7 @@ export function AddNewPracticePage() {
<>
{/* Back 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"
>
<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">
<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">
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>
</div>
@ -646,7 +636,6 @@ export function AddNewPracticePage() {
<option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option>
<option value="SHORT">Short Answer</option>
<option value="AUDIO">Audio</option>
</Select>
</div>
@ -811,19 +800,6 @@ export function AddNewPracticePage() {
/>
</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>
</Card>
))}
@ -949,13 +925,7 @@ export function AddNewPracticePage() {
<p className="text-sm font-medium leading-relaxed text-grayScale-900">{question.questionText}</p>
<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">
{question.questionType === "MCQ"
? "Multiple Choice"
: question.questionType === "TRUE_FALSE"
? "True/False"
: question.questionType === "AUDIO"
? "Audio"
: "Short Answer"}
{question.questionType === "MCQ" ? "Multiple Choice" : question.questionType === "TRUE_FALSE" ? "True/False" : "Short Answer"}
</span>
<span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600">
{question.difficultyLevel}
@ -1031,7 +1001,7 @@ export function AddNewPracticePage() {
<div className="mt-10 flex w-full max-w-sm flex-col gap-3">
<Button
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
</Button>

View File

@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from "react"
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, Search } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { createCourse, createCourseCategory, createHumanLanguageLesson, deleteSubCourse, getHumanLanguageHierarchy } from "../../api/courses.api"
import { createCourse, createCourseCategory, createHumanLanguageLesson, getHumanLanguageHierarchy } from "../../api/courses.api"
import type { HumanLanguageCourseTree, HumanLanguageSubCategoryTree } from "../../types/course.types"
import { toast } from "sonner"
@ -24,7 +24,6 @@ export function HumanLanguagePage() {
const [quickCourseName, setQuickCourseName] = useState("")
const [quickSearch, setQuickSearch] = useState("")
const [quickCreating, setQuickCreating] = useState(false)
const [deletingKey, setDeletingKey] = useState<string | null>(null)
const loadHierarchy = async () => {
setLoading(true)
@ -70,13 +69,6 @@ export function HumanLanguagePage() {
[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) => {
setCollapsedLevels((prev) => (prev.includes(level) ? prev.filter((l) => l !== level) : [...prev, level]))
}
@ -159,55 +151,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")
@ -313,25 +256,6 @@ export function HumanLanguagePage() {
</select>
</div>
</CardContent>
<CardContent className="border-t border-grayScale-100 pt-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-grayScale-500">
{selectedCourseId === "ALL"
? "Select a specific course above to enable adding the next CEFR level (starts at A1) and to use remove actions."
: levelsForSelectedCourse.length >= CEFR_LEVELS.length
? "All CEFR levels (A1C3) already have content for this course."
: `Next level to add: ${CEFR_LEVELS.find((l) => !levelsForSelectedCourse.includes(l)) ?? "—"}`}
</p>
<Button
size="sm"
className="shrink-0"
onClick={handleCreateNextLevel}
disabled={selectedCourseId === "ALL" || levelsForSelectedCourse.length >= CEFR_LEVELS.length || creatingKey?.startsWith("next-level-")}
>
{creatingKey?.startsWith("next-level-") ? "Creating level..." : "Add next CEFR level"}
</Button>
</div>
</CardContent>
</Card>
{categoryId && selectedCourseId !== "ALL" ? (
@ -396,43 +320,30 @@ export function HumanLanguagePage() {
{availableCourses.length > 0
? CEFR_LEVELS.filter((l) => selectedLevel === "ALL" || l === selectedLevel).map((level) => {
const modulesByCourse = selectedCourses.map((course: HumanLanguageCourseTree) => {
const levelNode = course.levels.find((item) => item.level.toUpperCase() === level)
return {
course,
modules: levelNode?.modules ?? [],
}
})
const modulesByCourse = selectedCourses
.map((course: HumanLanguageCourseTree) => {
const levelNode = course.levels.find((item) => item.level.toUpperCase() === level)
return {
course,
modules: levelNode?.modules ?? [],
}
})
.filter((entry) => entry.modules.length > 0 || selectedCourses.length > 0)
return (
<Card key={level} className="overflow-hidden border-grayScale-200/80 shadow-sm">
<div className="flex w-full flex-wrap items-center justify-between gap-2 border-b border-grayScale-100 bg-grayScale-50/60 px-4 py-3">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => toggleLevel(level)}
>
{collapsedLevels.includes(level) ? <ChevronRight className="h-4 w-4 shrink-0" /> : <ChevronDown className="h-4 w-4 shrink-0" />}
<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>
</button>
<Button
size="sm"
variant="outline"
className="border-red-200 text-red-600 hover:bg-red-50"
disabled={selectedCourseId === "ALL" || deletingKey === `level-${selectedCourseId}-${level}`}
onClick={() => {
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>
</div>
</button>
{!collapsedLevels.includes(level) ? (
<CardContent className="space-y-3 p-4">
{modulesByCourse.length === 0 ? (
@ -464,39 +375,21 @@ export function HumanLanguagePage() {
<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">
<Button
size="sm"
variant="outline"
onClick={() =>
handleCreateSubModule(entry.course.course_id, level, module.title, module.sub_modules)
}
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>
<Button
size="sm"
variant="outline"
onClick={() =>
handleCreateSubModule(entry.course.course_id, level, module.title, module.sub_modules)
}
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>
</div>
{module.sub_modules.map((subModule) => (
<div key={subModule.id} className="mt-2 rounded-md border border-grayScale-100 bg-white p-2">
@ -507,25 +400,9 @@ export function HumanLanguagePage() {
<Link to={`/content/category/${categoryId}/courses/${entry.course.course_id}/sub-courses/${subModule.id}`}>
<Button size="sm" variant="outline">Manage lesson videos/audio</Button>
</Link>
<Link to={`/content/category/${categoryId}/courses/${entry.course.course_id}/sub-courses/${subModule.id}/add-practice?source=human-language`}>
<Link to={`/content/category/${categoryId}/courses/${entry.course.course_id}/sub-courses/${subModule.id}/add-practice`}>
<Button size="sm">Add practice/audio questions</Button>
</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>