From 5ddfed8d28cae437b2062aab01f9431d5115894f Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 14 Apr 2026 07:04:02 -0700 Subject: [PATCH] add full-page lesson creation wizard flow Introduce a practice-style Add New Lesson page routed from human language sub-modules, wire it to sub_module_lessons-compatible save logic, and remove the temporary lesson modal path. Made-with: Cursor --- src/api/courses.api.ts | 10 + src/app/AppRoutes.tsx | 7 + .../content-management/AddNewLessonPage.tsx | 479 ++++++++++++++++++ .../content-management/HumanLanguagePage.tsx | 208 +------- 4 files changed, 502 insertions(+), 202 deletions(-) create mode 100644 src/pages/content-management/AddNewLessonPage.tsx diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index f8604ee..f94ccb6 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -354,6 +354,11 @@ export const createLesson = (data: { title: string description?: string intro_video_url?: string + persona?: string + status?: "DRAFT" | "PUBLISHED" + passing_score?: number + time_limit_minutes?: number + shuffle_questions?: boolean }) => http .post("/question-sets", { @@ -363,6 +368,11 @@ export const createLesson = (data: { owner_id: data.sub_module_id, ...(data.description?.trim() ? { description: data.description.trim() } : {}), ...(data.intro_video_url?.trim() ? { intro_video_url: data.intro_video_url.trim() } : {}), + ...(data.persona?.trim() ? { persona: data.persona.trim() } : {}), + ...(data.status ? { status: data.status } : {}), + ...(Number.isFinite(data.passing_score) ? { passing_score: data.passing_score } : {}), + ...(Number.isFinite(data.time_limit_minutes) ? { time_limit_minutes: data.time_limit_minutes } : {}), + ...(typeof data.shuffle_questions === "boolean" ? { shuffle_questions: data.shuffle_questions } : {}), }) .then((res) => { const questionSetID = res.data?.data?.id diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 5c75232..5903160 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -10,6 +10,7 @@ import { ContentOverviewPage } from "../pages/content-management/ContentOverview import { CoursesPage } from "../pages/content-management/CoursesPage" import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage" import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage" +import { AddNewLessonPage } from "../pages/content-management/AddNewLessonPage" import { SubModulesPage } from "../pages/content-management/SubCoursesPage" import { SubModuleContentPage } from "../pages/content-management/SubCourseContentPage" import { SpeakingPage } from "../pages/content-management/SpeakingPage" @@ -83,6 +84,10 @@ export function AppRoutes() { path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice" element={} /> + } + /> } @@ -97,11 +102,13 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> {/* Legacy aliases */} } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/content-management/AddNewLessonPage.tsx b/src/pages/content-management/AddNewLessonPage.tsx new file mode 100644 index 0000000..7c6e955 --- /dev/null +++ b/src/pages/content-management/AddNewLessonPage.tsx @@ -0,0 +1,479 @@ +import { useMemo, useRef, useState, type ChangeEvent } from "react" +import { ArrowLeft, ArrowRight, Check, GripVertical, Loader2, Plus, Rocket, Trash2, Upload } from "lucide-react" +import { Link, useLocation, useNavigate, useParams } from "react-router-dom" +import { toast } from "sonner" +import { createLesson, createQuestion, addQuestionToSet } from "../../api/courses.api" +import { uploadVideoFile } from "../../api/files.api" +import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields" +import { Button } from "../../components/ui/button" +import { Card } from "../../components/ui/card" +import { Input } from "../../components/ui/input" +import type { QuestionOption } from "../../types/course.types" + +type Step = 1 | 2 | 3 | 4 | 5 +type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" +type DifficultyLevel = "EASY" | "MEDIUM" | "HARD" +type ResultStatus = "success" | "error" + +interface Persona { + id: string + name: string + avatar: string +} + +interface MCQOption { + text: string + isCorrect: boolean +} + +interface Question { + id: string + questionText: string + questionType: QuestionType + difficultyLevel: DifficultyLevel + points: number + tips: string + explanation: string + options: MCQOption[] + voicePrompt: string + sampleAnswerVoicePrompt: string + audioCorrectAnswerText: string + shortAnswers: string[] + imageUrl: string +} + +const PERSONAS: Persona[] = [ + { id: "1", name: "Dawit", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit" }, + { id: "2", name: "Mahlet", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet" }, + { id: "3", name: "Amanuel", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel" }, + { id: "4", name: "Bethel", avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel" }, +] + +const STEPS = [ + { number: 1, label: "Context" }, + { number: 2, label: "Persona" }, + { number: 3, label: "Questions" }, + { number: 4, label: "Review" }, +] + +function createEmptyQuestion(id: string): Question { + return { + id, + questionText: "", + questionType: "MCQ", + difficultyLevel: "EASY", + points: 1, + tips: "", + explanation: "", + options: [ + { text: "", isCorrect: true }, + { text: "", isCorrect: false }, + { text: "", isCorrect: false }, + { text: "", isCorrect: false }, + ], + voicePrompt: "", + sampleAnswerVoicePrompt: "", + audioCorrectAnswerText: "", + shortAnswers: [], + imageUrl: "", + } +} + +function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: string } | undefined): string | null { + if (!data) return null + const pageUrl = data.url?.trim() + const embedUrl = data.embed_url?.trim() + if (embedUrl) { + const hashFromUrl = pageUrl ? pageUrl.split("/").filter(Boolean).at(-1) : undefined + return hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl + } + return pageUrl || null +} + +export function AddNewLessonPage() { + const { categoryId, courseId, subModuleId } = useParams() + const navigate = useNavigate() + const location = useLocation() + const introVideoFileInputRef = useRef(null) + + const backTo = useMemo(() => { + if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) { + return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}` + } + return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}` + }, [categoryId, courseId, subModuleId, location.pathname]) + + const [currentStep, setCurrentStep] = useState(1) + const [saving, setSaving] = useState(false) + const [resultStatus, setResultStatus] = useState(null) + const [resultMessage, setResultMessage] = useState("") + + const [lessonTitle, setLessonTitle] = useState("") + const [lessonDescription, setLessonDescription] = useState("") + const [introVideoUrl, setIntroVideoUrl] = useState("") + const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false) + const [shuffleQuestions, setShuffleQuestions] = useState(false) + const [passingScore, setPassingScore] = useState(50) + const [timeLimitMinutes, setTimeLimitMinutes] = useState(60) + const [selectedPersona, setSelectedPersona] = useState(null) + const [questions, setQuestions] = useState([createEmptyQuestion("1")]) + + const handleNext = () => setCurrentStep((s) => (s < 4 ? ((s + 1) as Step) : s)) + const handleBack = () => setCurrentStep((s) => (s > 1 ? ((s - 1) as Step) : s)) + + const handleIntroVideoFileChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = "" + if (!file) return + setUploadingIntroVideo(true) + try { + const uploadRes = await uploadVideoFile(file, { + title: lessonTitle.trim() || file.name.replace(/\.[^.]+$/, "") || "Lesson intro", + description: lessonDescription.trim() || undefined, + }) + const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data) + if (!finalUrl) throw new Error("Missing uploaded video url") + setIntroVideoUrl(finalUrl) + toast.success("Intro video uploaded") + } catch (error) { + console.error("Failed to upload lesson intro video:", error) + toast.error("Failed to upload intro video") + } finally { + setUploadingIntroVideo(false) + } + } + + const addQuestion = () => setQuestions((prev) => [...prev, createEmptyQuestion(String(Date.now()))]) + const removeQuestion = (id: string) => setQuestions((prev) => (prev.length > 1 ? prev.filter((q) => q.id !== id) : prev)) + const updateQuestion = (id: string, updates: Partial) => + setQuestions((prev) => prev.map((q) => (q.id === id ? { ...q, ...updates } : q))) + + const saveLesson = async (status: "DRAFT" | "PUBLISHED") => { + if (!subModuleId) { + toast.error("Missing sub-module id") + return + } + setSaving(true) + try { + const persona = PERSONAS.find((p) => p.id === selectedPersona)?.name + const lessonRes = await createLesson({ + sub_module_id: Number(subModuleId), + title: lessonTitle.trim() || "Untitled Lesson", + description: lessonDescription.trim() || undefined, + intro_video_url: introVideoUrl.trim() || undefined, + persona, + status, + passing_score: passingScore, + time_limit_minutes: timeLimitMinutes, + shuffle_questions: shuffleQuestions, + }) + + const questionSetId = lessonRes.data?.data?.id + if (questionSetId) { + for (let i = 0; i < questions.length; i++) { + const q = questions[i] + if (!q.questionText.trim()) continue + const options: QuestionOption[] = + q.questionType === "MCQ" + ? q.options.map((opt, idx) => ({ + option_order: idx + 1, + option_text: opt.text, + is_correct: opt.isCorrect, + })) + : [] + + const qRes = await createQuestion({ + question_text: q.questionText, + question_type: q.questionType, + difficulty_level: q.difficultyLevel, + points: q.points, + tips: q.tips || undefined, + explanation: q.explanation || undefined, + status: "PUBLISHED", + 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, + image_url: q.imageUrl.trim() || undefined, + short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined, + }) + const questionId = qRes.data?.data?.id + if (questionId) { + await addQuestionToSet(questionSetId, { question_id: questionId, display_order: i + 1 }) + } + } + } + + setResultStatus("success") + setResultMessage(status === "PUBLISHED" ? "Lesson published successfully." : "Lesson saved as draft.") + setCurrentStep(5) + } catch (error) { + console.error("Failed to save lesson:", error) + setResultStatus("error") + setResultMessage(error instanceof Error ? error.message : "Failed to save lesson") + setCurrentStep(5) + } finally { + setSaving(false) + } + } + + return ( +
+
+ {currentStep !== 5 ? ( + <> + + + Back to Sub-course + +
+

Add New Lesson

+

+ Create a lesson using the same guided flow and save it under `sub_module_lessons`. +

+
+
+ {STEPS.map((step, index) => ( +
+
+
step.number + ? "bg-brand-500 text-white" + : "border-2 border-grayScale-300 bg-white text-grayScale-400" + }`} + > + {currentStep > step.number ? : step.number} +
+ {step.label} +
+ {index < STEPS.length - 1 ?
: null} +
+ ))} +
+ + ) : null} + + {currentStep === 1 ? ( + +

Step 1: Context

+
+
+
+ + setLessonTitle(e.target.value)} placeholder="Enter lesson title" /> +
+
+ +