From fd0790fe7f8284ddcb48c6c3c294cf196393966c Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 7 Apr 2026 04:51:50 -0700 Subject: [PATCH] add collapsible speaking groups and practice editing Improve Speaking tab UX with collapsible practice sections, searchable practice filter picker, whole-practice selection controls, and a practice metadata editor wired to the backend practice update API. Made-with: Cursor --- src/pages/content-management/SpeakingPage.tsx | 212 ++++++++++++++++-- 1 file changed, 193 insertions(+), 19 deletions(-) diff --git a/src/pages/content-management/SpeakingPage.tsx b/src/pages/content-management/SpeakingPage.tsx index 5cc1c91..50a6d1f 100644 --- a/src/pages/content-management/SpeakingPage.tsx +++ b/src/pages/content-management/SpeakingPage.tsx @@ -1,5 +1,5 @@ import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react" -import { ArrowLeft, ChevronDown, Image as ImageIcon, Loader2, Mic, Plus, Trash2, Upload } from "lucide-react" +import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Loader2, Mic, Pencil, Plus, Trash2, Upload } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card" import { Button } from "../../components/ui/button" import { Input } from "../../components/ui/input" @@ -17,6 +17,7 @@ import { getQuestions, getPracticeQuestionsByPractice, getQuestionSets, + updatePractice, updateQuestion, } from "../../api/courses.api" import { resolveFileUrl, uploadAudioFile, uploadImageFile, uploadVideoFile } from "../../api/files.api" @@ -69,6 +70,9 @@ type AudioQuestionDraft = { type PracticeFilterOption = { id: number title: string + description?: string + persona?: string + status?: string } type AudioListQuestion = QuestionDetail & { @@ -149,11 +153,19 @@ export function SpeakingPage() { const [audioPageSize] = useState(12) const [practiceOptions, setPracticeOptions] = useState([]) const [selectedPracticeId, setSelectedPracticeId] = useState("") + const [practiceFilterOpen, setPracticeFilterOpen] = useState(false) + const [practiceFilterSearch, setPracticeFilterSearch] = useState("") const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState>({}) const [imagePreviewByQuestionId, setImagePreviewByQuestionId] = useState>({}) const [searchQuery, setSearchQuery] = useState("") const [selectedQuestionIds, setSelectedQuestionIds] = useState([]) const [bulkDeleting, setBulkDeleting] = useState(false) + const [collapsedPracticeIds, setCollapsedPracticeIds] = useState([]) + const [editingPractice, setEditingPractice] = useState(null) + const [practiceEditTitle, setPracticeEditTitle] = useState("") + const [practiceEditDescription, setPracticeEditDescription] = useState("") + const [practiceEditPersona, setPracticeEditPersona] = useState("") + const [practiceEditSaving, setPracticeEditSaving] = useState(false) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [openCreate, setOpenCreate] = useState(false) @@ -388,6 +400,9 @@ export function SpeakingPage() { speakingPractices.map((p) => ({ id: p.id, title: p.title, + description: p.description ?? "", + persona: p.persona ?? "", + status: p.status ?? "", })), ) }, []) @@ -626,6 +641,14 @@ export function SpeakingPage() { if (vimeoEmbedUrl) return { kind: "iframe" as const, src: vimeoEmbedUrl } return { kind: "video" as const, src: value } }, [introVideoUrl]) + const filteredPracticeOptions = useMemo(() => { + const query = practiceFilterSearch.trim().toLowerCase() + if (!query) return practiceOptions + return practiceOptions.filter((practice) => { + const haystack = `${practice.title} ${practice.id}`.toLowerCase() + return haystack.includes(query) + }) + }, [practiceOptions, practiceFilterSearch]) const updateDraft = (index: number, updater: (draft: AudioQuestionDraft) => AudioQuestionDraft) => { setQuestionDrafts((prev) => prev.map((draft, idx) => (idx === index ? updater(draft) : draft))) @@ -1224,6 +1247,58 @@ export function SpeakingPage() { } } + const togglePracticeCollapsed = (practiceId: number | null) => { + if (!practiceId) return + setCollapsedPracticeIds((prev) => + prev.includes(practiceId) ? prev.filter((id) => id !== practiceId) : [...prev, practiceId], + ) + } + + const togglePracticeSelection = (practiceId: number | null) => { + if (!practiceId) return + const questionIds = audioQuestions.filter((q) => q.practice_id === practiceId).map((q) => q.id) + if (questionIds.length === 0) return + const allSelected = questionIds.every((id) => selectedQuestionIds.includes(id)) + setSelectedQuestionIds((prev) => + allSelected ? prev.filter((id) => !questionIds.includes(id)) : Array.from(new Set([...prev, ...questionIds])), + ) + } + + const handleOpenPracticeEdit = (practiceId: number | null) => { + if (!practiceId) return + const practice = practiceOptions.find((p) => p.id === practiceId) + if (!practice) return + setEditingPractice(practice) + setPracticeEditTitle(practice.title) + setPracticeEditDescription(practice.description ?? "") + setPracticeEditPersona(practice.persona ?? "") + } + + const handleSavePracticeEdit = async () => { + if (!editingPractice) return + if (!practiceEditTitle.trim()) { + toast.error("Practice title is required") + return + } + setPracticeEditSaving(true) + try { + await updatePractice(editingPractice.id, { + title: practiceEditTitle.trim(), + description: practiceEditDescription.trim(), + ...(practiceEditPersona.trim() ? { persona: practiceEditPersona.trim() } : {}), + }) + toast.success("Practice updated") + setEditingPractice(null) + await fetchPracticeOptions() + await fetchAudioQuestions() + } catch (error) { + console.error("Failed to update practice:", error) + toast.error("Failed to update practice") + } finally { + setPracticeEditSaving(false) + } + } + const groupedAudioQuestions = useMemo(() => { const groups = new Map() for (const q of audioQuestions) { @@ -1274,21 +1349,47 @@ export function SpeakingPage() { - + + + + + + setPracticeFilterSearch(e.target.value)} + placeholder="Search practices..." + className="mb-2 h-9" + /> +
+ { + setSelectedPracticeId(value) + setAudioPage(1) + setPracticeFilterOpen(false) + }} + > + All practices + {filteredPracticeOptions.map((practice) => ( + + {practice.title} (#{practice.id}) + + ))} + +
+
+
@@ -1348,10 +1449,45 @@ export function SpeakingPage() {
{groupedAudioQuestions.map((group) => (
-
- {group.practiceTitle} {group.practiceId ? `(#${group.practiceId})` : ""} +
+
+
+ + +
+ +
- {group.questions.map((question, idx) => ( + {(group.practiceId && collapsedPracticeIds.includes(group.practiceId) ? [] : group.questions).map((question, idx) => (
)} + {editingPractice && ( +
+
+
+

+ Edit practice metadata ({editingPractice.id}) +

+
+
+
+ + setPracticeEditTitle(e.target.value)} /> +
+
+ +