From 06af3a97f2382b9ff06b8d7ea70bc536a70e4283 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Wed, 8 Apr 2026 01:53:48 -0700 Subject: [PATCH] enhance human language practice editing and collapsible hierarchy Expand edit-practice modal to include full question-set metadata fields, raise recorder modal overlay, and add module/sub-module collapse toggles to match path and level expand/collapse behavior. Made-with: Cursor --- src/api/courses.api.ts | 3 + .../PracticeQuestionEditorFields.tsx | 2 +- .../content-management/HumanLanguagePage.tsx | 262 +++++++++++++++--- 3 files changed, 233 insertions(+), 34 deletions(-) diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index 4319d54..c3caf0c 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -242,6 +242,9 @@ export const getQuestionSetQuestions = (questionSetId: number) => export const createQuestionSet = (data: CreateQuestionSetRequest) => http.post("/question-sets", data) +export const updateQuestionSet = (questionSetId: number, data: Partial) => + http.put(`/question-sets/${questionSetId}`, data) + export const addQuestionToSet = (questionSetId: number, data: AddQuestionToSetRequest) => http.post(`/question-sets/${questionSetId}/questions`, data) diff --git a/src/components/content-management/PracticeQuestionEditorFields.tsx b/src/components/content-management/PracticeQuestionEditorFields.tsx index bcf6779..4a63c3d 100644 --- a/src/components/content-management/PracticeQuestionEditorFields.tsx +++ b/src/components/content-management/PracticeQuestionEditorFields.tsx @@ -909,7 +909,7 @@ export function PracticeQuestionEditorFields({ {recordingModal ? ( -
+

Recording {recordingModal.label}

diff --git a/src/pages/content-management/HumanLanguagePage.tsx b/src/pages/content-management/HumanLanguagePage.tsx index 2bc70f0..0f421d7 100644 --- a/src/pages/content-management/HumanLanguagePage.tsx +++ b/src/pages/content-management/HumanLanguagePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useState, type ChangeEvent } from "react" import { Link, useNavigate } from "react-router-dom" import { ChevronDown, @@ -42,7 +42,8 @@ import { getQuestionById, getPracticeQuestions, getPracticeQuestionsByPractice, - updatePractice, + getQuestionSetById, + updateQuestionSet, updateQuestion, } from "../../api/courses.api" import { Badge } from "../../components/ui/badge" @@ -58,6 +59,7 @@ import type { import { cn } from "../../lib/utils" import { toast } from "sonner" import { Input } from "../../components/ui/input" +import { uploadVideoFile } from "../../api/files.api" import { resolveMediaPreviewUrl } from "../../lib/practiceMedia" import { createEmptyPracticeQuestionDraft, @@ -309,6 +311,8 @@ export function HumanLanguagePage() { const [selectedCourseId, setSelectedCourseId] = useState("ALL") const [selectedLevel, setSelectedLevel] = useState("ALL") const [collapsedLevels, setCollapsedLevels] = useState([]) + const [collapsedModuleIds, setCollapsedModuleIds] = useState([]) + const [collapsedSubModuleIds, setCollapsedSubModuleIds] = useState([]) const [creatingKey, setCreatingKey] = useState(null) const [quickSubCategoryName, setQuickSubCategoryName] = useState("") const [quickCourseName, setQuickCourseName] = useState("") @@ -325,7 +329,15 @@ export function HumanLanguagePage() { const [practiceQuestionsState, setPracticeQuestionsState] = useState>({}) const [practiceDialog, setPracticeDialog] = useState({ open: false }) const [questionDialog, setQuestionDialog] = useState({ open: false }) - const [practiceForm, setPracticeForm] = useState({ title: "", description: "", persona: "" }) + const [practiceForm, setPracticeForm] = useState({ + title: "", + description: "", + persona: "", + introVideoUrl: "", + passingScore: 50, + timeLimitMinutes: 60, + shuffleQuestions: false, + }) const [questionDraft, setQuestionDraft] = useState(() => createEmptyPracticeQuestionDraft()) const [questionDetailById, setQuestionDetailById] = useState>({}) const [practiceTargetDelete, setPracticeTargetDelete] = useState<{ id: number; title: string } | null>(null) @@ -341,6 +353,8 @@ export function HumanLanguagePage() { const [questionSubmitAttempted, setQuestionSubmitAttempted] = useState(false) const [practiceFormTouched, setPracticeFormTouched] = useState(false) const [questionFormTouched, setQuestionFormTouched] = useState(false) + const [loadingPracticeForm, setLoadingPracticeForm] = useState(false) + const [uploadingPracticeIntroVideo, setUploadingPracticeIntroVideo] = useState(false) const renderMediaPreview = ( urlRaw: string, @@ -428,6 +442,18 @@ export function HumanLanguagePage() { ) } + const toggleModuleCollapsed = (moduleId: number) => { + setCollapsedModuleIds((prev) => + prev.includes(moduleId) ? prev.filter((id) => id !== moduleId) : [...prev, moduleId], + ) + } + + const toggleSubModuleCollapsed = (subModuleId: number) => { + setCollapsedSubModuleIds((prev) => + prev.includes(subModuleId) ? prev.filter((id) => id !== subModuleId) : [...prev, subModuleId], + ) + } + const levelsWithContentForCourse = (course: HumanLanguageCourseTree) => course.levels.filter((l) => (l.modules?.length ?? 0) > 0).map((l) => l.level.toUpperCase()) @@ -643,7 +669,16 @@ export function HumanLanguagePage() { const getSubModuleSelection = (smKey: string): SubModuleCardSelection => subModuleCardSelection[smKey] ?? { lessonId: null, practiceId: null } - const resetPracticeForm = () => setPracticeForm({ title: "", description: "", persona: "" }) + const resetPracticeForm = () => + setPracticeForm({ + title: "", + description: "", + persona: "", + introVideoUrl: "", + passingScore: 50, + timeLimitMinutes: 60, + shuffleQuestions: false, + }) const resetQuestionForm = () => { setQuestionDraft(createEmptyPracticeQuestionDraft()) } @@ -656,11 +691,37 @@ export function HumanLanguagePage() { navigate(`/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}/add-practice`) } - const openEditPracticeDialog = (subModuleId: number, p: LearningPathPractice) => { + const openEditPracticeDialog = async (subModuleId: number, p: LearningPathPractice) => { setPracticeSubmitAttempted(false) setPracticeFormTouched(false) - setPracticeForm({ title: p.title ?? "", description: "", persona: "" }) setPracticeDialog({ open: true, mode: "edit", subModuleId, practiceId: p.id }) + setLoadingPracticeForm(true) + try { + const detail = (await getQuestionSetById(p.id)).data?.data + setPracticeForm({ + title: detail?.title ?? p.title ?? "", + description: detail?.description ?? "", + persona: detail?.persona ?? "", + introVideoUrl: detail?.intro_video_url ?? "", + passingScore: detail?.passing_score ?? 50, + timeLimitMinutes: detail?.time_limit_minutes ?? 60, + shuffleQuestions: detail?.shuffle_questions ?? false, + }) + } catch (error) { + console.error("Failed to load practice detail:", error) + setPracticeForm({ + title: p.title ?? "", + description: "", + persona: "", + introVideoUrl: "", + passingScore: 50, + timeLimitMinutes: 60, + shuffleQuestions: false, + }) + toast.error("Could not load full practice details") + } finally { + setLoadingPracticeForm(false) + } } const practiceFieldErrors = useMemo(() => { @@ -689,10 +750,14 @@ export function HumanLanguagePage() { }) toast.success("Practice created") } else if (practiceDialog.practiceId) { - await updatePractice(practiceDialog.practiceId, { + await updateQuestionSet(practiceDialog.practiceId, { title: practiceForm.title.trim(), - description: practiceForm.description.trim(), + description: practiceForm.description.trim() || undefined, persona: practiceForm.persona.trim() || undefined, + intro_video_url: practiceForm.introVideoUrl.trim() || undefined, + passing_score: Number.isFinite(practiceForm.passingScore) ? practiceForm.passingScore : undefined, + time_limit_minutes: Number.isFinite(practiceForm.timeLimitMinutes) ? practiceForm.timeLimitMinutes : undefined, + shuffle_questions: practiceForm.shuffleQuestions, }) toast.success("Practice updated") } @@ -709,6 +774,30 @@ export function HumanLanguagePage() { } } + const handlePracticeIntroVideoFileChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = "" + if (!file) return + setUploadingPracticeIntroVideo(true) + try { + const uploadRes = await uploadVideoFile(file, { + title: practiceForm.title.trim() || file.name.replace(/\.[^.]+$/, "") || "Practice intro", + description: practiceForm.description.trim() || undefined, + }) + const finalUrl = uploadRes.data?.data?.embed_url?.trim() + ? `${uploadRes.data.data.embed_url}?h=${uploadRes.data.data.url?.split("/").filter(Boolean).at(-1) ?? ""}` + : uploadRes.data?.data?.url?.trim() + if (!finalUrl) throw new Error("Missing uploaded video url") + setPracticeForm((prev) => ({ ...prev, introVideoUrl: finalUrl })) + toast.success("Intro video uploaded") + } catch (error) { + console.error("Failed to upload intro video:", error) + toast.error("Failed to upload intro video") + } finally { + setUploadingPracticeIntroVideo(false) + } + } + const openCreateQuestionDialog = (practiceId: number) => { setQuestionSubmitAttempted(false) setQuestionFormTouched(false) @@ -1215,8 +1304,26 @@ export function HumanLanguagePage() { ) : ( modules.map((module) => (

+ {(() => { + const moduleCollapsed = collapsedModuleIds.includes(module.id) + return ( + <>
-

Module: {module.title}

+
- {module.sub_modules.map((subModule) => { + {!moduleCollapsed ? module.sub_modules.map((subModule) => { + const subModuleCollapsed = collapsedSubModuleIds.includes(subModule.id) const smKey = `${course.course_id}-${subModule.id}` const panelTab = subModulePanelTab[smKey] ?? "lessons" const cardSel = getSubModuleSelection(smKey) @@ -1282,9 +1390,20 @@ export function HumanLanguagePage() { className="mt-2 overflow-hidden rounded-xl border border-grayScale-200/90 bg-white shadow-sm" >
-

- Sub-module: {subModule.title} -

+ {categoryId ? (
) : null}
- + {!subModuleCollapsed ? ( + <>
@@ -1580,15 +1700,6 @@ export function HumanLanguagePage() { {practiceFetch.questions.length} loaded ) : null} - {categoryId ? ( - - ) : null}
@@ -1623,8 +1734,7 @@ export function HumanLanguagePage() { No questions in this practice yet.

- Add them via Open editor{" "} - or Edit in full view. + Add them via Open editor.

) : ( @@ -1785,9 +1895,7 @@ export function HumanLanguagePage() { practiceFetch.totalCount > practiceFetch.questions.length ? (
Showing {practiceFetch.questions.length} of{" "} - {practiceFetch.totalCount} questions. Open{" "} - Edit in full view for the - rest. + {practiceFetch.totalCount} questions.
) : null}
@@ -1800,9 +1908,14 @@ export function HumanLanguagePage() {
)}
+ + ) : null}
) - })} + }) : null} + + ) + })()} )) )} @@ -1848,17 +1961,23 @@ export function HumanLanguagePage() { } }} > - + {practiceDialog.open && practiceDialog.mode === "edit" ? "Edit Practice" : "Create Practice"} - Manage practice metadata directly from this page. + Manage full practice (question set) metadata directly from this page. {!practiceCanSave ? ( Required fields must be completed before you can save. ) : null} -
+ {loadingPracticeForm ? ( +
+ + Loading practice details... +
+ ) : ( +
+
+ + { + setPracticeFormTouched(true) + setPracticeForm((p) => ({ ...p, introVideoUrl: e.target.value })) + }} + placeholder="https://..." + className="h-10 font-mono text-[13px]" + /> +
+ +
+ {practiceForm.introVideoUrl.trim() + ? renderMediaPreview(practiceForm.introVideoUrl, "video", "", "Intro video") + : null} +
+
+
+ + { + setPracticeFormTouched(true) + setPracticeForm((p) => ({ ...p, passingScore: Number(e.target.value) || 0 })) + }} + className="h-10" + /> +
+
+ + { + setPracticeFormTouched(true) + setPracticeForm((p) => ({ ...p, timeLimitMinutes: Number(e.target.value) || 0 })) + }} + className="h-10" + /> +
+
+
+ + +
+ )} -