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 <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-20 08:00:31 -07:00
parent 38550f9519
commit b8a73c73db
39 changed files with 2145 additions and 1354 deletions

View File

@ -106,6 +106,7 @@ import type {
UpdateParentLinkedPracticeResponse, UpdateParentLinkedPracticeResponse,
PublishParentLinkedPracticeRequest, PublishParentLinkedPracticeRequest,
UpdateTopLevelModuleLessonRequest, UpdateTopLevelModuleLessonRequest,
PublishTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonRequest, CreateTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonResponse, CreateTopLevelModuleLessonResponse,
} from "../types/course.types" } from "../types/course.types"
@ -647,6 +648,12 @@ export const updateTopLevelModuleLesson = (
data: UpdateTopLevelModuleLessonRequest, data: UpdateTopLevelModuleLessonRequest,
) => http.put(`/lessons/${lessonId}`, data) ) => 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 */ /** Learn English top-level module lesson — DELETE /lessons/:id */
export const deleteTopLevelModuleLesson = (lessonId: number) => export const deleteTopLevelModuleLesson = (lessonId: number) =>
http.delete(`/lessons/${lessonId}`) http.delete(`/lessons/${lessonId}`)

6
src/api/personas.api.ts Normal file
View File

@ -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<GetPersonasResponse>("/personas", { params })

View File

@ -15,6 +15,7 @@ import { SpeakingPage } from "../pages/content-management/SpeakingPage";
import { AddVideoPage } from "../pages/content-management/AddVideoPage"; import { AddVideoPage } from "../pages/content-management/AddVideoPage";
import { AddPracticePage } from "../pages/content-management/AddPracticePage"; import { AddPracticePage } from "../pages/content-management/AddPracticePage";
import { NewContentPage } from "../pages/content-management/NewContentPage"; import { NewContentPage } from "../pages/content-management/NewContentPage";
import { ReorderContentPage } from "../pages/content-management/ReorderContentPage";
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage"; import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage"; import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage"; import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
@ -162,6 +163,7 @@ export function AppRoutes() {
</Route> </Route>
<Route path="/new-content" element={<NewContentPage />} /> <Route path="/new-content" element={<NewContentPage />} />
<Route path="/new-content/reorder" element={<ReorderContentPage />} />
<Route <Route
path="/new-content/courses" path="/new-content/courses"
element={<ProgramTypeSelectionPage />} element={<ProgramTypeSelectionPage />}

View File

@ -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<PersonaCardModel[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 }
}

View File

@ -63,6 +63,9 @@ export async function executeLearnEnglishPracticeCreation(opts: {
storyDescription: string storyDescription: string
storyImage: string storyImage: string
quickTips: string quickTips: string
personaName?: string | null
/** Selected persona from step 2 — sent as `persona_id` on POST /practices. */
personaId: number
questions: LearnEnglishDefinitionQuestionInput[] questions: LearnEnglishDefinitionQuestionInput[]
definitions: QuestionTypeDefinition[] definitions: QuestionTypeDefinition[]
}): Promise<{ questionSetId: number; practiceId: number }> { }): Promise<{ questionSetId: number; practiceId: number }> {
@ -72,6 +75,10 @@ export async function executeLearnEnglishPracticeCreation(opts: {
) )
if (err) throw new Error(err) 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 byId = new Map(opts.definitions.map((d) => [d.id, d]))
const setRes = await createQuestionSet({ const setRes = await createQuestionSet({
@ -82,6 +89,7 @@ export async function executeLearnEnglishPracticeCreation(opts: {
owner_id: opts.parentId, owner_id: opts.parentId,
shuffle_questions: opts.shuffleQuestions, shuffle_questions: opts.shuffleQuestions,
status: opts.status, status: opts.status,
...(opts.personaName?.trim() ? { persona: opts.personaName.trim() } : {}),
}) })
const setId = setRes.data?.data?.id const setId = setRes.data?.data?.id
@ -122,6 +130,7 @@ export async function executeLearnEnglishPracticeCreation(opts: {
question_set_id: setId, question_set_id: setId,
quick_tips: opts.quickTips.trim(), quick_tips: opts.quickTips.trim(),
publish_status: opts.status, publish_status: opts.status,
persona_id: opts.personaId,
}) })
const practiceId = practiceRes.data?.data?.id const practiceId = practiceRes.data?.data?.id

52
src/lib/personaDisplay.ts Normal file
View File

@ -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 : []
}

View File

@ -88,6 +88,19 @@ export function formatPreviewLength(totalSeconds: number): string {
return `${totalSeconds} seconds`; 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. * 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). * Vimeo: time range in URL fragment (supported on many vimeo.com player links).

View File

