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" ? ( -
-