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
This commit is contained in:
Yared Yemane 2026-04-14 07:04:02 -07:00
parent 177d10de15
commit 5ddfed8d28
4 changed files with 502 additions and 202 deletions

View File

@ -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<CreateQuestionSetResponse>("/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

View File

@ -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={<AddNewPracticePage />}
/>
<Route
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-lesson"
element={<AddNewLessonPage />}
/>
<Route
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/practices/:practiceId/questions"
element={<PracticeQuestionsPage />}
@ -97,11 +102,13 @@ export function AppRoutes() {
<Route path="category/:categoryId/courses/:courseId/sub-modules" element={<SubModulesPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId" element={<SubModuleContentPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-practice" element={<AddNewPracticePage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-lesson" element={<AddNewLessonPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
{/* Legacy aliases */}
<Route path="category/:categoryId/courses/:courseId/sub-courses" element={<SubModulesPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId" element={<SubModuleContentPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-practice" element={<AddNewPracticePage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-lesson" element={<AddNewLessonPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
<Route path="category/:categoryId/courses/add-video" element={<AddVideoPage />} />
<Route path="speaking" element={<SpeakingPage />} />

View File

@ -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<HTMLInputElement>(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<Step>(1)
const [saving, setSaving] = useState(false)
const [resultStatus, setResultStatus] = useState<ResultStatus | null>(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<string | null>(null)
const [questions, setQuestions] = useState<Question[]>([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<HTMLInputElement>) => {
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<Question>) =>
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 (
<div className="mx-auto w-full max-w-7xl px-4 pb-12 sm:px-6 lg:px-8">
<div className="space-y-5 sm:space-y-6">
{currentStep !== 5 ? (
<>
<Link
to={backTo}
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" />
Back to Sub-course
</Link>
<div className="border-b border-grayScale-100 pb-6 sm:pb-8">
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-3xl">Add New Lesson</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-grayScale-500 sm:text-[15px]">
Create a lesson using the same guided flow and save it under `sub_module_lessons`.
</p>
</div>
<div className="flex items-center justify-center rounded-2xl border border-grayScale-100 bg-grayScale-50/40 px-3 py-4 sm:px-6 sm:py-5">
{STEPS.map((step, index) => (
<div key={step.number} className="flex items-center">
<div className="flex flex-col items-center">
<div
className={`flex h-9 w-9 items-center justify-center rounded-full text-xs font-semibold shadow-sm sm:h-10 sm:w-10 sm:text-sm ${
currentStep === step.number
? "bg-brand-500 text-white ring-4 ring-brand-100"
: currentStep > step.number
? "bg-brand-500 text-white"
: "border-2 border-grayScale-300 bg-white text-grayScale-400"
}`}
>
{currentStep > step.number ? <Check className="h-4 w-4" /> : step.number}
</div>
<span className="mt-2 text-xs font-semibold text-grayScale-500">{step.label}</span>
</div>
{index < STEPS.length - 1 ? <div className="mx-4 h-0.5 w-20 bg-grayScale-200" /> : null}
</div>
))}
</div>
</>
) : null}
{currentStep === 1 ? (
<Card className="p-6">
<h2 className="text-lg font-semibold text-grayScale-900">Step 1: Context</h2>
<div className="mt-5 grid gap-5 lg:grid-cols-2">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Lesson title</label>
<Input value={lessonTitle} onChange={(e) => setLessonTitle(e.target.value)} placeholder="Enter lesson title" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Description</label>
<textarea
value={lessonDescription}
onChange={(e) => setLessonDescription(e.target.value)}
className="min-h-[88px] w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm"
placeholder="Enter lesson description"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Intro video URL (optional)</label>
<Input value={introVideoUrl} onChange={(e) => setIntroVideoUrl(e.target.value)} placeholder="https://..." />
<input
ref={introVideoFileInputRef}
type="file"
accept="video/*"
className="hidden"
onChange={handleIntroVideoFileChange}
disabled={uploadingIntroVideo}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => introVideoFileInputRef.current?.click()}
disabled={uploadingIntroVideo}
>
{uploadingIntroVideo ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
{uploadingIntroVideo ? "Uploading..." : "Upload intro video"}
</Button>
</div>
</div>
<div className="space-y-4 rounded-xl border border-grayScale-200 bg-grayScale-50/40 p-4">
<h3 className="text-sm font-semibold text-grayScale-700">Scoring & behavior</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-grayScale-500">Passing score</label>
<Input type="number" value={passingScore} onChange={(e) => setPassingScore(Number(e.target.value))} />
</div>
<div>
<label className="text-xs text-grayScale-500">Time (min)</label>
<Input type="number" value={timeLimitMinutes} onChange={(e) => setTimeLimitMinutes(Number(e.target.value))} />
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-grayScale-200 bg-white px-3 py-2">
<span className="text-sm text-grayScale-700">Shuffle questions</span>
<button
type="button"
onClick={() => setShuffleQuestions((p) => !p)}
className={`relative inline-flex h-6 w-11 rounded-full ${shuffleQuestions ? "bg-brand-500" : "bg-grayScale-300"}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${
shuffleQuestions ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<Button onClick={handleNext}>
Next: Persona
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</Card>
) : null}
{currentStep === 2 ? (
<Card className="p-6">
<h2 className="text-lg font-semibold text-grayScale-900">Step 2: Persona</h2>
<p className="mt-1 text-sm text-grayScale-500">Optional field stored on the lesson question set.</p>
<div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-4">
{PERSONAS.map((p) => (
<button
key={p.id}
type="button"
className={`rounded-xl border-2 p-4 text-center ${selectedPersona === p.id ? "border-brand-500 bg-brand-50" : "border-grayScale-200 bg-white"}`}
onClick={() => setSelectedPersona(p.id)}
>
<img src={p.avatar} alt={p.name} className="mx-auto mb-2 h-12 w-12 rounded-full" />
<p className="text-sm font-medium">{p.name}</p>
</button>
))}
</div>
<div className="mt-6 flex justify-between">
<Button variant="outline" onClick={handleBack}>
Back
</Button>
<Button onClick={handleNext}>
Next: Questions
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</Card>
) : null}
{currentStep === 3 ? (
<div className="space-y-4">
{questions.map((question, index) => (
<Card key={question.id} className="p-5">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-grayScale-400" />
<span className="font-semibold">Question {index + 1}</span>
</div>
<button type="button" onClick={() => removeQuestion(question.id)} className="text-grayScale-400 hover:text-red-500">
<Trash2 className="h-4 w-4" />
</button>
</div>
<PracticeQuestionEditorFields
value={{
questionText: question.questionText,
questionType: question.questionType,
difficultyLevel: question.difficultyLevel,
points: question.points,
tips: question.tips,
explanation: question.explanation,
options: question.options,
voicePrompt: question.voicePrompt,
sampleAnswerVoicePrompt: question.sampleAnswerVoicePrompt,
audioCorrectAnswerText: question.audioCorrectAnswerText,
shortAnswer: question.shortAnswers[0] ?? "",
imageUrl: question.imageUrl,
}}
onChange={(next) =>
updateQuestion(question.id, {
questionText: next.questionText,
questionType: next.questionType as QuestionType,
difficultyLevel: next.difficultyLevel as DifficultyLevel,
points: next.points,
tips: next.tips,
explanation: next.explanation,
options: next.options,
voicePrompt: next.voicePrompt,
sampleAnswerVoicePrompt: next.sampleAnswerVoicePrompt,
audioCorrectAnswerText: next.audioCorrectAnswerText,
shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
imageUrl: next.imageUrl,
})
}
mediaBusy={saving}
/>
</Card>
))}
<Button variant="outline" onClick={addQuestion} className="w-full border-dashed">
<Plus className="mr-2 h-4 w-4" />
Add another question
</Button>
<div className="flex justify-between">
<Button variant="outline" onClick={handleBack}>
Back
</Button>
<Button onClick={handleNext}>
Next: Review
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
) : null}
{currentStep === 4 ? (
<Card className="p-6">
<h2 className="text-lg font-semibold text-grayScale-900">Step 4: Review & publish</h2>
<div className="mt-4 space-y-2 text-sm text-grayScale-700">
<p><span className="font-medium">Title:</span> {lessonTitle || "Untitled Lesson"}</p>
<p><span className="font-medium">Description:</span> {lessonDescription || "—"}</p>
<p><span className="font-medium">Intro video:</span> {introVideoUrl || "—"}</p>
<p><span className="font-medium">Persona:</span> {PERSONAS.find((p) => p.id === selectedPersona)?.name || "None"}</p>
<p><span className="font-medium">Questions:</span> {questions.length}</p>
</div>
<div className="mt-6 flex justify-between">
<Button variant="outline" onClick={handleBack}>
Back
</Button>
<div className="flex gap-3">
<Button variant="outline" onClick={() => void saveLesson("DRAFT")} disabled={saving}>
{saving ? "Saving..." : "Save as Draft"}
</Button>
<Button onClick={() => void saveLesson("PUBLISHED")} disabled={saving}>
<Rocket className="mr-2 h-4 w-4" />
{saving ? "Publishing..." : "Publish Now"}
</Button>
</div>
</div>
</Card>
) : null}
{currentStep === 5 && resultStatus ? (
<div className="flex flex-col items-center py-16">
<h2 className="text-2xl font-bold text-grayScale-900">
{resultStatus === "success" ? "Lesson saved successfully!" : "Lesson save failed"}
</h2>
<p className="mt-3 text-sm text-grayScale-500">{resultMessage}</p>
<div className="mt-6 flex gap-3">
<Button onClick={() => navigate(backTo)}>Go back to course</Button>
{resultStatus === "success" ? (
<Button variant="outline" onClick={() => navigate(0)}>
Add another lesson
</Button>
) : null}
</div>
</div>
) : null}
</div>
</div>
)
}

View File

@ -31,7 +31,6 @@ import { SpinnerIcon } from "../../components/ui/spinner-icon"
import {
addQuestionToSet,
createPractice,
createLesson,
createQuestion,
createCourse,
createCourseCategory,
@ -93,14 +92,6 @@ type PracticeDialogState =
practiceId?: number
}
type LessonDialogState =
| { open: false }
| {
open: true
subModuleId: number
defaultIndex: number
}
type QuestionDialogState =
| { open: false }
| {
@ -354,13 +345,7 @@ export function HumanLanguagePage() {
const [subModuleCardSelection, setSubModuleCardSelection] = useState<Record<string, SubModuleCardSelection>>({})
const [practiceQuestionsState, setPracticeQuestionsState] = useState<Record<number, PracticeQuestionsFetchState>>({})
const [practiceDialog, setPracticeDialog] = useState<PracticeDialogState>({ open: false })
const [lessonDialog, setLessonDialog] = useState<LessonDialogState>({ open: false })
const [questionDialog, setQuestionDialog] = useState<QuestionDialogState>({ open: false })
const [lessonForm, setLessonForm] = useState({
title: "",
description: "",
introVideoUrl: "",
})
const [practiceForm, setPracticeForm] = useState({
title: "",
description: "",
@ -375,7 +360,6 @@ export function HumanLanguagePage() {
const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null)
const [questionTargetDelete, setQuestionTargetDelete] = useState<{ id: number; practiceId: number; text: string } | null>(null)
const [savingPractice, setSavingPractice] = useState(false)
const [savingLesson, setSavingLesson] = useState(false)
const [savingQuestion, setSavingQuestion] = useState(false)
const [deletingPractice, setDeletingPractice] = useState(false)
const [deletingQuestion, setDeletingQuestion] = useState(false)
@ -383,14 +367,11 @@ export function HumanLanguagePage() {
const [loadingQuestionEditId, setLoadingQuestionEditId] = useState<number | null>(null)
/** Show inline field errors only after an explicit save attempt (avoids noisy empty-state on open). */
const [practiceSubmitAttempted, setPracticeSubmitAttempted] = useState(false)
const [lessonSubmitAttempted, setLessonSubmitAttempted] = useState(false)
const [questionSubmitAttempted, setQuestionSubmitAttempted] = useState(false)
const [lessonFormTouched, setLessonFormTouched] = useState(false)
const [practiceFormTouched, setPracticeFormTouched] = useState(false)
const [questionFormTouched, setQuestionFormTouched] = useState(false)
const [loadingPracticeForm, setLoadingPracticeForm] = useState(false)
const [uploadingPracticeIntroVideo, setUploadingPracticeIntroVideo] = useState(false)
const [uploadingLessonIntroVideo, setUploadingLessonIntroVideo] = useState(false)
const renderMediaPreview = (
urlRaw: string,
@ -584,85 +565,12 @@ export function HumanLanguagePage() {
}
}
const resetLessonForm = () =>
setLessonForm({
title: "",
description: "",
introVideoUrl: "",
})
const openCreateLessonDialog = (subModuleId: number, currentLessonsCount: number) => {
setLessonSubmitAttempted(false)
setLessonFormTouched(false)
const next = (currentLessonsCount || 0) + 1
setLessonForm({
title: `Lesson ${next}`,
description: "",
introVideoUrl: "",
})
setLessonDialog({ open: true, subModuleId, defaultIndex: next })
}
const lessonFieldErrors = useMemo(() => {
const title = lessonForm.title.trim()
return {
title: title ? undefined : "Title is required.",
}
}, [lessonForm.title])
const lessonCanSave = !lessonFieldErrors.title
const handleSaveLesson = async () => {
if (!lessonDialog.open) return
if (!lessonCanSave) {
setLessonSubmitAttempted(true)
const openCreateLessonDialog = (courseId: number, subModuleId: number) => {
if (!categoryId) {
toast.error("Category is not ready yet. Please try again.")
return
}
try {
setSavingLesson(true)
await createLesson({
sub_module_id: lessonDialog.subModuleId,
title: lessonForm.title.trim(),
description: lessonForm.description.trim() || undefined,
intro_video_url: lessonForm.introVideoUrl.trim() || undefined,
})
toast.success("Lesson created")
setLessonDialog({ open: false })
setLessonSubmitAttempted(false)
setLessonFormTouched(false)
resetLessonForm()
await loadHierarchy(false)
} catch (error) {
console.error("Failed to create lesson:", error)
toast.error("Failed to create lesson")
} finally {
setSavingLesson(false)
}
}
const handleLessonIntroVideoFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ""
if (!file) return
setUploadingLessonIntroVideo(true)
try {
const uploadRes = await uploadVideoFile(file, {
title: lessonForm.title.trim() || file.name.replace(/\.[^.]+$/, "") || "Lesson intro",
description: lessonForm.description.trim() || undefined,
})
const finalUrl = uploadRes.data?.data?.embed_url?.trim()
? `${uploadRes.data.data.embed_url}?h=${uploadRes.data.data.url?.split("/").filter(Boolean).at(-1) ?? ""}`
: uploadRes.data?.data?.url?.trim()
if (!finalUrl) throw new Error("Missing uploaded video url")
setLessonForm((prev) => ({ ...prev, introVideoUrl: finalUrl }))
toast.success("Lesson intro video uploaded")
} catch (error) {
console.error("Failed to upload lesson intro video:", error)
toast.error("Failed to upload lesson intro video")
} finally {
setUploadingLessonIntroVideo(false)
}
navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}/add-lesson`)
}
const requestRemove = (payload: PendingRemove) => {
@ -1722,7 +1630,7 @@ export function HumanLanguagePage() {
size="sm"
variant="outline"
className="h-8 border-grayScale-200 bg-white px-2 text-[11px] hover:border-brand-200 hover:bg-brand-50/40"
onClick={() => openCreateLessonDialog(subModule.id, lessonRows.length)}
onClick={() => openCreateLessonDialog(course.course_id, subModule.id)}
>
<Plus className="h-3.5 w-3.5" />
New lesson
@ -2193,110 +2101,6 @@ export function HumanLanguagePage() {
</DialogContent>
</Dialog>
<Dialog
open={lessonDialog.open}
onOpenChange={(open) => {
if (!open) {
setLessonDialog({ open: false })
setLessonSubmitAttempted(false)
setLessonFormTouched(false)
resetLessonForm()
}
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Create Lesson</DialogTitle>
<DialogDescription>
Create a lesson as a `sub_module_lessons` entry linked to a QUIZ question set.
{!lessonCanSave ? (
<span className="mt-1 block text-amber-700/90">Required fields must be completed before you can save.</span>
) : null}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Title</label>
<input
value={lessonForm.title}
onChange={(e) => {
setLessonFormTouched(true)
setLessonForm((p) => ({ ...p, title: e.target.value }))
}}
className={cn(
"h-10 w-full rounded-md border px-3 text-sm",
(lessonSubmitAttempted || lessonFormTouched) && lessonFieldErrors.title
? "border-red-300 ring-1 ring-red-200"
: "border-grayScale-200",
)}
placeholder={lessonDialog.open ? `Lesson ${lessonDialog.defaultIndex}` : "Lesson title"}
aria-invalid={Boolean((lessonSubmitAttempted || lessonFormTouched) && lessonFieldErrors.title)}
/>
{(lessonSubmitAttempted || lessonFormTouched) && lessonFieldErrors.title ? (
<p className="text-xs text-red-600">{lessonFieldErrors.title}</p>
) : null}
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-grayScale-600">Description</label>
<textarea
value={lessonForm.description}
onChange={(e) => {
setLessonFormTouched(true)
setLessonForm((p) => ({ ...p, description: e.target.value }))
}}
className="min-h-[88px] w-full rounded-md border border-grayScale-200 px-3 py-2 text-sm"
placeholder="Optional description"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-grayScale-600">Intro video URL</label>
<Input
value={lessonForm.introVideoUrl}
onChange={(e) => {
setLessonFormTouched(true)
setLessonForm((p) => ({ ...p, introVideoUrl: e.target.value }))
}}
placeholder="https://..."
className="h-10 font-mono text-[13px]"
/>
<div className="flex flex-wrap items-center gap-2">
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-xs text-grayScale-700 hover:bg-grayScale-50">
{uploadingLessonIntroVideo ? <Loader2 className="h-4 w-4 animate-spin" /> : <Video className="h-4 w-4" />}
{uploadingLessonIntroVideo ? "Uploading..." : "Upload intro video"}
<input
type="file"
accept="video/*"
className="hidden"
onChange={(e) => void handleLessonIntroVideoFileChange(e)}
disabled={uploadingLessonIntroVideo || savingLesson}
/>
</label>
</div>
{lessonForm.introVideoUrl.trim() ? renderMediaPreview(lessonForm.introVideoUrl, "video", "", "Intro video") : null}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setLessonDialog({ open: false })
setLessonSubmitAttempted(false)
setLessonFormTouched(false)
resetLessonForm()
}}
disabled={savingLesson}
>
Cancel
</Button>
<Button type="button" onClick={() => void handleSaveLesson()} disabled={savingLesson || !lessonCanSave}>
{savingLesson ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{savingLesson ? "Saving..." : "Create lesson"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={practiceDialog.open}
onOpenChange={(open) => {