align lesson wizard UI with lesson schema flow
Remove practice-specific fields from the lesson page and keep the flow focused on question_sets + sub_module_lessons while preserving intro video upload/import and preview behavior. Made-with: Cursor
This commit is contained in:
parent
0cc2e4ce4e
commit
38c5c16824
|
|
@ -2,7 +2,7 @@ import { useMemo, useRef, useState, type ChangeEvent } from "react"
|
||||||
import { ArrowLeft, ArrowRight, Check, GripVertical, Loader2, Plus, Rocket, Trash2, Upload } from "lucide-react"
|
import { ArrowLeft, ArrowRight, Check, GripVertical, Loader2, Plus, Rocket, Trash2, Upload } from "lucide-react"
|
||||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"
|
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { createLesson, createQuestion, addQuestionToSet } from "../../api/courses.api"
|
import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api"
|
||||||
import { uploadVideoFile } from "../../api/files.api"
|
import { uploadVideoFile } from "../../api/files.api"
|
||||||
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"
|
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
|
|
@ -10,17 +10,11 @@ import { Card } from "../../components/ui/card"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import type { QuestionOption } from "../../types/course.types"
|
import type { QuestionOption } from "../../types/course.types"
|
||||||
|
|
||||||
type Step = 1 | 2 | 3 | 4 | 5
|
type Step = 1 | 2 | 3 | 4
|
||||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
||||||
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
||||||
type ResultStatus = "success" | "error"
|
type ResultStatus = "success" | "error"
|
||||||
|
|
||||||
interface Persona {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
avatar: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MCQOption {
|
interface MCQOption {
|
||||||
text: string
|
text: string
|
||||||
isCorrect: boolean
|
isCorrect: boolean
|
||||||
|
|
@ -42,18 +36,10 @@ interface Question {
|
||||||
imageUrl: 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 = [
|
const STEPS = [
|
||||||
{ number: 1, label: "Context" },
|
{ number: 1, label: "Context" },
|
||||||
{ number: 2, label: "Persona" },
|
{ number: 2, label: "Questions" },
|
||||||
{ number: 3, label: "Questions" },
|
{ number: 3, label: "Review" },
|
||||||
{ number: 4, label: "Review" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function createEmptyQuestion(id: string): Question {
|
function createEmptyQuestion(id: string): Question {
|
||||||
|
|
@ -136,13 +122,9 @@ export function AddNewLessonPage() {
|
||||||
const [introVideoUrl, setIntroVideoUrl] = useState("")
|
const [introVideoUrl, setIntroVideoUrl] = useState("")
|
||||||
const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false)
|
const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false)
|
||||||
const [importingIntroVideoUrl, setImportingIntroVideoUrl] = useState(false)
|
const [importingIntroVideoUrl, setImportingIntroVideoUrl] = 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 [questions, setQuestions] = useState<Question[]>([createEmptyQuestion("1")])
|
||||||
|
|
||||||
const handleNext = () => setCurrentStep((s) => (s < 4 ? ((s + 1) as Step) : s))
|
const handleNext = () => setCurrentStep((s) => (s < 3 ? ((s + 1) as Step) : s))
|
||||||
const handleBack = () => setCurrentStep((s) => (s > 1 ? ((s - 1) as Step) : s))
|
const handleBack = () => setCurrentStep((s) => (s > 1 ? ((s - 1) as Step) : s))
|
||||||
|
|
||||||
const handleIntroVideoFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
const handleIntroVideoFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -171,8 +153,6 @@ export function AddNewLessonPage() {
|
||||||
const source = introVideoUrl.trim()
|
const source = introVideoUrl.trim()
|
||||||
if (!source || !/^https?:\/\//i.test(source)) return
|
if (!source || !/^https?:\/\//i.test(source)) return
|
||||||
const vimeoEmbed = toVimeoEmbedUrl(source)
|
const vimeoEmbed = toVimeoEmbedUrl(source)
|
||||||
// Vimeo page URLs can be protected by anti-bot checks when server-side fetched.
|
|
||||||
// For those links, prefer local normalization to player URL instead of failing import.
|
|
||||||
if (vimeoEmbed) {
|
if (vimeoEmbed) {
|
||||||
setIntroVideoUrl(vimeoEmbed)
|
setIntroVideoUrl(vimeoEmbed)
|
||||||
return
|
return
|
||||||
|
|
@ -217,17 +197,12 @@ export function AddNewLessonPage() {
|
||||||
}
|
}
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const persona = PERSONAS.find((p) => p.id === selectedPersona)?.name
|
|
||||||
const lessonRes = await createLesson({
|
const lessonRes = await createLesson({
|
||||||
sub_module_id: Number(subModuleId),
|
sub_module_id: Number(subModuleId),
|
||||||
title: lessonTitle.trim() || "Untitled Lesson",
|
title: lessonTitle.trim() || "Untitled Lesson",
|
||||||
description: lessonDescription.trim() || undefined,
|
description: lessonDescription.trim() || undefined,
|
||||||
intro_video_url: introVideoUrl.trim() || undefined,
|
intro_video_url: introVideoUrl.trim() || undefined,
|
||||||
persona,
|
|
||||||
status,
|
status,
|
||||||
passing_score: passingScore,
|
|
||||||
time_limit_minutes: timeLimitMinutes,
|
|
||||||
shuffle_questions: shuffleQuestions,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const questionSetId = lessonRes.data?.data?.id
|
const questionSetId = lessonRes.data?.data?.id
|
||||||
|
|
@ -268,12 +243,12 @@ export function AddNewLessonPage() {
|
||||||
|
|
||||||
setResultStatus("success")
|
setResultStatus("success")
|
||||||
setResultMessage(status === "PUBLISHED" ? "Lesson published successfully." : "Lesson saved as draft.")
|
setResultMessage(status === "PUBLISHED" ? "Lesson published successfully." : "Lesson saved as draft.")
|
||||||
setCurrentStep(5)
|
setCurrentStep(4)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save lesson:", error)
|
console.error("Failed to save lesson:", error)
|
||||||
setResultStatus("error")
|
setResultStatus("error")
|
||||||
setResultMessage(error instanceof Error ? error.message : "Failed to save lesson")
|
setResultMessage(error instanceof Error ? error.message : "Failed to save lesson")
|
||||||
setCurrentStep(5)
|
setCurrentStep(4)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|
@ -282,7 +257,7 @@ export function AddNewLessonPage() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-7xl px-4 pb-12 sm:px-6 lg:px-8">
|
<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">
|
<div className="space-y-5 sm:space-y-6">
|
||||||
{currentStep !== 5 ? (
|
{currentStep !== 4 ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to={backTo}
|
to={backTo}
|
||||||
|
|
@ -294,7 +269,7 @@ export function AddNewLessonPage() {
|
||||||
<div className="border-b border-grayScale-100 pb-6 sm:pb-8">
|
<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>
|
<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]">
|
<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`.
|
Create a lesson backed by `question_sets` and attach it through `sub_module_lessons`.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
|
|
@ -324,8 +299,7 @@ export function AddNewLessonPage() {
|
||||||
{currentStep === 1 ? (
|
{currentStep === 1 ? (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Step 1: Context</h2>
|
<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="mt-5 space-y-4">
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Lesson title</label>
|
<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" />
|
<Input value={lessonTitle} onChange={(e) => setLessonTitle(e.target.value)} placeholder="Enter lesson title" />
|
||||||
|
|
@ -415,69 +389,9 @@ export function AddNewLessonPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-xs leading-relaxed text-grayScale-500">
|
|
||||||
File uploads and URL imports both go through `/files/upload`.
|
|
||||||
</p>
|
|
||||||
</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>
|
</div>
|
||||||
<div className="mt-6 flex justify-end">
|
<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}>
|
<Button onClick={handleNext}>
|
||||||
Next: Questions
|
Next: Questions
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
|
@ -486,7 +400,7 @@ export function AddNewLessonPage() {
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{currentStep === 3 ? (
|
{currentStep === 2 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{questions.map((question, index) => (
|
{questions.map((question, index) => (
|
||||||
<Card key={question.id} className="p-5">
|
<Card key={question.id} className="p-5">
|
||||||
|
|
@ -550,14 +464,13 @@ export function AddNewLessonPage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{currentStep === 4 ? (
|
{currentStep === 3 ? (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-900">Step 4: Review & publish</h2>
|
<h2 className="text-lg font-semibold text-grayScale-900">Step 3: Review & publish</h2>
|
||||||
<div className="mt-4 space-y-2 text-sm text-grayScale-700">
|
<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">Question set title:</span> {lessonTitle || "Untitled Lesson"}</p>
|
||||||
<p><span className="font-medium">Description:</span> {lessonDescription || "—"}</p>
|
<p><span className="font-medium">Question set description:</span> {lessonDescription || "—"}</p>
|
||||||
<p><span className="font-medium">Intro video:</span> {introVideoUrl || "—"}</p>
|
<p><span className="font-medium">Sub-module lesson 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>
|
<p><span className="font-medium">Questions:</span> {questions.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex justify-between">
|
<div className="mt-6 flex justify-between">
|
||||||
|
|
@ -577,7 +490,7 @@ export function AddNewLessonPage() {
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{currentStep === 5 && resultStatus ? (
|
{currentStep === 4 && resultStatus ? (
|
||||||
<div className="flex flex-col items-center py-16">
|
<div className="flex flex-col items-center py-16">
|
||||||
<h2 className="text-2xl font-bold text-grayScale-900">
|
<h2 className="text-2xl font-bold text-grayScale-900">
|
||||||
{resultStatus === "success" ? "Lesson saved successfully!" : "Lesson save failed"}
|
{resultStatus === "success" ? "Lesson saved successfully!" : "Lesson save failed"}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user