practice creation UI fix
This commit is contained in:
parent
1014f4a72f
commit
095e690a68
|
|
@ -97,6 +97,9 @@ import type {
|
|||
CreateExamPrepModuleLessonResponse,
|
||||
UpdateExamPrepModuleLessonRequest,
|
||||
UpdateExamPrepModuleLessonResponse,
|
||||
PublishExamPrepModuleLessonRequest,
|
||||
CreateExamPrepLessonPracticeRequest,
|
||||
CreateExamPrepLessonPracticeResponse,
|
||||
GetExamPrepModuleLessonsResponse,
|
||||
GetTopLevelModuleLessonsResponse,
|
||||
GetPracticesByParentContextResponse,
|
||||
|
|
@ -587,10 +590,26 @@ export const updateExamPrepModuleLesson = (
|
|||
data,
|
||||
)
|
||||
|
||||
/** PUT /exam-prep/lessons/:lessonId — set publish_status only (draft or published). */
|
||||
export const publishExamPrepModuleLesson = (
|
||||
lessonId: number,
|
||||
data: PublishExamPrepModuleLessonRequest,
|
||||
) => http.put(`/exam-prep/lessons/${lessonId}`, data)
|
||||
|
||||
/** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */
|
||||
export const deleteExamPrepModuleLesson = (lessonId: number) =>
|
||||
http.delete(`/exam-prep/lessons/${lessonId}`)
|
||||
|
||||
/** POST /exam-prep/lessons/:lessonId/practices */
|
||||
export const createExamPrepLessonPractice = (
|
||||
lessonId: number,
|
||||
data: CreateExamPrepLessonPracticeRequest,
|
||||
) =>
|
||||
http.post<CreateExamPrepLessonPracticeResponse>(
|
||||
`/exam-prep/lessons/${lessonId}/practices`,
|
||||
data,
|
||||
)
|
||||
|
||||
/** Top-level course resource (Learn English track) — PUT /courses/:id */
|
||||
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
|
||||
http.put(`/courses/${courseId}`, data)
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
|
|||
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
|
||||
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
|
||||
import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage";
|
||||
import { AttachPracticeFlow } from "../pages/content-management/AttachPracticeFlow";
|
||||
import { AttachProgramPracticeFlow } from "../pages/content-management/AttachProgramPracticeFlow";
|
||||
import { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTypeSelectionPage";
|
||||
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
|
||||
import { CourseManagementPage } from "../pages/content-management/CourseManagementPage";
|
||||
|
|
@ -191,12 +189,16 @@ export function AppRoutes() {
|
|||
element={<ProgramDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/attach-practice"
|
||||
element={<AttachProgramPracticeFlow />}
|
||||
path="/new-content/courses/:programType/add-practice"
|
||||
element={<AddPracticeFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/:courseId/unit/:unitId/module/:moduleId/attach-practice"
|
||||
element={<AttachPracticeFlow />}
|
||||
path="/new-content/courses/:programType/:courseId/add-practice"
|
||||
element={<AddPracticeFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/add-practice"
|
||||
element={<AddPracticeFlow />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/:courseId"
|
||||
|
|
@ -210,6 +212,10 @@ export function AppRoutes() {
|
|||
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId"
|
||||
element={<CourseModuleDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/lessons/:lessonId/practices"
|
||||
element={<LessonPracticesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/new-content/learn-english"
|
||||
element={<LearnEnglishPage />}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { AxiosError } from "axios"
|
||||
import {
|
||||
addQuestionToSet,
|
||||
createExamPrepLessonPractice,
|
||||
createParentLinkedPractice,
|
||||
createQuestion,
|
||||
createQuestionSet,
|
||||
|
|
@ -72,6 +73,8 @@ export async function executeLearnEnglishPracticeCreation(opts: {
|
|||
personaId: number
|
||||
questions: LearnEnglishDefinitionQuestionInput[]
|
||||
definitions: QuestionTypeDefinition[]
|
||||
/** When set, links practice via POST /exam-prep/lessons/:id/practices instead of POST /practices. */
|
||||
examPrepLessonId?: number
|
||||
}): Promise<{ questionSetId: number; practiceId: number }> {
|
||||
const err = validateLearnEnglishQuestionsWithDefinitions(
|
||||
opts.questions,
|
||||
|
|
@ -128,7 +131,16 @@ export async function executeLearnEnglishPracticeCreation(opts: {
|
|||
})
|
||||
}
|
||||
|
||||
const practiceRes = await createParentLinkedPractice({
|
||||
const practiceRes = opts.examPrepLessonId
|
||||
? await createExamPrepLessonPractice(opts.examPrepLessonId, {
|
||||
title: opts.practiceTitle.trim(),
|
||||
story_description: opts.storyDescription.trim(),
|
||||
story_image: opts.storyImage.trim(),
|
||||
persona_id: opts.personaId,
|
||||
question_set_id: setId,
|
||||
quick_tips: opts.quickTips.trim(),
|
||||
})
|
||||
: await createParentLinkedPractice({
|
||||
parent_kind: opts.parentKind,
|
||||
parent_id: opts.parentId,
|
||||
title: opts.practiceTitle.trim(),
|
||||
|
|
|
|||
124
src/lib/multipleChoiceSlotValue.ts
Normal file
124
src/lib/multipleChoiceSlotValue.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
export interface MultipleChoiceOptionValue {
|
||||
id: string
|
||||
text: string
|
||||
is_correct: boolean
|
||||
}
|
||||
|
||||
export interface MultipleChoiceSlotValue {
|
||||
options: MultipleChoiceOptionValue[]
|
||||
}
|
||||
|
||||
const DEFAULT_OPTION_IDS = ["a", "b", "c", "d", "e", "f", "g", "h"] as const
|
||||
|
||||
export function defaultMultipleChoiceSlotValue(
|
||||
count = 4,
|
||||
): MultipleChoiceSlotValue {
|
||||
return {
|
||||
options: Array.from({ length: count }, (_, index) => ({
|
||||
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
|
||||
text: "",
|
||||
is_correct: index === 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeMultipleChoiceSlotValue(
|
||||
value: MultipleChoiceSlotValue,
|
||||
): string {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
export function parseMultipleChoiceSlotValue(
|
||||
raw: string | undefined,
|
||||
): MultipleChoiceSlotValue {
|
||||
const trimmed = (raw ?? "").trim()
|
||||
if (!trimmed) return defaultMultipleChoiceSlotValue()
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown
|
||||
return normalizeMultipleChoiceValue(parsed)
|
||||
} catch {
|
||||
return defaultMultipleChoiceSlotValue()
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMultipleChoiceValue(
|
||||
raw: unknown,
|
||||
mcqOptions?: { option_text?: string; text?: string; is_correct?: boolean; isCorrect?: boolean }[],
|
||||
): MultipleChoiceSlotValue {
|
||||
if (mcqOptions?.some((o) => (o.option_text ?? o.text ?? "").trim())) {
|
||||
return {
|
||||
options: mcqOptions
|
||||
.map((option, index) => ({
|
||||
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
|
||||
text: (option.option_text ?? option.text ?? "").trim(),
|
||||
is_correct: Boolean(option.is_correct ?? option.isCorrect),
|
||||
}))
|
||||
.filter((option) => option.text),
|
||||
}
|
||||
}
|
||||
|
||||
if (raw && typeof raw === "object") {
|
||||
const record = raw as Record<string, unknown>
|
||||
if (Array.isArray(record.options)) {
|
||||
return {
|
||||
options: record.options.map((option, index) =>
|
||||
normalizeMultipleChoiceOption(option, index),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
return {
|
||||
options: raw.map((option, index) =>
|
||||
normalizeMultipleChoiceOption(option, index),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
try {
|
||||
return normalizeMultipleChoiceValue(JSON.parse(raw) as unknown)
|
||||
} catch {
|
||||
return defaultMultipleChoiceSlotValue()
|
||||
}
|
||||
}
|
||||
|
||||
return defaultMultipleChoiceSlotValue()
|
||||
}
|
||||
|
||||
function normalizeMultipleChoiceOption(
|
||||
raw: unknown,
|
||||
index: number,
|
||||
): MultipleChoiceOptionValue {
|
||||
if (raw && typeof raw === "object") {
|
||||
const record = raw as Record<string, unknown>
|
||||
return {
|
||||
id: String(record.id ?? DEFAULT_OPTION_IDS[index] ?? index + 1),
|
||||
text: String(record.text ?? record.option_text ?? "").trim(),
|
||||
is_correct: Boolean(record.is_correct ?? record.isCorrect),
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
|
||||
text: String(raw ?? "").trim(),
|
||||
is_correct: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function multipleChoiceSlotHasContent(
|
||||
value: MultipleChoiceSlotValue,
|
||||
): boolean {
|
||||
return value.options.some((option) => option.text.trim())
|
||||
}
|
||||
|
||||
export function validateMultipleChoiceSlotValue(
|
||||
value: MultipleChoiceSlotValue,
|
||||
): string | null {
|
||||
const filled = value.options.filter((option) => option.text.trim())
|
||||
if (filled.length < 2) return "Add at least two choices with text."
|
||||
if (!filled.some((option) => option.is_correct)) {
|
||||
return "Mark one choice as correct."
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
import type { DynamicQuestionPayload } from "../types/questionTypeDefinition.types"
|
||||
import {
|
||||
multipleChoiceSlotHasContent,
|
||||
normalizeMultipleChoiceValue,
|
||||
parseMultipleChoiceSlotValue,
|
||||
} from "./multipleChoiceSlotValue"
|
||||
|
||||
/** Parse a single slot value: plain string/URL, or JSON object/array when input looks like JSON. */
|
||||
export function parseDynamicSlotValue(raw: string | undefined): unknown {
|
||||
|
|
@ -14,21 +19,78 @@ export function parseDynamicSlotValue(raw: string | undefined): unknown {
|
|||
return t
|
||||
}
|
||||
|
||||
const PLAIN_TEXT_STIMULUS_KINDS = new Set([
|
||||
"INSTRUCTION",
|
||||
"QUESTION_TEXT",
|
||||
"TEXT_PASSAGE",
|
||||
"TEXT",
|
||||
"TEXT_INPUT",
|
||||
])
|
||||
|
||||
function isMultipleChoiceKind(kind: string): boolean {
|
||||
const upper = kind.trim().toUpperCase()
|
||||
return upper === "MULTIPLE_CHOICE" || upper === "OPTION"
|
||||
}
|
||||
|
||||
function slotValueForRow(
|
||||
row: { id: string; kind: string },
|
||||
side: "stimulus" | "response",
|
||||
fieldValues: Record<string, string>,
|
||||
mcqOptions: { option_text: string; is_correct: boolean }[] | undefined,
|
||||
mcqOptionsConsumed: { current: boolean },
|
||||
): unknown {
|
||||
const fieldKey = `${side}:${row.id}`
|
||||
const rawField = fieldValues[fieldKey]
|
||||
|
||||
if (isMultipleChoiceKind(row.kind)) {
|
||||
const fromField = parseMultipleChoiceSlotValue(rawField)
|
||||
if (multipleChoiceSlotHasContent(fromField)) {
|
||||
return normalizeMultipleChoiceValue(fromField)
|
||||
}
|
||||
if (mcqOptions && !mcqOptionsConsumed.current) {
|
||||
mcqOptionsConsumed.current = true
|
||||
return normalizeMultipleChoiceValue(undefined, mcqOptions)
|
||||
}
|
||||
return normalizeMultipleChoiceValue(fromField)
|
||||
}
|
||||
|
||||
if (side === "stimulus" && PLAIN_TEXT_STIMULUS_KINDS.has(row.kind.trim().toUpperCase())) {
|
||||
return (rawField ?? "").trim()
|
||||
}
|
||||
|
||||
return parseDynamicSlotValue(rawField)
|
||||
}
|
||||
|
||||
export function buildDynamicQuestionPayload(input: {
|
||||
stimulusRows: { id: string; kind: string }[]
|
||||
responseRows: { id: string; kind: string }[]
|
||||
fieldValues: Record<string, string>
|
||||
mcqOptions?: { option_text: string; is_correct: boolean }[]
|
||||
}): DynamicQuestionPayload {
|
||||
const mcqOptionsConsumed = { current: false }
|
||||
|
||||
return {
|
||||
stimulus: input.stimulusRows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
value: parseDynamicSlotValue(input.fieldValues[`stimulus:${row.id}`]),
|
||||
value: slotValueForRow(
|
||||
row,
|
||||
"stimulus",
|
||||
input.fieldValues,
|
||||
input.mcqOptions,
|
||||
mcqOptionsConsumed,
|
||||
),
|
||||
})),
|
||||
response: input.responseRows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
value: parseDynamicSlotValue(input.fieldValues[`response:${row.id}`]),
|
||||
value: slotValueForRow(
|
||||
row,
|
||||
"response",
|
||||
input.fieldValues,
|
||||
input.mcqOptions,
|
||||
mcqOptionsConsumed,
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,13 +35,41 @@ const STEP_LABELS = ["Practice", "Persona", "Questions", "Review"] as const;
|
|||
|
||||
export function AddPracticeFlow() {
|
||||
const navigate = useNavigate();
|
||||
const { level } = useParams<{ level: string }>();
|
||||
const {
|
||||
level,
|
||||
programType,
|
||||
courseId: routeCourseId,
|
||||
unitId: routeUnitId,
|
||||
moduleId: routeModuleId,
|
||||
} = useParams<{
|
||||
level?: string;
|
||||
programType?: string;
|
||||
courseId?: string;
|
||||
unitId?: string;
|
||||
moduleId?: string;
|
||||
}>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const backTo = searchParams.get("backTo");
|
||||
const courseId = searchParams.get("courseId");
|
||||
const moduleId = searchParams.get("moduleId");
|
||||
const backToParam = searchParams.get("backTo");
|
||||
const lessonId = searchParams.get("lessonId");
|
||||
const lessonTitleRaw = searchParams.get("lessonTitle");
|
||||
|
||||
const isExamPrep = Boolean(programType?.trim());
|
||||
|
||||
const effectiveBackTo = useMemo(() => {
|
||||
if (backToParam?.trim()) return backToParam.trim();
|
||||
if (isExamPrep && routeModuleId) return "module";
|
||||
if (isExamPrep && routeCourseId) return "courses";
|
||||
return null;
|
||||
}, [backToParam, isExamPrep, routeModuleId, routeCourseId]);
|
||||
|
||||
const courseId = isExamPrep
|
||||
? routeCourseId ?? searchParams.get("courseId")
|
||||
: searchParams.get("courseId");
|
||||
const moduleId = isExamPrep
|
||||
? routeModuleId ?? searchParams.get("moduleId")
|
||||
: searchParams.get("moduleId");
|
||||
const unitId = isExamPrep ? routeUnitId : null;
|
||||
|
||||
const lessonTitleDisplay = (() => {
|
||||
const raw = lessonTitleRaw?.trim();
|
||||
if (!raw) return null;
|
||||
|
|
@ -52,12 +80,15 @@ export function AddPracticeFlow() {
|
|||
}
|
||||
})();
|
||||
|
||||
const isModuleContext = backTo === "module";
|
||||
const isCourseContext = backTo === "modules";
|
||||
const isModuleContext = effectiveBackTo === "module";
|
||||
const isCourseContext =
|
||||
effectiveBackTo === "modules" || effectiveBackTo === "courses";
|
||||
const isLessonPractice = useMemo(() => {
|
||||
const lid = lessonId ? Number(lessonId) : NaN;
|
||||
return Number.isFinite(lid) && lid > 0;
|
||||
}, [lessonId]);
|
||||
/** Learn English lesson practices skip story fields; exam prep lessons use the full form. */
|
||||
const isLearnEnglishLessonPractice = isLessonPractice && !isExamPrep;
|
||||
|
||||
const parentContext = useMemo((): {
|
||||
kind: PracticeParentKind;
|
||||
|
|
@ -89,18 +120,60 @@ export function AddPracticeFlow() {
|
|||
courseId,
|
||||
]);
|
||||
|
||||
const programLabel = isExamPrep
|
||||
? programType === "skill"
|
||||
? "Skill-Based Courses"
|
||||
: "English Proficiency Exams"
|
||||
: level
|
||||
? `Program ${level}`
|
||||
: null;
|
||||
|
||||
const backLabel =
|
||||
backTo === "module"
|
||||
effectiveBackTo === "module"
|
||||
? "Back to Module"
|
||||
: backTo === "modules"
|
||||
: effectiveBackTo === "modules"
|
||||
? "Back to Modules"
|
||||
: effectiveBackTo === "courses"
|
||||
? "Back to Course"
|
||||
: isExamPrep
|
||||
? "Back to Program"
|
||||
: "Back to Courses";
|
||||
const backPath =
|
||||
backTo === "module" && courseId && moduleId
|
||||
? `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`
|
||||
: backTo === "modules" && courseId
|
||||
? `/new-content/learn-english/${level}/courses/${courseId}`
|
||||
: `/new-content/learn-english/${level}/courses`;
|
||||
|
||||
const backPath = useMemo(() => {
|
||||
if (isExamPrep) {
|
||||
if (
|
||||
effectiveBackTo === "module" &&
|
||||
programType &&
|
||||
courseId &&
|
||||
unitId &&
|
||||
moduleId
|
||||
) {
|
||||
return `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`;
|
||||
}
|
||||
if (effectiveBackTo === "courses" && programType && courseId) {
|
||||
return `/new-content/courses/${programType}/${courseId}`;
|
||||
}
|
||||
if (programType) {
|
||||
return `/new-content/courses/${programType}`;
|
||||
}
|
||||
return "/new-content";
|
||||
}
|
||||
if (effectiveBackTo === "module" && level && courseId && moduleId) {
|
||||
return `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
||||
}
|
||||
if (effectiveBackTo === "modules" && level && courseId) {
|
||||
return `/new-content/learn-english/${level}/courses/${courseId}`;
|
||||
}
|
||||
return `/new-content/learn-english/${level}/courses`;
|
||||
}, [
|
||||
isExamPrep,
|
||||
effectiveBackTo,
|
||||
programType,
|
||||
courseId,
|
||||
unitId,
|
||||
moduleId,
|
||||
level,
|
||||
]);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
|
|
@ -194,7 +267,7 @@ export function AddPracticeFlow() {
|
|||
return;
|
||||
}
|
||||
if (
|
||||
!isLessonPractice &&
|
||||
!isLearnEnglishLessonPractice &&
|
||||
(!formData.title.trim() || !formData.description.trim())
|
||||
) {
|
||||
toast.error("Title and story description are required", {
|
||||
|
|
@ -243,26 +316,35 @@ export function AddPracticeFlow() {
|
|||
lessonTitleDisplay?.trim() ||
|
||||
(lessonId ? `Lesson ${lessonId} practice` : "Lesson practice");
|
||||
|
||||
const useExamPrepLessonApi =
|
||||
isExamPrep &&
|
||||
isLessonPractice &&
|
||||
parentContext.kind === "LESSON" &&
|
||||
Number.isFinite(parentContext.id);
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await executeLearnEnglishPracticeCreation({
|
||||
parentKind: parentContext.kind,
|
||||
parentId: parentContext.id,
|
||||
examPrepLessonId: useExamPrepLessonApi ? parentContext.id : undefined,
|
||||
status,
|
||||
questionSetTitle: isLessonPractice
|
||||
questionSetTitle: isLearnEnglishLessonPractice
|
||||
? lessonDefaultTitle
|
||||
: formData.title.trim() || "Practice set",
|
||||
questionSetDescription: isLessonPractice
|
||||
questionSetDescription: isLearnEnglishLessonPractice
|
||||
? null
|
||||
: formData.description.trim() || null,
|
||||
shuffleQuestions: formData.shuffleQuestions,
|
||||
practiceTitle: isLessonPractice
|
||||
practiceTitle: isLearnEnglishLessonPractice
|
||||
? lessonDefaultTitle
|
||||
: formData.title.trim() || "Untitled practice",
|
||||
storyDescription: isLessonPractice
|
||||
storyDescription: isLearnEnglishLessonPractice
|
||||
? ""
|
||||
: formData.description.trim(),
|
||||
storyImage: isLessonPractice ? "" : formData.storyImageUrl.trim(),
|
||||
storyImage: isLearnEnglishLessonPractice
|
||||
? ""
|
||||
: formData.storyImageUrl.trim(),
|
||||
quickTips: formData.tips.trim(),
|
||||
personaName: persona?.name ?? null,
|
||||
personaId,
|
||||
|
|
@ -368,7 +450,7 @@ export function AddPracticeFlow() {
|
|||
setFormData={setFormData}
|
||||
nextStep={nextStep}
|
||||
onCancel={() => navigate(backPath)}
|
||||
isLessonPractice={isLessonPractice}
|
||||
isLessonPractice={isLearnEnglishLessonPractice}
|
||||
lessonTitle={lessonTitleDisplay}
|
||||
parentSummary={parentSummary}
|
||||
/>
|
||||
|
|
@ -404,9 +486,9 @@ export function AddPracticeFlow() {
|
|||
formData={formData}
|
||||
selectedPersona={selectedPersona}
|
||||
personas={personas}
|
||||
isLessonPractice={isLessonPractice}
|
||||
isLessonPractice={isLearnEnglishLessonPractice}
|
||||
lessonTitle={lessonTitleDisplay}
|
||||
programLabel={level ? `Program ${level}` : null}
|
||||
programLabel={programLabel}
|
||||
courseLabel={courseId ? `Course ${courseId}` : null}
|
||||
moduleLabel={moduleId ? `Module ${moduleId}` : null}
|
||||
prevStep={prevStep}
|
||||
|
|
@ -466,9 +548,9 @@ export function AddPracticeFlow() {
|
|||
formData={formData}
|
||||
selectedPersona={selectedPersona}
|
||||
personas={personas}
|
||||
isLessonPractice={isLessonPractice}
|
||||
isLessonPractice={isLearnEnglishLessonPractice}
|
||||
lessonTitle={lessonTitleDisplay}
|
||||
programLabel={level ? `Program ${level}` : null}
|
||||
programLabel={programLabel}
|
||||
courseLabel={courseId ? `Course ${courseId}` : null}
|
||||
moduleLabel={moduleId ? `Module ${moduleId}` : null}
|
||||
prevStep={prevStep}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
|
|
@ -26,17 +26,27 @@ import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.s
|
|||
import alertSrc from "../../assets/Alert.svg";
|
||||
import {
|
||||
deleteTopLevelCourseModule,
|
||||
getPracticesByParentCourse,
|
||||
getProgramCourses,
|
||||
getTopLevelCourseModules,
|
||||
publishParentLinkedPractice,
|
||||
updateParentLinkedPractice,
|
||||
updateTopLevelCourseModule,
|
||||
} from "../../api/courses.api";
|
||||
import { refreshFileUrl, resolveFileUrl } from "../../api/files.api";
|
||||
import type {
|
||||
ParentContextPractice,
|
||||
ProgramCourseListItem,
|
||||
TopLevelCourseModuleItem,
|
||||
} from "../../types/course.types";
|
||||
import {
|
||||
isPracticeDraft,
|
||||
isPracticePublished,
|
||||
unwrapPracticesList,
|
||||
} from "../../lib/parentContextPractice";
|
||||
import { AddModuleModal } from "./components/AddModuleModal";
|
||||
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
|
||||
import { ModulePracticeCard } from "./components/ModulePracticeCard";
|
||||
import { PublishPracticeButton } from "./components/PublishPracticeButton";
|
||||
|
||||
const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const;
|
||||
|
|
@ -155,6 +165,17 @@ export function CourseDetailPage() {
|
|||
useState<TopLevelCourseModuleItem | null>(null);
|
||||
const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"modules" | "practice">("modules");
|
||||
const [practiceFilter, setPracticeFilter] = useState("All");
|
||||
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 openEditModule = (module: TopLevelCourseModuleItem) => {
|
||||
setEditingModule(module);
|
||||
setEditModuleName(module.name ?? "");
|
||||
|
|
@ -260,6 +281,91 @@ export function CourseDetailPage() {
|
|||
void loadPage();
|
||||
}, [loadPage]);
|
||||
|
||||
const loadCoursePractices = useCallback(async () => {
|
||||
if (!Number.isFinite(courseIdNum) || courseIdNum < 1) {
|
||||
setPractices([]);
|
||||
setPracticesLoadError(null);
|
||||
setPracticesLoading(false);
|
||||
return;
|
||||
}
|
||||
setPracticesLoading(true);
|
||||
setPracticesLoadError(null);
|
||||
try {
|
||||
const res = await getPracticesByParentCourse(courseIdNum, {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
setPractices(unwrapPracticesList(res));
|
||||
} catch {
|
||||
setPractices([]);
|
||||
setPracticesLoadError("Failed to load practices. Please try again.");
|
||||
} finally {
|
||||
setPracticesLoading(false);
|
||||
}
|
||||
}, [courseIdNum]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "practice") return;
|
||||
void loadCoursePractices();
|
||||
}, [activeTab, loadCoursePractices]);
|
||||
|
||||
const filteredPractices = useMemo(() => {
|
||||
if (practiceFilter === "Published") {
|
||||
return practices.filter(isPracticePublished);
|
||||
}
|
||||
if (practiceFilter === "Draft") {
|
||||
return practices.filter(isPracticeDraft);
|
||||
}
|
||||
if (practiceFilter === "Archived") {
|
||||
return [];
|
||||
}
|
||||
return practices;
|
||||
}, [practices, practiceFilter]);
|
||||
|
||||
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 handleSaveModuleEdit = async () => {
|
||||
if (!editingModule) return;
|
||||
const name = editModuleName.trim();
|
||||
|
|
@ -391,20 +497,32 @@ export function CourseDetailPage() {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
<div className="border-b border-grayScale-200">
|
||||
<div className="flex gap-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("modules")}
|
||||
className={cn(
|
||||
"pb-4 text-[16px] font-medium transition-all relative",
|
||||
activeTab === "modules"
|
||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
|
||||
: "text-grayScale-400 hover:text-grayScale-600",
|
||||
)}
|
||||
>
|
||||
<div className="w-full border-t border-grayScale-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<div
|
||||
className="h-[0.5px] w-full rounded-full opacity-20"
|
||||
style={{
|
||||
background: "gray",
|
||||
}}
|
||||
/>
|
||||
Modules
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("practice")}
|
||||
className={cn(
|
||||
"pb-4 text-[16px] font-medium transition-all relative",
|
||||
activeTab === "practice"
|
||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
|
||||
: "text-grayScale-400 hover:text-grayScale-600",
|
||||
)}
|
||||
>
|
||||
Practice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -496,14 +614,14 @@ export function CourseDetailPage() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{modules.length === 0 ? (
|
||||
{activeTab === "modules" ? (
|
||||
modules.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No modules in this course yet
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
Add modules when your workflow is connected, or create them via
|
||||
the API.
|
||||
Add a module to organize lessons and practices for this course.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -590,6 +708,91 @@ export function CourseDetailPage() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-10 overflow-x-auto whitespace-nowrap rounded-2xl border border-grayScale-100 bg-white px-8 py-4 shadow-sm">
|
||||
<div className="mr-2 flex items-center gap-2 text-[12px] font-bold uppercase tracking-widest text-grayScale-300">
|
||||
STATUS:
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{["All", "Published", "Draft", "Archived"].map((label) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => setPracticeFilter(label)}
|
||||
className={cn(
|
||||
"h-9 rounded-full px-5 text-[13px] font-bold transition-all",
|
||||
practiceFilter === label
|
||||
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
|
||||
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{practicesLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-[15px] font-medium text-grayScale-500">
|
||||
Loading practices…
|
||||
</div>
|
||||
) : practicesLoadError ? (
|
||||
<div className="mx-auto max-w-lg rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900">
|
||||
{practicesLoadError}
|
||||
</div>
|
||||
) : filteredPractices.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{filteredPractices.map((practice) => (
|
||||
<ModulePracticeCard
|
||||
key={practice.id}
|
||||
practice={practice}
|
||||
statusUpdating={publishStatusPracticeId === practice.id}
|
||||
onEdit={() =>
|
||||
navigate(`/content/practices?type=course&id=${courseIdNum}`)
|
||||
}
|
||||
onPublish={() => void handlePublishPractice(practice.id)}
|
||||
onSaveAsDraft={() =>
|
||||
void handleSavePracticeAsDraft(practice.id)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto flex max-w-4xl flex-col items-center justify-center rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white px-4 py-32 shadow-sm">
|
||||
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-[#FAF5FF]">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-[#F5EBFF]">
|
||||
<Calendar className="h-7 w-7 text-brand-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mb-3 text-2xl font-extrabold text-grayScale-900">
|
||||
{practices.length === 0
|
||||
? "No practices for this course yet"
|
||||
: "No practices match this filter"}
|
||||
</h2>
|
||||
<p className="mb-10 max-w-sm text-center text-[15px] font-medium leading-relaxed text-grayScale-400">
|
||||
{practices.length === 0
|
||||
? "Add a course-level practice to give learners exercises attached to this course."
|
||||
: "Try another status filter or add a new practice."}
|
||||
</p>
|
||||
{practices.length === 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex h-12 items-center gap-2 rounded-xl border-brand-500 px-8 font-bold text-brand-500 transition-all hover:bg-brand-50"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Calendar className="h-5 w-5" />
|
||||
Add Practice
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deletingModule && (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { Link, useParams, useNavigate } from "react-router-dom";
|
|||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
PlayCircle,
|
||||
ClipboardCheck,
|
||||
|
|
@ -556,17 +555,6 @@ export function CourseManagementPage() {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
|
||||
onClick={() =>
|
||||
navigate(`/new-content/courses/${programType}/attach-practice`)
|
||||
}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
Attach Practice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowLeft, Plus, FileText, Pencil, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, Plus, FileText, Video } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
|
@ -23,10 +23,19 @@ import {
|
|||
updateExamPrepModuleLesson,
|
||||
deleteExamPrepModuleLesson,
|
||||
getExamPrepModuleLessons,
|
||||
publishExamPrepModuleLesson,
|
||||
} from "../../api/courses.api";
|
||||
import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
|
||||
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
|
||||
import type { PracticePublishStatus } from "../../types/course.types";
|
||||
|
||||
const LESSON_THUMB_GRADIENTS = [
|
||||
"from-[#CBD5E1] to-[#94A3B8]",
|
||||
"from-[#DBEAFE] to-[#93C5FD]",
|
||||
"from-[#FEF3C7] to-[#FCD34D]",
|
||||
"from-[#FCE7F3] to-[#F9A8D4]",
|
||||
] as const;
|
||||
|
||||
const MOCK_PRACTICES = [
|
||||
{
|
||||
id: "p1",
|
||||
|
|
@ -61,13 +70,17 @@ export function CourseModuleDetailPage() {
|
|||
id: number;
|
||||
title: string;
|
||||
videoUrl: string;
|
||||
description: string;
|
||||
description: string | null;
|
||||
thumbnail: string;
|
||||
sortOrder: number;
|
||||
gradient: string;
|
||||
publishStatus: PracticePublishStatus | string | null;
|
||||
durationSeconds: number | null;
|
||||
}>
|
||||
>([]);
|
||||
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
|
||||
const [publishStatusLessonId, setPublishStatusLessonId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [createLessonOpen, setCreateLessonOpen] = useState(false);
|
||||
const [createTitle, setCreateTitle] = useState("");
|
||||
const [createVideoUrl, setCreateVideoUrl] = useState("");
|
||||
|
|
@ -123,6 +136,7 @@ export function CourseModuleDetailPage() {
|
|||
return;
|
||||
}
|
||||
setLessonsLoading(true);
|
||||
setLessonsLoadError(null);
|
||||
try {
|
||||
const response = await getExamPrepModuleLessons(parsedModuleId, {
|
||||
limit: 20,
|
||||
|
|
@ -131,7 +145,7 @@ export function CourseModuleDetailPage() {
|
|||
const rows = response.data?.data?.lessons;
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
setLessons(
|
||||
list.map((row, index) => {
|
||||
list.map((row) => {
|
||||
const raw = row.duration_seconds ?? row.duration ?? null;
|
||||
const n =
|
||||
raw == null ? NaN : typeof raw === "number" ? raw : Number(raw);
|
||||
|
|
@ -141,22 +155,17 @@ export function CourseModuleDetailPage() {
|
|||
id: Number(row.id),
|
||||
title: row.title?.trim() || `Lesson ${row.id}`,
|
||||
videoUrl: row.video_url?.trim() || "",
|
||||
description: row.description?.trim() || "—",
|
||||
description: row.description?.trim() || null,
|
||||
thumbnail: row.thumbnail?.trim() || "",
|
||||
sortOrder: Number(row.sort_order ?? 0),
|
||||
publishStatus: row.publish_status ?? null,
|
||||
durationSeconds,
|
||||
gradient:
|
||||
index % 3 === 1
|
||||
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)"
|
||||
: index % 3 === 2
|
||||
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)"
|
||||
: "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)",
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to load lessons");
|
||||
setLessonsLoadError("Failed to load lessons. Please try again.");
|
||||
setLessons([]);
|
||||
} finally {
|
||||
setLessonsLoading(false);
|
||||
|
|
@ -463,6 +472,45 @@ export function CourseModuleDetailPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleToggleLessonPublishStatus = async (
|
||||
lessonId: number,
|
||||
nextStatus: PracticePublishStatus,
|
||||
) => {
|
||||
setPublishStatusLessonId(lessonId);
|
||||
try {
|
||||
await publishExamPrepModuleLesson(lessonId, {
|
||||
publish_status: nextStatus,
|
||||
});
|
||||
setLessons((prev) =>
|
||||
prev.map((l) =>
|
||||
l.id === lessonId ? { ...l, publishStatus: nextStatus } : l,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextStatus === "PUBLISHED"
|
||||
? "Lesson published"
|
||||
: "Lesson saved as draft",
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(nextStatus === "PUBLISHED"
|
||||
? "Failed to publish lesson"
|
||||
: "Failed to save lesson as draft");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setPublishStatusLessonId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const lessonAttachPracticePath = (lesson: (typeof lessons)[number]) =>
|
||||
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`;
|
||||
|
||||
const lessonPracticesPath = (lesson: (typeof lessons)[number]) =>
|
||||
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/lessons/${lesson.id}/practices?lessonTitle=${encodeURIComponent(lesson.title)}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
||||
{/* Navigation */}
|
||||
|
|
@ -491,12 +539,12 @@ export function CourseModuleDetailPage() {
|
|||
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2 shadow-sm"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/new-content/courses/${programType}/${courseId}/unit/${unitId}/module/${moduleId}/attach-practice`,
|
||||
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
Attach Practice
|
||||
Add Practice
|
||||
</Button>
|
||||
<Dialog
|
||||
open={createLessonOpen}
|
||||
|
|
@ -693,13 +741,14 @@ export function CourseModuleDetailPage() {
|
|||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-10 border-b border-grayScale-100">
|
||||
<div className="border-b border-grayScale-200">
|
||||
<div className="flex gap-10">
|
||||
<button
|
||||
onClick={() => setActiveTab("video")}
|
||||
className={cn(
|
||||
"pb-4 text-[16px] font-bold transition-all relative px-2",
|
||||
"pb-4 text-[16px] font-medium transition-all relative",
|
||||
activeTab === "video"
|
||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
|
||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
|
||||
: "text-grayScale-400 hover:text-grayScale-600",
|
||||
)}
|
||||
>
|
||||
|
|
@ -708,48 +757,85 @@ export function CourseModuleDetailPage() {
|
|||
<button
|
||||
onClick={() => setActiveTab("practice")}
|
||||
className={cn(
|
||||
"pb-4 text-[16px] font-bold transition-all relative px-2",
|
||||
"pb-4 text-[16px] font-medium transition-all relative",
|
||||
activeTab === "practice"
|
||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
|
||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
|
||||
: "text-grayScale-400 hover:text-grayScale-600",
|
||||
)}
|
||||
>
|
||||
Practice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid of Content */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
|
||||
{/* Content */}
|
||||
<div className="mt-8">
|
||||
{activeTab === "video" ? (
|
||||
lessonsLoading ? (
|
||||
<p className="text-sm text-grayScale-500">Loading lessons...</p>
|
||||
) : lessons.length === 0 ? (
|
||||
<div className="col-span-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No lessons for this module yet
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
Create your first lesson to start building this module.
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
|
||||
Loading lessons…
|
||||
</div>
|
||||
) : (
|
||||
lessons.map((lesson) => (
|
||||
) : lessonsLoadError ? (
|
||||
<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">
|
||||
{lessonsLoadError}
|
||||
</div>
|
||||
) : lessons.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{lessons.map((lesson, i) => (
|
||||
<VideoCard
|
||||
key={lesson.id}
|
||||
id={lesson.id}
|
||||
title={lesson.title}
|
||||
thumbnailUrl={lesson.thumbnail}
|
||||
videoUrl={lesson.videoUrl}
|
||||
thumbnailGradient={lesson.gradient}
|
||||
durationSeconds={lesson.durationSeconds}
|
||||
publishStatus={lesson.publishStatus}
|
||||
hoverModuleActions
|
||||
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
|
||||
thumbnailGradient={
|
||||
LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]
|
||||
}
|
||||
durationSeconds={lesson.durationSeconds}
|
||||
onEdit={() => openEditLesson(lesson)}
|
||||
onDelete={() => setDeletingLessonId(lesson.id)}
|
||||
description={lesson.description}
|
||||
onAddPractice={() => navigate(lessonAttachPracticePath(lesson))}
|
||||
onViewPractices={() => navigate(lessonPracticesPath(lesson))}
|
||||
onTogglePublishStatus={(nextStatus) =>
|
||||
void handleToggleLessonPublishStatus(lesson.id, nextStatus)
|
||||
}
|
||||
publishStatusUpdating={publishStatusLessonId === lesson.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">
|
||||
<Video className="h-7 w-7 text-brand-500 fill-brand-500/10" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
|
||||
No lessons in this module yet
|
||||
</h2>
|
||||
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
|
||||
Lessons are a great way to engage students. Add your first
|
||||
lesson to get started.
|
||||
</p>
|
||||
<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={() => setCreateLessonOpen(true)}
|
||||
>
|
||||
<Video className="h-5 w-5" />
|
||||
Add Lesson
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
MOCK_PRACTICES.map((item) => <PracticeCard key={item.id} {...item} />)
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{MOCK_PRACTICES.map((item) => (
|
||||
<PracticeCard key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -145,16 +145,21 @@ function PracticeCard({
|
|||
|
||||
export function LessonPracticesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { level, courseId, moduleId, lessonId } = useParams<{
|
||||
level: string;
|
||||
courseId: string;
|
||||
moduleId: string;
|
||||
lessonId: string;
|
||||
const { level, programType, courseId, unitId, moduleId, lessonId } = useParams<{
|
||||
level?: string;
|
||||
programType?: string;
|
||||
courseId?: string;
|
||||
unitId?: string;
|
||||
moduleId?: string;
|
||||
lessonId?: string;
|
||||
}>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const lessonTitle = searchParams.get("lessonTitle")?.trim() || "";
|
||||
|
||||
const backHref = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
||||
const isExamPrep = Boolean(programType?.trim());
|
||||
const backHref = isExamPrep
|
||||
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`
|
||||
: `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
||||
|
||||
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
|
@ -200,7 +205,9 @@ export function LessonPracticesPage() {
|
|||
const displayTitle =
|
||||
lessonTitle || (validLesson ? `Lesson #${lid}` : "Lesson practices");
|
||||
|
||||
const addPracticeHref = `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
|
||||
const addPracticeHref = isExamPrep
|
||||
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`
|
||||
: `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#F4F6FB] via-white to-[#F8FAFC]">
|
||||
|
|
|
|||
|
|
@ -557,11 +557,11 @@ export function ProgramDetailPage() {
|
|||
variant="outline"
|
||||
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold flex items-center gap-2"
|
||||
onClick={() =>
|
||||
navigate(`/new-content/courses/${programType}/attach-practice`)
|
||||
navigate(`/new-content/courses/${programType}/add-practice`)
|
||||
}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
Attach Practice
|
||||
Add Practice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -330,6 +330,7 @@ export interface ExamPrepModuleLessonItem {
|
|||
thumbnail?: string | null
|
||||
description?: string | null
|
||||
sort_order?: number
|
||||
publish_status?: PracticePublishStatus | string | null
|
||||
/** Total length in seconds when the API provides it. */
|
||||
duration?: number | null
|
||||
duration_seconds?: number | null
|
||||
|
|
@ -361,6 +362,29 @@ export interface UpdateExamPrepModuleLessonRequest {
|
|||
sort_order: number
|
||||
}
|
||||
|
||||
/** Publish-only patch: PUT /exam-prep/lessons/:lessonId with { publish_status }. */
|
||||
export interface PublishExamPrepModuleLessonRequest {
|
||||
publish_status: PracticePublishStatus
|
||||
}
|
||||
|
||||
/** POST /exam-prep/lessons/:lessonId/practices */
|
||||
export interface CreateExamPrepLessonPracticeRequest {
|
||||
title: string
|
||||
story_description: string
|
||||
story_image: string
|
||||
persona_id: number
|
||||
question_set_id: number
|
||||
quick_tips: string
|
||||
}
|
||||
|
||||
export interface CreateExamPrepLessonPracticeResponse {
|
||||
message: string
|
||||
data: ParentContextPractice
|
||||
success: boolean
|
||||
status_code: number
|
||||
metadata: unknown | null
|
||||
}
|
||||
|
||||
export interface UpdateExamPrepModuleLessonResponse {
|
||||
message: string
|
||||
data: ExamPrepModuleLessonItem
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user