@ -9,8 +9,6 @@ import {
Plus, Plus,
Trash2, Trash2,
GripVertical, GripVertical,
Edit,
Rocket,
Loader2, Loader2,
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
@ -19,6 +17,9 @@ import { Card } from "../../components/ui/card";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"; 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 { import {
createQuestionSet, createQuestionSet,
createQuestion, createQuestion,
@ -34,12 +35,6 @@ type ResultStatus = "success" | "error";
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC"; type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC";
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"; type DifficultyLevel = "EASY" | "MEDIUM" | "HARD";
interface Persona {
id: string;
name: string;
avatar: string;
}
interface MCQOption { interface MCQOption {
text: string; text: string;
isCorrect: boolean; isCorrect: boolean;
@ -65,49 +60,6 @@ interface Question {
dynamicFieldValues: Record<string, string>; dynamicFieldValues: Record<string, string>;
} }
const PERSONAS: Persona[] = [
{
id: "1",
name: "Dawit",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit",
},
{
id: "2",
name: "Mahlet",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet",
},
{
id: "3",
name: "Amanuel",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel",
},
{
id: "4",
name: "Bethel",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel",
},
{
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 = [ const STEPS = [
{ number: 1, label: "Context" }, { number: 1, label: "Context" },
{ number: 2, label: "Persona" }, { number: 2, label: "Persona" },
@ -158,64 +110,6 @@ function isDirectVideoFile(url: string): boolean {
return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean); return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
} }
function escapeHtml(raw: string): string {
return raw
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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, "<br />");
}
}
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, "<br />");
}
function createEmptyQuestion(id: string): Question { function createEmptyQuestion(id: string): Question {
return { return {
id, id,
@ -281,6 +175,12 @@ export function AddNewPracticePage() {
// Step 2: Persona // Step 2: Persona
const [selectedPersona, setSelectedPersona] = useState<string | null>(null); const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
const {
personas,
loading: personasLoading,
error: personasError,
reload: reloadPersonas,
} = useActivePersonas();
// Step 3: Questions // Step 3: Questions
const [questions, setQuestions] = useState<Question[]>([ const [questions, setQuestions] = useState<Question[]>([
@ -373,11 +273,6 @@ export function AddNewPracticePage() {
return null; return null;
}, [introVideoUrl]); }, [introVideoUrl]);
const descriptionPreviewHtml = useMemo(
() => formatDescriptionForPreview(practiceDescription),
[practiceDescription],
);
const addQuestion = () => { const addQuestion = () => {
setQuestions([...questions, createEmptyQuestion(String(Date.now()))]); setQuestions([...questions, createEmptyQuestion(String(Date.now()))]);
}; };
@ -398,7 +293,12 @@ export function AddNewPracticePage() {
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
try { 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({ const setRes = await createQuestionSet({
title: practiceTitle || "Untitled Practice", title: practiceTitle || "Untitled Practice",
set_type: "PRACTICE", set_type: "PRACTICE",
@ -899,66 +799,17 @@ export function AddNewPracticePage() {
practice. practice.
</p> </p>
</div> </div>
<div className="p-5 sm:p-8 lg:p-10"> <div className="p-5 sm:p-8 lg:p-10">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4 lg:gap-5"> <PersonaStep
{PERSONAS.map((persona) => ( personas={personas}
<button loading={personasLoading}
key={persona.id} error={personasError}
onClick={() => setSelectedPersona(persona.id)} onRetry={() => void reloadPersonas()}
className={`group relative flex flex-col items-center rounded-xl border-2 p-6 transition-all duration-200 ${ selectedPersona={selectedPersona}
selectedPersona === persona.id setSelectedPersona={setSelectedPersona}
? "border-brand-500 bg-brand-50 shadow-md shadow-brand-100" nextStep={handleNext}
: "border-grayScale-200 bg-white hover:border-brand-300 hover:shadow-sm" prevStep={handleBack}
}`} />
>
{selectedPersona === persona.id && (
<div className="absolute right-2.5 top-2.5 flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-white shadow-sm">
<Check className="h-3.5 w-3.5" />
</div>
)}
<div
className={`mb-3 h-20 w-20 overflow-hidden rounded-full bg-grayScale-100 ring-2 transition-all duration-200 ${
selectedPersona === persona.id
? "ring-brand-300 ring-offset-2"
: "ring-transparent group-hover:ring-grayScale-200"
}`}
>
<img
src={persona.avatar}
alt={persona.name}
className="h-full w-full object-cover"
/>
</div>
<span
className={`text-sm font-semibold transition-colors ${
selectedPersona === persona.id
? "text-brand-600"
: "text-grayScale-900"
}`}
>
{persona.name}
</span>
</button>
))}
</div>
</div>
<div className="flex flex-col-reverse items-stretch justify-between gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:items-center sm:px-8 sm:py-5">
<Button
variant="outline"
onClick={handleBack}
className="sm:w-auto"
>
Back
</Button>
<Button
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto sm:min-w-[180px]"
onClick={handleNext}
>
{getNextButtonLabel()}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div> </div>
</Card> </Card>
)} )}
@ -1075,261 +926,26 @@ export function AddNewPracticePage() {
)} )}
{currentStep === 4 && ( {currentStep === 4 && (
<div className="w-full space-y-6"> <AddNewPracticeReviewStep
<div className="rounded-2xl border border-grayScale-200/80 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 shadow-sm sm:px-8 sm:py-6"> practiceTitle={practiceTitle}
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl"> practiceDescription={practiceDescription}
Step 4: Review & publish selectedProgram={selectedProgram}
</h2> selectedCourse={selectedCourse}
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500"> moduleLabel={
Confirm context, persona, and questions before saving or subModuleId ? `Module ${subModuleId}` : "Current module"
publishing. }
</p> selectedPersona={selectedPersona}
</div> personas={personas}
introVideoPreview={introVideoPreview}
<div className="grid gap-6 lg:grid-cols-2 lg:items-start lg:gap-8"> questions={questions}
{/* Basic Information Card */} saving={saving}
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm"> saveError={saveError}
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4"> onEditContext={() => setCurrentStep(1)}
<h3 className="font-semibold text-grayScale-900"> onEditQuestions={() => setCurrentStep(3)}
Basic Information onBack={handleBack}
</h3> onSaveDraft={handleSaveAsDraft}
<button onPublish={handlePublish}
onClick={() => 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 className="h-3.5 w-3.5" />
Edit
</button>
</div>
<div className="divide-y divide-grayScale-100">
<div className="flex justify-between px-6 py-3.5 odd:bg-grayScale-50/50">
<span className="text-sm text-grayScale-500">Title</span>
<span className="text-sm font-medium text-grayScale-900">
{practiceTitle || "Untitled Practice"}
</span>
</div>
<div className="bg-grayScale-50/50 px-6 py-4">
<span className="text-sm text-grayScale-500">
Description
</span>
{descriptionPreviewHtml ? (
<div
className="mt-2 rounded-lg border border-grayScale-200 bg-white px-4 py-3 text-sm leading-relaxed text-grayScale-800 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-2 [&_strong]:font-semibold [&_ul]:list-disc [&_ul]:pl-6"
dangerouslySetInnerHTML={{
__html: descriptionPreviewHtml,
}}
/>
) : (
<p className="mt-2 text-sm text-grayScale-400"></p>
)}
</div>
<div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">
Intro video URL
</span>
<span className="max-w-[min(28rem,55%)] break-all text-right text-sm text-grayScale-700">
{introVideoUrl.trim() || "—"}
</span>
</div>
{introVideoPreview ? (
<div className="bg-grayScale-50/50 px-6 py-4">
<span className="text-sm text-grayScale-500">
Intro video preview
</span>
<div className="mt-2 rounded-lg border border-grayScale-200 bg-white p-3">
{introVideoPreview.kind === "vimeo" ? (
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-black">
<iframe
src={introVideoPreview.url}
title="Intro video preview"
className="aspect-video w-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
</div>
) : (
<video
controls
src={introVideoPreview.url}
className="aspect-video w-full rounded-lg border border-grayScale-200 bg-black"
/>
)}
</div>
</div>
) : null}
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
<span className="text-sm text-grayScale-500">
Passing Score
</span>
<span className="text-sm font-medium text-grayScale-900">
{passingScore}%
</span>
</div>
<div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">
Time Limit
</span>
<span className="text-sm font-medium text-grayScale-900">
{timeLimitMinutes} minutes
</span>
</div>
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
<span className="text-sm text-grayScale-500">
Shuffle Questions
</span>
<span className="text-sm font-medium text-grayScale-900">
{shuffleQuestions ? "Yes" : "No"}
</span>
</div>
<div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">Persona</span>
<div className="flex items-center gap-2">
{selectedPersona && (
<div className="h-6 w-6 overflow-hidden rounded-full bg-grayScale-100 ring-2 ring-brand-100">
<img
src={
PERSONAS.find((p) => p.id === selectedPersona)
?.avatar
}
alt="Persona"
className="h-full w-full object-cover"
/>
</div>
)}
<span className="text-sm font-medium text-brand-600">
{PERSONAS.find((p) => p.id === selectedPersona)?.name ||
"None selected"}
</span>
</div>
</div>
</div>
</Card>
{/* Questions Review */}
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm lg:min-h-0">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<div className="flex items-center gap-2.5">
<h3 className="font-semibold text-grayScale-900">
Questions
</h3>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-600">
{questions.length}
</span>
</div>
<button
onClick={() => 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 className="h-3.5 w-3.5" />
Edit
</button>
</div>
<div className="max-h-[min(70vh,52rem)] space-y-3 overflow-y-auto px-4 py-4 sm:px-6">
{questions.map((question, index) => (
<div
key={question.id}
className="rounded-xl border border-grayScale-200 bg-grayScale-50/20 p-4 transition-colors hover:border-grayScale-300 sm:p-4"
>
<div className="flex items-start gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-brand-100 text-xs font-bold text-brand-600">
{index + 1}
</span>
<div className="flex-1 space-y-2.5">
<p className="text-sm font-medium leading-relaxed text-grayScale-900">
{question.questionText}
</p>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600">
{question.questionType === "MCQ"
? "Multiple Choice"
: question.questionType === "TRUE_FALSE"
? "True/False"
: question.questionType === "AUDIO"
? "Audio"
: question.questionType === "DYNAMIC"
? "Dynamic"
: "Short Answer"}
</span>
<span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600">
{question.difficultyLevel}
</span>
<span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-xs font-medium text-grayScale-600">
{question.points} pt
{question.points !== 1 ? "s" : ""}
</span>
</div>
{question.questionType === "MCQ" &&
question.options.length > 0 && (
<div className="mt-2 space-y-1">
{question.options.map((opt, i) => (
<div
key={i}
className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm ${
opt.isCorrect
? "bg-green-50 font-medium text-green-700"
: "text-grayScale-600"
}`}
>
{opt.isCorrect && (
<Check className="h-3.5 w-3.5" />
)}
{opt.text || `Option ${i + 1}`}
</div>
))}
</div>
)}
{question.tips && (
<p className="rounded-md bg-amber-50 px-2.5 py-1.5 text-xs text-amber-600">
💡 Tip: {question.tips}
</p>
)}
{question.explanation && (
<p className="rounded-md bg-grayScale-50 px-2.5 py-1.5 text-xs text-grayScale-500">
Explanation: {question.explanation}
</p>
)}
</div>
</div>
</div>
))}
</div>
</Card>
</div>
{saveError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p className="text-sm font-medium text-red-600">{saveError}</p>
</div>
)}
<div className="flex flex-col-reverse items-stretch justify-between gap-3 rounded-2xl border border-grayScale-200/80 bg-grayScale-50/30 px-4 py-4 sm:flex-row sm:items-center sm:px-6 sm:py-5">
<Button
variant="outline"
onClick={handleBack}
className="sm:w-auto"
>
Back
</Button>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:justify-end">
<Button
variant="outline"
onClick={handleSaveAsDraft}
disabled={saving}
className="sm:min-w-[140px]"
>
{saving ? "Saving..." : "Save as Draft"}
</Button>
<Button
className="bg-brand-500 hover:bg-brand-600 sm:min-w-[160px]"
onClick={handlePublish}
disabled={saving}
>
<Rocket className="mr-2 h-4 w-4" />
{saving ? "Publishing..." : "Publish Now"}
</Button>
</div>
</div>
</div>
)} )}
{/* Step 5: Result */} {/* Step 5: Result */}

View File

@ -22,10 +22,16 @@ import {
import { ContextStep } from "./components/practice-steps/ContextStep"; import { ContextStep } from "./components/practice-steps/ContextStep";
import { ScenarioStep } from "./components/practice-steps/ScenarioStep"; import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
import { PersonaStep } from "./components/practice-steps/PersonaStep";
import { QuestionsStep } from "./components/practice-steps/QuestionsStep"; import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
import { ReviewStep } from "./components/practice-steps/ReviewStep"; 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() { export function AddPracticeFlow() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -48,6 +54,10 @@ export function AddPracticeFlow() {
const isModuleContext = backTo === "module"; const isModuleContext = backTo === "module";
const isCourseContext = backTo === "modules"; const isCourseContext = backTo === "modules";
const isLessonPractice = useMemo(() => {
const lid = lessonId ? Number(lessonId) : NaN;
return Number.isFinite(lid) && lid > 0;
}, [lessonId]);
const parentContext = useMemo((): { const parentContext = useMemo((): {
kind: PracticeParentKind; kind: PracticeParentKind;
@ -96,12 +106,19 @@ export function AddPracticeFlow() {
const [isPublished, setIsPublished] = useState(false); const [isPublished, setIsPublished] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
const {
personas,
loading: personasLoading,
error: personasError,
reload: reloadPersonas,
} = useActivePersonas();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: "", title: "",
description: "", description: "",
storyImageUrl: "", storyImageUrl: "",
shuffleQuestions: false, shuffleQuestions: false,
publishStatus: "DRAFT" as const,
tips: "", tips: "",
questions: [ questions: [
{ {
@ -176,12 +193,29 @@ export function AddPracticeFlow() {
}); });
return; return;
} }
if (!formData.title.trim() || !formData.description.trim()) { if (
!isLessonPractice &&
(!formData.title.trim() || !formData.description.trim())
) {
toast.error("Title and story description are required", { toast.error("Title and story description are required", {
description: "Complete the first step before publishing.", description: "Complete the first step before publishing.",
}); });
return; 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 const mappedQuestions = formData.questions
.filter((q) => String(q.text ?? "").trim()) .filter((q) => String(q.text ?? "").trim())
.map((q) => ({ .map((q) => ({
@ -207,19 +241,33 @@ export function AddPracticeFlow() {
return; return;
} }
const lessonDefaultTitle =
lessonTitleDisplay?.trim() ||
(lessonId ? `Lesson ${lessonId} practice` : "Lesson practice");
setSubmitting(true); setSubmitting(true);
try { try {
await executeLearnEnglishPracticeCreation({ await executeLearnEnglishPracticeCreation({
parentKind: parentContext.kind, parentKind: parentContext.kind,
parentId: parentContext.id, parentId: parentContext.id,
status, status,
questionSetTitle: formData.title.trim() || "Practice set", questionSetTitle: isLessonPractice
questionSetDescription: formData.description.trim() || null, ? lessonDefaultTitle
: formData.title.trim() || "Practice set",
questionSetDescription: isLessonPractice
? null
: formData.description.trim() || null,
shuffleQuestions: formData.shuffleQuestions, shuffleQuestions: formData.shuffleQuestions,
practiceTitle: formData.title.trim() || "Untitled practice", practiceTitle: isLessonPractice
storyDescription: formData.description.trim(), ? lessonDefaultTitle
storyImage: formData.storyImageUrl.trim(), : formData.title.trim() || "Untitled practice",
storyDescription: isLessonPractice
? ""
: formData.description.trim(),
storyImage: isLessonPractice ? "" : formData.storyImageUrl.trim(),
quickTips: formData.tips.trim(), quickTips: formData.tips.trim(),
personaName: persona?.name ?? null,
personaId,
questions: mappedQuestions, questions: mappedQuestions,
definitions: typeDefinitions, definitions: typeDefinitions,
}); });
@ -274,12 +322,12 @@ export function AddPracticeFlow() {
onClick={() => { onClick={() => {
setIsPublished(false); setIsPublished(false);
setCurrentStep(1); setCurrentStep(1);
setSelectedPersona(null);
setFormData({ setFormData({
title: "", title: "",
description: "", description: "",
storyImageUrl: "", storyImageUrl: "",
shuffleQuestions: false, shuffleQuestions: false,
publishStatus: "DRAFT" as const,
tips: "", tips: "",
questions: [ questions: [
{ {
@ -321,11 +369,25 @@ export function AddPracticeFlow() {
formData={formData} formData={formData}
setFormData={setFormData} setFormData={setFormData}
nextStep={nextStep} nextStep={nextStep}
navigate={navigate} onCancel={() => navigate(backPath)}
level={level!} isLessonPractice={isLessonPractice}
lessonTitle={lessonTitleDisplay}
/> />
); );
case 2: case 2:
return (
<PersonaStep
personas={personas}
loading={personasLoading}
error={personasError}
onRetry={() => void reloadPersonas()}
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 3:
return ( return (
<QuestionsStep <QuestionsStep
formData={formData} formData={formData}
@ -337,12 +399,20 @@ export function AddPracticeFlow() {
definitionsError={definitionsError} definitionsError={definitionsError}
/> />
); );
case 3: case 4:
return ( return (
<ReviewStep <ReviewStep
formData={formData} formData={formData}
setFormData={setFormData} selectedPersona={selectedPersona}
personas={personas}
isLessonPractice={isLessonPractice}
lessonTitle={lessonTitleDisplay}
programLabel={level ? `Program ${level}` : null}
courseLabel={courseId ? `Course ${courseId}` : null}
moduleLabel={moduleId ? `Module ${moduleId}` : null}
prevStep={prevStep} prevStep={prevStep}
onEditContext={() => setCurrentStep(1)}
onEditQuestions={() => setCurrentStep(3)}
parentSummary={parentSummary} parentSummary={parentSummary}
typeDefinitions={typeDefinitions} typeDefinitions={typeDefinitions}
canPublish={parentContext !== null} canPublish={parentContext !== null}
@ -367,6 +437,19 @@ export function AddPracticeFlow() {
/> />
); );
case 2: case 2:
return (
<PersonaStep
personas={personas}
loading={personasLoading}
error={personasError}
onRetry={() => void reloadPersonas()}
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 3:
return ( return (
<QuestionsStep <QuestionsStep
formData={formData} formData={formData}
@ -378,12 +461,20 @@ export function AddPracticeFlow() {
definitionsError={definitionsError} definitionsError={definitionsError}
/> />
); );
case 3: case 4:
return ( return (
<ReviewStep <ReviewStep
formData={formData} formData={formData}
setFormData={setFormData} selectedPersona={selectedPersona}
personas={personas}
isLessonPractice={isLessonPractice}
lessonTitle={lessonTitleDisplay}
programLabel={level ? `Program ${level}` : null}
courseLabel={courseId ? `Course ${courseId}` : null}
moduleLabel={moduleId ? `Module ${moduleId}` : null}
prevStep={prevStep} prevStep={prevStep}
onEditContext={() => setCurrentStep(1)}
onEditQuestions={() => setCurrentStep(3)}
parentSummary={parentSummary} parentSummary={parentSummary}
typeDefinitions={typeDefinitions} typeDefinitions={typeDefinitions}
canPublish={parentContext !== null} canPublish={parentContext !== null}
@ -453,7 +544,7 @@ export function AddPracticeFlow() {
</div> </div>
<div <div
className={`mx-auto ${currentStep === 2 ? "max-w-6xl" : "max-w-4xl"}`} className={`mx-auto ${currentStep === 3 || currentStep === 4 ? "max-w-6xl" : "max-w-4xl"}`}
> >
{renderStep()} {renderStep()}
</div> </div>

View File

@ -5,6 +5,7 @@ import { ArrowLeft } from "lucide-react";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper"; import { Stepper } from "../../components/ui/stepper";
import { createModuleLesson } from "../../api/courses.api"; import { createModuleLesson } from "../../api/courses.api";
import type { PracticePublishStatus } from "../../types/course.types";
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep"; import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep"; import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
@ -17,7 +18,7 @@ const STEPS = [
export type AddLessonFormData = { export type AddLessonFormData = {
title: string; title: string;
order: string; sortOrder: string;
description: string; description: string;
videoUrl: string; videoUrl: string;
thumbnailUrl: string; thumbnailUrl: string;
@ -25,7 +26,7 @@ export type AddLessonFormData = {
const emptyForm = (): AddLessonFormData => ({ const emptyForm = (): AddLessonFormData => ({
title: "", title: "",
order: "1", sortOrder: "0",
description: "", description: "",
videoUrl: "", videoUrl: "",
thumbnailUrl: "", thumbnailUrl: "",
@ -51,6 +52,8 @@ export function AddVideoFlow() {
}>(); }>();
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [isPublished, setIsPublished] = useState(false); const [isPublished, setIsPublished] = useState(false);
const [lastCreatedPublishStatus, setLastCreatedPublishStatus] =
useState<PracticePublishStatus>("PUBLISHED");
const [formData, setFormData] = useState<AddLessonFormData>(emptyForm); const [formData, setFormData] = useState<AddLessonFormData>(emptyForm);
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
const [formResetKey, setFormResetKey] = useState(0); const [formResetKey, setFormResetKey] = useState(0);
@ -60,7 +63,7 @@ export function AddVideoFlow() {
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`; const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
const handlePublish = async () => { const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
const mid = Number(moduleId); const mid = Number(moduleId);
if (!Number.isFinite(mid) || mid < 1) { if (!Number.isFinite(mid) || mid < 1) {
toast.error("Invalid module"); toast.error("Invalid module");
@ -86,6 +89,16 @@ export function AddVideoFlow() {
toast.error("Description is required"); toast.error("Description is required");
return; 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); setPublishing(true);
try { try {
await createModuleLesson(mid, { await createModuleLesson(mid, {
@ -93,8 +106,15 @@ export function AddVideoFlow() {
video_url: videoUrl, video_url: videoUrl,
thumbnail, thumbnail,
description, description,
sort_order,
publish_status: publishStatus,
}); });
toast.success("Lesson created"); setLastCreatedPublishStatus(publishStatus);
toast.success(
publishStatus === "DRAFT"
? "Lesson saved as draft"
: "Lesson published",
);
setIsPublished(true); setIsPublished(true);
} catch (e: unknown) { } catch (e: unknown) {
console.error(e); console.error(e);
@ -123,10 +143,14 @@ export function AddVideoFlow() {
</div> </div>
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4"> <h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
Lesson created successfully {lastCreatedPublishStatus === "DRAFT"
? "Lesson saved as draft"
: "Lesson published successfully"}
</h1> </h1>
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed"> <p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
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."}
</p> </p>
<div className="flex flex-col gap-4 w-full max-w-[400px]"> <div className="flex flex-col gap-4 w-full max-w-[400px]">
@ -140,6 +164,7 @@ export function AddVideoFlow() {
onClick={() => { onClick={() => {
setFormData(emptyForm()); setFormData(emptyForm());
setFormResetKey((k) => k + 1); setFormResetKey((k) => k + 1);
setLastCreatedPublishStatus("PUBLISHED");
setIsPublished(false); setIsPublished(false);
setCurrentStep(1); setCurrentStep(1);
}} }}
@ -205,7 +230,7 @@ export function AddVideoFlow() {
<ReviewPublishStep <ReviewPublishStep
formData={formData} formData={formData}
prevStep={prevStep} prevStep={prevStep}
onPublish={() => void handlePublish()} onCreateLesson={(status) => void handleCreateLesson(status)}
publishing={publishing} publishing={publishing}
/> />
)} )}

View File

@ -21,7 +21,6 @@ import {
DialogTitle, DialogTitle,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"; import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
import alertSrc from "../../assets/Alert.svg"; import alertSrc from "../../assets/Alert.svg";
@ -146,7 +145,6 @@ export function CourseDetailPage() {
const [editingModule, setEditingModule] = const [editingModule, setEditingModule] =
useState<TopLevelCourseModuleItem | null>(null); useState<TopLevelCourseModuleItem | null>(null);
const [editModuleName, setEditModuleName] = useState(""); const [editModuleName, setEditModuleName] = useState("");
const [editModuleDescription, setEditModuleDescription] = useState("");
const [editModuleSortOrder, setEditModuleSortOrder] = useState(""); const [editModuleSortOrder, setEditModuleSortOrder] = useState("");
const [editModuleIcon, setEditModuleIcon] = useState(""); const [editModuleIcon, setEditModuleIcon] = useState("");
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] = const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
@ -160,7 +158,6 @@ export function CourseDetailPage() {
const openEditModule = (module: TopLevelCourseModuleItem) => { const openEditModule = (module: TopLevelCourseModuleItem) => {
setEditingModule(module); setEditingModule(module);
setEditModuleName(module.name ?? ""); setEditModuleName(module.name ?? "");
setEditModuleDescription(module.description ?? "");
setEditModuleSortOrder(String(module.sort_order ?? 0)); setEditModuleSortOrder(String(module.sort_order ?? 0));
setEditModuleIcon(module.icon?.trim() ?? ""); setEditModuleIcon(module.icon?.trim() ?? "");
setEditModuleIconUploadBusy(false); setEditModuleIconUploadBusy(false);
@ -284,7 +281,7 @@ export function CourseDetailPage() {
try { try {
await updateTopLevelCourseModule(editingModule.id, { await updateTopLevelCourseModule(editingModule.id, {
name, name,
description: editModuleDescription.trim(), description: editingModule.description?.trim() ?? "",
icon: editModuleIcon.trim(), icon: editModuleIcon.trim(),
sort_order, sort_order,
}); });
@ -430,8 +427,7 @@ export function CourseDetailPage() {
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12"> <DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit module</DialogTitle> <DialogTitle>Edit module</DialogTitle>
<DialogDescription> <DialogDescription>
Update name, description, sort order, and icon (upload or URL). Update name, sort order, and icon (upload or URL). Saved with{" "}
Saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]"> <code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /modules/:id PUT /modules/:id
</code> </code>
@ -452,19 +448,6 @@ export function CourseDetailPage() {
disabled={savingModuleEdit} disabled={savingModuleEdit}
/> />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Description
</label>
<Textarea
value={editModuleDescription}
onChange={(e) => setEditModuleDescription(e.target.value)}
rows={4}
className="min-h-[100px] resize-y rounded-xl"
placeholder="Optional short description."
disabled={savingModuleEdit || editModuleIconUploadBusy}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label <label
htmlFor="edit-module-sort-order" htmlFor="edit-module-sort-order"

View File

@ -15,7 +15,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card"; import { Card } from "../../components/ui/card";
import { Textarea } from "../../components/ui/textarea";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -45,7 +44,7 @@ export function CourseManagementPage() {
const catalogCourseId = Number(courseId); const catalogCourseId = Number(courseId);
const [addUnitOpen, setAddUnitOpen] = useState(false); const [addUnitOpen, setAddUnitOpen] = useState(false);
const [createName, setCreateName] = useState(""); const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState(""); const [createSortOrder, setCreateSortOrder] = useState("");
const [createThumbnail, setCreateThumbnail] = useState(""); const [createThumbnail, setCreateThumbnail] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [uploadingThumbnail, setUploadingThumbnail] = useState(false); const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
@ -66,7 +65,6 @@ export function CourseManagementPage() {
const [unitsLoading, setUnitsLoading] = useState(false); const [unitsLoading, setUnitsLoading] = useState(false);
const [editingUnitId, setEditingUnitId] = useState<number | null>(null); const [editingUnitId, setEditingUnitId] = useState<number | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState(""); const [editThumbnail, setEditThumbnail] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1"); const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false); const [savingEdit, setSavingEdit] = useState(false);
@ -152,7 +150,7 @@ export function CourseManagementPage() {
const clearCreateUnitForm = () => { const clearCreateUnitForm = () => {
setCreateName(""); setCreateName("");
setCreateDescription(""); setCreateSortOrder("");
setCreateThumbnail(""); setCreateThumbnail("");
if (createThumbnailFileInputRef.current) { if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = ""; createThumbnailFileInputRef.current.value = "";
@ -202,13 +200,24 @@ export function CourseManagementPage() {
toast.error("Unit name is required"); toast.error("Unit name is required");
return; 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); setCreating(true);
try { try {
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail); const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
const response = await createExamPrepCatalogUnit(catalogCourseId, { const response = await createExamPrepCatalogUnit(catalogCourseId, {
name, name,
description: createDescription.trim() || null, description: null,
thumbnail: minioThumbnail || null, thumbnail: minioThumbnail || null,
sort_order,
}); });
void response; void response;
await loadUnits(); await loadUnits();
@ -271,18 +280,16 @@ export function CourseManagementPage() {
const openEditUnit = (unit: (typeof units)[number]) => { const openEditUnit = (unit: (typeof units)[number]) => {
setEditingUnitId(unit.id); setEditingUnitId(unit.id);
setEditName(unit.name ?? ""); setEditName(unit.name ?? "");
setEditDescription(unit.description ?? "");
setEditThumbnail(unit.thumbnail ?? ""); setEditThumbnail(unit.thumbnail ?? "");
setEditSortOrder(String(unit.sortOrder ?? 1)); setEditSortOrder(String(unit.sortOrder ?? 0));
}; };
const closeEditUnit = () => { const closeEditUnit = () => {
if (savingEdit || uploadingEditThumbnail) return; if (savingEdit || uploadingEditThumbnail) return;
setEditingUnitId(null); setEditingUnitId(null);
setEditName(""); setEditName("");
setEditDescription("");
setEditThumbnail(""); setEditThumbnail("");
setEditSortOrder("1"); setEditSortOrder("");
}; };
const handleEditUnitThumbnailFile = async ( const handleEditUnitThumbnailFile = async (
@ -320,20 +327,30 @@ export function CourseManagementPage() {
toast.error("Unit name is required"); toast.error("Unit name is required");
return; return;
} }
const sortOrderNum = Number(editSortOrder); const sortOrderRaw = editSortOrder.trim();
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) { if (!sortOrderRaw) {
toast.error("Sort order must be a valid number"); 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; return;
} }
setSavingEdit(true); setSavingEdit(true);
try { try {
const existing = units.find((u) => u.id === editingUnitId);
const preservedDescription =
existing?.description && existing.description !== "—"
? existing.description
: null;
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail); const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
await updateExamPrepCatalogUnit(editingUnitId, { await updateExamPrepCatalogUnit(editingUnitId, {
name, name,
description: editDescription.trim() || null, description: preservedDescription,
thumbnail: minioThumbnail || null, thumbnail: minioThumbnail || null,
sort_order: sortOrderNum, sort_order,
}); });
await loadUnits(); await loadUnits();
toast.success("Unit updated"); toast.success("Unit updated");
@ -425,18 +442,29 @@ export function CourseManagementPage() {
disabled={creating || uploadingThumbnail} disabled={creating || uploadingThumbnail}
/> />
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800"> <label
Description htmlFor="create-unit-sort-order"
className="text-[15px] text-grayScale-800"
>
Sort Order
</label> </label>
<Textarea <Input
value={createDescription} id="create-unit-sort-order"
onChange={(e) => setCreateDescription(e.target.value)} type="number"
placeholder="Short unit description" min={0}
rows={4} step={1}
className="min-h-[96px] rounded-[8px] border-grayScale-400" inputMode="numeric"
value={createSortOrder}
onChange={(e) => setCreateSortOrder(e.target.value)}
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={creating || uploadingThumbnail} disabled={creating || uploadingThumbnail}
/> />
<p className="text-xs text-grayScale-500">
Lower numbers appear first when units are listed.
</p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@ -690,25 +718,27 @@ export function CourseManagementPage() {
/> />
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label> <label
<Textarea htmlFor="edit-unit-sort-order"
value={editDescription} className="text-[15px] text-grayScale-800"
onChange={(e) => setEditDescription(e.target.value)} >
rows={4} Sort Order
className="min-h-[96px] rounded-[8px] border-grayScale-400" </label>
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input <Input
id="edit-unit-sort-order"
type="number" type="number"
min={0} min={0}
step={1}
inputMode="numeric"
value={editSortOrder} value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)} onChange={(e) => 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} disabled={savingEdit || uploadingEditThumbnail}
/> />
<p className="text-xs text-grayScale-500">
Lower numbers appear first when units are listed.
</p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label> <label className="text-[15px] text-grayScale-800">Thumbnail</label>

View File

@ -25,6 +25,7 @@ import {
getExamPrepModuleLessons, getExamPrepModuleLessons,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { uploadImageFile, uploadVideoFile } from "../../api/files.api"; import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
import type { PracticePublishStatus } from "../../types/course.types";
const MOCK_PRACTICES = [ const MOCK_PRACTICES = [
{ {
@ -64,6 +65,7 @@ export function CourseModuleDetailPage() {
thumbnail: string; thumbnail: string;
sortOrder: number; sortOrder: number;
gradient: string; gradient: string;
durationSeconds: number | null;
}> }>
>([]); >([]);
const [createLessonOpen, setCreateLessonOpen] = useState(false); const [createLessonOpen, setCreateLessonOpen] = useState(false);
@ -129,20 +131,28 @@ export function CourseModuleDetailPage() {
const rows = response.data?.data?.lessons; const rows = response.data?.data?.lessons;
const list = Array.isArray(rows) ? rows : []; const list = Array.isArray(rows) ? rows : [];
setLessons( 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), id: Number(row.id),
title: row.title?.trim() || `Lesson ${row.id}`, title: row.title?.trim() || `Lesson ${row.id}`,
videoUrl: row.video_url?.trim() || "", videoUrl: row.video_url?.trim() || "",
description: row.description?.trim() || "—", description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || "", thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0), sortOrder: Number(row.sort_order ?? 0),
durationSeconds,
gradient: gradient:
index % 3 === 1 index % 3 === 1
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)" ? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)"
: index % 3 === 2 : index % 3 === 2
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)" ? "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%)", : "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)",
})), };
}),
); );
} catch (error) { } catch (error) {
console.error(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) { if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
toast.error("Invalid module"); toast.error("Invalid module");
return; return;
@ -276,9 +286,14 @@ export function CourseModuleDetailPage() {
video_url: videoUrl, video_url: videoUrl,
thumbnail: minioThumbnail || null, thumbnail: minioThumbnail || null,
description: createDescription.trim() || null, description: createDescription.trim() || null,
publish_status: publishStatus,
}); });
await loadLessons(); await loadLessons();
toast.success("Lesson created"); toast.success(
publishStatus === "DRAFT"
? "Lesson saved as draft"
: "Lesson created",
);
clearCreateLessonForm(); clearCreateLessonForm();
setCreateLessonOpen(false); setCreateLessonOpen(false);
} catch (error: unknown) { } catch (error: unknown) {
@ -641,7 +656,7 @@ export function CourseModuleDetailPage() {
/> />
</div> </div>
</div> </div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3"> <div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex flex-wrap justify-end gap-3">
<DialogClose asChild> <DialogClose asChild>
<Button <Button
type="button" type="button"
@ -653,13 +668,22 @@ export function CourseModuleDetailPage() {
Cancel Cancel
</Button> </Button>
</DialogClose> </DialogClose>
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold hover:bg-grayScale-50"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
onClick={() => void handleCreateLesson("DRAFT")}
>
{creatingLesson ? "Saving…" : "Save as draft"}
</Button>
<Button <Button
type="button" type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600" className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo} disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
onClick={() => void handleCreateLesson()} onClick={() => void handleCreateLesson("PUBLISHED")}
> >
{creatingLesson ? "Creating..." : "Create Lesson"} {creatingLesson ? "Creating..." : "Publish lesson"}
</Button> </Button>
</div> </div>
</div> </div>
@ -716,6 +740,7 @@ export function CourseModuleDetailPage() {
thumbnailUrl={lesson.thumbnail} thumbnailUrl={lesson.thumbnail}
videoUrl={lesson.videoUrl} videoUrl={lesson.videoUrl}
thumbnailGradient={lesson.gradient} thumbnailGradient={lesson.gradient}
durationSeconds={lesson.durationSeconds}
hoverModuleActions hoverModuleActions
onEdit={() => openEditLesson(lesson)} onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLessonId(lesson.id)} onDelete={() => setDeletingLessonId(lesson.id)}

View File

@ -9,7 +9,6 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select" import { Select } from "../../components/ui/select"
import { SpinnerIcon } from "../../components/ui/spinner-icon" import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { Textarea } from "../../components/ui/textarea"
import { import {
createModule, createModule,
deleteModule, deleteModule,
@ -241,7 +240,6 @@ export function HumanLanguageHierarchyPage() {
const [createModuleTitle, setCreateModuleTitle] = useState("") const [createModuleTitle, setCreateModuleTitle] = useState("")
const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false) const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false)
const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("") const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("")
const [createModuleDescription, setCreateModuleDescription] = useState("")
const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url") const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url")
const [createModuleIconUrl, setCreateModuleIconUrl] = useState("") const [createModuleIconUrl, setCreateModuleIconUrl] = useState("")
const [createModuleIconFile, setCreateModuleIconFile] = useState<File | null>(null) const [createModuleIconFile, setCreateModuleIconFile] = useState<File | null>(null)
@ -253,7 +251,6 @@ export function HumanLanguageHierarchyPage() {
const [editModuleSaving, setEditModuleSaving] = useState(false) const [editModuleSaving, setEditModuleSaving] = useState(false)
const [editModuleTarget, setEditModuleTarget] = useState<EditModuleTarget | null>(null) const [editModuleTarget, setEditModuleTarget] = useState<EditModuleTarget | null>(null)
const [editModuleTitle, setEditModuleTitle] = useState("") const [editModuleTitle, setEditModuleTitle] = useState("")
const [editModuleDescription, setEditModuleDescription] = useState("")
const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0) const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0)
const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url") const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url")
const [editModuleIconUrl, setEditModuleIconUrl] = useState("") const [editModuleIconUrl, setEditModuleIconUrl] = useState("")
@ -467,7 +464,6 @@ export function HumanLanguageHierarchyPage() {
setCreateModuleUseDefaultNaming(false) setCreateModuleUseDefaultNaming(false)
setCreateModuleDefaultTitle(getNextDefaultModuleName(level)) setCreateModuleDefaultTitle(getNextDefaultModuleName(level))
setCreateModuleTitle("") setCreateModuleTitle("")
setCreateModuleDescription("")
setCreateModuleIconSource("url") setCreateModuleIconSource("url")
setCreateModuleIconUrl("") setCreateModuleIconUrl("")
setCreateModuleIconFile(null) setCreateModuleIconFile(null)
@ -503,7 +499,6 @@ export function HumanLanguageHierarchyPage() {
await createModule({ await createModule({
level_id: createModuleLevelId, level_id: createModuleLevelId,
title, title,
description: createModuleDescription.trim() || undefined,
icon_url: uploadedIconUrl, icon_url: uploadedIconUrl,
display_order: createModuleDisplayOrder, display_order: createModuleDisplayOrder,
is_active: true, is_active: true,
@ -553,7 +548,6 @@ export function HumanLanguageHierarchyPage() {
levelKey, levelKey,
}) })
setEditModuleTitle(module.title) setEditModuleTitle(module.title)
setEditModuleDescription("")
setEditModuleDisplayOrder(moduleDisplayOrder) setEditModuleDisplayOrder(moduleDisplayOrder)
setEditModuleIconSource("url") setEditModuleIconSource("url")
setEditModuleIconUrl(existingIconUrl) setEditModuleIconUrl(existingIconUrl)
@ -594,7 +588,6 @@ export function HumanLanguageHierarchyPage() {
await updateModule(editModuleTarget.moduleId, { await updateModule(editModuleTarget.moduleId, {
title, title,
description: editModuleDescription.trim() || undefined,
icon_url: uploadedIconUrl, icon_url: uploadedIconUrl,
display_order: editModuleDisplayOrder, display_order: editModuleDisplayOrder,
is_active: true, is_active: true,
@ -1068,17 +1061,6 @@ export function HumanLanguageHierarchyPage() {
/> />
</div> </div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Description (optional)</label>
<Textarea
rows={3}
value={createModuleDescription}
onChange={(event) => setCreateModuleDescription(event.target.value)}
placeholder="Optional description"
disabled={createModuleSaving}
/>
</div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Icon URL (optional)</label> <label className="mb-1.5 block text-sm font-medium text-grayScale-600">Icon URL (optional)</label>
<div className="mb-2 grid grid-cols-2 gap-2"> <div className="mb-2 grid grid-cols-2 gap-2">
@ -1173,17 +1155,6 @@ export function HumanLanguageHierarchyPage() {
/> />
</div> </div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Description</label>
<Textarea
rows={3}
value={editModuleDescription}
onChange={(event) => setEditModuleDescription(event.target.value)}
placeholder="New description"
disabled={editModuleSaving}
/>
</div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Display order</label> <label className="mb-1.5 block text-sm font-medium text-grayScale-600">Display order</label>
<Input <Input

View File

@ -1,23 +1,27 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import { ArrowLeft, Video, Calendar, Trash2, X } from "lucide-react";
ArrowLeft,
Video,
Calendar,
Mic,
Layers,
Edit2,
Trash2,
X,
} from "lucide-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
deleteTopLevelModuleLesson, deleteTopLevelModuleLesson,
getModuleLessons, getModuleLessons,
getPracticesByParentModule,
getTopLevelCourseModules, getTopLevelCourseModules,
publishParentLinkedPractice,
publishTopLevelModuleLesson,
updateParentLinkedPractice,
updateTopLevelModuleLesson, updateTopLevelModuleLesson,
} from "../../api/courses.api"; } from "../../api/courses.api";
import type { TopLevelModuleLessonItem } from "../../types/course.types"; import type {
ParentContextPractice,
PracticePublishStatus,
TopLevelModuleLessonItem,
} from "../../types/course.types";
import {
isPracticeDraft,
isPracticePublished,
unwrapPracticesList,
} from "../../lib/parentContextPractice";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
Dialog, Dialog,
@ -32,6 +36,7 @@ import { Textarea } from "../../components/ui/textarea";
import { resolveThumbnailForPreview } from "../../lib/videoPreview"; import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { LessonMediaUploadField } from "./components/LessonMediaUploadField"; import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
import { ModulePracticeCard } from "./components/ModulePracticeCard";
import { VideoCard } from "./components/VideoCard"; import { VideoCard } from "./components/VideoCard";
const LESSON_THUMB_GRADIENTS = [ const LESSON_THUMB_GRADIENTS = [
@ -41,37 +46,6 @@ const LESSON_THUMB_GRADIENTS = [
"from-[#FCE7F3] to-[#F9A8D4]", "from-[#FCE7F3] to-[#F9A8D4]",
] as const; ] as const;
const MOCK_PRACTICES = [
{
id: "p1",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p2",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p3",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p4",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
];
type ModuleDetailState = { type ModuleDetailState = {
moduleName?: string; moduleName?: string;
moduleDescription?: string; moduleDescription?: string;
@ -87,13 +61,14 @@ export function ModuleDetailPage() {
moduleId: string; moduleId: string;
}>(); }>();
const [activeTab, setActiveTab] = useState<"video" | "practice">("video"); const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [activeFilter, setActiveFilter] = useState("Draft"); const [activeFilter, setActiveFilter] = useState("All");
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]); const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(true); const [lessonsLoading, setLessonsLoading] = useState(true);
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null); const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
const [editingLesson, setEditingLesson] = const [editingLesson, setEditingLesson] =
useState<TopLevelModuleLessonItem | null>(null); useState<TopLevelModuleLessonItem | null>(null);
const [editLessonTitle, setEditLessonTitle] = useState(""); const [editLessonTitle, setEditLessonTitle] = useState("");
const [editLessonSortOrder, setEditLessonSortOrder] = useState("");
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState(""); const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
const [editLessonThumbnail, setEditLessonThumbnail] = useState(""); const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
const [editLessonDescription, setEditLessonDescription] = useState(""); const [editLessonDescription, setEditLessonDescription] = useState("");
@ -104,7 +79,17 @@ export function ModuleDetailPage() {
const [deletingLesson, setDeletingLesson] = const [deletingLesson, setDeletingLesson] =
useState<TopLevelModuleLessonItem | null>(null); useState<TopLevelModuleLessonItem | null>(null);
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false); const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
const [practices] = useState(MOCK_PRACTICES); const [publishStatusLessonId, setPublishStatusLessonId] = useState<
number | null
>(null);
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [practicesLoading, setPracticesLoading] = useState(false);
const [practicesLoadError, setPracticesLoadError] = useState<string | null>(
null,
);
const [publishStatusPracticeId, setPublishStatusPracticeId] = useState<
number | null
>(null);
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null); const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
const [loadedModuleDescription, setLoadedModuleDescription] = useState< const [loadedModuleDescription, setLoadedModuleDescription] = useState<
string | null string | null
@ -233,9 +218,96 @@ export function ModuleDetailPage() {
void loadModuleLessons({ showPageLoading: true }); void loadModuleLessons({ showPageLoading: true });
}, [loadModuleLessons]); }, [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) => { const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
setEditingLesson(lesson); setEditingLesson(lesson);
setEditLessonTitle(lesson.title ?? ""); setEditLessonTitle(lesson.title ?? "");
setEditLessonSortOrder(String(lesson.sort_order ?? 0));
setEditLessonVideoUrl(lesson.video_url ?? ""); setEditLessonVideoUrl(lesson.video_url ?? "");
setEditLessonThumbnail(lesson.thumbnail ?? ""); setEditLessonThumbnail(lesson.thumbnail ?? "");
setEditLessonDescription(lesson.description ?? ""); setEditLessonDescription(lesson.description ?? "");
@ -253,6 +325,16 @@ export function ModuleDetailPage() {
toast.error("Title is required"); toast.error("Title is required");
return; 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); setSavingLessonEdit(true);
try { try {
await updateTopLevelModuleLesson(editingLesson.id, { await updateTopLevelModuleLesson(editingLesson.id, {
@ -260,6 +342,7 @@ export function ModuleDetailPage() {
video_url: editLessonVideoUrl.trim(), video_url: editLessonVideoUrl.trim(),
thumbnail: editLessonThumbnail.trim(), thumbnail: editLessonThumbnail.trim(),
description: editLessonDescription.trim(), description: editLessonDescription.trim(),
sort_order,
}); });
toast.success("Lesson updated"); toast.success("Lesson updated");
setEditingLesson(null); 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 () => { const handleConfirmDeleteLesson = async () => {
if (!deletingLesson) return; if (!deletingLesson) return;
setDeletingLessonInFlight(true); setDeletingLessonInFlight(true);
@ -393,9 +509,17 @@ export function ModuleDetailPage() {
id={lesson.id} id={lesson.id}
title={lesson.title} title={lesson.title}
videoUrl={lesson.video_url} videoUrl={lesson.video_url}
publishStatus={lesson.publish_status}
hoverModuleActions hoverModuleActions
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)} thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]} 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)} onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLesson(lesson)} onDelete={() => setDeletingLesson(lesson)}
description={lesson.description} 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 ?? "")}`, `/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}
/> />
))} ))}
</div> </div>
@ -465,12 +593,66 @@ export function ModuleDetailPage() {
</div> </div>
</div> </div>
{/* Practice Cards Grid */} {practicesLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
{practices.map((practice) => ( Loading practices
<PracticeCard key={practice.id} {...practice} /> </div>
))} ) : practicesLoadError ? (
</div> <div className="rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900 max-w-lg mx-auto">
{practicesLoadError}
</div>
) : filteredPractices.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{filteredPractices.map((practice) => (
<ModulePracticeCard
key={practice.id}
practice={practice}
statusUpdating={publishStatusPracticeId === practice.id}
onEdit={() =>
navigate(
`/content/practices?type=module&id=${moduleId}`,
)
}
onPublish={() => void handlePublishPractice(practice.id)}
onSaveAsDraft={() =>
void handleSavePracticeAsDraft(practice.id)
}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
<div className="h-14 w-14 rounded-full bg-[#F5EBFF] flex items-center justify-center">
<Calendar className="h-7 w-7 text-brand-500" />
</div>
</div>
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
{practices.length === 0
? "No practices in this module yet"
: "No practices match this filter"}
</h2>
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
{practices.length === 0
? "Add a practice to give learners speaking exercises for this module."
: "Try another status filter or add a new practice."}
</p>
{practices.length === 0 ? (
<Button
variant="outline"
className="h-12 px-8 rounded-xl border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
onClick={() =>
navigate(
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}`,
)
}
>
<Calendar className="h-5 w-5" />
Add Practice
</Button>
) : null}
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -512,6 +694,28 @@ export function ModuleDetailPage() {
disabled={savingLessonEdit} disabled={savingLessonEdit}
/> />
</div> </div>
<div className="space-y-2">
<label
className="text-sm font-medium text-grayScale-700"
htmlFor="edit-lesson-sort-order"
>
Sort order
</label>
<Input
id="edit-lesson-sort-order"
type="number"
inputMode="numeric"
min={0}
step={1}
value={editLessonSortOrder}
onChange={(e) => setEditLessonSortOrder(e.target.value)}
disabled={savingLessonEdit}
className="max-w-[200px]"
/>
<p className="text-xs text-grayScale-500">
Whole number, 0 or greater.
</p>
</div>
<LessonMediaUploadField <LessonMediaUploadField
kind="video" kind="video"
value={editLessonVideoUrl} value={editLessonVideoUrl}
@ -620,68 +824,3 @@ export function ModuleDetailPage() {
</div> </div>
); );
} }
function PracticeCard({
title,
level,
variations,
status,
}: {
title: string;
level: string;
variations: number;
status: string;
}) {
return (
<div className="bg-white rounded-[24px] border border-grayScale-50 shadow-sm overflow-hidden hover:shadow-xl hover:shadow-grayScale-400/5 transition-all group p-6 flex flex-col h-full min-h-[340px]">
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-[18px] font-bold text-grayScale-900 line-clamp-1">
{title}
</h3>
</div>
<div className="flex items-center justify-between gap-3">
<span className="bg-[#22C55E] text-white text-[11px] font-bold px-2 py-1 rounded-[4px]">
{level}
</span>
<div className="flex items-center gap-1.5 text-grayScale-500">
<Mic className="h-4 w-4" />
<span className="text-[13px] font-bold">Speaking</span>
</div>
</div>
<div className="flex items-center gap-2.5 text-brand-400 w-fit py-2 rounded-xl">
<Layers className="h-4 w-4" />
<span className="text-[14px] font-bold">{variations} Variations</span>
</div>
<div className="flex border-t border-grayScale-200 items-center justify-between pt-2">
<div className="bg-grayScale-100 text-grayScale-400 text-[11px] font-bold px-3 py-1.5 rounded-[6px] tracking-wide uppercase">
{status}
</div>
<div className="flex items-center gap-3">
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-brand-500 hover:border-brand-100 transition-all">
<Edit2 className="h-5 w-5" />
</button>
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-red-500 hover:border-red-100 transition-all">
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</div>
<div className="mt-8 grid grid-cols-2 gap-3">
<Button className="bg-brand-500 text-white rounded-xl h-11 text-[13px] font-bold shadow-md shadow-brand-500/10 hover:bg-brand-600 transition-all px-0">
Publish Practice
</Button>
<Button
variant="outline"
className="border-brand-500 text-brand-500 rounded-xl h-11 text-[13px] font-bold bg-white hover:bg-brand-50 transition-all px-0"
>
Publish Video
</Button>
</div>
</div>
);
}

View File

@ -7,14 +7,31 @@ export function NewContentPage() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Header section */} {/* Header section */}
<div> <div className="flex items-start justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700"> <div>
Content Management <h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
</h1> Content Management
<p className="mt-1 text-sm text-grayScale-500"> </h1>
Upload, organize, and manage learning content across programs and <p className="mt-1 text-sm text-grayScale-500">
courses Upload, organize, and manage learning content across programs and
</p> courses
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-3">
<Link to="/new-content/question-types">
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all">
Manage Question Types
</Button>
</Link>
<Link to="/new-content/reorder">
<Button
variant="outline"
className="h-10 px-6 rounded-[6px] border-brand-500 font-bold text-brand-500 hover:bg-brand-50 transition-all"
>
Reorder Content
</Button>
</Link>
</div>
</div> </div>
{/* Gradient Divider */} {/* Gradient Divider */}

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; 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 { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Card, CardContent } from "../../components/ui/card"; import { Card, CardContent } from "../../components/ui/card";
@ -14,7 +14,6 @@ import {
DialogTrigger, DialogTrigger,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import uploadIcon from "../../assets/icons/upload.png"; import uploadIcon from "../../assets/icons/upload.png";
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"; import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
import alertSrc from "../../assets/Alert.svg"; import alertSrc from "../../assets/Alert.svg";
@ -52,7 +51,6 @@ export function ProgramCoursesPage() {
null, null,
); );
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editSortOrder, setEditSortOrder] = useState(""); const [editSortOrder, setEditSortOrder] = useState("");
const [editThumbnail, setEditThumbnail] = useState(""); const [editThumbnail, setEditThumbnail] = useState("");
const [savingEdit, setSavingEdit] = useState(false); const [savingEdit, setSavingEdit] = useState(false);
@ -61,7 +59,6 @@ export function ProgramCoursesPage() {
const [createCourseOpen, setCreateCourseOpen] = useState(false); const [createCourseOpen, setCreateCourseOpen] = useState(false);
const [createName, setCreateName] = useState(""); const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createSortOrder, setCreateSortOrder] = useState(""); const [createSortOrder, setCreateSortOrder] = useState("");
const [createThumbnail, setCreateThumbnail] = useState(""); const [createThumbnail, setCreateThumbnail] = useState("");
const [createSaving, setCreateSaving] = useState(false); const [createSaving, setCreateSaving] = useState(false);
@ -136,7 +133,6 @@ export function ProgramCoursesPage() {
const openEditCourse = (course: ProgramCourseListItem) => { const openEditCourse = (course: ProgramCourseListItem) => {
setEditingCourse(course); setEditingCourse(course);
setEditName(course.name ?? ""); setEditName(course.name ?? "");
setEditDescription(course.description?.trim() ?? "");
setEditThumbnail( setEditThumbnail(
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "", course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
); );
@ -146,7 +142,6 @@ export function ProgramCoursesPage() {
const closeEditCourse = () => { const closeEditCourse = () => {
setEditingCourse(null); setEditingCourse(null);
setEditName(""); setEditName("");
setEditDescription("");
setEditSortOrder(""); setEditSortOrder("");
setEditThumbnail(""); setEditThumbnail("");
setUploadingEditThumbnail(false); setUploadingEditThumbnail(false);
@ -211,7 +206,7 @@ export function ProgramCoursesPage() {
try { try {
await updateTopLevelCourse(editingCourse.id, { await updateTopLevelCourse(editingCourse.id, {
name, name,
description: editDescription.trim(), description: editingCourse.description?.trim() ?? "",
thumbnail: editThumbnail.trim(), thumbnail: editThumbnail.trim(),
sort_order, sort_order,
}); });
@ -231,7 +226,6 @@ export function ProgramCoursesPage() {
const clearCreateCourseForm = () => { const clearCreateCourseForm = () => {
setCreateName(""); setCreateName("");
setCreateDescription("");
setCreateSortOrder(""); setCreateSortOrder("");
setCreateThumbnail(""); setCreateThumbnail("");
setCreateUploadingThumbnail(false); setCreateUploadingThumbnail(false);
@ -302,7 +296,7 @@ export function ProgramCoursesPage() {
try { try {
await createProgramCourse(programId, { await createProgramCourse(programId, {
name, name,
description: createDescription.trim(), description: "",
thumbnail: createThumbnail.trim(), thumbnail: createThumbnail.trim(),
sort_order, sort_order,
}); });
@ -365,18 +359,6 @@ export function ProgramCoursesPage() {
<div className="flex gap-3"> <div className="flex gap-3">
{programIdValid ? ( {programIdValid ? (
<> <>
<Link
to={`/new-content/learn-english/${programIdParam}/courses/add-practice`}
>
<Button
variant="outline"
className="rounded-[6px] border-brand-500 text-brand-500 "
>
<FileText className="mr-2 h-4 w-4" />
Add Practice
</Button>
</Link>
<Dialog <Dialog
open={createCourseOpen} open={createCourseOpen}
onOpenChange={handleCreateCourseDialogOpenChange} onOpenChange={handleCreateCourseDialogOpenChange}
@ -449,20 +431,6 @@ export function ProgramCoursesPage() {
/> />
</div> </div>
<div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700">
Description
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Short summary of the course"
rows={3}
className="min-h-[88px] resize-y rounded-xl"
disabled={createSaving || createUploadingThumbnail}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label <label
htmlFor="create-course-sort-order" htmlFor="create-course-sort-order"
@ -740,7 +708,7 @@ export function ProgramCoursesPage() {
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12"> <DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit course</DialogTitle> <DialogTitle>Edit course</DialogTitle>
<DialogDescription> <DialogDescription>
Update name, description, sort order, and thumbnail. Saved with{" "} Update name, sort order, and thumbnail. Saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]"> <code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /courses/:id PUT /courses/:id
</code> </code>
@ -761,19 +729,6 @@ export function ProgramCoursesPage() {
disabled={savingEdit || uploadingEditThumbnail} disabled={savingEdit || uploadingEditThumbnail}
/> />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Description
</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[100px] resize-y rounded-xl"
placeholder="Short summary"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label <label
htmlFor="edit-course-sort-order" htmlFor="edit-course-sort-order"

View File

@ -13,7 +13,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card"; import { Card } from "../../components/ui/card";
import { Textarea } from "../../components/ui/textarea";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -39,7 +38,6 @@ export function ProgramDetailPage() {
const { programType } = useParams<{ programType: string }>(); const { programType } = useParams<{ programType: string }>();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState(""); const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createThumbnail, setCreateThumbnail] = useState(""); const [createThumbnail, setCreateThumbnail] = useState("");
const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false); const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@ -60,7 +58,6 @@ export function ProgramDetailPage() {
const [catalogLoading, setCatalogLoading] = useState(false); const [catalogLoading, setCatalogLoading] = useState(false);
const [editingCourseId, setEditingCourseId] = useState<number | null>(null); const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState(""); const [editThumbnail, setEditThumbnail] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1"); const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false); const [savingEdit, setSavingEdit] = useState(false);
@ -216,7 +213,7 @@ export function ProgramDetailPage() {
const response = await createExamPrepCatalogCourse({ const response = await createExamPrepCatalogCourse({
name, name,
description: createDescription.trim() || null, description: null,
thumbnail: thumbnailToSend, thumbnail: thumbnailToSend,
}); });
const row = response.data?.data; const row = response.data?.data;
@ -227,7 +224,7 @@ export function ProgramDetailPage() {
{ {
id: row.id, id: row.id,
name: row.name ?? name, name: row.name ?? name,
description: row.description?.trim() || createDescription.trim() || "—", description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || null, thumbnail: row.thumbnail?.trim() || null,
sortOrder: Number(row.sort_order ?? 0), sortOrder: Number(row.sort_order ?? 0),
unitsCount: Number(row.units_count ?? 0), unitsCount: Number(row.units_count ?? 0),
@ -239,7 +236,6 @@ export function ProgramDetailPage() {
await loadCatalogCourses(); await loadCatalogCourses();
toast.success("Course created"); toast.success("Course created");
setCreateName(""); setCreateName("");
setCreateDescription("");
setCreateThumbnail(""); setCreateThumbnail("");
setCreateThumbnailFromUpload(false); setCreateThumbnailFromUpload(false);
setCreateOpen(false); setCreateOpen(false);
@ -259,7 +255,6 @@ export function ProgramDetailPage() {
if (!Number.isFinite(idNum)) return; if (!Number.isFinite(idNum)) return;
setEditingCourseId(idNum); setEditingCourseId(idNum);
setEditName(String(course.name ?? "")); setEditName(String(course.name ?? ""));
setEditDescription(String(course.description ?? ""));
setEditThumbnail(String(course.thumbnail ?? "")); setEditThumbnail(String(course.thumbnail ?? ""));
setEditSortOrder(String(course.sort_order ?? 1)); setEditSortOrder(String(course.sort_order ?? 1));
}; };
@ -268,7 +263,6 @@ export function ProgramDetailPage() {
if (savingEdit || uploadingEditThumbnail) return; if (savingEdit || uploadingEditThumbnail) return;
setEditingCourseId(null); setEditingCourseId(null);
setEditName(""); setEditName("");
setEditDescription("");
setEditThumbnail(""); setEditThumbnail("");
setEditSortOrder("1"); setEditSortOrder("1");
}; };
@ -317,9 +311,14 @@ export function ProgramDetailPage() {
setSavingEdit(true); setSavingEdit(true);
try { try {
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail); 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, { const response = await updateExamPrepCatalogCourse(editingCourseId, {
name, name,
description: editDescription.trim() || null, description: preservedDescription,
thumbnail: minioThumbnail || null, thumbnail: minioThumbnail || null,
sort_order: sortOrderNum, sort_order: sortOrderNum,
}); });
@ -330,7 +329,7 @@ export function ProgramDetailPage() {
? { ? {
...course, ...course,
name: row?.name ?? name, name: row?.name ?? name,
description: row?.description?.trim() || editDescription.trim() || "—", description: row?.description?.trim() || preservedDescription || "—",
thumbnail: row?.thumbnail?.trim() || null, thumbnail: row?.thumbnail?.trim() || null,
sortOrder: Number(row?.sort_order ?? sortOrderNum), sortOrder: Number(row?.sort_order ?? sortOrderNum),
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0), unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
@ -467,20 +466,6 @@ export function ProgramDetailPage() {
/> />
</div> </div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Description
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Optional description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={creating}
/>
</div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800"> <label className="text-[15px] text-grayScale-800">
Thumbnail Thumbnail
@ -735,17 +720,6 @@ export function ProgramDetailPage() {
/> />
</div> </div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label> <label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input <Input

View File

@ -1,26 +1,18 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { GraduationCap, Brain } from "lucide-react"; import { GraduationCap, Brain } from "lucide-react";
import { Button } from "../../components/ui/button";
export function ProgramTypeSelectionPage() { export function ProgramTypeSelectionPage() {
return ( return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500"> <div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Header section */} {/* Header section */}
<div className="flex items-start justify-between"> <div className="space-y-1.5 pt-2">
<div className="space-y-1.5 pt-2"> <h1 className="text-[28px] font-bold tracking-tight text-grayScale-900">
<h1 className="text-[28px] font-bold tracking-tight text-grayScale-900"> Courses
Courses </h1>
</h1> <p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500"> Organize courses under skill-based learning or English proficiency
Organize courses under skill-based learning or English proficiency exams. Select a program type to manage curriculum and modules.
exams. Select a program type to manage curriculum and modules. </p>
</p>
</div>
<Link to="/new-content/question-types">
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2 mt-4">
Manage Question Types
</Button>
</Link>
</div> </div>
{/* Gradient Divider */} {/* Gradient Divider */}

View File

@ -113,11 +113,11 @@ export function QuestionTypeLibraryPage() {
<div className="space-y-8 animate-in fade-in duration-500 pb-20"> <div className="space-y-8 animate-in fade-in duration-500 pb-20">
<div className="space-y-6"> <div className="space-y-6">
<Link <Link
to="/new-content/courses" to="/new-content"
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit" className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
> >
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" /> <ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Courses Back to Content Management
</Link> </Link>
<div className="flex items-start justify-between gap-4 flex-wrap"> <div className="flex items-start justify-between gap-4 flex-wrap">

View File

@ -0,0 +1,31 @@
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { ContentHierarchyList } from "./components/ContentHierarchyList";
export function ReorderContentPage() {
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
<div className="space-y-6">
<Link
to="/new-content"
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
>
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Content Management
</Link>
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
Reorder Content
</h1>
<p className="max-w-2xl text-sm text-grayScale-500">
Drag and drop programs, courses, modules, and lessons to change
their display order.
</p>
</div>
</div>
<ContentHierarchyList />
</div>
);
}

View File

@ -13,7 +13,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card"; import { Card } from "../../components/ui/card";
import { Textarea } from "../../components/ui/textarea";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -55,7 +54,6 @@ export function UnitManagementPage() {
const parsedUnitId = Number(unitId); const parsedUnitId = Number(unitId);
const [addModuleOpen, setAddModuleOpen] = useState(false); const [addModuleOpen, setAddModuleOpen] = useState(false);
const [createName, setCreateName] = useState(""); const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createThumbnail, setCreateThumbnail] = useState(""); const [createThumbnail, setCreateThumbnail] = useState("");
const [createIcon, setCreateIcon] = useState(""); const [createIcon, setCreateIcon] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@ -79,7 +77,6 @@ export function UnitManagementPage() {
>([]); >([]);
const [editingModuleId, setEditingModuleId] = useState<number | null>(null); const [editingModuleId, setEditingModuleId] = useState<number | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState(""); const [editThumbnail, setEditThumbnail] = useState("");
const [editIcon, setEditIcon] = useState(""); const [editIcon, setEditIcon] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1"); const [editSortOrder, setEditSortOrder] = useState("1");
@ -159,7 +156,6 @@ export function UnitManagementPage() {
const clearCreateModuleForm = () => { const clearCreateModuleForm = () => {
setCreateName(""); setCreateName("");
setCreateDescription("");
setCreateThumbnail(""); setCreateThumbnail("");
setCreateIcon(""); setCreateIcon("");
if (createThumbnailFileInputRef.current) { if (createThumbnailFileInputRef.current) {
@ -264,7 +260,7 @@ export function UnitManagementPage() {
const minioIcon = await resolveToMinioUrl(createIcon); const minioIcon = await resolveToMinioUrl(createIcon);
await createExamPrepUnitModule(parsedUnitId, { await createExamPrepUnitModule(parsedUnitId, {
name, name,
description: createDescription.trim() || null, description: null,
thumbnail: minioThumbnail || null, thumbnail: minioThumbnail || null,
icon: minioIcon || null, icon: minioIcon || null,
}); });
@ -286,7 +282,6 @@ export function UnitManagementPage() {
const openEditModule = (module: (typeof modules)[number]) => { const openEditModule = (module: (typeof modules)[number]) => {
setEditingModuleId(module.id); setEditingModuleId(module.id);
setEditName(module.name ?? ""); setEditName(module.name ?? "");
setEditDescription(module.description ?? "");
setEditThumbnail(module.thumbnail ?? ""); setEditThumbnail(module.thumbnail ?? "");
setEditIcon(module.icon ?? ""); setEditIcon(module.icon ?? "");
setEditSortOrder(String(module.sortOrder ?? 1)); setEditSortOrder(String(module.sortOrder ?? 1));
@ -296,7 +291,6 @@ export function UnitManagementPage() {
if (savingEdit || uploadingEditThumbnail || uploadingEditIcon) return; if (savingEdit || uploadingEditThumbnail || uploadingEditIcon) return;
setEditingModuleId(null); setEditingModuleId(null);
setEditName(""); setEditName("");
setEditDescription("");
setEditThumbnail(""); setEditThumbnail("");
setEditIcon(""); setEditIcon("");
setEditSortOrder("1"); setEditSortOrder("1");
@ -391,11 +385,16 @@ export function UnitManagementPage() {
setSavingEdit(true); setSavingEdit(true);
try { try {
const existing = modules.find((m) => m.id === editingModuleId);
const preservedDescription =
existing?.description && existing.description !== "—"
? existing.description
: null;
const minioThumbnail = await resolveToMinioUrl(editThumbnail); const minioThumbnail = await resolveToMinioUrl(editThumbnail);
const minioIcon = await resolveToMinioUrl(editIcon); const minioIcon = await resolveToMinioUrl(editIcon);
await updateExamPrepUnitModule(editingModuleId, { await updateExamPrepUnitModule(editingModuleId, {
name, name,
description: editDescription.trim() || null, description: preservedDescription,
thumbnail: minioThumbnail || null, thumbnail: minioThumbnail || null,
icon: minioIcon || null, icon: minioIcon || null,
sort_order: sortOrderNum, sort_order: sortOrderNum,
@ -489,20 +488,6 @@ export function UnitManagementPage() {
/> />
</div> </div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Description
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Optional module description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={creating || uploadingThumbnail || uploadingIcon}
/>
</div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label> <label className="text-[15px] text-grayScale-800">Thumbnail</label>
<input <input
@ -812,16 +797,6 @@ export function UnitManagementPage() {
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon} disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
/> />
</div> </div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
/>
</div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label> <label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input <Input

View File

@ -9,7 +9,6 @@ import {
DialogClose, DialogClose,
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { Textarea } from "../../../components/ui/textarea";
import { toast } from "sonner"; import { toast } from "sonner";
import { createTopLevelCourseModule } from "../../../api/courses.api"; import { createTopLevelCourseModule } from "../../../api/courses.api";
import { ModuleIconUploadField } from "./ModuleIconUploadField"; import { ModuleIconUploadField } from "./ModuleIconUploadField";
@ -28,7 +27,6 @@ export function AddModuleModal({
onCreated, onCreated,
}: AddModuleModalProps) { }: AddModuleModalProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [sortOrder, setSortOrder] = useState(""); const [sortOrder, setSortOrder] = useState("");
const [icon, setIcon] = useState(""); const [icon, setIcon] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@ -37,7 +35,6 @@ export function AddModuleModal({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setName(""); setName("");
setDescription("");
setSortOrder(""); setSortOrder("");
setIcon(""); setIcon("");
setSubmitting(false); setSubmitting(false);
@ -47,7 +44,6 @@ export function AddModuleModal({
const resetAndClose = () => { const resetAndClose = () => {
setName(""); setName("");
setDescription("");
setSortOrder(""); setSortOrder("");
setIcon(""); setIcon("");
setIconUploadBusy(false); setIconUploadBusy(false);
@ -86,7 +82,7 @@ export function AddModuleModal({
try { try {
await createTopLevelCourseModule(courseId, { await createTopLevelCourseModule(courseId, {
name: trimmedName, name: trimmedName,
description: description.trim(), description: "",
icon: icon.trim(), icon: icon.trim(),
sort_order, sort_order,
}); });
@ -157,20 +153,6 @@ export function AddModuleModal({
/> />
</div> </div>
<div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700">
Description
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Learn to introduce yourself and talk about your life."
className="min-h-[88px] resize-y rounded-xl"
disabled={submitting}
rows={3}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label <label
htmlFor="create-module-sort-order" htmlFor="create-module-sort-order"

View File

@ -0,0 +1,104 @@
import { useMemo } from "react";
import {
PracticeSequentialReview,
type PracticeReviewQuestion,
} from "./practice-steps/PracticeSequentialReview";
import type { PersonaCardModel } from "../../../lib/personaDisplay";
type IntroVideoPreview =
| { kind: "vimeo"; url: string }
| { kind: "video"; url: string }
| null;
function plainTextFromHtml(raw: string): string {
if (!raw.trim()) return "";
if (!/<\/?[a-z][\s\S]*>/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 (
<PracticeSequentialReview
practiceTitle={practiceTitle}
thumbnailUrl={
introVideoPreview?.kind === "video" ? introVideoPreview.url : null
}
thumbnailKind={thumbnailKind}
persona={persona ?? null}
metadata={[
{ label: "Program", value: selectedProgram },
{ label: "Course", value: selectedCourse },
{ label: "Module", value: moduleLabel },
]}
guidanceText={guidanceText}
questions={questions}
saving={saving}
saveError={saveError}
onEditContext={onEditContext}
onEditQuestions={onEditQuestions}
onBack={onBack}
onSaveDraft={onSaveDraft}
onPublish={onPublish}
/>
);
}

View File

@ -16,7 +16,6 @@ import type {
PracticeParentKind, PracticeParentKind,
PracticePublishStatus, PracticePublishStatus,
} from "../../../types/course.types" } from "../../../types/course.types"
import { PublishStatusField } from "./practice-steps/PublishStatusField"
import { cn } from "../../../lib/utils" import { cn } from "../../../lib/utils"
import { SpinnerIcon } from "../../../components/ui/spinner-icon" import { SpinnerIcon } from "../../../components/ui/spinner-icon"
@ -65,7 +64,8 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
const [storyDescription, setStoryDescription] = useState("") const [storyDescription, setStoryDescription] = useState("")
const [storyImage, setStoryImage] = useState("") const [storyImage, setStoryImage] = useState("")
const [quickTips, setQuickTips] = useState("") const [quickTips, setQuickTips] = useState("")
const [publishStatus, setPublishStatus] = useState<PracticePublishStatus>("DRAFT") const [pendingSaveStatus, setPendingSaveStatus] =
useState<PracticePublishStatus | null>(null)
const canUseWizard = parent != null const canUseWizard = parent != null
@ -85,7 +85,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
setStoryDescription("") setStoryDescription("")
setStoryImage("") setStoryImage("")
setQuickTips("") setQuickTips("")
setPublishStatus("DRAFT") setPendingSaveStatus(null)
}, []) }, [])
const handleStep1 = async () => { const handleStep1 = async () => {
@ -186,6 +186,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
toast.error("Title, story description, and story image are required") toast.error("Title, story description, and story image are required")
return return
} }
setPendingSaveStatus(status)
setSaving(true) setSaving(true)
try { try {
await createParentLinkedPractice({ await createParentLinkedPractice({
@ -208,6 +209,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
toast.error(err.response?.data?.message || err.message || "Failed to create practice") toast.error(err.response?.data?.message || err.message || "Failed to create practice")
} finally { } finally {
setSaving(false) setSaving(false)
setPendingSaveStatus(null)
} }
} }
@ -473,11 +475,6 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
disabled={saving} disabled={saving}
/> />
</div> </div>
<PublishStatusField
value={publishStatus}
onChange={setPublishStatus}
disabled={saving}
/>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => setStep(3)} disabled={saving}> <Button type="button" variant="outline" onClick={() => setStep(3)} disabled={saving}>
<ChevronLeft className="mr-1 h-4 w-4" /> <ChevronLeft className="mr-1 h-4 w-4" />
@ -487,12 +484,9 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
type="button" type="button"
variant="outline" variant="outline"
disabled={saving} disabled={saving}
onClick={() => { onClick={() => void handleStep4("DRAFT")}
setPublishStatus("DRAFT")
void handleStep4("DRAFT")
}}
> >
{saving && publishStatus === "DRAFT" ? ( {saving && pendingSaveStatus === "DRAFT" ? (
<SpinnerIcon className="h-4 w-4" /> <SpinnerIcon className="h-4 w-4" />
) : null} ) : null}
Save as draft Save as draft
@ -500,12 +494,9 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
<Button <Button
type="button" type="button"
disabled={saving} disabled={saving}
onClick={() => { onClick={() => void handleStep4("PUBLISHED")}
setPublishStatus("PUBLISHED")
void handleStep4("PUBLISHED")
}}
> >
{saving && publishStatus === "PUBLISHED" ? ( {saving && pendingSaveStatus === "PUBLISHED" ? (
<SpinnerIcon className="h-4 w-4" /> <SpinnerIcon className="h-4 w-4" />
) : null} ) : null}
Publish practice Publish practice

View File

@ -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 (
<Card className="group flex flex-col overflow-hidden rounded-[20px] border border-grayScale-50 bg-white shadow-sm transition-all hover:shadow-xl hover:shadow-grayScale-400/5">
<div className="relative h-44 w-full overflow-hidden bg-gradient-to-br from-[#E0F2FE] to-[#BFDBFE]">
{thumbnailSrc && !thumbFailed ? (
<ResolvedImage
src={thumbnailSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover"
onError={() => setThumbFailed(true)}
/>
) : null}
</div>
<div className="flex flex-1 flex-col space-y-5 p-5">
<div className="flex items-center justify-between gap-2">
<div
className={cn(
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider",
isPublished
? "border-[#DCFCE7] bg-[#F0FDF4] text-[#16A34A]"
: "border-grayScale-100 bg-grayScale-50 text-grayScale-400",
)}
>
<div
className={cn(
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
isPublished ? "bg-[#16A34A]" : "bg-grayScale-300",
)}
/>
{statusLabel}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0 rounded-full text-grayScale-400 hover:bg-grayScale-50 hover:text-grayScale-600"
disabled={statusUpdating}
aria-label={`Practice options: ${practice.title}`}
onClick={(e) => e.stopPropagation()}
>
{statusUpdating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={statusUpdating}
onClick={(e) => {
e.stopPropagation();
if (isPublished) {
onSaveAsDraft?.();
} else {
onPublish?.();
}
}}
>
{isPublished ? "Save as draft" : "Publish practice"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<h3 className="line-clamp-3 min-h-[2.75rem] text-[14px] font-bold leading-snug text-[#0F172A]">
{practice.title}
</h3>
<div className="mt-auto grid grid-cols-1 gap-2 pt-2">
<Button
type="button"
variant="outline"
className="h-10 w-full rounded-[10px] border-brand-500 text-[12px] font-bold text-brand-500 hover:bg-brand-50"
onClick={(e) => {
e.stopPropagation();
onEdit?.();
}}
>
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
<Button
type="button"
disabled={isPublished || statusUpdating}
className={cn(
"h-10 w-full rounded-[10px] text-[12px] font-bold shadow-sm transition-all",
isPublished
? "cursor-default bg-[#ECD5E9] text-[#9E2891] hover:bg-[#ECD5E9]"
: "bg-brand-500 text-white hover:bg-brand-600",
)}
onClick={(e) => {
e.stopPropagation();
if (!isPublished) onPublish?.();
}}
>
{statusUpdating
? "Updating…"
: isPublished
? "Published"
: "Publish"}
</Button>
</div>
</div>
</Card>
);
}

View File

@ -5,6 +5,7 @@ import {
getPracticesByParentCourse, getPracticesByParentCourse,
getPracticesByParentModule, getPracticesByParentModule,
publishParentLinkedPractice, publishParentLinkedPractice,
updateParentLinkedPractice,
} from "../../../api/courses.api" } from "../../../api/courses.api"
import type { PracticeParentKind } from "../../../types/course.types" import type { PracticeParentKind } from "../../../types/course.types"
import { Button } from "../../../components/ui/button" import { Button } from "../../../components/ui/button"
@ -29,7 +30,7 @@ export function PublishPracticeButton({
onPublished, onPublished,
}: Props) { }: Props) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [publishing, setPublishing] = useState(false) const [acting, setActing] = useState(false)
const [hasDraft, setHasDraft] = useState(false) const [hasDraft, setHasDraft] = useState(false)
const [allPublished, setAllPublished] = useState(false) const [allPublished, setAllPublished] = useState(false)
const [hasPractice, setHasPractice] = useState(false) const [hasPractice, setHasPractice] = useState(false)
@ -68,9 +69,11 @@ export function PublishPracticeButton({
void loadPractices() void loadPractices()
}, [loadPractices]) }, [loadPractices])
const isDraftMode = allPublished
const handlePublish = async () => { const handlePublish = async () => {
if (!Number.isFinite(parentId) || parentId < 1) return if (!Number.isFinite(parentId) || parentId < 1) return
setPublishing(true) setActing(true)
try { try {
const res = const res =
parentKind === "COURSE" parentKind === "COURSE"
@ -98,34 +101,82 @@ export function PublishPracticeButton({
?.message ?? "Failed to publish practice" ?.message ?? "Failed to publish practice"
toast.error(msg) toast.error(msg)
} finally { } 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 = const disabled =
loading || publishing || !hasPractice || !hasDraft || allPublished loading ||
acting ||
!hasPractice ||
(!hasDraft && !allPublished)
let label = "Publish Practice" let label = "Publish Practice"
if (loading) label = "Loading…" if (loading) label = "Loading…"
else if (publishing) label = "Publishing…" else if (acting) label = isDraftMode ? "Saving…" : "Publishing…"
else if (!hasPractice) label = "No practice" else if (allPublished) label = "Save as Draft"
else if (allPublished) label = "Published"
const handleClick = () => {
if (isDraftMode) {
void handleSaveAsDraft()
return
}
void handlePublish()
}
return ( return (
<Button <Button
type="button" type="button"
className={cn(className)} className={cn(className)}
disabled={disabled} disabled={disabled}
onClick={() => void handlePublish()} onClick={handleClick}
title={ title={
allPublished !hasPractice
? "Practice is already published" ? "No practice linked to this course yet"
: !hasPractice : allPublished
? "No practice linked to this item yet" ? "Move published practice back to draft"
: undefined : hasDraft
? "Publish draft practice"
: undefined
} }
> >
{(loading || publishing) && ( {(loading || acting) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
)} )}
{label} {label}

View File

@ -3,12 +3,19 @@ import {
BookOpen, BookOpen,
Calendar, Calendar,
Edit2, Edit2,
Loader2,
MoreVertical, MoreVertical,
Pencil, Pencil,
Play, Play,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -21,17 +28,46 @@ import {
applyShortPreviewToEmbedUrl, applyShortPreviewToEmbedUrl,
DEFAULT_PREVIEW_MAX_SECONDS, DEFAULT_PREVIEW_MAX_SECONDS,
formatPreviewLength, formatPreviewLength,
formatVideoDurationLabel,
getVideoPreview, getVideoPreview,
isDirectVideoFileUrl,
} from "../../../lib/videoPreview"; } from "../../../lib/videoPreview";
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo"; 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 { interface VideoCardProps {
id?: string | number; id?: string | number;
title: string; title: string;
/** Omits the duration chip when not provided (e.g. API has no length yet). */ /** Omits the duration chip when not provided (e.g. API has no length yet). */
duration?: string; 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. */ /** When omitted, shows a neutral "Lesson" chip and no Publish button. */
status?: "Draft" | "Published"; status?: "Draft" | "Published";
/** From GET lesson list — preferred for module lesson cards (`PUBLISHED` / `DRAFT`). */
publishStatus?: PracticePublishStatus | string | null;
thumbnailGradient?: string; thumbnailGradient?: string;
thumbnailUrl?: string | null; thumbnailUrl?: string | null;
/** /**
@ -51,6 +87,9 @@ interface VideoCardProps {
/** When set with hoverModuleActions, shows a book icon next to edit/delete on thumbnail hover. */ /** When set with hoverModuleActions, shows a book icon next to edit/delete on thumbnail hover. */
onViewPractices?: () => void; onViewPractices?: () => void;
onPublish?: () => 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. */ /** Shown under title on module lesson cards; reserved height keeps grid rows even. */
description?: string | null; description?: string | null;
} }
@ -58,7 +97,9 @@ interface VideoCardProps {
export function VideoCard({ export function VideoCard({
title, title,
duration, duration,
durationSeconds,
status, status,
publishStatus,
thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]", thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]",
thumbnailUrl, thumbnailUrl,
videoUrl, videoUrl,
@ -67,10 +108,15 @@ export function VideoCard({
onPublish, onPublish,
onAddPractice, onAddPractice,
onViewPractices, onViewPractices,
onTogglePublishStatus,
publishStatusUpdating = false,
hoverModuleActions = false, hoverModuleActions = false,
description, description,
}: VideoCardProps) { }: VideoCardProps) {
const [thumbFailed, setThumbFailed] = useState(false); const [thumbFailed, setThumbFailed] = useState(false);
const [probedDurationSeconds, setProbedDurationSeconds] = useState<
number | null
>(null);
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
/** Iframe players ignore URL limits in many cases — unmount after real time. */ /** Iframe players ignore URL limits in many cases — unmount after real time. */
const [iframeSessionDone, setIframeSessionDone] = useState(false); const [iframeSessionDone, setIframeSessionDone] = useState(false);
@ -92,6 +138,77 @@ export function VideoCard({
const previewLengthLabel = formatPreviewLength( const previewLengthLabel = formatPreviewLength(
DEFAULT_PREVIEW_MAX_SECONDS, 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(() => { useEffect(() => {
if (!previewOpen) { if (!previewOpen) {
@ -198,10 +315,10 @@ export function VideoCard({
onError={() => setThumbFailed(true)} onError={() => setThumbFailed(true)}
/> />
) : null} ) : null}
{/* Duration Badge */} {/* Duration — bottom-right on thumbnail */}
{duration ? ( {durationLabel ? (
<div className="absolute bottom-3 right-3 z-10 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm"> <div className="pointer-events-none absolute bottom-2 right-2 z-[12] rounded bg-black/75 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-white shadow-sm backdrop-blur-sm">
{duration} {durationLabel}
</div> </div>
) : null} ) : null}
{/* Play: opens preview dialog when videoUrl is set */} {/* Play: opens preview dialog when videoUrl is set */}
@ -333,34 +450,69 @@ export function VideoCard({
<div <div
className={cn( className={cn(
"mb-4 flex shrink-0 items-center gap-2", "mb-4 flex shrink-0 items-center gap-2",
hoverModuleActions ? "justify-start" : "justify-between", "justify-between",
)} )}
> >
{/* Status Badge */} {/* Publish status badge */}
{status ? ( {publishBadge ? (
<div <div
className={cn( className={cn(
"flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border min-w-0", "flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-wider",
status === "Published" publishBadge.isPublished
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]" ? "border-[#D1FAE5] bg-[#ECFDF5] text-[#059669]"
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]", : "border-[#E5E7EB] bg-grayScale-50 text-grayScale-500",
)} )}
> >
<div <div
className={cn( className={cn(
"h-1.5 w-1.5 rounded-full flex-shrink-0", "h-1.5 w-1.5 flex-shrink-0 rounded-full",
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]", publishBadge.isPublished ? "bg-[#10B981]" : "bg-[#9CA3AF]",
)} )}
/> />
{status} {publishBadge.label}
</div> </div>
) : ( ) : (
<div className="flex min-w-0 items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border border-[#E5E7EB] bg-grayScale-50 text-grayScale-500"> <div className="flex min-w-0 items-center gap-1.5 rounded-full border border-[#E5E7EB] bg-grayScale-50 px-3 py-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-500">
<div className="h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#9CA3AF]" /> <div className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[#9CA3AF]" />
Lesson Lesson
</div> </div>
)} )}
{!hoverModuleActions ? ( {hoverModuleActions && onTogglePublishStatus ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0 rounded-full text-grayScale-400 hover:bg-grayScale-50 hover:text-grayScale-600"
disabled={publishStatusUpdating}
aria-label={`Lesson options: ${title}`}
onClick={(e) => e.stopPropagation()}
>
{publishStatusUpdating ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<MoreVertical className="h-5 w-5" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={publishStatusUpdating}
onClick={(e) => {
e.stopPropagation();
onTogglePublishStatus(
publishBadge?.isPublished ? "DRAFT" : "PUBLISHED",
);
}}
>
{publishBadge?.isPublished
? "Save as draft"
: "Publish lesson"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : !hoverModuleActions ? (
<button <button
type="button" type="button"
className="h-8 w-8 flex flex-shrink-0 items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400" className="h-8 w-8 flex flex-shrink-0 items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400"

View File

@ -6,15 +6,15 @@ import { Input } from "../../../../components/ui/input";
import { Textarea } from "../../../../components/ui/textarea"; import { Textarea } from "../../../../components/ui/textarea";
import { toast } from "sonner"; import { toast } from "sonner";
import { uploadImageFile } from "../../../../api/files.api"; import { uploadImageFile } from "../../../../api/files.api";
import { PublishStatusField } from "./PublishStatusField";
import type { PracticePublishStatus } from "../../../../types/course.types";
interface ContextStepProps { interface ContextStepProps {
formData: any; formData: any;
setFormData: (data: any) => void; setFormData: (data: any) => void;
nextStep: () => void; nextStep: () => void;
navigate: (path: string) => void; onCancel: () => void;
level: string; /** 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, formData,
setFormData, setFormData,
nextStep, nextStep,
navigate, onCancel,
level, isLessonPractice = false,
lessonTitle = null,
}: ContextStepProps) { }: ContextStepProps) {
const storyFileRef = useRef<HTMLInputElement>(null); const storyFileRef = useRef<HTMLInputElement>(null);
const [uploadingStory, setUploadingStory] = useState(false); const [uploadingStory, setUploadingStory] = useState(false);
@ -48,18 +49,31 @@ export function ContextStep({
} }
}; };
const canContinue = const canContinue = isLessonPractice
Boolean(formData.title?.trim()) && Boolean(formData.description?.trim()); ? true
: Boolean(formData.title?.trim()) && Boolean(formData.description?.trim());
return ( return (
<Card className="overflow-hidden border-grayScale-300 rounded-2xl bg-white animate-in fade-in duration-500"> <Card className="overflow-hidden border-grayScale-300 rounded-2xl bg-white animate-in fade-in duration-500">
<div className="border-b border-grayScale-50 px-8 pt-8 pb-4"> <div className="border-b border-grayScale-50 px-8 pt-8 pb-4">
<h2 className="text-xl font-bold text-grayScale-900 leading-none"> <h2 className="text-xl font-bold text-grayScale-900 leading-none">
Practice details {isLessonPractice ? "Practice options" : "Practice details"}
</h2> </h2>
<p className="text-grayScale-600 text-base mt-3"> <p className="text-grayScale-600 text-base mt-3">
Title, story, optional image, shuffle, and quick tips match the create {isLessonPractice ? (
practice and question set APIs. <>
This practice is linked to{" "}
<span className="font-medium text-grayScale-800">
{lessonTitle?.trim() || "the selected lesson"}
</span>
. Set optional quick tips and question order below.
</>
) : (
<>
Title, story, optional image, shuffle, and quick tips match the create
practice and question set APIs.
</>
)}
</p> </p>
</div> </div>
@ -76,34 +90,38 @@ export function ContextStep({
</div> </div>
<div className="space-y-8 p-10"> <div className="space-y-8 p-10">
<div className="space-y-2"> {!isLessonPractice ? (
<label className="text-sm font-medium text-grayScale-700"> <>
Practice title <span className="text-red-500">*</span> <div className="space-y-2">
</label> <label className="text-sm font-medium text-grayScale-700">
<Input Practice title <span className="text-red-500">*</span>
value={formData.title ?? ""} </label>
onChange={(e) => <Input
setFormData({ ...formData, title: e.target.value }) value={formData.title ?? ""}
} onChange={(e) =>
placeholder="e.g. Lesson 12 conversation drill" setFormData({ ...formData, title: e.target.value })
className="h-11 rounded-xl border-grayScale-200" }
/> placeholder="e.g. Module conversation drill"
</div> className="h-11 rounded-xl border-grayScale-200"
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700"> <label className="text-sm font-medium text-grayScale-700">
Story description <span className="text-red-500">*</span> Story description <span className="text-red-500">*</span>
</label> </label>
<Textarea <Textarea
value={formData.description ?? ""} value={formData.description ?? ""}
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, description: e.target.value }) setFormData({ ...formData, description: e.target.value })
} }
placeholder="Short scenario for learners…" placeholder="Short scenario for learners…"
className="min-h-[120px] rounded-xl border-grayScale-200" className="min-h-[120px] rounded-xl border-grayScale-200"
maxLength={2000} maxLength={2000}
/> />
</div> </div>
</>
) : null}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700"> <label className="text-sm font-medium text-grayScale-700">
@ -120,41 +138,43 @@ export function ContextStep({
/> />
</div> </div>
<div className="space-y-2"> {!isLessonPractice ? (
<label className="text-sm font-medium text-grayScale-700"> <div className="space-y-2">
Story image <span className="text-grayScale-400">(optional)</span> <label className="text-sm font-medium text-grayScale-700">
</label> Story image <span className="text-grayScale-400">(optional)</span>
<Input </label>
value={formData.storyImageUrl ?? ""} <Input
onChange={(e) => value={formData.storyImageUrl ?? ""}
setFormData({ ...formData, storyImageUrl: e.target.value }) onChange={(e) =>
} setFormData({ ...formData, storyImageUrl: e.target.value })
placeholder="https://… or upload" }
className="h-11 rounded-xl border-grayScale-200 font-mono text-[13px]" placeholder="https://… or upload"
/> className="h-11 rounded-xl border-grayScale-200 font-mono text-[13px]"
<input />
ref={storyFileRef} <input
type="file" ref={storyFileRef}
accept="image/*" type="file"
className="hidden" accept="image/*"
onChange={handleStoryImageFile} className="hidden"
/> onChange={handleStoryImageFile}
<Button />
type="button" <Button
variant="outline" type="button"
size="sm" variant="outline"
disabled={uploadingStory} size="sm"
onClick={() => storyFileRef.current?.click()} disabled={uploadingStory}
className="gap-2" onClick={() => storyFileRef.current?.click()}
> className="gap-2"
{uploadingStory ? ( >
<Loader2 className="h-4 w-4 animate-spin" /> {uploadingStory ? (
) : ( <Loader2 className="h-4 w-4 animate-spin" />
<Upload className="h-4 w-4" /> ) : (
)} <Upload className="h-4 w-4" />
Upload image )}
</Button> Upload image
</div> </Button>
</div>
) : null}
<label className="flex cursor-pointer items-center gap-3 text-sm text-grayScale-700"> <label className="flex cursor-pointer items-center gap-3 text-sm text-grayScale-700">
<input <input
@ -170,23 +190,13 @@ export function ContextStep({
/> />
<span>Shuffle questions in the set</span> <span>Shuffle questions in the set</span>
</label> </label>
<PublishStatusField
value={(formData.publishStatus ?? "DRAFT") as PracticePublishStatus}
onChange={(publishStatus) =>
setFormData({ ...formData, publishStatus })
}
disabled={uploadingStory}
/>
</div> </div>
<div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12"> <div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12">
<button <button
type="button" type="button"
className="text-[14px] font-bold text-grayScale-500 transition-colors hover:text-grayScale-700" className="text-[14px] font-bold text-grayScale-500 transition-colors hover:text-grayScale-700"
onClick={() => onClick={onCancel}
navigate(`/new-content/learn-english/${level}/courses`)
}
> >
Cancel Cancel
</button> </button>
@ -196,7 +206,7 @@ export function ContextStep({
disabled={!canContinue} 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" 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 <ArrowRight className="h-5 w-5" /> Next: Persona <ArrowRight className="h-5 w-5" />
</Button> </Button>
</div> </div>
</Card> </Card>

View File

@ -1,4 +1,4 @@
import { Check, ArrowRight } from "lucide-react"; import { Check, ArrowRight, Loader2 } from "lucide-react";
import { Button } from "../../../../components/ui/button"; import { Button } from "../../../../components/ui/button";
import { import {
Avatar, Avatar,
@ -6,9 +6,13 @@ import {
AvatarImage, AvatarImage,
} from "../../../../components/ui/avatar"; } from "../../../../components/ui/avatar";
import { cn } from "../../../../lib/utils"; import { cn } from "../../../../lib/utils";
import { PERSONAS } from "./constants"; import type { PersonaCardModel } from "../../../../lib/personaDisplay";
interface PersonaStepProps { interface PersonaStepProps {
personas: PersonaCardModel[];
loading?: boolean;
error?: string | null;
onRetry?: () => void;
selectedPersona: string | null; selectedPersona: string | null;
setSelectedPersona: (id: string) => void; setSelectedPersona: (id: string) => void;
nextStep: () => void; nextStep: () => void;
@ -16,6 +20,10 @@ interface PersonaStepProps {
} }
export function PersonaStep({ export function PersonaStep({
personas,
loading = false,
error = null,
onRetry,
selectedPersona, selectedPersona,
setSelectedPersona, setSelectedPersona,
nextStep, nextStep,
@ -25,66 +33,109 @@ export function PersonaStep({
<div className="space-y-8"> <div className="space-y-8">
<div className="space-y-1 px-2"> <div className="space-y-1 px-2">
<h2 className="text-2xl font-extrabold text-grayScale-700"> <h2 className="text-2xl font-extrabold text-grayScale-700">
Select Personas Select Persona
</h2> </h2>
<p className="text-grayScale-400 text-lg"> <p className="text-lg text-grayScale-400">
Choose the characters that will participate in this practice scenario. Choose the character that will guide this practice scenario.
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
{PERSONAS.map((persona) => { {loading ? (
const isSelected = selectedPersona === persona.id; <div className="flex flex-col items-center justify-center py-16">
return ( <Loader2 className="h-10 w-10 animate-spin text-brand-500" />
<div <p className="mt-4 text-sm font-medium text-grayScale-500">
key={persona.id} Loading personas
onClick={() => setSelectedPersona(persona.id)} </p>
className={cn( </div>
"group relative w-[260px] cursor-pointer rounded-2xl border-2 bg-white p-6 transition-all duration-300", ) : error ? (
isSelected <div className="rounded-xl border border-red-200 bg-red-50 px-6 py-8 text-center">
? "border-brand-500" <p className="text-sm font-medium text-red-700">{error}</p>
: "border-grayScale-100 hover:border-brand-200", {onRetry ? (
)} <Button
type="button"
variant="outline"
className="mt-4"
onClick={onRetry}
> >
{/* Top-right checkmark badge */} Try again
{isSelected && ( </Button>
<div className="absolute right-2.5 top-2.5 grid h-6 w-6 place-items-center rounded-full bg-brand-500 text-white z-10"> ) : null}
<Check className="h-4 w-4 stroke-[3]" /> </div>
) : personas.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-12 text-center">
<p className="text-sm font-medium text-grayScale-600">
No active personas available.
</p>
<p className="mt-1 text-sm text-grayScale-400">
Add personas in the admin panel, then return here.
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{personas.map((persona) => {
const isSelected = selectedPersona === persona.id;
return (
<button
key={persona.id}
type="button"
onClick={() => 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 && (
<div className="absolute right-2.5 top-2.5 z-10 grid h-6 w-6 place-items-center rounded-full bg-brand-500 text-white">
<Check className="h-4 w-4 stroke-[3]" />
</div>
)}
<div className="flex flex-col items-center gap-4">
<div
className={cn(
"rounded-full p-[3px] transition-all duration-300",
isSelected ? "bg-brand-500" : "bg-transparent",
)}
>
<Avatar className="h-24 w-24 border-2 border-white">
<AvatarImage src={persona.avatar} alt={persona.name} />
<AvatarFallback>
{persona.name.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
<div className="space-y-1 text-center">
<span className="block text-lg font-bold text-grayScale-700">
{persona.name}
</span>
{persona.description ? (
<span className="block text-xs leading-relaxed text-grayScale-500 line-clamp-3">
{persona.description}
</span>
) : null}
</div>
</div> </div>
)} </button>
<div className="flex flex-col items-center gap-4"> );
{/* Avatar with conditional purple ring */} })}
<div </div>
className={cn( )}
"rounded-full p-[3px] transition-all duration-300",
isSelected ? "bg-brand-500" : "bg-transparent",
)}
>
<Avatar className="h-24 w-24 border-2 border-white">
<AvatarImage src={persona.avatar} />
<AvatarFallback>
{persona.name.substring(0, 2)}
</AvatarFallback>
</Avatar>
</div>
<span className="text-lg font-bold text-grayScale-700">
{persona.name}
</span>
</div>
</div>
);
})}
</div>
<div className="flex items-center justify-between pt-8"> <div className="flex items-center justify-between pt-8">
<Button <Button
type="button"
onClick={prevStep} onClick={prevStep}
variant="outline" variant="outline"
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600" className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600"
> >
Back Back
</Button> </Button>
<Button <Button
type="button"
onClick={nextStep} onClick={nextStep}
className="h-10 rounded-[6px] bg-brand-500 px-8 hover:bg-brand-600 shadow-md shadow-brand-500/20" disabled={!selectedPersona || loading || personas.length === 0}
className="h-10 rounded-[6px] bg-brand-500 px-8 shadow-md shadow-brand-500/20 hover:bg-brand-600 disabled:opacity-50"
> >
Next: Questions <ArrowRight className="ml-2 h-4 w-4" /> Next: Questions <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>

View File

@ -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 (
<p className="text-xs italic text-grayScale-400">No audio URL provided</p>
);
}
return (
<div
className={cn(
"flex items-center gap-2 rounded-lg border border-brand-100 bg-brand-50/40 px-2 py-1.5",
className,
)}
>
<button
type="button"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-brand-500 text-white shadow-sm transition-colors hover:bg-brand-600"
aria-label={`Play ${fileName}`}
onClick={() => {
const audio = new Audio(src);
setPlaying(true);
void audio.play().finally(() => setPlaying(false));
}}
>
<Play
className={cn("h-3.5 w-3.5", playing && "opacity-80")}
fill="currentColor"
/>
</button>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div
className="flex h-6 flex-1 items-end gap-0.5 px-1 opacity-70"
aria-hidden
>
{[3, 5, 4, 7, 5, 8, 4, 6, 5, 4, 6, 4].map((h, i) => (
<span
key={i}
className="w-0.5 shrink-0 rounded-full bg-brand-400"
style={{ height: `${h + 4}px` }}
/>
))}
</div>
<span className="max-w-[8rem] truncate text-[11px] font-medium text-brand-700 sm:max-w-[10rem]">
{fileName}
</span>
</div>
<span className="sr-only">{src}</span>
</div>
);
}
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 (
<div className="w-full space-y-6">
{sectionTitle ? (
<div className="space-y-1 px-0.5">
<h2 className="text-xl font-bold tracking-tight text-grayScale-900 sm:text-2xl">
{sectionTitle}
</h2>
{sectionSubtitle ? (
<p className="text-sm text-grayScale-500 sm:text-[15px]">
{sectionSubtitle}
</p>
) : null}
</div>
) : null}
{showMissingParentWarning && !canPublish ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
<p className="font-semibold">Missing parent for the API</p>
<p className="mt-1 text-amber-900/90">
Open Add Practice from a course, module, or lesson so parent IDs are
in the URL.
</p>
</div>
) : null}
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h3 className="font-semibold text-grayScale-900">Basic Information</h3>
{onEditContext ? (
<button
type="button"
onClick={onEditContext}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<Edit className="h-3.5 w-3.5" />
Edit
</button>
) : null}
</div>
<div className="flex flex-col gap-6 p-6 sm:flex-row sm:items-start">
<div className="h-[70px] w-[85px] shrink-0 overflow-hidden rounded-xl bg-grayScale-100 shadow-inner sm:h-20 sm:w-24">
{thumbnailKind === "video" && thumbnailUrl ? (
<video
src={thumbnailUrl}
className="h-full w-full object-cover"
muted
playsInline
/>
) : thumbnailKind === "vimeo" ? (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-grayScale-200 to-grayScale-300">
<Play className="h-6 w-6 text-brand-500" fill="currentColor" />
</div>
) : thumbnailUrl?.trim() ? (
<img
src={thumbnailUrl}
alt=""
className="h-full w-full object-cover"
/>
) : persona?.avatar ? (
<img
src={persona.avatar}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-[#E0F2FE] to-[#BFDBFE]" />
)}
</div>
<div className="min-w-0 flex-1 space-y-3">
<h4 className="text-lg font-bold leading-tight text-grayScale-900 sm:text-xl">
{practiceTitle.trim() || "Untitled Practice"}
</h4>
{metadata.length > 0 ? (
<dl className="flex flex-wrap gap-x-6 gap-y-1 text-sm">
{metadata.map((item) => (
<div key={item.label}>
<dt className="inline text-grayScale-900">{item.label}: </dt>
<dd className="inline font-medium text-brand-600">
{item.value}
</dd>
</div>
))}
</dl>
) : parentLink ? (
<p className="text-sm text-grayScale-600">
<span className="font-medium text-grayScale-800">Link:</span>{" "}
{parentLink}
</p>
) : null}
</div>
<div className="flex shrink-0 flex-col items-center gap-2 sm:items-end">
<span className="text-[10px] font-bold uppercase tracking-wider text-grayScale-500">
Persona
</span>
{persona ? (
<div className="flex flex-col items-center gap-1.5">
<div className="h-12 w-12 overflow-hidden rounded-full bg-grayScale-100 ring-2 ring-brand-100">
<img
src={persona.avatar}
alt=""
className="h-full w-full object-cover"
/>
</div>
<span className="text-sm font-semibold text-grayScale-900">
{persona.name}
</span>
</div>
) : (
<span className="text-sm text-grayScale-400">None selected</span>
)}
</div>
</div>
</Card>
<div className="space-y-3 px-0.5">
<div className="flex items-center gap-2">
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-grayScale-900">
Tips / Guidance
</span>
<Info className="h-4 w-4 text-brand-500" />
</div>
<div className="rounded-xl border border-grayScale-200 bg-white px-5 py-4 shadow-sm">
<p className="text-sm leading-relaxed text-grayScale-600">
{guidanceText.trim() || "—"}
</p>
</div>
</div>
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm">
<div className="grid md:grid-cols-2 md:divide-x md:divide-grayScale-100">
<div className="flex flex-col">
<div className="flex items-center gap-2.5 border-b border-grayScale-100 px-6 py-4">
<h3 className="font-semibold text-grayScale-900">Questions</h3>
<span className="flex h-6 min-w-6 items-center justify-center rounded-full bg-grayScale-100 px-2 text-xs font-semibold text-grayScale-500">
{filledQuestions.length}
</span>
</div>
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto px-6 py-5">
{filledQuestions.map((question, index) => (
<div key={question.id} className="space-y-3">
<span className="text-sm font-bold text-grayScale-400">
{formatQuestionIndex(index)}
</span>
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-500">
Text prompt
</p>
<p className="text-sm leading-relaxed text-grayScale-800">
{question.questionText.trim() || "—"}
</p>
</div>
{question.voicePrompt.trim() ? (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-500">
Voice prompt
</p>
<ReviewAudioPlayer
src={question.voicePrompt}
label={`prompt_q${index + 1}.mp3`}
/>
</div>
) : null}
</div>
))}
</div>
</div>
<div className="flex flex-col border-t border-grayScale-100 md:border-t-0">
<div className="flex items-center justify-between gap-2 border-b border-grayScale-100 px-6 py-4">
<div className="flex items-center gap-2.5">
<h3 className="font-semibold text-grayScale-900">Answers</h3>
<span className="flex h-6 min-w-6 items-center justify-center rounded-full bg-grayScale-100 px-2 text-xs font-semibold text-grayScale-500">
{filledQuestions.length}
</span>
</div>
{onEditQuestions ? (
<button
type="button"
onClick={onEditQuestions}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<Edit className="h-3.5 w-3.5" />
Edit
</button>
) : null}
</div>
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto px-6 py-5">
{filledQuestions.map((question, index) => (
<div key={question.id} className="space-y-3">
<span className="text-sm font-bold text-grayScale-400">
{formatQuestionIndex(index)}
</span>
{question.sampleAnswerVoicePrompt.trim() ? (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-500">
Voice prompt
</p>
<ReviewAudioPlayer
src={question.sampleAnswerVoicePrompt}
label={`answer_q${index + 1}.mp3`}
/>
</div>
) : (
<p className="text-xs text-grayScale-400"></p>
)}
</div>
))}
</div>
</div>
</div>
</Card>
{saveError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p className="text-sm font-medium text-red-600">{saveError}</p>
</div>
) : null}
<div className="flex flex-col-reverse items-stretch justify-between gap-3 pt-2 sm:flex-row sm:items-center">
<Button
type="button"
variant="outline"
onClick={onBack}
className="h-10 rounded-[6px] border-grayScale-200 bg-white px-8 text-sm font-bold text-grayScale-600 shadow-sm hover:bg-grayScale-50"
>
Back
</Button>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={onSaveDraft}
disabled={saving || !canPublish}
className="h-10 rounded-[6px] border-brand-500 bg-white px-8 text-sm font-bold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving
</>
) : (
"Save as Draft"
)}
</Button>
<Button
type="button"
onClick={onPublish}
disabled={saving || !canPublish}
className="h-10 gap-2 rounded-[6px] bg-brand-500 px-8 text-sm font-bold text-white shadow-md shadow-brand-500/20 hover:bg-brand-600 disabled:opacity-50"
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Rocket className="h-4 w-4" />
)}
{saving ? "Publishing…" : "Publish Now"}
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,19 +1,37 @@
import { Rocket, Info, Loader2 } from "lucide-react"; import { useMemo } from "react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Input } from "../../../../components/ui/input";
import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types"; import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
import { personaFromId } from "./constants";
import type { PersonaCardModel } from "../../../../lib/personaDisplay";
import { mapFormQuestionsForPracticeReview } from "./mapQuestionsForPracticeReview";
import { import {
definitionUsesDynamicPayload, PracticeSequentialReview,
legacyQuestionTypeFromDefinition, type PracticeReviewMetadataItem,
} from "../../../../lib/learnEnglishDefinitionQuestion"; } from "./PracticeSequentialReview";
import { PublishStatusField } from "./PublishStatusField";
import type { PracticePublishStatus } from "../../../../types/course.types";
interface ReviewStepProps { interface ReviewStepProps {
formData: any; formData: {
setFormData: (data: any) => void; title?: string;
description?: string;
storyImageUrl?: string;
tips?: string;
shuffleQuestions?: boolean;
questions: {
id: string;
text?: string;
dynamicFieldValues?: Record<string, string>;
questionTypeDefinitionId?: number | null;
}[];
};
selectedPersona?: string | null;
personas?: PersonaCardModel[];
isLessonPractice?: boolean;
lessonTitle?: string | null;
programLabel?: string | null;
courseLabel?: string | null;
moduleLabel?: string | null;
prevStep: () => void; prevStep: () => void;
onEditContext?: () => void;
onEditQuestions?: () => void;
parentSummary: string | null; parentSummary: string | null;
typeDefinitions: QuestionTypeDefinition[]; typeDefinitions: QuestionTypeDefinition[];
canPublish: boolean; canPublish: boolean;
@ -24,8 +42,16 @@ interface ReviewStepProps {
export function ReviewStep({ export function ReviewStep({
formData, formData,
setFormData, selectedPersona = null,
personas = [],
isLessonPractice = false,
lessonTitle = null,
programLabel = null,
courseLabel = null,
moduleLabel = null,
prevStep, prevStep,
onEditContext,
onEditQuestions,
parentSummary, parentSummary,
typeDefinitions, typeDefinitions,
canPublish, canPublish,
@ -33,292 +59,61 @@ export function ReviewStep({
onSaveDraft, onSaveDraft,
onPublish, onPublish,
}: ReviewStepProps) { }: ReviewStepProps) {
return ( const persona = personaFromId(selectedPersona, personas);
<div className="space-y-10 animate-in fade-in duration-700">
<div className="flex items-center justify-between px-2">
<h2 className="text-2xl font-bold text-grayScale-900 tracking-tight">
Review
</h2>
</div>
{!canPublish && ( const reviewQuestions = useMemo(
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950"> () => mapFormQuestionsForPracticeReview(formData.questions, typeDefinitions),
<p className="font-semibold">Missing parent for the API</p> [formData.questions, typeDefinitions],
<p className="mt-1 text-amber-900/90">
Open Add Practice from a course, module, or lesson so parent IDs are
in the URL.
</p>
</div>
)}
<Card className="overflow-hidden border border-grayScale-200 rounded-2xl bg-white">
<div className="border-b border-grayScale-50 px-5 py-4">
<h3 className="text-[17px] font-extrabold text-grayScale-900">
Practice
</h3>
</div>
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="w-full border-t border-grayScale-100" />
</div>
</div>
<div className="p-6 sm:p-8">
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
<div className="h-[70px] w-[85px] shrink-0 overflow-hidden rounded-xl bg-grayScale-100 shadow-inner">
<img
src={
formData.storyImageUrl?.trim() ||
"https://images.unsplash.com/photo-1558403194-611308249627?auto=format&fit=crop&q=80&w=200"
}
alt="Story"
className="h-full w-full object-cover opacity-80"
/>
</div>
<div className="min-w-0 flex-1 space-y-2">
<h4 className="text-xl font-bold leading-tight text-grayScale-900">
{formData.title || "Untitled"}
</h4>
<p className="text-sm text-grayScale-600">
{parentSummary ? (
<span>
<span className="font-medium text-grayScale-800">Link:</span>{" "}
{parentSummary}
</span>
) : (
"—"
)}
</p>
{formData.shuffleQuestions ? (
<p className="text-xs text-grayScale-500">Shuffle questions: on</p>
) : null}
</div>
</div>
</div>
</Card>
<div className="space-y-4 px-2">
<div className="flex items-center gap-2">
<label className="text-[12px] font-bold uppercase tracking-widest text-grayScale-900">
Quick tips
</label>
<Info className="h-4 w-4 text-brand-500" />
</div>
<div className="rounded-xl border border-[#E2E8F0] bg-white px-5 py-4 shadow-sm">
<p className="text-[14px] font-medium leading-relaxed text-grayScale-600">
{formData.tips?.trim() || "—"}
</p>
</div>
</div>
<div className="space-y-4">
<h3 className="px-2 text-lg font-bold text-grayScale-900">Questions</h3>
<div className="space-y-4">
{formData.questions.map((q: any, i: number) => (
<QuestionReviewBlock
key={q.id}
q={q}
index={i}
typeDefinitions={typeDefinitions}
/>
))}
</div>
</div>
<PublishStatusField
className="px-2"
value={(formData.publishStatus ?? "DRAFT") as PracticePublishStatus}
onChange={(publishStatus) => setFormData({ ...formData, publishStatus })}
disabled={submitting}
/>
<div className="flex items-center justify-between pt-12">
<Button
onClick={prevStep}
variant="outline"
className="h-10 rounded-[6px] border-grayScale-200 bg-white px-10 text-sm font-bold text-grayScale-600 shadow-sm transition-all hover:bg-grayScale-50"
>
Back
</Button>
<div className="flex gap-4">
<Button
variant="outline"
disabled={submitting || !canPublish}
onClick={() => {
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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
Save as Draft
</Button>
<Button
disabled={submitting || !canPublish}
onClick={() => {
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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Rocket className="h-4 w-4" />
)}
Publish Now
</Button>
</div>
</div>
</div>
); );
}
function QuestionReviewBlock({ const metadata = useMemo((): PracticeReviewMetadataItem[] => {
q, const items: PracticeReviewMetadataItem[] = [];
index, if (programLabel?.trim()) {
typeDefinitions, items.push({ label: "Program", value: programLabel.trim() });
}: {
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<string, string>;
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] ?? "",
});
} }
for (const r of def.response_schema) { if (courseLabel?.trim()) {
const k = `response:${r.id}`; items.push({ label: "Course", value: courseLabel.trim() });
schemaRows.push({
key: k,
label: r.label?.trim() || r.kind,
value: vals[k] ?? "",
});
} }
} 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 ( return (
<Card className="relative overflow-hidden rounded-2xl border-grayScale-50 bg-white shadow-soft"> <PracticeSequentialReview
<div className="absolute bottom-0 left-0 top-0 w-[5px] bg-brand-500" /> practiceTitle={practiceTitle}
<div className="space-y-4 px-5 pb-6 pt-4 pl-7"> thumbnailUrl={thumbnailUrl}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-grayScale-50 pb-3"> thumbnailKind={thumbnailUrl ? "image" : "gradient"}
<span className="text-base font-bold text-grayScale-500"> persona={persona ?? null}
Question {index + 1} metadata={metadata}
</span> parentLink={metadata.length === 0 ? parentSummary : null}
<span className="rounded-md bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700"> guidanceText={guidanceText}
{badge} questions={reviewQuestions}
</span> saving={submitting}
</div> canPublish={canPublish}
showMissingParentWarning
<div className="space-y-2"> onEditContext={onEditContext}
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600"> onEditQuestions={onEditQuestions}
Question text onBack={prevStep}
</span> onSaveDraft={onSaveDraft}
<Input onPublish={onPublish}
value={q.text} />
readOnly
className="min-h-[52px] rounded-xl border-grayScale-200 bg-white px-4 py-3 text-base font-medium text-grayScale-700"
/>
</div>
{isDynamic && schemaRows.length > 0 ? (
<div className="space-y-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
Stimulus and response fields
</span>
<ul className="space-y-2 text-sm">
{schemaRows.map((row) => (
<li key={row.key} className="rounded-lg border border-grayScale-100 bg-grayScale-50/50 px-3 py-2">
<span className="block text-[10px] font-bold uppercase tracking-wide text-grayScale-500">
{row.label}
</span>
<span className="mt-1 block break-all font-mono text-xs text-grayScale-800">
{row.value?.trim() ? row.value : "—"}
</span>
</li>
))}
</ul>
</div>
) : null}
{legacy === "MCQ" && (
<div className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
Choices
</span>
<ul className="space-y-1.5 text-sm">
{(q.mcqOptions ?? []).map(
(opt: { text?: string; isCorrect?: boolean }, j: number) =>
opt.text?.trim() ? (
<li
key={j}
className={
opt.isCorrect
? "font-medium text-green-700"
: "text-grayScale-600"
}
>
{opt.isCorrect ? "✓ " : ""}
{opt.text}
</li>
) : null,
)}
</ul>
</div>
)}
{legacy === "TRUE_FALSE" && (
<p className="text-sm text-grayScale-700">
<span className="font-semibold">Correct:</span>{" "}
{q.trueFalseCorrect !== false ? "True" : "False"}
</p>
)}
{legacy === "SHORT_ANSWER" && (
<div className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
Acceptable answers
</span>
<ul className="list-disc space-y-1 pl-5 text-sm text-grayScale-700">
{(q.shortAnswers ?? [])
.filter((s: string) => s?.trim())
.map((s: string, j: number) => (
<li key={j}>{s}</li>
))}
</ul>
</div>
)}
{def != null && legacy == null && !isDynamic ? (
<p className="text-xs text-amber-800">
This type has no schema and is not mapped to a classic MCQ / truefalse /
short-answer form. Publish still sends the best-effort payload from the
builder.
</p>
) : null}
</div>
</Card>
); );
} }

View File

@ -7,9 +7,6 @@ import { Input } from "../../../../components/ui/input";
import { Textarea } from "../../../../components/ui/textarea"; import { Textarea } from "../../../../components/ui/textarea";
import { toast } from "sonner"; import { toast } from "sonner";
import { uploadImageFile } from "../../../../api/files.api"; import { uploadImageFile } from "../../../../api/files.api";
import { PublishStatusField } from "./PublishStatusField";
import type { PracticePublishStatus } from "../../../../types/course.types";
interface ScenarioStepProps { interface ScenarioStepProps {
formData: any; formData: any;
setFormData: (data: any) => void; setFormData: (data: any) => void;
@ -160,13 +157,6 @@ export function ScenarioStep({
maxLength={1000} maxLength={1000}
/> />
</div> </div>
<PublishStatusField
value={(formData.publishStatus ?? "DRAFT") as PracticePublishStatus}
onChange={(publishStatus) =>
setFormData({ ...formData, publishStatus })
}
disabled={uploadingBanner}
/>
</Card> </Card>
<div className="flex items-center justify-between pt-4"> <div className="flex items-center justify-between pt-4">
@ -179,7 +169,7 @@ export function ScenarioStep({
disabled={!canContinue} disabled={!canContinue}
className="h-10 rounded-[6px] bg-brand-500 px-8 disabled:opacity-50" className="h-10 rounded-[6px] bg-brand-500 px-8 disabled:opacity-50"
> >
Next: Questions <ArrowRight className="ml-2 h-4 w-4" /> Next: Persona <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1,44 +1,16 @@
export const PERSONAS = [ import type { PersonaCardModel } from "../../../../lib/personaDisplay"
{
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",
},
];
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
}

View File

@ -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, string>,
): 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<string, string>;
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,
};
});
}

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Rocket, Edit2, Link2, Video } from "lucide-react"; import { Rocket, Edit2, Link2, Video } from "lucide-react";
import { Button } from "../../../../components/ui/button"; import { Button } from "../../../../components/ui/button";
import { toast } from "sonner"; import type { PracticePublishStatus } from "../../../../types/course.types";
import type { AddLessonFormData } from "../../AddVideoFlow"; import type { AddLessonFormData } from "../../AddVideoFlow";
import { import {
applyShortPreviewToEmbedUrl, applyShortPreviewToEmbedUrl,
@ -15,7 +15,7 @@ import { PreviewLimitedFileVideo } from "../PreviewLimitedFileVideo";
interface ReviewPublishStepProps { interface ReviewPublishStepProps {
formData: AddLessonFormData; formData: AddLessonFormData;
prevStep: () => void; prevStep: () => void;
onPublish: () => void; onCreateLesson: (publishStatus: PracticePublishStatus) => void;
publishing: boolean; publishing: boolean;
} }
@ -27,7 +27,7 @@ function truncate(s: string, max: number): string {
export function ReviewPublishStep({ export function ReviewPublishStep({
formData, formData,
prevStep, prevStep,
onPublish, onCreateLesson,
publishing, publishing,
}: ReviewPublishStepProps) { }: ReviewPublishStepProps) {
const [thumbBroken, setThumbBroken] = useState(false); const [thumbBroken, setThumbBroken] = useState(false);
@ -180,6 +180,17 @@ export function ReviewPublishStep({
</p> </p>
</div> </div>
<div className="space-y-2">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
Sort order
</span>
<p className="text-[15px] font-medium text-grayScale-900">
{formData.sortOrder.trim() !== ""
? formData.sortOrder.trim()
: "—"}
</p>
</div>
<div className="space-y-3"> <div className="space-y-3">
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block"> <span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
Description Description
@ -226,20 +237,18 @@ export function ReviewPublishStep({
variant="outline" 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" 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} disabled={publishing}
onClick={() => onClick={() => onCreateLesson("DRAFT")}
toast.info("Drafts are not supported yet. Use Create lesson.")
}
> >
Save as draft Save as draft
</Button> </Button>
<Button <Button
type="button" type="button"
onClick={onPublish} onClick={() => onCreateLesson("PUBLISHED")}
disabled={publishing} 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" 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"
> >
<Rocket className="h-4 w-4" /> <Rocket className="h-4 w-4" />
{publishing ? "Creating…" : "Create lesson"} {publishing ? "Creating…" : "Publish lesson"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -70,6 +70,16 @@ export function VideoDetailStep({
toast.error("Title is required"); toast.error("Title is required");
return; 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()) { if (!formData.videoUrl.trim()) {
toast.error("Add a video URL or upload a video"); toast.error("Add a video URL or upload a video");
return; return;
@ -141,6 +151,35 @@ export function VideoDetailStep({
/> />
</div> </div>
<div className="space-y-3">
<label
className="text-[14px] font-medium text-grayScale-900 ml-1"
htmlFor="lesson-sort-order"
>
Sort order
</label>
<Input
id="lesson-sort-order"
type="number"
inputMode="numeric"
min={0}
step={1}
placeholder="0"
className="h-12 max-w-[200px] rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 placeholder:text-grayScale-500 focus:border-brand-500 font-medium transition-all shadow-sm"
value={formData.sortOrder}
onChange={(e) =>
setFormData((prev) => ({
...prev,
sortOrder: e.target.value,
}))
}
/>
<p className="text-xs text-grayScale-500 ml-1">
Whole number, 0 or greater. Lower numbers appear first in the
module.
</p>
</div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[14px] font-medium text-grayScale-900 ml-1"> <label className="text-[14px] font-medium text-grayScale-900 ml-1">
Description Description

View File

@ -224,6 +224,7 @@ export interface CreateExamPrepCatalogUnitRequest {
name: string name: string
description?: string | null description?: string | null
thumbnail?: string | null thumbnail?: string | null
sort_order: number
} }
export interface CreateExamPrepCatalogUnitResponse { export interface CreateExamPrepCatalogUnitResponse {
@ -329,6 +330,9 @@ export interface ExamPrepModuleLessonItem {
thumbnail?: string | null thumbnail?: string | null
description?: string | null description?: string | null
sort_order?: number sort_order?: number
/** Total length in seconds when the API provides it. */
duration?: number | null
duration_seconds?: number | null
created_at?: string created_at?: string
updated_at?: string updated_at?: string
} }
@ -338,6 +342,7 @@ export interface CreateExamPrepModuleLessonRequest {
video_url: string video_url: string
thumbnail?: string | null thumbnail?: string | null
description?: string | null description?: string | null
publish_status: PracticePublishStatus
} }
export interface CreateExamPrepModuleLessonResponse { export interface CreateExamPrepModuleLessonResponse {
@ -448,6 +453,11 @@ export interface TopLevelModuleLessonItem {
thumbnail: string thumbnail: string
description: string description: string
sort_order: number 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 created_at: string
} }
@ -547,6 +557,12 @@ export interface UpdateTopLevelModuleLessonRequest {
video_url: string video_url: string
thumbnail: string thumbnail: string
description: 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. */ /** Body for POST /modules/:moduleId/lessons. */
@ -555,6 +571,8 @@ export interface CreateTopLevelModuleLessonRequest {
video_url: string video_url: string
thumbnail: string thumbnail: string
description: string description: string
sort_order: number
publish_status: PracticePublishStatus
} }
export interface CreateTopLevelModuleLessonResponse { export interface CreateTopLevelModuleLessonResponse {

View File

@ -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
}