diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts
index 5aab2e9..65b9c26 100644
--- a/src/api/courses.api.ts
+++ b/src/api/courses.api.ts
@@ -106,6 +106,7 @@ import type {
UpdateParentLinkedPracticeResponse,
PublishParentLinkedPracticeRequest,
UpdateTopLevelModuleLessonRequest,
+ PublishTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonResponse,
} from "../types/course.types"
@@ -647,6 +648,12 @@ export const updateTopLevelModuleLesson = (
data: UpdateTopLevelModuleLessonRequest,
) => http.put(`/lessons/${lessonId}`, data)
+/** PUT /lessons/:id — set publish_status only (draft or published). */
+export const publishTopLevelModuleLesson = (
+ lessonId: number,
+ data: PublishTopLevelModuleLessonRequest,
+) => http.put(`/lessons/${lessonId}`, data)
+
/** Learn English top-level module lesson — DELETE /lessons/:id */
export const deleteTopLevelModuleLesson = (lessonId: number) =>
http.delete(`/lessons/${lessonId}`)
diff --git a/src/api/personas.api.ts b/src/api/personas.api.ts
new file mode 100644
index 0000000..b356c20
--- /dev/null
+++ b/src/api/personas.api.ts
@@ -0,0 +1,6 @@
+import http from "./http"
+import type { GetPersonasParams, GetPersonasResponse } from "../types/persona.types"
+
+/** GET /personas — list personas (filter active client-side when needed). */
+export const getPersonas = (params?: GetPersonasParams) =>
+ http.get("/personas", { params })
diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx
index 441ec61..73edf24 100644
--- a/src/app/AppRoutes.tsx
+++ b/src/app/AppRoutes.tsx
@@ -15,6 +15,7 @@ import { SpeakingPage } from "../pages/content-management/SpeakingPage";
import { AddVideoPage } from "../pages/content-management/AddVideoPage";
import { AddPracticePage } from "../pages/content-management/AddPracticePage";
import { NewContentPage } from "../pages/content-management/NewContentPage";
+import { ReorderContentPage } from "../pages/content-management/ReorderContentPage";
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
@@ -162,6 +163,7 @@ export function AppRoutes() {
} />
+ } />
}
diff --git a/src/hooks/useActivePersonas.ts b/src/hooks/useActivePersonas.ts
new file mode 100644
index 0000000..2893e43
--- /dev/null
+++ b/src/hooks/useActivePersonas.ts
@@ -0,0 +1,43 @@
+import { useCallback, useEffect, useState } from "react"
+import { getPersonas } from "../api/personas.api"
+import {
+ mapPersonaToCard,
+ unwrapPersonasList,
+ type PersonaCardModel,
+} from "../lib/personaDisplay"
+
+type UseActivePersonasOptions = {
+ limit?: number
+ offset?: number
+}
+
+export function useActivePersonas(options: UseActivePersonasOptions = {}) {
+ const { limit = 50, offset = 0 } = options
+ const [personas, setPersonas] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const res = await getPersonas({ limit, offset })
+ const list = unwrapPersonasList(res).filter((p) => p.is_active)
+ setPersonas(list.map(mapPersonaToCard))
+ } catch (e: unknown) {
+ const msg =
+ (e as { response?: { data?: { message?: string } } })?.response?.data
+ ?.message ?? "Failed to load personas"
+ setError(msg)
+ setPersonas([])
+ } finally {
+ setLoading(false)
+ }
+ }, [limit, offset])
+
+ useEffect(() => {
+ void load()
+ }, [load])
+
+ return { personas, loading, error, reload: load }
+}
diff --git a/src/lib/learnEnglishPracticePublish.ts b/src/lib/learnEnglishPracticePublish.ts
index 1f07ae2..2d01f24 100644
--- a/src/lib/learnEnglishPracticePublish.ts
+++ b/src/lib/learnEnglishPracticePublish.ts
@@ -63,6 +63,9 @@ export async function executeLearnEnglishPracticeCreation(opts: {
storyDescription: string
storyImage: string
quickTips: string
+ personaName?: string | null
+ /** Selected persona from step 2 — sent as `persona_id` on POST /practices. */
+ personaId: number
questions: LearnEnglishDefinitionQuestionInput[]
definitions: QuestionTypeDefinition[]
}): Promise<{ questionSetId: number; practiceId: number }> {
@@ -72,6 +75,10 @@ export async function executeLearnEnglishPracticeCreation(opts: {
)
if (err) throw new Error(err)
+ if (!Number.isFinite(opts.personaId) || opts.personaId < 1) {
+ throw new Error("persona_id is required. Select a persona before saving.")
+ }
+
const byId = new Map(opts.definitions.map((d) => [d.id, d]))
const setRes = await createQuestionSet({
@@ -82,6 +89,7 @@ export async function executeLearnEnglishPracticeCreation(opts: {
owner_id: opts.parentId,
shuffle_questions: opts.shuffleQuestions,
status: opts.status,
+ ...(opts.personaName?.trim() ? { persona: opts.personaName.trim() } : {}),
})
const setId = setRes.data?.data?.id
@@ -122,6 +130,7 @@ export async function executeLearnEnglishPracticeCreation(opts: {
question_set_id: setId,
quick_tips: opts.quickTips.trim(),
publish_status: opts.status,
+ persona_id: opts.personaId,
})
const practiceId = practiceRes.data?.data?.id
diff --git a/src/lib/personaDisplay.ts b/src/lib/personaDisplay.ts
new file mode 100644
index 0000000..be0ca8e
--- /dev/null
+++ b/src/lib/personaDisplay.ts
@@ -0,0 +1,52 @@
+import type {
+ GetPersonasResponse,
+ PersonaListItem,
+} from "../types/persona.types"
+
+export type PersonaCardModel = {
+ id: string
+ name: string
+ description: string
+ avatar: string
+}
+
+/** Soft, professional palette aligned with the admin brand (slate, indigo, violet). */
+const PERSONA_FALLBACK_BACKGROUNDS = "f1f5f9,e0e7ff,ede9fe,fdf4ff,ecfeff"
+
+/**
+ * Default avatar when `profile_picture` is null: professional illustrated portrait
+ * (DiceBear personas), not casual cartoon avataaars.
+ */
+export function personaAvatarUrl(
+ profilePicture: string | null | undefined,
+ name: string,
+ personaId?: number | string,
+): string {
+ const url = profilePicture?.trim()
+ if (url) return url
+ const params = new URLSearchParams({
+ seed: personaId != null ? `yimaru-persona-${personaId}` : `yimaru-persona-${name}`,
+ backgroundColor: PERSONA_FALLBACK_BACKGROUNDS,
+ radius: "50",
+ })
+ return `https://api.dicebear.com/7.x/personas/svg?${params.toString()}`
+}
+
+export function mapPersonaToCard(persona: PersonaListItem): PersonaCardModel {
+ return {
+ id: String(persona.id),
+ name: persona.name,
+ description: persona.description?.trim() ?? "",
+ avatar: personaAvatarUrl(persona.profile_picture, persona.name, persona.id),
+ }
+}
+
+export function unwrapPersonasList(
+ res: { data?: GetPersonasResponse & { Data?: GetPersonasResponse["data"] } },
+): PersonaListItem[] {
+ const body = res.data
+ if (!body) return []
+ const data = body.data ?? body.Data
+ const raw = data?.personas
+ return Array.isArray(raw) ? raw : []
+}
diff --git a/src/lib/videoPreview.ts b/src/lib/videoPreview.ts
index e83eaf9..086ea63 100644
--- a/src/lib/videoPreview.ts
+++ b/src/lib/videoPreview.ts
@@ -88,6 +88,19 @@ export function formatPreviewLength(totalSeconds: number): string {
return `${totalSeconds} seconds`;
}
+/** Compact label for thumbnails (e.g. `3:02`, `1:05:07`). */
+export function formatVideoDurationLabel(totalSeconds: number): string {
+ if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return "";
+ const s = Math.round(totalSeconds);
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ if (h > 0) {
+ return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
+ }
+ return `${m}:${String(sec).padStart(2, "0")}`;
+}
+
/**
* YouTube: `end` = stop after this many seconds from the start of the video.
* Vimeo: time range in URL fragment (supported on many vimeo.com player links).
diff --git a/src/pages/content-management/AddNewPracticePage.tsx b/src/pages/content-management/AddNewPracticePage.tsx
index e36964a..a4b7928 100644
--- a/src/pages/content-management/AddNewPracticePage.tsx
+++ b/src/pages/content-management/AddNewPracticePage.tsx
@@ -9,8 +9,6 @@ import {
Plus,
Trash2,
GripVertical,
- Edit,
- Rocket,
Loader2,
Upload,
} from "lucide-react";
@@ -19,6 +17,9 @@ import { Card } from "../../components/ui/card";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields";
+import { AddNewPracticeReviewStep } from "./components/AddNewPracticeReviewStep";
+import { PersonaStep } from "./components/practice-steps/PersonaStep";
+import { useActivePersonas } from "../../hooks/useActivePersonas";
import {
createQuestionSet,
createQuestion,
@@ -34,12 +35,6 @@ type ResultStatus = "success" | "error";
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC";
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD";
-interface Persona {
- id: string;
- name: string;
- avatar: string;
-}
-
interface MCQOption {
text: string;
isCorrect: boolean;
@@ -65,49 +60,6 @@ interface Question {
dynamicFieldValues: Record;
}
-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",
- },
- {
- id: "5",
- name: "Liya",
- avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya",
- },
- {
- id: "6",
- name: "Aseffa",
- avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa",
- },
- {
- id: "7",
- name: "Hana",
- avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana",
- },
- {
- id: "8",
- name: "Nahom",
- avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom",
- },
-];
-
const STEPS = [
{ number: 1, label: "Context" },
{ number: 2, label: "Persona" },
@@ -158,64 +110,6 @@ function isDirectVideoFile(url: string): boolean {
return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
}
-function escapeHtml(raw: string): string {
- return raw
- .replaceAll("&", "&")
- .replaceAll("<", "<")
- .replaceAll(">", ">")
- .replaceAll('"', """)
- .replaceAll("'", "'");
-}
-
-function sanitizeAdminRichTextHtml(input: string): string {
- if (!input.trim()) return "";
- try {
- const parser = new DOMParser();
- const doc = parser.parseFromString(input, "text/html");
- const blockedTags = new Set([
- "script",
- "style",
- "iframe",
- "object",
- "embed",
- "link",
- "meta",
- ]);
- doc.body.querySelectorAll("*").forEach((el) => {
- const tagName = el.tagName.toLowerCase();
- if (blockedTags.has(tagName)) {
- el.remove();
- return;
- }
- const attrs = [...el.attributes];
- attrs.forEach((attr) => {
- const name = attr.name.toLowerCase();
- const value = attr.value.trim().toLowerCase();
- if (name.startsWith("on")) {
- el.removeAttribute(attr.name);
- return;
- }
- if (
- (name === "href" || name === "src") &&
- value.startsWith("javascript:")
- ) {
- el.removeAttribute(attr.name);
- }
- });
- });
- return doc.body.innerHTML;
- } catch {
- return escapeHtml(input).replace(/\r?\n/g, " ");
- }
-}
-
-function formatDescriptionForPreview(raw: string): string {
- if (!raw.trim()) return "";
- const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw);
- if (hasHtml) return sanitizeAdminRichTextHtml(raw);
- return escapeHtml(raw).replace(/\r?\n/g, " ");
-}
-
function createEmptyQuestion(id: string): Question {
return {
id,
@@ -281,6 +175,12 @@ export function AddNewPracticePage() {
// Step 2: Persona
const [selectedPersona, setSelectedPersona] = useState(null);
+ const {
+ personas,
+ loading: personasLoading,
+ error: personasError,
+ reload: reloadPersonas,
+ } = useActivePersonas();
// Step 3: Questions
const [questions, setQuestions] = useState([
@@ -373,11 +273,6 @@ export function AddNewPracticePage() {
return null;
}, [introVideoUrl]);
- const descriptionPreviewHtml = useMemo(
- () => formatDescriptionForPreview(practiceDescription),
- [practiceDescription],
- );
-
const addQuestion = () => {
setQuestions([...questions, createEmptyQuestion(String(Date.now()))]);
};
@@ -398,7 +293,12 @@ export function AddNewPracticePage() {
setSaving(true);
setSaveError(null);
try {
- const persona = PERSONAS.find((p) => p.id === selectedPersona);
+ if (!selectedPersona) {
+ toast.error("Select a persona before saving.");
+ setSaving(false);
+ return;
+ }
+ const persona = personas.find((p) => p.id === selectedPersona);
const setRes = await createQuestionSet({
title: practiceTitle || "Untitled Practice",
set_type: "PRACTICE",
@@ -899,66 +799,17 @@ export function AddNewPracticePage() {
practice.
-
-
- {PERSONAS.map((persona) => (
-
setSelectedPersona(persona.id)}
- className={`group relative flex flex-col items-center rounded-xl border-2 p-6 transition-all duration-200 ${
- selectedPersona === persona.id
- ? "border-brand-500 bg-brand-50 shadow-md shadow-brand-100"
- : "border-grayScale-200 bg-white hover:border-brand-300 hover:shadow-sm"
- }`}
- >
- {selectedPersona === persona.id && (
-
-
-
- )}
-
-
-
-
- {persona.name}
-
-
- ))}
-
-
-
-
-
- Back
-
-
- {getNextButtonLabel()}
-
-
+
void reloadPersonas()}
+ selectedPersona={selectedPersona}
+ setSelectedPersona={setSelectedPersona}
+ nextStep={handleNext}
+ prevStep={handleBack}
+ />
)}
@@ -1075,261 +926,26 @@ export function AddNewPracticePage() {
)}
{currentStep === 4 && (
-
-
-
- Step 4: Review & publish
-
-
- Confirm context, persona, and questions before saving or
- publishing.
-
-
-
-
- {/* Basic Information Card */}
-
-
-
- Basic Information
-
- setCurrentStep(1)}
- className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
- >
-
- Edit
-
-
-
-
- Title
-
- {practiceTitle || "Untitled Practice"}
-
-
-
-
- Description
-
- {descriptionPreviewHtml ? (
-
- ) : (
-
—
- )}
-
-
-
- Intro video URL
-
-
- {introVideoUrl.trim() || "—"}
-
-
- {introVideoPreview ? (
-
-
- Intro video preview
-
-
- {introVideoPreview.kind === "vimeo" ? (
-
-
-
- ) : (
-
- )}
-
-
- ) : null}
-
-
- Passing Score
-
-
- {passingScore}%
-
-
-
-
- Time Limit
-
-
- {timeLimitMinutes} minutes
-
-
-
-
- Shuffle Questions
-
-
- {shuffleQuestions ? "Yes" : "No"}
-
-
-
-
Persona
-
- {selectedPersona && (
-
-
p.id === selectedPersona)
- ?.avatar
- }
- alt="Persona"
- className="h-full w-full object-cover"
- />
-
- )}
-
- {PERSONAS.find((p) => p.id === selectedPersona)?.name ||
- "None selected"}
-
-
-
-
-
-
- {/* Questions Review */}
-
-
-
-
- Questions
-
-
- {questions.length}
-
-
-
setCurrentStep(3)}
- className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
- >
-
- Edit
-
-
-
- {questions.map((question, index) => (
-
-
-
- {index + 1}
-
-
-
- {question.questionText}
-
-
-
- {question.questionType === "MCQ"
- ? "Multiple Choice"
- : question.questionType === "TRUE_FALSE"
- ? "True/False"
- : question.questionType === "AUDIO"
- ? "Audio"
- : question.questionType === "DYNAMIC"
- ? "Dynamic"
- : "Short Answer"}
-
-
- {question.difficultyLevel}
-
-
- {question.points} pt
- {question.points !== 1 ? "s" : ""}
-
-
- {question.questionType === "MCQ" &&
- question.options.length > 0 && (
-
- {question.options.map((opt, i) => (
-
- {opt.isCorrect && (
-
- )}
- {opt.text || `Option ${i + 1}`}
-
- ))}
-
- )}
- {question.tips && (
-
- 💡 Tip: {question.tips}
-
- )}
- {question.explanation && (
-
- Explanation: {question.explanation}
-
- )}
-
-
-
- ))}
-
-
-
-
- {saveError && (
-
- )}
-
-
-
- Back
-
-
-
- {saving ? "Saving..." : "Save as Draft"}
-
-
-
- {saving ? "Publishing..." : "Publish Now"}
-
-
-
-
+ setCurrentStep(1)}
+ onEditQuestions={() => setCurrentStep(3)}
+ onBack={handleBack}
+ onSaveDraft={handleSaveAsDraft}
+ onPublish={handlePublish}
+ />
)}
{/* Step 5: Result */}
diff --git a/src/pages/content-management/AddPracticeFlow.tsx b/src/pages/content-management/AddPracticeFlow.tsx
index 19fcc70..7139b17 100644
--- a/src/pages/content-management/AddPracticeFlow.tsx
+++ b/src/pages/content-management/AddPracticeFlow.tsx
@@ -22,10 +22,16 @@ import {
import { ContextStep } from "./components/practice-steps/ContextStep";
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
+import { PersonaStep } from "./components/practice-steps/PersonaStep";
import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
import { ReviewStep } from "./components/practice-steps/ReviewStep";
+import {
+ personaFromId,
+ personaIdNumber,
+} from "./components/practice-steps/constants";
+import { useActivePersonas } from "../../hooks/useActivePersonas";
-const STEP_LABELS = ["Practice", "Questions", "Review"] as const;
+const STEP_LABELS = ["Practice", "Persona", "Questions", "Review"] as const;
export function AddPracticeFlow() {
const navigate = useNavigate();
@@ -48,6 +54,10 @@ export function AddPracticeFlow() {
const isModuleContext = backTo === "module";
const isCourseContext = backTo === "modules";
+ const isLessonPractice = useMemo(() => {
+ const lid = lessonId ? Number(lessonId) : NaN;
+ return Number.isFinite(lid) && lid > 0;
+ }, [lessonId]);
const parentContext = useMemo((): {
kind: PracticeParentKind;
@@ -96,12 +106,19 @@ export function AddPracticeFlow() {
const [isPublished, setIsPublished] = useState(false);
const [submitting, setSubmitting] = useState(false);
+ const [selectedPersona, setSelectedPersona] = useState(null);
+ const {
+ personas,
+ loading: personasLoading,
+ error: personasError,
+ reload: reloadPersonas,
+ } = useActivePersonas();
+
const [formData, setFormData] = useState({
title: "",
description: "",
storyImageUrl: "",
shuffleQuestions: false,
- publishStatus: "DRAFT" as const,
tips: "",
questions: [
{
@@ -176,12 +193,29 @@ export function AddPracticeFlow() {
});
return;
}
- if (!formData.title.trim() || !formData.description.trim()) {
+ if (
+ !isLessonPractice &&
+ (!formData.title.trim() || !formData.description.trim())
+ ) {
toast.error("Title and story description are required", {
description: "Complete the first step before publishing.",
});
return;
}
+ if (!selectedPersona) {
+ toast.error("Select a persona", {
+ description: "Choose a character on the Persona step before publishing.",
+ });
+ return;
+ }
+ const personaId = personaIdNumber(selectedPersona);
+ if (!personaId) {
+ toast.error("Invalid persona", {
+ description: "Re-select a persona from the list and try again.",
+ });
+ return;
+ }
+ const persona = personaFromId(selectedPersona, personas);
const mappedQuestions = formData.questions
.filter((q) => String(q.text ?? "").trim())
.map((q) => ({
@@ -207,19 +241,33 @@ export function AddPracticeFlow() {
return;
}
+ const lessonDefaultTitle =
+ lessonTitleDisplay?.trim() ||
+ (lessonId ? `Lesson ${lessonId} practice` : "Lesson practice");
+
setSubmitting(true);
try {
await executeLearnEnglishPracticeCreation({
parentKind: parentContext.kind,
parentId: parentContext.id,
status,
- questionSetTitle: formData.title.trim() || "Practice set",
- questionSetDescription: formData.description.trim() || null,
+ questionSetTitle: isLessonPractice
+ ? lessonDefaultTitle
+ : formData.title.trim() || "Practice set",
+ questionSetDescription: isLessonPractice
+ ? null
+ : formData.description.trim() || null,
shuffleQuestions: formData.shuffleQuestions,
- practiceTitle: formData.title.trim() || "Untitled practice",
- storyDescription: formData.description.trim(),
- storyImage: formData.storyImageUrl.trim(),
+ practiceTitle: isLessonPractice
+ ? lessonDefaultTitle
+ : formData.title.trim() || "Untitled practice",
+ storyDescription: isLessonPractice
+ ? ""
+ : formData.description.trim(),
+ storyImage: isLessonPractice ? "" : formData.storyImageUrl.trim(),
quickTips: formData.tips.trim(),
+ personaName: persona?.name ?? null,
+ personaId,
questions: mappedQuestions,
definitions: typeDefinitions,
});
@@ -274,12 +322,12 @@ export function AddPracticeFlow() {
onClick={() => {
setIsPublished(false);
setCurrentStep(1);
+ setSelectedPersona(null);
setFormData({
title: "",
description: "",
storyImageUrl: "",
shuffleQuestions: false,
- publishStatus: "DRAFT" as const,
tips: "",
questions: [
{
@@ -321,11 +369,25 @@ export function AddPracticeFlow() {
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
- navigate={navigate}
- level={level!}
+ onCancel={() => navigate(backPath)}
+ isLessonPractice={isLessonPractice}
+ lessonTitle={lessonTitleDisplay}
/>
);
case 2:
+ return (
+ void reloadPersonas()}
+ selectedPersona={selectedPersona}
+ setSelectedPersona={setSelectedPersona}
+ nextStep={nextStep}
+ prevStep={prevStep}
+ />
+ );
+ case 3:
return (
);
- case 3:
+ case 4:
return (
setCurrentStep(1)}
+ onEditQuestions={() => setCurrentStep(3)}
parentSummary={parentSummary}
typeDefinitions={typeDefinitions}
canPublish={parentContext !== null}
@@ -367,6 +437,19 @@ export function AddPracticeFlow() {
/>
);
case 2:
+ return (
+ void reloadPersonas()}
+ selectedPersona={selectedPersona}
+ setSelectedPersona={setSelectedPersona}
+ nextStep={nextStep}
+ prevStep={prevStep}
+ />
+ );
+ case 3:
return (
);
- case 3:
+ case 4:
return (
setCurrentStep(1)}
+ onEditQuestions={() => setCurrentStep(3)}
parentSummary={parentSummary}
typeDefinitions={typeDefinitions}
canPublish={parentContext !== null}
@@ -453,7 +544,7 @@ export function AddPracticeFlow() {
{renderStep()}
diff --git a/src/pages/content-management/AddVideoFlow.tsx b/src/pages/content-management/AddVideoFlow.tsx
index 3130445..18e879c 100644
--- a/src/pages/content-management/AddVideoFlow.tsx
+++ b/src/pages/content-management/AddVideoFlow.tsx
@@ -5,6 +5,7 @@ import { ArrowLeft } from "lucide-react";
import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper";
import { createModuleLesson } from "../../api/courses.api";
+import type { PracticePublishStatus } from "../../types/course.types";
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
@@ -17,7 +18,7 @@ const STEPS = [
export type AddLessonFormData = {
title: string;
- order: string;
+ sortOrder: string;
description: string;
videoUrl: string;
thumbnailUrl: string;
@@ -25,7 +26,7 @@ export type AddLessonFormData = {
const emptyForm = (): AddLessonFormData => ({
title: "",
- order: "1",
+ sortOrder: "0",
description: "",
videoUrl: "",
thumbnailUrl: "",
@@ -51,6 +52,8 @@ export function AddVideoFlow() {
}>();
const [currentStep, setCurrentStep] = useState(1);
const [isPublished, setIsPublished] = useState(false);
+ const [lastCreatedPublishStatus, setLastCreatedPublishStatus] =
+ useState("PUBLISHED");
const [formData, setFormData] = useState(emptyForm);
const [publishing, setPublishing] = useState(false);
const [formResetKey, setFormResetKey] = useState(0);
@@ -60,7 +63,7 @@ export function AddVideoFlow() {
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
- const handlePublish = async () => {
+ const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
const mid = Number(moduleId);
if (!Number.isFinite(mid) || mid < 1) {
toast.error("Invalid module");
@@ -86,6 +89,16 @@ export function AddVideoFlow() {
toast.error("Description is required");
return;
}
+ const sortOrderRaw = formData.sortOrder.trim();
+ if (sortOrderRaw === "") {
+ toast.error("Sort order is required");
+ return;
+ }
+ const sort_order = Number(sortOrderRaw);
+ if (!Number.isInteger(sort_order) || sort_order < 0) {
+ toast.error("Sort order must be a whole number of 0 or greater");
+ return;
+ }
setPublishing(true);
try {
await createModuleLesson(mid, {
@@ -93,8 +106,15 @@ export function AddVideoFlow() {
video_url: videoUrl,
thumbnail,
description,
+ sort_order,
+ publish_status: publishStatus,
});
- toast.success("Lesson created");
+ setLastCreatedPublishStatus(publishStatus);
+ toast.success(
+ publishStatus === "DRAFT"
+ ? "Lesson saved as draft"
+ : "Lesson published",
+ );
setIsPublished(true);
} catch (e: unknown) {
console.error(e);
@@ -123,10 +143,14 @@ export function AddVideoFlow() {
- Lesson created successfully
+ {lastCreatedPublishStatus === "DRAFT"
+ ? "Lesson saved as draft"
+ : "Lesson published successfully"}
- Your lesson is now available in this module.
+ {lastCreatedPublishStatus === "DRAFT"
+ ? "You can finish editing and publish it later from the module."
+ : "Your lesson is now available in this module."}
@@ -140,6 +164,7 @@ export function AddVideoFlow() {
onClick={() => {
setFormData(emptyForm());
setFormResetKey((k) => k + 1);
+ setLastCreatedPublishStatus("PUBLISHED");
setIsPublished(false);
setCurrentStep(1);
}}
@@ -205,7 +230,7 @@ export function AddVideoFlow() {
void handlePublish()}
+ onCreateLesson={(status) => void handleCreateLesson(status)}
publishing={publishing}
/>
)}
diff --git a/src/pages/content-management/CourseDetailPage.tsx b/src/pages/content-management/CourseDetailPage.tsx
index 1e39b58..6de9458 100644
--- a/src/pages/content-management/CourseDetailPage.tsx
+++ b/src/pages/content-management/CourseDetailPage.tsx
@@ -21,7 +21,6 @@ import {
DialogTitle,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
-import { Textarea } from "../../components/ui/textarea";
import { cn } from "../../lib/utils";
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
import alertSrc from "../../assets/Alert.svg";
@@ -146,7 +145,6 @@ export function CourseDetailPage() {
const [editingModule, setEditingModule] =
useState(null);
const [editModuleName, setEditModuleName] = useState("");
- const [editModuleDescription, setEditModuleDescription] = useState("");
const [editModuleSortOrder, setEditModuleSortOrder] = useState("");
const [editModuleIcon, setEditModuleIcon] = useState("");
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
@@ -160,7 +158,6 @@ export function CourseDetailPage() {
const openEditModule = (module: TopLevelCourseModuleItem) => {
setEditingModule(module);
setEditModuleName(module.name ?? "");
- setEditModuleDescription(module.description ?? "");
setEditModuleSortOrder(String(module.sort_order ?? 0));
setEditModuleIcon(module.icon?.trim() ?? "");
setEditModuleIconUploadBusy(false);
@@ -284,7 +281,7 @@ export function CourseDetailPage() {
try {
await updateTopLevelCourseModule(editingModule.id, {
name,
- description: editModuleDescription.trim(),
+ description: editingModule.description?.trim() ?? "",
icon: editModuleIcon.trim(),
sort_order,
});
@@ -430,8 +427,7 @@ export function CourseDetailPage() {
Edit module
- Update name, description, sort order, and icon (upload or URL).
- Saved with{" "}
+ Update name, sort order, and icon (upload or URL). Saved with{" "}
PUT /modules/:id
@@ -452,19 +448,6 @@ export function CourseDetailPage() {
disabled={savingModuleEdit}
/>
-
-
- Description
-
-
(null);
const [editName, setEditName] = useState("");
- const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false);
@@ -152,7 +150,7 @@ export function CourseManagementPage() {
const clearCreateUnitForm = () => {
setCreateName("");
- setCreateDescription("");
+ setCreateSortOrder("");
setCreateThumbnail("");
if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = "";
@@ -202,13 +200,24 @@ export function CourseManagementPage() {
toast.error("Unit name is required");
return;
}
+ const sortOrderRaw = createSortOrder.trim();
+ if (!sortOrderRaw) {
+ toast.error("Sort order is required");
+ return;
+ }
+ const sort_order = Number(sortOrderRaw);
+ if (!Number.isInteger(sort_order) || sort_order < 0) {
+ toast.error("Sort order must be a whole number of 0 or greater");
+ return;
+ }
setCreating(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
const response = await createExamPrepCatalogUnit(catalogCourseId, {
name,
- description: createDescription.trim() || null,
+ description: null,
thumbnail: minioThumbnail || null,
+ sort_order,
});
void response;
await loadUnits();
@@ -271,18 +280,16 @@ export function CourseManagementPage() {
const openEditUnit = (unit: (typeof units)[number]) => {
setEditingUnitId(unit.id);
setEditName(unit.name ?? "");
- setEditDescription(unit.description ?? "");
setEditThumbnail(unit.thumbnail ?? "");
- setEditSortOrder(String(unit.sortOrder ?? 1));
+ setEditSortOrder(String(unit.sortOrder ?? 0));
};
const closeEditUnit = () => {
if (savingEdit || uploadingEditThumbnail) return;
setEditingUnitId(null);
setEditName("");
- setEditDescription("");
setEditThumbnail("");
- setEditSortOrder("1");
+ setEditSortOrder("");
};
const handleEditUnitThumbnailFile = async (
@@ -320,20 +327,30 @@ export function CourseManagementPage() {
toast.error("Unit name is required");
return;
}
- const sortOrderNum = Number(editSortOrder);
- if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
- toast.error("Sort order must be a valid number");
+ const sortOrderRaw = editSortOrder.trim();
+ if (!sortOrderRaw) {
+ toast.error("Sort order is required");
+ return;
+ }
+ const sort_order = Number(sortOrderRaw);
+ if (!Number.isInteger(sort_order) || sort_order < 0) {
+ toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setSavingEdit(true);
try {
+ const existing = units.find((u) => u.id === editingUnitId);
+ const preservedDescription =
+ existing?.description && existing.description !== "—"
+ ? existing.description
+ : null;
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
await updateExamPrepCatalogUnit(editingUnitId, {
name,
- description: editDescription.trim() || null,
+ description: preservedDescription,
thumbnail: minioThumbnail || null,
- sort_order: sortOrderNum,
+ sort_order,
});
await loadUnits();
toast.success("Unit updated");
@@ -425,18 +442,29 @@ export function CourseManagementPage() {
disabled={creating || uploadingThumbnail}
/>
+
-
- Description
+
+ Sort Order
-
@@ -690,25 +718,27 @@ export function CourseManagementPage() {
/>
- Description
-
-
-
Sort Order
+
+ Sort Order
+
setEditSortOrder(e.target.value)}
- className="h-12 border-grayScale-400 rounded-[8px] px-4"
+ placeholder="e.g. 0"
+ className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={savingEdit || uploadingEditThumbnail}
/>
+
+ Lower numbers appear first when units are listed.
+
Thumbnail
diff --git a/src/pages/content-management/CourseModuleDetailPage.tsx b/src/pages/content-management/CourseModuleDetailPage.tsx
index 7d51e2d..ddb07aa 100644
--- a/src/pages/content-management/CourseModuleDetailPage.tsx
+++ b/src/pages/content-management/CourseModuleDetailPage.tsx
@@ -25,6 +25,7 @@ import {
getExamPrepModuleLessons,
} from "../../api/courses.api";
import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
+import type { PracticePublishStatus } from "../../types/course.types";
const MOCK_PRACTICES = [
{
@@ -64,6 +65,7 @@ export function CourseModuleDetailPage() {
thumbnail: string;
sortOrder: number;
gradient: string;
+ durationSeconds: number | null;
}>
>([]);
const [createLessonOpen, setCreateLessonOpen] = useState(false);
@@ -129,20 +131,28 @@ export function CourseModuleDetailPage() {
const rows = response.data?.data?.lessons;
const list = Array.isArray(rows) ? rows : [];
setLessons(
- list.map((row, index) => ({
+ list.map((row, index) => {
+ const raw = row.duration_seconds ?? row.duration ?? null;
+ const n =
+ raw == null ? NaN : typeof raw === "number" ? raw : Number(raw);
+ const durationSeconds =
+ Number.isFinite(n) && n > 0 ? n : null;
+ return {
id: Number(row.id),
title: row.title?.trim() || `Lesson ${row.id}`,
videoUrl: row.video_url?.trim() || "",
description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0),
+ durationSeconds,
gradient:
index % 3 === 1
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)"
: index % 3 === 2
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)"
: "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)",
- })),
+ };
+ }),
);
} catch (error) {
console.error(error);
@@ -252,7 +262,7 @@ export function CourseModuleDetailPage() {
}
};
- const handleCreateLesson = async () => {
+ const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
toast.error("Invalid module");
return;
@@ -276,9 +286,14 @@ export function CourseModuleDetailPage() {
video_url: videoUrl,
thumbnail: minioThumbnail || null,
description: createDescription.trim() || null,
+ publish_status: publishStatus,
});
await loadLessons();
- toast.success("Lesson created");
+ toast.success(
+ publishStatus === "DRAFT"
+ ? "Lesson saved as draft"
+ : "Lesson created",
+ );
clearCreateLessonForm();
setCreateLessonOpen(false);
} catch (error: unknown) {
@@ -641,7 +656,7 @@ export function CourseModuleDetailPage() {
/>
-
+
+ void handleCreateLesson("DRAFT")}
+ >
+ {creatingLesson ? "Saving…" : "Save as draft"}
+
void handleCreateLesson()}
+ onClick={() => void handleCreateLesson("PUBLISHED")}
>
- {creatingLesson ? "Creating..." : "Create Lesson"}
+ {creatingLesson ? "Creating..." : "Publish lesson"}
@@ -716,6 +740,7 @@ export function CourseModuleDetailPage() {
thumbnailUrl={lesson.thumbnail}
videoUrl={lesson.videoUrl}
thumbnailGradient={lesson.gradient}
+ durationSeconds={lesson.durationSeconds}
hoverModuleActions
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLessonId(lesson.id)}
diff --git a/src/pages/content-management/HumanLanguageHierarchyPage.tsx b/src/pages/content-management/HumanLanguageHierarchyPage.tsx
index 8b604af..495090a 100644
--- a/src/pages/content-management/HumanLanguageHierarchyPage.tsx
+++ b/src/pages/content-management/HumanLanguageHierarchyPage.tsx
@@ -9,7 +9,6 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
-import { Textarea } from "../../components/ui/textarea"
import {
createModule,
deleteModule,
@@ -241,7 +240,6 @@ export function HumanLanguageHierarchyPage() {
const [createModuleTitle, setCreateModuleTitle] = useState("")
const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false)
const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("")
- const [createModuleDescription, setCreateModuleDescription] = useState("")
const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url")
const [createModuleIconUrl, setCreateModuleIconUrl] = useState("")
const [createModuleIconFile, setCreateModuleIconFile] = useState(null)
@@ -253,7 +251,6 @@ export function HumanLanguageHierarchyPage() {
const [editModuleSaving, setEditModuleSaving] = useState(false)
const [editModuleTarget, setEditModuleTarget] = useState(null)
const [editModuleTitle, setEditModuleTitle] = useState("")
- const [editModuleDescription, setEditModuleDescription] = useState("")
const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0)
const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url")
const [editModuleIconUrl, setEditModuleIconUrl] = useState("")
@@ -467,7 +464,6 @@ export function HumanLanguageHierarchyPage() {
setCreateModuleUseDefaultNaming(false)
setCreateModuleDefaultTitle(getNextDefaultModuleName(level))
setCreateModuleTitle("")
- setCreateModuleDescription("")
setCreateModuleIconSource("url")
setCreateModuleIconUrl("")
setCreateModuleIconFile(null)
@@ -503,7 +499,6 @@ export function HumanLanguageHierarchyPage() {
await createModule({
level_id: createModuleLevelId,
title,
- description: createModuleDescription.trim() || undefined,
icon_url: uploadedIconUrl,
display_order: createModuleDisplayOrder,
is_active: true,
@@ -553,7 +548,6 @@ export function HumanLanguageHierarchyPage() {
levelKey,
})
setEditModuleTitle(module.title)
- setEditModuleDescription("")
setEditModuleDisplayOrder(moduleDisplayOrder)
setEditModuleIconSource("url")
setEditModuleIconUrl(existingIconUrl)
@@ -594,7 +588,6 @@ export function HumanLanguageHierarchyPage() {
await updateModule(editModuleTarget.moduleId, {
title,
- description: editModuleDescription.trim() || undefined,
icon_url: uploadedIconUrl,
display_order: editModuleDisplayOrder,
is_active: true,
@@ -1068,17 +1061,6 @@ export function HumanLanguageHierarchyPage() {
/>
-
- Description (optional)
-
-
Icon URL (optional)
@@ -1173,17 +1155,6 @@ export function HumanLanguageHierarchyPage() {
/>
-
- Description
-
-
Display order
();
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
- const [activeFilter, setActiveFilter] = useState("Draft");
+ const [activeFilter, setActiveFilter] = useState("All");
const [lessons, setLessons] = useState
([]);
const [lessonsLoading, setLessonsLoading] = useState(true);
const [lessonsLoadError, setLessonsLoadError] = useState(null);
const [editingLesson, setEditingLesson] =
useState(null);
const [editLessonTitle, setEditLessonTitle] = useState("");
+ const [editLessonSortOrder, setEditLessonSortOrder] = useState("");
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
const [editLessonDescription, setEditLessonDescription] = useState("");
@@ -104,7 +79,17 @@ export function ModuleDetailPage() {
const [deletingLesson, setDeletingLesson] =
useState(null);
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
- const [practices] = useState(MOCK_PRACTICES);
+ const [publishStatusLessonId, setPublishStatusLessonId] = useState<
+ number | null
+ >(null);
+ const [practices, setPractices] = useState([]);
+ const [practicesLoading, setPracticesLoading] = useState(false);
+ const [practicesLoadError, setPracticesLoadError] = useState(
+ null,
+ );
+ const [publishStatusPracticeId, setPublishStatusPracticeId] = useState<
+ number | null
+ >(null);
const [loadedModuleName, setLoadedModuleName] = useState(null);
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
string | null
@@ -233,9 +218,96 @@ export function ModuleDetailPage() {
void loadModuleLessons({ showPageLoading: true });
}, [loadModuleLessons]);
+ const loadModulePractices = useCallback(async () => {
+ const mid = Number(moduleId);
+ if (!Number.isFinite(mid) || mid < 1) {
+ setPractices([]);
+ setPracticesLoadError(null);
+ setPracticesLoading(false);
+ return;
+ }
+ setPracticesLoading(true);
+ setPracticesLoadError(null);
+ try {
+ const res = await getPracticesByParentModule(mid, {
+ limit: 100,
+ offset: 0,
+ });
+ setPractices(unwrapPracticesList(res));
+ } catch {
+ setPractices([]);
+ setPracticesLoadError("Failed to load practices. Please try again.");
+ } finally {
+ setPracticesLoading(false);
+ }
+ }, [moduleId]);
+
+ useEffect(() => {
+ if (activeTab !== "practice") return;
+ void loadModulePractices();
+ }, [activeTab, loadModulePractices]);
+
+ const filteredPractices = useMemo(() => {
+ if (activeFilter === "Published") {
+ return practices.filter(isPracticePublished);
+ }
+ if (activeFilter === "Draft") {
+ return practices.filter(isPracticeDraft);
+ }
+ if (activeFilter === "Archived") {
+ return [];
+ }
+ return practices;
+ }, [practices, activeFilter]);
+
+ const handlePublishPractice = async (practiceId: number) => {
+ setPublishStatusPracticeId(practiceId);
+ try {
+ await publishParentLinkedPractice(practiceId);
+ setPractices((prev) =>
+ prev.map((p) =>
+ p.id === practiceId ? { ...p, publish_status: "PUBLISHED" } : p,
+ ),
+ );
+ toast.success("Practice published");
+ } catch (e: unknown) {
+ console.error(e);
+ const msg =
+ (e as { response?: { data?: { message?: string } } })?.response?.data
+ ?.message ?? "Failed to publish practice";
+ toast.error(msg);
+ } finally {
+ setPublishStatusPracticeId(null);
+ }
+ };
+
+ const handleSavePracticeAsDraft = async (practiceId: number) => {
+ setPublishStatusPracticeId(practiceId);
+ try {
+ await updateParentLinkedPractice(practiceId, {
+ publish_status: "DRAFT",
+ });
+ setPractices((prev) =>
+ prev.map((p) =>
+ p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p,
+ ),
+ );
+ toast.success("Practice saved as draft");
+ } catch (e: unknown) {
+ console.error(e);
+ const msg =
+ (e as { response?: { data?: { message?: string } } })?.response?.data
+ ?.message ?? "Failed to save practice as draft";
+ toast.error(msg);
+ } finally {
+ setPublishStatusPracticeId(null);
+ }
+ };
+
const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
setEditingLesson(lesson);
setEditLessonTitle(lesson.title ?? "");
+ setEditLessonSortOrder(String(lesson.sort_order ?? 0));
setEditLessonVideoUrl(lesson.video_url ?? "");
setEditLessonThumbnail(lesson.thumbnail ?? "");
setEditLessonDescription(lesson.description ?? "");
@@ -253,6 +325,16 @@ export function ModuleDetailPage() {
toast.error("Title is required");
return;
}
+ const sortOrderRaw = editLessonSortOrder.trim();
+ if (sortOrderRaw === "") {
+ toast.error("Sort order is required");
+ return;
+ }
+ const sort_order = Number(sortOrderRaw);
+ if (!Number.isInteger(sort_order) || sort_order < 0) {
+ toast.error("Sort order must be a whole number of 0 or greater");
+ return;
+ }
setSavingLessonEdit(true);
try {
await updateTopLevelModuleLesson(editingLesson.id, {
@@ -260,6 +342,7 @@ export function ModuleDetailPage() {
video_url: editLessonVideoUrl.trim(),
thumbnail: editLessonThumbnail.trim(),
description: editLessonDescription.trim(),
+ sort_order,
});
toast.success("Lesson updated");
setEditingLesson(null);
@@ -275,6 +358,39 @@ export function ModuleDetailPage() {
}
};
+ const handleToggleLessonPublishStatus = async (
+ lessonId: number,
+ nextStatus: PracticePublishStatus,
+ ) => {
+ setPublishStatusLessonId(lessonId);
+ try {
+ await publishTopLevelModuleLesson(lessonId, {
+ publish_status: nextStatus,
+ });
+ setLessons((prev) =>
+ prev.map((l) =>
+ l.id === lessonId ? { ...l, publish_status: nextStatus } : l,
+ ),
+ );
+ toast.success(
+ nextStatus === "PUBLISHED"
+ ? "Lesson published"
+ : "Lesson saved as draft",
+ );
+ } catch (e: unknown) {
+ console.error(e);
+ const msg =
+ (e as { response?: { data?: { message?: string } } })?.response?.data
+ ?.message ??
+ (nextStatus === "PUBLISHED"
+ ? "Failed to publish lesson"
+ : "Failed to save lesson as draft");
+ toast.error(msg);
+ } finally {
+ setPublishStatusLessonId(null);
+ }
+ };
+
const handleConfirmDeleteLesson = async () => {
if (!deletingLesson) return;
setDeletingLessonInFlight(true);
@@ -393,9 +509,17 @@ export function ModuleDetailPage() {
id={lesson.id}
title={lesson.title}
videoUrl={lesson.video_url}
+ publishStatus={lesson.publish_status}
hoverModuleActions
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]}
+ durationSeconds={(() => {
+ const raw =
+ lesson.duration_seconds ?? lesson.duration ?? null;
+ if (raw == null) return null;
+ const n = typeof raw === "number" ? raw : Number(raw);
+ return Number.isFinite(n) && n > 0 ? n : null;
+ })()}
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLesson(lesson)}
description={lesson.description}
@@ -409,6 +533,10 @@ export function ModuleDetailPage() {
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}/practices?lessonTitle=${encodeURIComponent(lesson.title ?? "")}`,
)
}
+ onTogglePublishStatus={(nextStatus) =>
+ void handleToggleLessonPublishStatus(lesson.id, nextStatus)
+ }
+ publishStatusUpdating={publishStatusLessonId === lesson.id}
/>
))}
@@ -465,12 +593,66 @@ export function ModuleDetailPage() {
- {/* Practice Cards Grid */}
-
- {practices.map((practice) => (
-
- ))}
-
+ {practicesLoading ? (
+
+ Loading practices…
+
+ ) : practicesLoadError ? (
+
+ {practicesLoadError}
+
+ ) : filteredPractices.length > 0 ? (
+
+ {filteredPractices.map((practice) => (
+
+ navigate(
+ `/content/practices?type=module&id=${moduleId}`,
+ )
+ }
+ onPublish={() => void handlePublishPractice(practice.id)}
+ onSaveAsDraft={() =>
+ void handleSavePracticeAsDraft(practice.id)
+ }
+ />
+ ))}
+
+ ) : (
+
+
+
+ {practices.length === 0
+ ? "No practices in this module yet"
+ : "No practices match this filter"}
+
+
+ {practices.length === 0
+ ? "Add a practice to give learners speaking exercises for this module."
+ : "Try another status filter or add a new practice."}
+
+ {practices.length === 0 ? (
+
+ navigate(
+ `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}`,
+ )
+ }
+ >
+
+ Add Practice
+
+ ) : null}
+
+ )}
)}
@@ -512,6 +694,28 @@ export function ModuleDetailPage() {
disabled={savingLessonEdit}
/>
+
+
+ Sort order
+
+
setEditLessonSortOrder(e.target.value)}
+ disabled={savingLessonEdit}
+ className="max-w-[200px]"
+ />
+
+ Whole number, 0 or greater.
+
+
);
}
-
-function PracticeCard({
- title,
- level,
- variations,
- status,
-}: {
- title: string;
- level: string;
- variations: number;
- status: string;
-}) {
- return (
-
-
-
-
- {title}
-
-
-
-
-
- {level}
-
-
-
- Speaking
-
-
-
-
-
- {variations} Variations
-
-
-
-
- {status}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Publish Practice
-
-
- Publish Video
-
-
-
- );
-}
diff --git a/src/pages/content-management/NewContentPage.tsx b/src/pages/content-management/NewContentPage.tsx
index 094e453..797db28 100644
--- a/src/pages/content-management/NewContentPage.tsx
+++ b/src/pages/content-management/NewContentPage.tsx
@@ -7,14 +7,31 @@ export function NewContentPage() {
return (
{/* Header section */}
-
-
- Content Management
-
-
- Upload, organize, and manage learning content across programs and
- courses
-
+
+
+
+ Content Management
+
+
+ Upload, organize, and manage learning content across programs and
+ courses
+
+
+
+
+
+ Manage Question Types
+
+
+
+
+ Reorder Content
+
+
+
{/* Gradient Divider */}
diff --git a/src/pages/content-management/ProgramCoursesPage.tsx b/src/pages/content-management/ProgramCoursesPage.tsx
index 395981b..afc5c81 100644
--- a/src/pages/content-management/ProgramCoursesPage.tsx
+++ b/src/pages/content-management/ProgramCoursesPage.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { ArrowLeft, Plus, FileText, Pencil, Trash2, X } from "lucide-react";
+import { ArrowLeft, Plus, Pencil, Trash2, X } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Card, CardContent } from "../../components/ui/card";
@@ -14,7 +14,6 @@ import {
DialogTrigger,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
-import { Textarea } from "../../components/ui/textarea";
import uploadIcon from "../../assets/icons/upload.png";
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
import alertSrc from "../../assets/Alert.svg";
@@ -52,7 +51,6 @@ export function ProgramCoursesPage() {
null,
);
const [editName, setEditName] = useState("");
- const [editDescription, setEditDescription] = useState("");
const [editSortOrder, setEditSortOrder] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
@@ -61,7 +59,6 @@ export function ProgramCoursesPage() {
const [createCourseOpen, setCreateCourseOpen] = useState(false);
const [createName, setCreateName] = useState("");
- const [createDescription, setCreateDescription] = useState("");
const [createSortOrder, setCreateSortOrder] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [createSaving, setCreateSaving] = useState(false);
@@ -136,7 +133,6 @@ export function ProgramCoursesPage() {
const openEditCourse = (course: ProgramCourseListItem) => {
setEditingCourse(course);
setEditName(course.name ?? "");
- setEditDescription(course.description?.trim() ?? "");
setEditThumbnail(
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
);
@@ -146,7 +142,6 @@ export function ProgramCoursesPage() {
const closeEditCourse = () => {
setEditingCourse(null);
setEditName("");
- setEditDescription("");
setEditSortOrder("");
setEditThumbnail("");
setUploadingEditThumbnail(false);
@@ -211,7 +206,7 @@ export function ProgramCoursesPage() {
try {
await updateTopLevelCourse(editingCourse.id, {
name,
- description: editDescription.trim(),
+ description: editingCourse.description?.trim() ?? "",
thumbnail: editThumbnail.trim(),
sort_order,
});
@@ -231,7 +226,6 @@ export function ProgramCoursesPage() {
const clearCreateCourseForm = () => {
setCreateName("");
- setCreateDescription("");
setCreateSortOrder("");
setCreateThumbnail("");
setCreateUploadingThumbnail(false);
@@ -302,7 +296,7 @@ export function ProgramCoursesPage() {
try {
await createProgramCourse(programId, {
name,
- description: createDescription.trim(),
+ description: "",
thumbnail: createThumbnail.trim(),
sort_order,
});
@@ -365,18 +359,6 @@ export function ProgramCoursesPage() {
{programIdValid ? (
<>
-
-
-
- Add Practice
-
-
-
-
-
- Description
-
-
-
Edit course
- Update name, description, sort order, and thumbnail. Saved with{" "}
+ Update name, sort order, and thumbnail. Saved with{" "}
PUT /courses/:id
@@ -761,19 +729,6 @@ export function ProgramCoursesPage() {
disabled={savingEdit || uploadingEditThumbnail}
/>
-
-
- Description
-
-
();
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState("");
- const [createDescription, setCreateDescription] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false);
const [creating, setCreating] = useState(false);
@@ -60,7 +58,6 @@ export function ProgramDetailPage() {
const [catalogLoading, setCatalogLoading] = useState(false);
const [editingCourseId, setEditingCourseId] = useState(null);
const [editName, setEditName] = useState("");
- const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false);
@@ -216,7 +213,7 @@ export function ProgramDetailPage() {
const response = await createExamPrepCatalogCourse({
name,
- description: createDescription.trim() || null,
+ description: null,
thumbnail: thumbnailToSend,
});
const row = response.data?.data;
@@ -227,7 +224,7 @@ export function ProgramDetailPage() {
{
id: row.id,
name: row.name ?? name,
- description: row.description?.trim() || createDescription.trim() || "—",
+ description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || null,
sortOrder: Number(row.sort_order ?? 0),
unitsCount: Number(row.units_count ?? 0),
@@ -239,7 +236,6 @@ export function ProgramDetailPage() {
await loadCatalogCourses();
toast.success("Course created");
setCreateName("");
- setCreateDescription("");
setCreateThumbnail("");
setCreateThumbnailFromUpload(false);
setCreateOpen(false);
@@ -259,7 +255,6 @@ export function ProgramDetailPage() {
if (!Number.isFinite(idNum)) return;
setEditingCourseId(idNum);
setEditName(String(course.name ?? ""));
- setEditDescription(String(course.description ?? ""));
setEditThumbnail(String(course.thumbnail ?? ""));
setEditSortOrder(String(course.sort_order ?? 1));
};
@@ -268,7 +263,6 @@ export function ProgramDetailPage() {
if (savingEdit || uploadingEditThumbnail) return;
setEditingCourseId(null);
setEditName("");
- setEditDescription("");
setEditThumbnail("");
setEditSortOrder("1");
};
@@ -317,9 +311,14 @@ export function ProgramDetailPage() {
setSavingEdit(true);
try {
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
+ const existing = createdCourses.find((c) => c.id === editingCourseId);
+ const preservedDescription =
+ existing?.description && existing.description !== "—"
+ ? existing.description
+ : null;
const response = await updateExamPrepCatalogCourse(editingCourseId, {
name,
- description: editDescription.trim() || null,
+ description: preservedDescription,
thumbnail: minioThumbnail || null,
sort_order: sortOrderNum,
});
@@ -330,7 +329,7 @@ export function ProgramDetailPage() {
? {
...course,
name: row?.name ?? name,
- description: row?.description?.trim() || editDescription.trim() || "—",
+ description: row?.description?.trim() || preservedDescription || "—",
thumbnail: row?.thumbnail?.trim() || null,
sortOrder: Number(row?.sort_order ?? sortOrderNum),
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
@@ -467,20 +466,6 @@ export function ProgramDetailPage() {
/>
-
-
- Description
-
-
-
Thumbnail
@@ -735,17 +720,6 @@ export function ProgramDetailPage() {
/>
-
- Description
-
-
Sort Order
{/* Header section */}
-
-
-
- Courses
-
-
- Organize courses under skill-based learning or English proficiency
- exams. Select a program type to manage curriculum and modules.
-
-
-
-
- Manage Question Types
-
-
+
+
+ Courses
+
+
+ Organize courses under skill-based learning or English proficiency
+ exams. Select a program type to manage curriculum and modules.
+
{/* Gradient Divider */}
diff --git a/src/pages/content-management/QuestionTypeLibraryPage.tsx b/src/pages/content-management/QuestionTypeLibraryPage.tsx
index 90b0d32..a342dbf 100644
--- a/src/pages/content-management/QuestionTypeLibraryPage.tsx
+++ b/src/pages/content-management/QuestionTypeLibraryPage.tsx
@@ -113,11 +113,11 @@ export function QuestionTypeLibraryPage() {
- Back to Courses
+ Back to Content Management
diff --git a/src/pages/content-management/ReorderContentPage.tsx b/src/pages/content-management/ReorderContentPage.tsx
new file mode 100644
index 0000000..7b56925
--- /dev/null
+++ b/src/pages/content-management/ReorderContentPage.tsx
@@ -0,0 +1,31 @@
+import { Link } from "react-router-dom";
+import { ArrowLeft } from "lucide-react";
+import { ContentHierarchyList } from "./components/ContentHierarchyList";
+
+export function ReorderContentPage() {
+ return (
+
+
+
+
+ Back to Content Management
+
+
+
+
+ Reorder Content
+
+
+ Drag and drop programs, courses, modules, and lessons to change
+ their display order.
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/content-management/UnitManagementPage.tsx b/src/pages/content-management/UnitManagementPage.tsx
index 41717aa..e02267b 100644
--- a/src/pages/content-management/UnitManagementPage.tsx
+++ b/src/pages/content-management/UnitManagementPage.tsx
@@ -13,7 +13,6 @@ import {
} from "lucide-react";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
-import { Textarea } from "../../components/ui/textarea";
import {
Dialog,
DialogContent,
@@ -55,7 +54,6 @@ export function UnitManagementPage() {
const parsedUnitId = Number(unitId);
const [addModuleOpen, setAddModuleOpen] = useState(false);
const [createName, setCreateName] = useState("");
- const [createDescription, setCreateDescription] = useState("");
const [createThumbnail, setCreateThumbnail] = useState("");
const [createIcon, setCreateIcon] = useState("");
const [creating, setCreating] = useState(false);
@@ -79,7 +77,6 @@ export function UnitManagementPage() {
>([]);
const [editingModuleId, setEditingModuleId] = useState
(null);
const [editName, setEditName] = useState("");
- const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
const [editIcon, setEditIcon] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1");
@@ -159,7 +156,6 @@ export function UnitManagementPage() {
const clearCreateModuleForm = () => {
setCreateName("");
- setCreateDescription("");
setCreateThumbnail("");
setCreateIcon("");
if (createThumbnailFileInputRef.current) {
@@ -264,7 +260,7 @@ export function UnitManagementPage() {
const minioIcon = await resolveToMinioUrl(createIcon);
await createExamPrepUnitModule(parsedUnitId, {
name,
- description: createDescription.trim() || null,
+ description: null,
thumbnail: minioThumbnail || null,
icon: minioIcon || null,
});
@@ -286,7 +282,6 @@ export function UnitManagementPage() {
const openEditModule = (module: (typeof modules)[number]) => {
setEditingModuleId(module.id);
setEditName(module.name ?? "");
- setEditDescription(module.description ?? "");
setEditThumbnail(module.thumbnail ?? "");
setEditIcon(module.icon ?? "");
setEditSortOrder(String(module.sortOrder ?? 1));
@@ -296,7 +291,6 @@ export function UnitManagementPage() {
if (savingEdit || uploadingEditThumbnail || uploadingEditIcon) return;
setEditingModuleId(null);
setEditName("");
- setEditDescription("");
setEditThumbnail("");
setEditIcon("");
setEditSortOrder("1");
@@ -391,11 +385,16 @@ export function UnitManagementPage() {
setSavingEdit(true);
try {
+ const existing = modules.find((m) => m.id === editingModuleId);
+ const preservedDescription =
+ existing?.description && existing.description !== "—"
+ ? existing.description
+ : null;
const minioThumbnail = await resolveToMinioUrl(editThumbnail);
const minioIcon = await resolveToMinioUrl(editIcon);
await updateExamPrepUnitModule(editingModuleId, {
name,
- description: editDescription.trim() || null,
+ description: preservedDescription,
thumbnail: minioThumbnail || null,
icon: minioIcon || null,
sort_order: sortOrderNum,
@@ -489,20 +488,6 @@ export function UnitManagementPage() {
/>
-
-
- Description
-
-
-
Thumbnail
-
- Description
-
Sort Order
{
if (isOpen) {
setName("");
- setDescription("");
setSortOrder("");
setIcon("");
setSubmitting(false);
@@ -47,7 +44,6 @@ export function AddModuleModal({
const resetAndClose = () => {
setName("");
- setDescription("");
setSortOrder("");
setIcon("");
setIconUploadBusy(false);
@@ -86,7 +82,7 @@ export function AddModuleModal({
try {
await createTopLevelCourseModule(courseId, {
name: trimmedName,
- description: description.trim(),
+ description: "",
icon: icon.trim(),
sort_order,
});
@@ -157,20 +153,6 @@ export function AddModuleModal({
/>
-
-
- Description
-
-
-
/i.test(raw)) return raw.trim();
+ try {
+ const doc = new DOMParser().parseFromString(raw, "text/html");
+ return doc.body.textContent?.replace(/\s+/g, " ").trim() ?? "";
+ } catch {
+ return raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
+ }
+}
+
+export type AddNewPracticeReviewStepProps = {
+ practiceTitle: string;
+ practiceDescription: string;
+ selectedProgram: string;
+ selectedCourse: string;
+ moduleLabel: string;
+ selectedPersona: string | null;
+ personas: PersonaCardModel[];
+ introVideoPreview: IntroVideoPreview;
+ questions: PracticeReviewQuestion[];
+ saving: boolean;
+ saveError: string | null;
+ onEditContext: () => void;
+ onEditQuestions: () => void;
+ onBack: () => void;
+ onSaveDraft: () => void;
+ onPublish: () => void;
+};
+
+export function AddNewPracticeReviewStep({
+ practiceTitle,
+ practiceDescription,
+ selectedProgram,
+ selectedCourse,
+ moduleLabel,
+ selectedPersona,
+ personas,
+ introVideoPreview,
+ questions,
+ saving,
+ saveError,
+ onEditContext,
+ onEditQuestions,
+ onBack,
+ onSaveDraft,
+ onPublish,
+}: AddNewPracticeReviewStepProps) {
+ const persona = personas.find((p) => p.id === selectedPersona);
+
+ const guidanceText = useMemo(() => {
+ const fromDescription = plainTextFromHtml(practiceDescription);
+ if (fromDescription) return fromDescription;
+ const tips = questions
+ .map((q) => q.tips?.trim() ?? "")
+ .filter(Boolean)
+ .join(" ");
+ return tips || "—";
+ }, [practiceDescription, questions]);
+
+ const thumbnailKind =
+ introVideoPreview?.kind === "video"
+ ? "video"
+ : introVideoPreview?.kind === "vimeo"
+ ? "vimeo"
+ : "gradient";
+
+ return (
+
+ );
+}
diff --git a/src/pages/content-management/components/CreatePracticeWizard.tsx b/src/pages/content-management/components/CreatePracticeWizard.tsx
index 2905d22..52fc7b7 100644
--- a/src/pages/content-management/components/CreatePracticeWizard.tsx
+++ b/src/pages/content-management/components/CreatePracticeWizard.tsx
@@ -16,7 +16,6 @@ import type {
PracticeParentKind,
PracticePublishStatus,
} from "../../../types/course.types"
-import { PublishStatusField } from "./practice-steps/PublishStatusField"
import { cn } from "../../../lib/utils"
import { SpinnerIcon } from "../../../components/ui/spinner-icon"
@@ -65,7 +64,8 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
const [storyDescription, setStoryDescription] = useState("")
const [storyImage, setStoryImage] = useState("")
const [quickTips, setQuickTips] = useState("")
- const [publishStatus, setPublishStatus] = useState("DRAFT")
+ const [pendingSaveStatus, setPendingSaveStatus] =
+ useState(null)
const canUseWizard = parent != null
@@ -85,7 +85,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
setStoryDescription("")
setStoryImage("")
setQuickTips("")
- setPublishStatus("DRAFT")
+ setPendingSaveStatus(null)
}, [])
const handleStep1 = async () => {
@@ -186,6 +186,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
toast.error("Title, story description, and story image are required")
return
}
+ setPendingSaveStatus(status)
setSaving(true)
try {
await createParentLinkedPractice({
@@ -208,6 +209,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
toast.error(err.response?.data?.message || err.message || "Failed to create practice")
} finally {
setSaving(false)
+ setPendingSaveStatus(null)
}
}
@@ -473,11 +475,6 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
disabled={saving}
/>
-
setStep(3)} disabled={saving}>
@@ -487,12 +484,9 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
type="button"
variant="outline"
disabled={saving}
- onClick={() => {
- setPublishStatus("DRAFT")
- void handleStep4("DRAFT")
- }}
+ onClick={() => void handleStep4("DRAFT")}
>
- {saving && publishStatus === "DRAFT" ? (
+ {saving && pendingSaveStatus === "DRAFT" ? (
) : null}
Save as draft
@@ -500,12 +494,9 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
{
- setPublishStatus("PUBLISHED")
- void handleStep4("PUBLISHED")
- }}
+ onClick={() => void handleStep4("PUBLISHED")}
>
- {saving && publishStatus === "PUBLISHED" ? (
+ {saving && pendingSaveStatus === "PUBLISHED" ? (
) : null}
Publish practice
diff --git a/src/pages/content-management/components/ModulePracticeCard.tsx b/src/pages/content-management/components/ModulePracticeCard.tsx
new file mode 100644
index 0000000..33dc5a4
--- /dev/null
+++ b/src/pages/content-management/components/ModulePracticeCard.tsx
@@ -0,0 +1,155 @@
+import { useEffect, useMemo, useState } from "react";
+import { Edit2, Loader2, MoreVertical } from "lucide-react";
+import { Button } from "../../../components/ui/button";
+import { Card } from "../../../components/ui/card";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "../../../components/ui/dropdown-menu";
+import { ResolvedImage } from "../../../components/media/ResolvedImage";
+import type { ParentContextPractice } from "../../../types/course.types";
+import {
+ isPracticePublished,
+ practicePublishStatus,
+} from "../../../lib/parentContextPractice";
+import { resolveThumbnailForPreview } from "../../../lib/videoPreview";
+import { cn } from "../../../lib/utils";
+
+type ModulePracticeCardProps = {
+ practice: ParentContextPractice;
+ statusUpdating?: boolean;
+ onEdit?: () => void;
+ onPublish?: () => void;
+ onSaveAsDraft?: () => void;
+};
+
+export function ModulePracticeCard({
+ practice,
+ statusUpdating = false,
+ onEdit,
+ onPublish,
+ onSaveAsDraft,
+}: ModulePracticeCardProps) {
+ const isPublished = isPracticePublished(practice);
+ const statusLabel = practicePublishStatus(practice) ?? "DRAFT";
+ const thumbnailSrc = useMemo(
+ () => resolveThumbnailForPreview(practice.story_image),
+ [practice.story_image],
+ );
+ const [thumbFailed, setThumbFailed] = useState(false);
+
+ useEffect(() => {
+ setThumbFailed(false);
+ }, [thumbnailSrc]);
+
+ return (
+
+
+ {thumbnailSrc && !thumbFailed ? (
+ setThumbFailed(true)}
+ />
+ ) : null}
+
+
+
+
+
+
+
+ e.stopPropagation()}
+ >
+ {statusUpdating ? (
+
+ ) : (
+
+ )}
+
+
+
+ {
+ e.stopPropagation();
+ if (isPublished) {
+ onSaveAsDraft?.();
+ } else {
+ onPublish?.();
+ }
+ }}
+ >
+ {isPublished ? "Save as draft" : "Publish practice"}
+
+
+
+
+
+
+ {practice.title}
+
+
+
+ {
+ e.stopPropagation();
+ onEdit?.();
+ }}
+ >
+
+ Edit
+
+ {
+ e.stopPropagation();
+ if (!isPublished) onPublish?.();
+ }}
+ >
+ {statusUpdating
+ ? "Updating…"
+ : isPublished
+ ? "Published"
+ : "Publish"}
+
+
+
+
+ );
+}
diff --git a/src/pages/content-management/components/PublishPracticeButton.tsx b/src/pages/content-management/components/PublishPracticeButton.tsx
index 5d9c647..8acbde0 100644
--- a/src/pages/content-management/components/PublishPracticeButton.tsx
+++ b/src/pages/content-management/components/PublishPracticeButton.tsx
@@ -5,6 +5,7 @@ import {
getPracticesByParentCourse,
getPracticesByParentModule,
publishParentLinkedPractice,
+ updateParentLinkedPractice,
} from "../../../api/courses.api"
import type { PracticeParentKind } from "../../../types/course.types"
import { Button } from "../../../components/ui/button"
@@ -29,7 +30,7 @@ export function PublishPracticeButton({
onPublished,
}: Props) {
const [loading, setLoading] = useState(true)
- const [publishing, setPublishing] = useState(false)
+ const [acting, setActing] = useState(false)
const [hasDraft, setHasDraft] = useState(false)
const [allPublished, setAllPublished] = useState(false)
const [hasPractice, setHasPractice] = useState(false)
@@ -68,9 +69,11 @@ export function PublishPracticeButton({
void loadPractices()
}, [loadPractices])
+ const isDraftMode = allPublished
+
const handlePublish = async () => {
if (!Number.isFinite(parentId) || parentId < 1) return
- setPublishing(true)
+ setActing(true)
try {
const res =
parentKind === "COURSE"
@@ -98,34 +101,82 @@ export function PublishPracticeButton({
?.message ?? "Failed to publish practice"
toast.error(msg)
} finally {
- setPublishing(false)
+ setActing(false)
+ }
+ }
+
+ const handleSaveAsDraft = async () => {
+ if (!Number.isFinite(parentId) || parentId < 1) return
+ setActing(true)
+ try {
+ const res =
+ parentKind === "COURSE"
+ ? await getPracticesByParentCourse(parentId, { limit: 50, offset: 0 })
+ : await getPracticesByParentModule(parentId, { limit: 50, offset: 0 })
+ const toDraft = unwrapPracticesList(res).filter(isPracticePublished)
+ if (toDraft.length === 0) {
+ toast.info("No published practice to save as draft")
+ await loadPractices()
+ return
+ }
+ for (const practice of toDraft) {
+ await updateParentLinkedPractice(practice.id, {
+ publish_status: "DRAFT",
+ })
+ }
+ toast.success(
+ toDraft.length === 1
+ ? "Practice saved as draft"
+ : `${toDraft.length} practices saved as draft`,
+ )
+ await loadPractices()
+ onPublished?.()
+ } catch (e: unknown) {
+ const msg =
+ (e as { response?: { data?: { message?: string } } })?.response?.data
+ ?.message ?? "Failed to save practice as draft"
+ toast.error(msg)
+ } finally {
+ setActing(false)
}
}
const disabled =
- loading || publishing || !hasPractice || !hasDraft || allPublished
+ loading ||
+ acting ||
+ !hasPractice ||
+ (!hasDraft && !allPublished)
let label = "Publish Practice"
if (loading) label = "Loading…"
- else if (publishing) label = "Publishing…"
- else if (!hasPractice) label = "No practice"
- else if (allPublished) label = "Published"
+ else if (acting) label = isDraftMode ? "Saving…" : "Publishing…"
+ else if (allPublished) label = "Save as Draft"
+
+ const handleClick = () => {
+ if (isDraftMode) {
+ void handleSaveAsDraft()
+ return
+ }
+ void handlePublish()
+ }
return (
void handlePublish()}
+ onClick={handleClick}
title={
- allPublished
- ? "Practice is already published"
- : !hasPractice
- ? "No practice linked to this item yet"
- : undefined
+ !hasPractice
+ ? "No practice linked to this course yet"
+ : allPublished
+ ? "Move published practice back to draft"
+ : hasDraft
+ ? "Publish draft practice"
+ : undefined
}
>
- {(loading || publishing) && (
+ {(loading || acting) && (
)}
{label}
diff --git a/src/pages/content-management/components/VideoCard.tsx b/src/pages/content-management/components/VideoCard.tsx
index ef3080c..50c590e 100644
--- a/src/pages/content-management/components/VideoCard.tsx
+++ b/src/pages/content-management/components/VideoCard.tsx
@@ -3,12 +3,19 @@ import {
BookOpen,
Calendar,
Edit2,
+ Loader2,
MoreVertical,
Pencil,
Play,
Trash2,
} from "lucide-react";
import { Button } from "../../../components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "../../../components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
@@ -21,17 +28,46 @@ import {
applyShortPreviewToEmbedUrl,
DEFAULT_PREVIEW_MAX_SECONDS,
formatPreviewLength,
+ formatVideoDurationLabel,
getVideoPreview,
+ isDirectVideoFileUrl,
} from "../../../lib/videoPreview";
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
+import type { PracticePublishStatus } from "../../../types/course.types";
+
+function resolvePublishBadge(
+ publishStatus?: PracticePublishStatus | string | null,
+ status?: "Draft" | "Published",
+ hoverModuleActions?: boolean,
+): { label: string; isPublished: boolean } | null {
+ const raw =
+ publishStatus ??
+ (status === "Published"
+ ? "PUBLISHED"
+ : status === "Draft"
+ ? "DRAFT"
+ : null);
+ if (raw) {
+ const label = String(raw).toUpperCase();
+ return { label, isPublished: label === "PUBLISHED" };
+ }
+ if (hoverModuleActions) {
+ return { label: "DRAFT", isPublished: false };
+ }
+ return null;
+}
interface VideoCardProps {
id?: string | number;
title: string;
/** Omits the duration chip when not provided (e.g. API has no length yet). */
duration?: string;
+ /** Total seconds; shown when `duration` string is omitted. Direct file URLs may still be probed in-browser. */
+ durationSeconds?: number | null;
/** When omitted, shows a neutral "Lesson" chip and no Publish button. */
status?: "Draft" | "Published";
+ /** From GET lesson list — preferred for module lesson cards (`PUBLISHED` / `DRAFT`). */
+ publishStatus?: PracticePublishStatus | string | null;
thumbnailGradient?: string;
thumbnailUrl?: string | null;
/**
@@ -51,6 +87,9 @@ interface VideoCardProps {
/** When set with hoverModuleActions, shows a book icon next to edit/delete on thumbnail hover. */
onViewPractices?: () => void;
onPublish?: () => void;
+ /** Toggle draft ↔ published via PUT /lessons/:id (module lesson cards). */
+ onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void;
+ publishStatusUpdating?: boolean;
/** Shown under title on module lesson cards; reserved height keeps grid rows even. */
description?: string | null;
}
@@ -58,7 +97,9 @@ interface VideoCardProps {
export function VideoCard({
title,
duration,
+ durationSeconds,
status,
+ publishStatus,
thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]",
thumbnailUrl,
videoUrl,
@@ -67,10 +108,15 @@ export function VideoCard({
onPublish,
onAddPractice,
onViewPractices,
+ onTogglePublishStatus,
+ publishStatusUpdating = false,
hoverModuleActions = false,
description,
}: VideoCardProps) {
const [thumbFailed, setThumbFailed] = useState(false);
+ const [probedDurationSeconds, setProbedDurationSeconds] = useState<
+ number | null
+ >(null);
const [previewOpen, setPreviewOpen] = useState(false);
/** Iframe players ignore URL limits in many cases — unmount after real time. */
const [iframeSessionDone, setIframeSessionDone] = useState(false);
@@ -92,6 +138,77 @@ export function VideoCard({
const previewLengthLabel = formatPreviewLength(
DEFAULT_PREVIEW_MAX_SECONDS,
);
+ const publishBadge = resolvePublishBadge(
+ publishStatus,
+ status,
+ hoverModuleActions,
+ );
+
+ useEffect(() => {
+ if (duration?.trim()) {
+ setProbedDurationSeconds(null);
+ return;
+ }
+ if (
+ typeof durationSeconds === "number" &&
+ Number.isFinite(durationSeconds) &&
+ durationSeconds > 0
+ ) {
+ setProbedDurationSeconds(null);
+ return;
+ }
+ const url = videoUrl?.trim() ?? "";
+ if (!isDirectVideoFileUrl(url)) {
+ setProbedDurationSeconds(null);
+ return;
+ }
+ let cancelled = false;
+ const video = document.createElement("video");
+ video.preload = "metadata";
+ video.muted = true;
+ const onLoaded = () => {
+ if (cancelled) return;
+ const d = video.duration;
+ if (Number.isFinite(d) && d > 0 && !Number.isNaN(d)) {
+ setProbedDurationSeconds(d);
+ } else {
+ setProbedDurationSeconds(null);
+ }
+ };
+ const onError = () => {
+ if (!cancelled) setProbedDurationSeconds(null);
+ };
+ video.addEventListener("loadedmetadata", onLoaded);
+ video.addEventListener("error", onError);
+ video.src = url;
+ return () => {
+ cancelled = true;
+ video.removeEventListener("loadedmetadata", onLoaded);
+ video.removeEventListener("error", onError);
+ video.removeAttribute("src");
+ video.load();
+ };
+ }, [duration, durationSeconds, videoUrl]);
+
+ const durationLabel = (() => {
+ const trimmed = duration?.trim();
+ if (trimmed) return trimmed;
+ const fromApi =
+ typeof durationSeconds === "number" &&
+ Number.isFinite(durationSeconds) &&
+ durationSeconds > 0
+ ? durationSeconds
+ : null;
+ if (fromApi != null) return formatVideoDurationLabel(fromApi);
+ if (
+ probedDurationSeconds != null &&
+ Number.isFinite(probedDurationSeconds) &&
+ probedDurationSeconds > 0
+ ) {
+ return formatVideoDurationLabel(probedDurationSeconds);
+ }
+ return null;
+ })();
useEffect(() => {
if (!previewOpen) {
@@ -198,10 +315,10 @@ export function VideoCard({
onError={() => setThumbFailed(true)}
/>
) : null}
- {/* Duration Badge */}
- {duration ? (
-
- {duration}
+ {/* Duration — bottom-right on thumbnail */}
+ {durationLabel ? (
+
+ {durationLabel}
) : null}
{/* Play: opens preview dialog when videoUrl is set */}
@@ -333,34 +450,69 @@ export function VideoCard({
- {/* Status Badge */}
- {status ? (
+ {/* Publish status badge */}
+ {publishBadge ? (
- {status}
+ {publishBadge.label}
) : (
-
-
+
)}
- {!hoverModuleActions ? (
+ {hoverModuleActions && onTogglePublishStatus ? (
+
+
+ e.stopPropagation()}
+ >
+ {publishStatusUpdating ? (
+
+ ) : (
+
+ )}
+
+
+
+ {
+ e.stopPropagation();
+ onTogglePublishStatus(
+ publishBadge?.isPublished ? "DRAFT" : "PUBLISHED",
+ );
+ }}
+ >
+ {publishBadge?.isPublished
+ ? "Save as draft"
+ : "Publish lesson"}
+
+
+
+ ) : !hoverModuleActions ? (
void;
nextStep: () => void;
- navigate: (path: string) => void;
- level: string;
+ onCancel: () => void;
+ /** Lesson-linked practice: no title, story description, or story image on step 1. */
+ isLessonPractice?: boolean;
+ lessonTitle?: string | null;
}
/**
@@ -24,8 +24,9 @@ export function ContextStep({
formData,
setFormData,
nextStep,
- navigate,
- level,
+ onCancel,
+ isLessonPractice = false,
+ lessonTitle = null,
}: ContextStepProps) {
const storyFileRef = useRef(null);
const [uploadingStory, setUploadingStory] = useState(false);
@@ -48,18 +49,31 @@ export function ContextStep({
}
};
- const canContinue =
- Boolean(formData.title?.trim()) && Boolean(formData.description?.trim());
+ const canContinue = isLessonPractice
+ ? true
+ : Boolean(formData.title?.trim()) && Boolean(formData.description?.trim());
return (
- Practice details
+ {isLessonPractice ? "Practice options" : "Practice details"}
- Title, story, optional image, shuffle, and quick tips match the create
- practice and question set APIs.
+ {isLessonPractice ? (
+ <>
+ This practice is linked to{" "}
+
+ {lessonTitle?.trim() || "the selected lesson"}
+
+ . Set optional quick tips and question order below.
+ >
+ ) : (
+ <>
+ Title, story, optional image, shuffle, and quick tips match the create
+ practice and question set APIs.
+ >
+ )}
@@ -76,34 +90,38 @@ export function ContextStep({
- navigate(`/new-content/learn-english/${level}/courses`)
- }
+ onClick={onCancel}
>
Cancel
@@ -196,7 +206,7 @@ export function ContextStep({
disabled={!canContinue}
className="h-10 px-10 rounded-[6px] bg-brand-500 text-[14px] font-bold text-white transition-all active:scale-95 flex items-center gap-2 disabled:opacity-50"
>
- Next: Questions
+ Next: Persona
diff --git a/src/pages/content-management/components/practice-steps/PersonaStep.tsx b/src/pages/content-management/components/practice-steps/PersonaStep.tsx
index d197a1a..966309a 100644
--- a/src/pages/content-management/components/practice-steps/PersonaStep.tsx
+++ b/src/pages/content-management/components/practice-steps/PersonaStep.tsx
@@ -1,4 +1,4 @@
-import { Check, ArrowRight } from "lucide-react";
+import { Check, ArrowRight, Loader2 } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import {
Avatar,
@@ -6,9 +6,13 @@ import {
AvatarImage,
} from "../../../../components/ui/avatar";
import { cn } from "../../../../lib/utils";
-import { PERSONAS } from "./constants";
+import type { PersonaCardModel } from "../../../../lib/personaDisplay";
interface PersonaStepProps {
+ personas: PersonaCardModel[];
+ loading?: boolean;
+ error?: string | null;
+ onRetry?: () => void;
selectedPersona: string | null;
setSelectedPersona: (id: string) => void;
nextStep: () => void;
@@ -16,6 +20,10 @@ interface PersonaStepProps {
}
export function PersonaStep({
+ personas,
+ loading = false,
+ error = null,
+ onRetry,
selectedPersona,
setSelectedPersona,
nextStep,
@@ -25,66 +33,109 @@ export function PersonaStep({
- Select Personas
+ Select Persona
-
- Choose the characters that will participate in this practice scenario.
+
+ Choose the character that will guide this practice scenario.
-
- {PERSONAS.map((persona) => {
- const isSelected = selectedPersona === persona.id;
- return (
-
setSelectedPersona(persona.id)}
- className={cn(
- "group relative w-[260px] cursor-pointer rounded-2xl border-2 bg-white p-6 transition-all duration-300",
- isSelected
- ? "border-brand-500"
- : "border-grayScale-100 hover:border-brand-200",
- )}
+
+ {loading ? (
+
+
+
+ Loading personas…
+
+
+ ) : error ? (
+
+
{error}
+ {onRetry ? (
+
- {/* Top-right checkmark badge */}
- {isSelected && (
-
-
+ Try again
+
+ ) : null}
+
+ ) : personas.length === 0 ? (
+
+
+ No active personas available.
+
+
+ Add personas in the admin panel, then return here.
+
+
+ ) : (
+
+ {personas.map((persona) => {
+ const isSelected = selectedPersona === persona.id;
+ return (
+
setSelectedPersona(persona.id)}
+ className={cn(
+ "group relative w-full cursor-pointer rounded-2xl border-2 bg-white p-6 text-left transition-all duration-300",
+ isSelected
+ ? "border-brand-500 shadow-md shadow-brand-100/50"
+ : "border-grayScale-100 hover:border-brand-200",
+ )}
+ >
+ {isSelected && (
+
+
+
+ )}
+
+
+
+
+
+ {persona.name.substring(0, 2).toUpperCase()}
+
+
+
+
+
+ {persona.name}
+
+ {persona.description ? (
+
+ {persona.description}
+
+ ) : null}
+
- )}
-
- {/* Avatar with conditional purple ring */}
-
-
-
-
- {persona.name.substring(0, 2)}
-
-
-
-
- {persona.name}
-
-
-
- );
- })}
-
+
+ );
+ })}
+
+ )}
+
Back
Next: Questions
diff --git a/src/pages/content-management/components/practice-steps/PracticeSequentialReview.tsx b/src/pages/content-management/components/practice-steps/PracticeSequentialReview.tsx
new file mode 100644
index 0000000..6a37695
--- /dev/null
+++ b/src/pages/content-management/components/practice-steps/PracticeSequentialReview.tsx
@@ -0,0 +1,407 @@
+import { useState } from "react";
+import { Edit, Info, Loader2, Play, Rocket } from "lucide-react";
+import { Button } from "../../../../components/ui/button";
+import { Card } from "../../../../components/ui/card";
+import { cn } from "../../../../lib/utils";
+
+export type PracticeReviewQuestion = {
+ id: string;
+ questionText: string;
+ voicePrompt: string;
+ sampleAnswerVoicePrompt: string;
+ tips?: string;
+};
+
+export type PracticeReviewMetadataItem = {
+ label: string;
+ value: string;
+};
+
+export type PracticeSequentialReviewProps = {
+ practiceTitle: string;
+ thumbnailUrl?: string | null;
+ thumbnailKind?: "image" | "video" | "vimeo" | "gradient";
+ persona?: { name: string; avatar: string } | null;
+ metadata?: PracticeReviewMetadataItem[];
+ parentLink?: string | null;
+ guidanceText: string;
+ questions: PracticeReviewQuestion[];
+ saving?: boolean;
+ saveError?: string | null;
+ canPublish?: boolean;
+ showMissingParentWarning?: boolean;
+ onEditContext?: () => void;
+ onEditQuestions?: () => void;
+ onBack: () => void;
+ onSaveDraft: () => void;
+ onPublish: () => void;
+ sectionTitle?: string;
+ sectionSubtitle?: string;
+};
+
+function audioFileLabel(url: string, fallback: string): string {
+ const trimmed = url.trim();
+ if (!trimmed) return fallback;
+ try {
+ const path = new URL(trimmed).pathname.split("/").filter(Boolean).pop();
+ if (path) return decodeURIComponent(path);
+ } catch {
+ // fall through
+ }
+ const seg = trimmed.split("/").filter(Boolean).pop();
+ return seg && seg.length < 64 ? seg : fallback;
+}
+
+export function ReviewAudioPlayer({
+ src,
+ label,
+ className,
+}: {
+ src: string;
+ label: string;
+ className?: string;
+}) {
+ const [playing, setPlaying] = useState(false);
+ const fileName = audioFileLabel(src, label);
+
+ if (!src.trim()) {
+ return (
+
No audio URL provided
+ );
+ }
+
+ return (
+
+
{
+ const audio = new Audio(src);
+ setPlaying(true);
+ void audio.play().finally(() => setPlaying(false));
+ }}
+ >
+
+
+
+
+ {[3, 5, 4, 7, 5, 8, 4, 6, 5, 4, 6, 4].map((h, i) => (
+
+ ))}
+
+
+ {fileName}
+
+
+
{src}
+
+ );
+}
+
+function formatQuestionIndex(index: number): string {
+ return String(index + 1).padStart(2, "0");
+}
+
+export function PracticeSequentialReview({
+ practiceTitle,
+ thumbnailUrl,
+ thumbnailKind = "gradient",
+ persona = null,
+ metadata = [],
+ parentLink = null,
+ guidanceText,
+ questions,
+ saving = false,
+ saveError = null,
+ canPublish = true,
+ showMissingParentWarning = false,
+ onEditContext,
+ onEditQuestions,
+ onBack,
+ onSaveDraft,
+ onPublish,
+ sectionTitle = "Create Practice Questions",
+ sectionSubtitle = "Define the dialogue flow and interactions for this scenario.",
+}: PracticeSequentialReviewProps) {
+ const filledQuestions = questions.filter((q) => q.questionText.trim());
+
+ return (
+
+ {sectionTitle ? (
+
+
+ {sectionTitle}
+
+ {sectionSubtitle ? (
+
+ {sectionSubtitle}
+
+ ) : null}
+
+ ) : null}
+
+ {showMissingParentWarning && !canPublish ? (
+
+
Missing parent for the API
+
+ Open Add Practice from a course, module, or lesson so parent IDs are
+ in the URL.
+
+
+ ) : null}
+
+
+
+
Basic Information
+ {onEditContext ? (
+
+
+ Edit
+
+ ) : null}
+
+
+
+ {thumbnailKind === "video" && thumbnailUrl ? (
+
+ ) : thumbnailKind === "vimeo" ? (
+
+ ) : thumbnailUrl?.trim() ? (
+
+ ) : persona?.avatar ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {practiceTitle.trim() || "Untitled Practice"}
+
+ {metadata.length > 0 ? (
+
+ {metadata.map((item) => (
+
+
{item.label}:
+
+ {item.value}
+
+
+ ))}
+
+ ) : parentLink ? (
+
+ Link: {" "}
+ {parentLink}
+
+ ) : null}
+
+
+
+
+ Persona
+
+ {persona ? (
+
+
+
+
+
+ {persona.name}
+
+
+ ) : (
+
None selected
+ )}
+
+
+
+
+
+
+
+ Tips / Guidance
+
+
+
+
+
+ {guidanceText.trim() || "—"}
+
+
+
+
+
+
+
+
+
Questions
+
+ {filledQuestions.length}
+
+
+
+ {filledQuestions.map((question, index) => (
+
+
+ {formatQuestionIndex(index)}
+
+
+
+ Text prompt
+
+
+ {question.questionText.trim() || "—"}
+
+
+ {question.voicePrompt.trim() ? (
+
+ ) : null}
+
+ ))}
+
+
+
+
+
+
+
Answers
+
+ {filledQuestions.length}
+
+
+ {onEditQuestions ? (
+
+
+ Edit
+
+ ) : null}
+
+
+ {filledQuestions.map((question, index) => (
+
+
+ {formatQuestionIndex(index)}
+
+ {question.sampleAnswerVoicePrompt.trim() ? (
+
+ ) : (
+
—
+ )}
+
+ ))}
+
+
+
+
+
+ {saveError ? (
+
+ ) : null}
+
+
+
+ Back
+
+
+
+ {saving ? (
+ <>
+
+ Saving…
+ >
+ ) : (
+ "Save as Draft"
+ )}
+
+
+ {saving ? (
+
+ ) : (
+
+ )}
+ {saving ? "Publishing…" : "Publish Now"}
+
+
+
+
+ );
+}
diff --git a/src/pages/content-management/components/practice-steps/ReviewStep.tsx b/src/pages/content-management/components/practice-steps/ReviewStep.tsx
index 1c76870..39db68e 100644
--- a/src/pages/content-management/components/practice-steps/ReviewStep.tsx
+++ b/src/pages/content-management/components/practice-steps/ReviewStep.tsx
@@ -1,19 +1,37 @@
-import { Rocket, Info, Loader2 } from "lucide-react";
-import { Button } from "../../../../components/ui/button";
-import { Card } from "../../../../components/ui/card";
-import { Input } from "../../../../components/ui/input";
+import { useMemo } from "react";
import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
+import { personaFromId } from "./constants";
+import type { PersonaCardModel } from "../../../../lib/personaDisplay";
+import { mapFormQuestionsForPracticeReview } from "./mapQuestionsForPracticeReview";
import {
- definitionUsesDynamicPayload,
- legacyQuestionTypeFromDefinition,
-} from "../../../../lib/learnEnglishDefinitionQuestion";
-import { PublishStatusField } from "./PublishStatusField";
-import type { PracticePublishStatus } from "../../../../types/course.types";
+ PracticeSequentialReview,
+ type PracticeReviewMetadataItem,
+} from "./PracticeSequentialReview";
interface ReviewStepProps {
- formData: any;
- setFormData: (data: any) => void;
+ formData: {
+ title?: string;
+ description?: string;
+ storyImageUrl?: string;
+ tips?: string;
+ shuffleQuestions?: boolean;
+ questions: {
+ id: string;
+ text?: string;
+ dynamicFieldValues?: Record
;
+ questionTypeDefinitionId?: number | null;
+ }[];
+ };
+ selectedPersona?: string | null;
+ personas?: PersonaCardModel[];
+ isLessonPractice?: boolean;
+ lessonTitle?: string | null;
+ programLabel?: string | null;
+ courseLabel?: string | null;
+ moduleLabel?: string | null;
prevStep: () => void;
+ onEditContext?: () => void;
+ onEditQuestions?: () => void;
parentSummary: string | null;
typeDefinitions: QuestionTypeDefinition[];
canPublish: boolean;
@@ -24,8 +42,16 @@ interface ReviewStepProps {
export function ReviewStep({
formData,
- setFormData,
+ selectedPersona = null,
+ personas = [],
+ isLessonPractice = false,
+ lessonTitle = null,
+ programLabel = null,
+ courseLabel = null,
+ moduleLabel = null,
prevStep,
+ onEditContext,
+ onEditQuestions,
parentSummary,
typeDefinitions,
canPublish,
@@ -33,292 +59,61 @@ export function ReviewStep({
onSaveDraft,
onPublish,
}: ReviewStepProps) {
- return (
-
-
-
- Review
-
-
+ const persona = personaFromId(selectedPersona, personas);
- {!canPublish && (
-
-
Missing parent for the API
-
- Open Add Practice from a course, module, or lesson so parent IDs are
- in the URL.
-
-
- )}
-
-
-
-
- Practice
-
-
-
-
-
-
-
-
-
-
- {formData.title || "Untitled"}
-
-
- {parentSummary ? (
-
- Link: {" "}
- {parentSummary}
-
- ) : (
- "—"
- )}
-
- {formData.shuffleQuestions ? (
-
Shuffle questions: on
- ) : null}
-
-
-
-
-
-
-
-
- Quick tips
-
-
-
-
-
- {formData.tips?.trim() || "—"}
-
-
-
-
-
-
Questions
-
- {formData.questions.map((q: any, i: number) => (
-
- ))}
-
-
-
-
setFormData({ ...formData, publishStatus })}
- disabled={submitting}
- />
-
-
-
- Back
-
-
- {
- setFormData({ ...formData, publishStatus: "DRAFT" });
- onSaveDraft();
- }}
- className="h-10 rounded-[6px] border-grayScale-100 bg-white px-8 text-sm font-bold text-grayScale-600 shadow-sm hover:bg-grayScale-50"
- >
- {submitting ? (
-
- ) : null}
- Save as Draft
-
- {
- setFormData({ ...formData, publishStatus: "PUBLISHED" });
- onPublish();
- }}
- className="h-10 gap-3 rounded-[6px] bg-brand-500 px-10 text-sm font-bold text-white shadow-xl shadow-brand-500/20 transition-all hover:bg-brand-600 active:scale-95 disabled:opacity-50"
- >
- {submitting ? (
-
- ) : (
-
- )}
- Publish Now
-
-
-
-
+ const reviewQuestions = useMemo(
+ () => mapFormQuestionsForPracticeReview(formData.questions, typeDefinitions),
+ [formData.questions, typeDefinitions],
);
-}
-function QuestionReviewBlock({
- q,
- index,
- typeDefinitions,
-}: {
- q: any;
- index: number;
- typeDefinitions: QuestionTypeDefinition[];
-}) {
- const def = typeDefinitions.find((d) => d.id === q.questionTypeDefinitionId);
- const badge =
- def != null
- ? `${def.display_name}${def.is_system ? "" : ` · ${def.key}`}`
- : q.questionTypeDefinitionId != null
- ? `Type #${q.questionTypeDefinitionId}`
- : "No type selected";
-
- const isDynamic = def != null && definitionUsesDynamicPayload(def);
- const legacy = def != null ? legacyQuestionTypeFromDefinition(def) : null;
-
- const schemaRows: { key: string; label: string; value: string }[] = [];
- if (isDynamic && def) {
- const vals = (q.dynamicFieldValues ?? {}) as Record;
- for (const r of def.stimulus_schema) {
- const k = `stimulus:${r.id}`;
- schemaRows.push({
- key: k,
- label: r.label?.trim() || r.kind,
- value: vals[k] ?? "",
- });
+ const metadata = useMemo((): PracticeReviewMetadataItem[] => {
+ const items: PracticeReviewMetadataItem[] = [];
+ if (programLabel?.trim()) {
+ items.push({ label: "Program", value: programLabel.trim() });
}
- for (const r of def.response_schema) {
- const k = `response:${r.id}`;
- schemaRows.push({
- key: k,
- label: r.label?.trim() || r.kind,
- value: vals[k] ?? "",
- });
+ if (courseLabel?.trim()) {
+ items.push({ label: "Course", value: courseLabel.trim() });
}
- }
+ if (moduleLabel?.trim()) {
+ items.push({ label: "Module", value: moduleLabel.trim() });
+ }
+ if (isLessonPractice && lessonTitle?.trim()) {
+ items.push({ label: "Lesson", value: lessonTitle.trim() });
+ }
+ return items;
+ }, [programLabel, courseLabel, moduleLabel, isLessonPractice, lessonTitle]);
+
+ const practiceTitle = isLessonPractice
+ ? lessonTitle?.trim() || parentSummary || "Lesson practice"
+ : formData.title?.trim() || "Untitled Practice";
+
+ const guidanceText =
+ formData.tips?.trim() ||
+ (!isLessonPractice ? formData.description?.trim() : "") ||
+ "—";
+
+ const thumbnailUrl = isLessonPractice
+ ? null
+ : formData.storyImageUrl?.trim() || null;
return (
-
-
-
-
-
- Question {index + 1}
-
-
- {badge}
-
-
-
-
-
- Question text
-
-
-
-
- {isDynamic && schemaRows.length > 0 ? (
-
-
- Stimulus and response fields
-
-
- {schemaRows.map((row) => (
-
-
- {row.label}
-
-
- {row.value?.trim() ? row.value : "—"}
-
-
- ))}
-
-
- ) : null}
-
- {legacy === "MCQ" && (
-
-
- Choices
-
-
- {(q.mcqOptions ?? []).map(
- (opt: { text?: string; isCorrect?: boolean }, j: number) =>
- opt.text?.trim() ? (
-
- {opt.isCorrect ? "✓ " : ""}
- {opt.text}
-
- ) : null,
- )}
-
-
- )}
-
- {legacy === "TRUE_FALSE" && (
-
- Correct: {" "}
- {q.trueFalseCorrect !== false ? "True" : "False"}
-
- )}
-
- {legacy === "SHORT_ANSWER" && (
-
-
- Acceptable answers
-
-
- {(q.shortAnswers ?? [])
- .filter((s: string) => s?.trim())
- .map((s: string, j: number) => (
- {s}
- ))}
-
-
- )}
-
- {def != null && legacy == null && !isDynamic ? (
-
- This type has no schema and is not mapped to a classic MCQ / true–false /
- short-answer form. Publish still sends the best-effort payload from the
- builder.
-
- ) : null}
-
-
+
);
}
diff --git a/src/pages/content-management/components/practice-steps/ScenarioStep.tsx b/src/pages/content-management/components/practice-steps/ScenarioStep.tsx
index 056cb27..cab260b 100644
--- a/src/pages/content-management/components/practice-steps/ScenarioStep.tsx
+++ b/src/pages/content-management/components/practice-steps/ScenarioStep.tsx
@@ -7,9 +7,6 @@ import { Input } from "../../../../components/ui/input";
import { Textarea } from "../../../../components/ui/textarea";
import { toast } from "sonner";
import { uploadImageFile } from "../../../../api/files.api";
-import { PublishStatusField } from "./PublishStatusField";
-import type { PracticePublishStatus } from "../../../../types/course.types";
-
interface ScenarioStepProps {
formData: any;
setFormData: (data: any) => void;
@@ -160,13 +157,6 @@ export function ScenarioStep({
maxLength={1000}
/>
-
- setFormData({ ...formData, publishStatus })
- }
- disabled={uploadingBanner}
- />
@@ -179,7 +169,7 @@ export function ScenarioStep({
disabled={!canContinue}
className="h-10 rounded-[6px] bg-brand-500 px-8 disabled:opacity-50"
>
- Next: Questions
+ Next: Persona
diff --git a/src/pages/content-management/components/practice-steps/constants.ts b/src/pages/content-management/components/practice-steps/constants.ts
index fdbc99b..9207a82 100644
--- a/src/pages/content-management/components/practice-steps/constants.ts
+++ b/src/pages/content-management/components/practice-steps/constants.ts
@@ -1,44 +1,16 @@
-export const PERSONAS = [
- {
- id: "dawit",
- name: "Dawit",
- avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit",
- },
- {
- id: "mahlet",
- name: "Mahlet",
- avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet",
- },
- {
- id: "amanuel",
- name: "Amanuel",
- avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel",
- },
- {
- id: "bethel",
- name: "Bethel",
- avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel",
- },
- {
- id: "liya",
- name: "Liya",
- avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya",
- },
- {
- id: "aseffa",
- name: "Aseffa",
- avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa",
- },
- {
- id: "hana",
- name: "Hana",
- avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana",
- },
- {
- id: "nahom",
- name: "Nahom",
- avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom",
- },
-];
+import type { PersonaCardModel } from "../../../../lib/personaDisplay"
-export const STEPS = ["Context", "Scenario", "Persona", "Questions", "Review"];
+export const STEPS = ["Context", "Scenario", "Persona", "Questions", "Review"]
+
+export function personaFromId(
+ selectedPersona: string | null,
+ personas: PersonaCardModel[],
+): PersonaCardModel | undefined {
+ if (!selectedPersona) return undefined
+ return personas.find((p) => p.id === selectedPersona)
+}
+
+export function personaIdNumber(selectedPersona: string | null): number | undefined {
+ const n = Number(selectedPersona)
+ return Number.isFinite(n) && n > 0 ? n : undefined
+}
diff --git a/src/pages/content-management/components/practice-steps/mapQuestionsForPracticeReview.ts b/src/pages/content-management/components/practice-steps/mapQuestionsForPracticeReview.ts
new file mode 100644
index 0000000..5048e95
--- /dev/null
+++ b/src/pages/content-management/components/practice-steps/mapQuestionsForPracticeReview.ts
@@ -0,0 +1,83 @@
+import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
+import {
+ definitionUsesDynamicPayload,
+ legacyQuestionTypeFromDefinition,
+} from "../../../../lib/learnEnglishDefinitionQuestion";
+import type { PracticeReviewQuestion } from "./PracticeSequentialReview";
+
+function isAudioLikeKind(kind: string): boolean {
+ const k = kind.toLowerCase();
+ return (
+ k.includes("audio") ||
+ k.includes("voice") ||
+ k === "url" ||
+ k === "file" ||
+ k === "media"
+ );
+}
+
+function firstUrlFromSchema(
+ schema: { id: number; kind: string }[],
+ prefix: "stimulus" | "response",
+ values: Record
,
+): string {
+ for (const row of schema) {
+ if (!isAudioLikeKind(row.kind)) continue;
+ const v = values[`${prefix}:${row.id}`]?.trim();
+ if (v) return v;
+ }
+ for (const row of schema) {
+ const v = values[`${prefix}:${row.id}`]?.trim();
+ if (v && /^https?:\/\//i.test(v)) return v;
+ }
+ return "";
+}
+
+export function mapFormQuestionsForPracticeReview(
+ questions: {
+ id: string;
+ text?: string;
+ dynamicFieldValues?: Record;
+ questionTypeDefinitionId?: number | null;
+ }[],
+ typeDefinitions: QuestionTypeDefinition[],
+): PracticeReviewQuestion[] {
+ return questions.map((q) => {
+ const def = typeDefinitions.find(
+ (d) => d.id === q.questionTypeDefinitionId,
+ );
+ const values = q.dynamicFieldValues ?? {};
+ let voicePrompt = "";
+ let sampleAnswerVoicePrompt = "";
+
+ if (def && definitionUsesDynamicPayload(def)) {
+ voicePrompt = firstUrlFromSchema(def.stimulus_schema, "stimulus", values);
+ sampleAnswerVoicePrompt = firstUrlFromSchema(
+ def.response_schema,
+ "response",
+ values,
+ );
+ } else if (def) {
+ const legacy = legacyQuestionTypeFromDefinition(def);
+ const key = def.key.toLowerCase();
+ if (legacy === null && key.includes("audio")) {
+ voicePrompt = Object.entries(values)
+ .filter(([k]) => k.startsWith("stimulus:"))
+ .map(([, v]) => v?.trim())
+ .find(Boolean) ?? "";
+ sampleAnswerVoicePrompt =
+ Object.entries(values)
+ .filter(([k]) => k.startsWith("response:"))
+ .map(([, v]) => v?.trim())
+ .find(Boolean) ?? "";
+ }
+ }
+
+ return {
+ id: q.id,
+ questionText: String(q.text ?? "").trim(),
+ voicePrompt,
+ sampleAnswerVoicePrompt,
+ };
+ });
+}
diff --git a/src/pages/content-management/components/video-steps/ReviewPublishStep.tsx b/src/pages/content-management/components/video-steps/ReviewPublishStep.tsx
index 013790f..8b034bb 100644
--- a/src/pages/content-management/components/video-steps/ReviewPublishStep.tsx
+++ b/src/pages/content-management/components/video-steps/ReviewPublishStep.tsx
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Rocket, Edit2, Link2, Video } from "lucide-react";
import { Button } from "../../../../components/ui/button";
-import { toast } from "sonner";
+import type { PracticePublishStatus } from "../../../../types/course.types";
import type { AddLessonFormData } from "../../AddVideoFlow";
import {
applyShortPreviewToEmbedUrl,
@@ -15,7 +15,7 @@ import { PreviewLimitedFileVideo } from "../PreviewLimitedFileVideo";
interface ReviewPublishStepProps {
formData: AddLessonFormData;
prevStep: () => void;
- onPublish: () => void;
+ onCreateLesson: (publishStatus: PracticePublishStatus) => void;
publishing: boolean;
}
@@ -27,7 +27,7 @@ function truncate(s: string, max: number): string {
export function ReviewPublishStep({
formData,
prevStep,
- onPublish,
+ onCreateLesson,
publishing,
}: ReviewPublishStepProps) {
const [thumbBroken, setThumbBroken] = useState(false);
@@ -180,6 +180,17 @@ export function ReviewPublishStep({
+
+
+ Sort order
+
+
+ {formData.sortOrder.trim() !== ""
+ ? formData.sortOrder.trim()
+ : "—"}
+
+
+
Description
@@ -226,20 +237,18 @@ export function ReviewPublishStep({
variant="outline"
className="h-12 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
disabled={publishing}
- onClick={() =>
- toast.info("Drafts are not supported yet. Use Create lesson.")
- }
+ onClick={() => onCreateLesson("DRAFT")}
>
Save as draft
onCreateLesson("PUBLISHED")}
disabled={publishing}
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white shadow-brand-500/20 transition-all flex items-center gap-2.5 disabled:opacity-60"
>
- {publishing ? "Creating…" : "Create lesson"}
+ {publishing ? "Creating…" : "Publish lesson"}
diff --git a/src/pages/content-management/components/video-steps/VideoDetailStep.tsx b/src/pages/content-management/components/video-steps/VideoDetailStep.tsx
index af053f4..033e86c 100644
--- a/src/pages/content-management/components/video-steps/VideoDetailStep.tsx
+++ b/src/pages/content-management/components/video-steps/VideoDetailStep.tsx
@@ -70,6 +70,16 @@ export function VideoDetailStep({
toast.error("Title is required");
return;
}
+ const sortOrderRaw = formData.sortOrder.trim();
+ if (sortOrderRaw === "") {
+ toast.error("Sort order is required");
+ return;
+ }
+ const sortOrderNum = Number(sortOrderRaw);
+ if (!Number.isInteger(sortOrderNum) || sortOrderNum < 0) {
+ toast.error("Sort order must be a whole number of 0 or greater");
+ return;
+ }
if (!formData.videoUrl.trim()) {
toast.error("Add a video URL or upload a video");
return;
@@ -141,6 +151,35 @@ export function VideoDetailStep({
/>
+
+
+ Sort order
+
+
+ setFormData((prev) => ({
+ ...prev,
+ sortOrder: e.target.value,
+ }))
+ }
+ />
+
+ Whole number, 0 or greater. Lower numbers appear first in the
+ module.
+
+
+
Description
diff --git a/src/types/course.types.ts b/src/types/course.types.ts
index 0ae19d5..afeba01 100644
--- a/src/types/course.types.ts
+++ b/src/types/course.types.ts
@@ -224,6 +224,7 @@ export interface CreateExamPrepCatalogUnitRequest {
name: string
description?: string | null
thumbnail?: string | null
+ sort_order: number
}
export interface CreateExamPrepCatalogUnitResponse {
@@ -329,6 +330,9 @@ export interface ExamPrepModuleLessonItem {
thumbnail?: string | null
description?: string | null
sort_order?: number
+ /** Total length in seconds when the API provides it. */
+ duration?: number | null
+ duration_seconds?: number | null
created_at?: string
updated_at?: string
}
@@ -338,6 +342,7 @@ export interface CreateExamPrepModuleLessonRequest {
video_url: string
thumbnail?: string | null
description?: string | null
+ publish_status: PracticePublishStatus
}
export interface CreateExamPrepModuleLessonResponse {
@@ -448,6 +453,11 @@ export interface TopLevelModuleLessonItem {
thumbnail: string
description: string
sort_order: number
+ publish_status?: PracticePublishStatus | string | null
+ has_practice?: boolean
+ /** Total length in seconds when the API provides it. */
+ duration?: number | null
+ duration_seconds?: number | null
created_at: string
}
@@ -547,6 +557,12 @@ export interface UpdateTopLevelModuleLessonRequest {
video_url: string
thumbnail: string
description: string
+ sort_order: number
+}
+
+/** Publish-only patch: PUT /lessons/:id with { publish_status }. */
+export interface PublishTopLevelModuleLessonRequest {
+ publish_status: PracticePublishStatus
}
/** Body for POST /modules/:moduleId/lessons. */
@@ -555,6 +571,8 @@ export interface CreateTopLevelModuleLessonRequest {
video_url: string
thumbnail: string
description: string
+ sort_order: number
+ publish_status: PracticePublishStatus
}
export interface CreateTopLevelModuleLessonResponse {
diff --git a/src/types/persona.types.ts b/src/types/persona.types.ts
new file mode 100644
index 0000000..1a73684
--- /dev/null
+++ b/src/types/persona.types.ts
@@ -0,0 +1,26 @@
+export interface PersonaListItem {
+ id: number
+ name: string
+ description: string
+ profile_picture: string | null
+ is_active: boolean
+ created_at: string
+}
+
+export interface GetPersonasParams {
+ limit?: number
+ offset?: number
+}
+
+export interface GetPersonasResponse {
+ message: string
+ data: {
+ personas: PersonaListItem[]
+ total_count: number
+ limit: number
+ offset: number
+ }
+ success: boolean
+ status_code: number
+ metadata: unknown | null
+}