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) => ( - - ))} -
-
- -
- - + 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 -

- -
-
-
- Title - - {practiceTitle || "Untitled Practice"} - -
-
- - Description - - {descriptionPreviewHtml ? ( -
- ) : ( -

- )} -
-
- - Intro video URL - - - {introVideoUrl.trim() || "—"} - -
- {introVideoPreview ? ( -
- - Intro video preview - -
- {introVideoPreview.kind === "vimeo" ? ( -
-