diff --git a/src/pages/content-management/SpeakingPage.tsx b/src/pages/content-management/SpeakingPage.tsx index daecc2c..5cc1c91 100644 --- a/src/pages/content-management/SpeakingPage.tsx +++ b/src/pages/content-management/SpeakingPage.tsx @@ -71,6 +71,11 @@ type PracticeFilterOption = { title: string } +type AudioListQuestion = QuestionDetail & { + practice_id: number | null + practice_title: string | null +} + const createEmptyDraft = (): AudioQuestionDraft => ({ questionText: "", difficulty: "EASY", @@ -138,13 +143,17 @@ function toVimeoEmbedUrl(rawUrl: string): string | null { } export function SpeakingPage() { - const [audioQuestions, setAudioQuestions] = useState([]) + const [audioQuestions, setAudioQuestions] = useState([]) const [audioTotalCount, setAudioTotalCount] = useState(0) const [audioPage, setAudioPage] = useState(1) const [audioPageSize] = useState(12) const [practiceOptions, setPracticeOptions] = useState([]) const [selectedPracticeId, setSelectedPracticeId] = useState("") const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState>({}) + const [imagePreviewByQuestionId, setImagePreviewByQuestionId] = useState>({}) + const [searchQuery, setSearchQuery] = useState("") + const [selectedQuestionIds, setSelectedQuestionIds] = useState([]) + const [bulkDeleting, setBulkDeleting] = useState(false) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [openCreate, setOpenCreate] = useState(false) @@ -228,17 +237,18 @@ export function SpeakingPage() { setLoading(true) try { const safePage = page < 1 ? 1 : page - const offset = (safePage - 1) * audioPageSize - let rows: QuestionDetail[] = [] + let rows: AudioListQuestion[] = [] let total = 0 if (selectedPracticeId) { + const offset = (safePage - 1) * audioPageSize const practiceRes = await getPracticeQuestionsByPractice(Number(selectedPracticeId), { limit: audioPageSize, offset, question_type: "AUDIO", }) const practiceData = practiceRes.data?.data + const selectedPractice = practiceOptions.find((p) => p.id === Number(selectedPracticeId)) rows = (practiceData?.questions ?? []).map((q) => ({ id: q.question_id || q.id, question_text: q.question_text, @@ -253,28 +263,62 @@ export function SpeakingPage() { status: q.question_status ?? "DRAFT", created_at: "", audio_correct_answer_text: q.audio_correct_answer_text ?? undefined, + practice_id: Number(selectedPracticeId), + practice_title: selectedPractice?.title ?? `Practice #${selectedPracticeId}`, })) - total = practiceData?.total_count ?? rows.length - } else { - const res = await getQuestions({ - question_type: "AUDIO", - limit: audioPageSize, - offset, - }) - const payload = res.data?.data as unknown - const meta = res.data?.metadata as { total_count?: number } | null | undefined - if (Array.isArray(payload)) { - rows = payload as QuestionDetail[] - total = meta?.total_count ?? rows.length - } else if ( - payload && - typeof payload === "object" && - Array.isArray((payload as { questions?: unknown[] }).questions) - ) { - const data = payload as { questions: QuestionDetail[]; total_count?: number } - rows = data.questions - total = data.total_count ?? meta?.total_count ?? rows.length + const q = searchQuery.trim().toLowerCase() + if (q) { + rows = rows.filter((question) => { + const haystack = + `${question.question_text} ${question.audio_correct_answer_text ?? ""} ${question.practice_title ?? ""}`.toLowerCase() + return haystack.includes(q) + }) } + total = searchQuery.trim() ? rows.length : (practiceData?.total_count ?? rows.length) + } else { + const groupedRows = await Promise.all( + practiceOptions.map(async (practice) => { + try { + const res = await getPracticeQuestionsByPractice(practice.id, { + limit: 100, + offset: 0, + question_type: "AUDIO", + }) + const questions = res.data?.data?.questions ?? [] + return questions.map((q) => ({ + id: q.question_id || q.id, + question_text: q.question_text, + question_type: q.question_type, + difficulty_level: q.difficulty_level ?? undefined, + points: q.points ?? 0, + explanation: q.explanation ?? undefined, + tips: q.tips ?? undefined, + voice_prompt: q.voice_prompt ?? undefined, + sample_answer_voice_prompt: q.sample_answer_voice_prompt ?? undefined, + image_url: q.image_url ?? undefined, + status: q.question_status ?? "DRAFT", + created_at: "", + audio_correct_answer_text: q.audio_correct_answer_text ?? undefined, + practice_id: practice.id, + practice_title: practice.title, + })) + } catch { + return [] + } + }), + ) + rows = groupedRows.flat() + const q = searchQuery.trim().toLowerCase() + if (q) { + rows = rows.filter((question) => { + const haystack = + `${question.question_text} ${question.audio_correct_answer_text ?? ""} ${question.practice_title ?? ""}`.toLowerCase() + return haystack.includes(q) + }) + } + total = rows.length + const offset = (safePage - 1) * audioPageSize + rows = rows.slice(offset, offset + audioPageSize) } setAudioQuestions(rows) @@ -287,7 +331,7 @@ export function SpeakingPage() { } finally { setLoading(false) } - }, [audioPage, audioPageSize, selectedPracticeId]) + }, [audioPage, audioPageSize, selectedPracticeId, practiceOptions, searchQuery]) useEffect(() => { fetchAudioQuestions() @@ -446,6 +490,40 @@ export function SpeakingPage() { } }, [audioQuestions, resolvePreviewUrl]) + useEffect(() => { + let cancelled = false + const withImages = audioQuestions.filter((q) => Boolean(q.image_url)) + if (withImages.length === 0) { + setImagePreviewByQuestionId({}) + return + } + + const resolveAll = async () => { + const entries = await Promise.all( + withImages.map(async (question) => { + try { + const url = await resolvePreviewUrl(question.image_url ?? "") + return [question.id, url] as const + } catch { + return [question.id, ""] as const + } + }), + ) + if (!cancelled) { + setImagePreviewByQuestionId(Object.fromEntries(entries)) + } + } + + resolveAll() + return () => { + cancelled = true + } + }, [audioQuestions, resolvePreviewUrl]) + + useEffect(() => { + setSelectedQuestionIds([]) + }, [selectedPracticeId, audioPage, searchQuery]) + const resetCreateForm = () => { setSetTitle("") setSetDescription("") @@ -1111,6 +1189,54 @@ export function SpeakingPage() { } } + const toggleQuestionSelection = (questionId: number) => { + setSelectedQuestionIds((prev) => + prev.includes(questionId) ? prev.filter((id) => id !== questionId) : [...prev, questionId], + ) + } + + const toggleSelectAllCurrent = () => { + const currentIds = audioQuestions.map((q) => q.id) + if (currentIds.length === 0) return + const allSelected = currentIds.every((id) => selectedQuestionIds.includes(id)) + if (allSelected) { + setSelectedQuestionIds((prev) => prev.filter((id) => !currentIds.includes(id))) + } else { + setSelectedQuestionIds((prev) => Array.from(new Set([...prev, ...currentIds]))) + } + } + + const handleBulkDeleteSelected = async () => { + if (selectedQuestionIds.length === 0) return + setBulkDeleting(true) + try { + for (const id of selectedQuestionIds) { + await deleteQuestion(id) + } + setSelectedQuestionIds([]) + await fetchAudioQuestions() + toast.success(`Deleted ${selectedQuestionIds.length} AUDIO question(s)`) + } catch (error) { + console.error("Failed to delete selected AUDIO questions:", error) + toast.error("Failed to delete selected AUDIO questions") + } finally { + setBulkDeleting(false) + } + } + + const groupedAudioQuestions = useMemo(() => { + const groups = new Map() + for (const q of audioQuestions) { + const key = q.practice_id ? String(q.practice_id) : "unassigned" + const title = q.practice_title || "Unknown practice" + if (!groups.has(key)) { + groups.set(key, { practiceId: q.practice_id, practiceTitle: title, questions: [] }) + } + groups.get(key)?.questions.push(q) + } + return Array.from(groups.values()) + }, [audioQuestions]) + return (
@@ -1164,6 +1290,18 @@ export function SpeakingPage() { ))}
+
+ + { + setSearchQuery(e.target.value) + setAudioPage(1) + }} + placeholder="Search question text, answer text, or practice..." + className="h-10" + /> +

Showing page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} ({audioTotalCount} total)

@@ -1186,57 +1324,105 @@ export function SpeakingPage() {
) : (
- {audioQuestions.map((question, idx) => ( -
handleOpenQuestionDetail(question.id)} +
+ + +
+ {groupedAudioQuestions.map((group) => ( +
+
+ {group.practiceTitle} {group.practiceId ? `(#${group.practiceId})` : ""} +
+ {group.questions.map((question, idx) => ( +
handleOpenQuestionDetail(question.id)} > - - -
-
- AUDIO - - Difficulty: {question.difficulty_level || "—"} - - - Points: {question.points ?? 0} - - - Status: {question.status || "—"} - -
- {question.voice_prompt ? ( -
-

Voice prompt preview

- {audioPreviewByQuestionId[question.id] ? ( -
- ) : null} - {question.audio_correct_answer_text ? ( -

- Correct answer text:{" "} - {question.audio_correct_answer_text} -

- ) : null} + ))}
))} {audioTotalCount > audioPageSize ? (