From b8a73c73db7bdbcb578c9f2b6355be55bbd20c6a Mon Sep 17 00:00:00 2001
From: Yared Yemane
Date: Wed, 20 May 2026 08:00:31 -0700
Subject: [PATCH] feat(content): admin UX for forms, practices, lessons, and
content hub
Remove description fields from course, unit, and module create/edit dialogs. Add unit sort order on create, lesson publish status and sort order, video duration on lesson cards, and personas API integration for Learn English practice flows.
Move Manage Question Types to the new content hub, add Reorder Content page with hierarchy drag-and-drop, shared practice review UI, module practice cards, and publish-practice controls on course listings.
Co-authored-by: Cursor
---
src/api/courses.api.ts | 7 +
src/api/personas.api.ts | 6 +
src/app/AppRoutes.tsx | 2 +
src/hooks/useActivePersonas.ts | 43 ++
src/lib/learnEnglishPracticePublish.ts | 9 +
src/lib/personaDisplay.ts | 52 ++
src/lib/videoPreview.ts | 13 +
.../content-management/AddNewPracticePage.tsx | 474 ++----------------
.../content-management/AddPracticeFlow.tsx | 123 ++++-
src/pages/content-management/AddVideoFlow.tsx | 39 +-
.../content-management/CourseDetailPage.tsx | 21 +-
.../CourseManagementPage.tsx | 98 ++--
.../CourseModuleDetailPage.tsx | 39 +-
.../HumanLanguageHierarchyPage.tsx | 29 --
.../content-management/ModuleDetailPage.tsx | 371 +++++++++-----
.../content-management/NewContentPage.tsx | 33 +-
.../content-management/ProgramCoursesPage.tsx | 53 +-
.../content-management/ProgramDetailPage.tsx | 44 +-
.../ProgramTypeSelectionPage.tsx | 24 +-
.../QuestionTypeLibraryPage.tsx | 4 +-
.../content-management/ReorderContentPage.tsx | 31 ++
.../content-management/UnitManagementPage.tsx | 39 +-
.../components/AddModuleModal.tsx | 20 +-
.../components/AddNewPracticeReviewStep.tsx | 104 ++++
.../components/CreatePracticeWizard.tsx | 27 +-
.../components/ModulePracticeCard.tsx | 155 ++++++
.../components/PublishPracticeButton.tsx | 79 ++-
.../components/VideoCard.tsx | 186 ++++++-
.../components/practice-steps/ContextStep.tsx | 180 +++----
.../components/practice-steps/PersonaStep.tsx | 147 ++++--
.../PracticeSequentialReview.tsx | 407 +++++++++++++++
.../components/practice-steps/ReviewStep.tsx | 379 ++++----------
.../practice-steps/ScenarioStep.tsx | 12 +-
.../components/practice-steps/constants.ts | 58 +--
.../mapQuestionsForPracticeReview.ts | 83 +++
.../video-steps/ReviewPublishStep.tsx | 25 +-
.../video-steps/VideoDetailStep.tsx | 39 ++
src/types/course.types.ts | 18 +
src/types/persona.types.ts | 26 +
39 files changed, 2145 insertions(+), 1354 deletions(-)
create mode 100644 src/api/personas.api.ts
create mode 100644 src/hooks/useActivePersonas.ts
create mode 100644 src/lib/personaDisplay.ts
create mode 100644 src/pages/content-management/ReorderContentPage.tsx
create mode 100644 src/pages/content-management/components/AddNewPracticeReviewStep.tsx
create mode 100644 src/pages/content-management/components/ModulePracticeCard.tsx
create mode 100644 src/pages/content-management/components/practice-steps/PracticeSequentialReview.tsx
create mode 100644 src/pages/content-management/components/practice-steps/mapQuestionsForPracticeReview.ts
create mode 100644 src/types/persona.types.ts
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
+}