From 21a23d9a88559a3e1eb52af8ee4e328fd2438583 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 7 Apr 2026 03:08:18 -0700 Subject: [PATCH] speaking and questions content filtering --- src/api/courses.api.ts | 9 ++ .../PracticeQuestionsPage.tsx | 63 +++++++++-- src/pages/content-management/SpeakingPage.tsx | 103 +++++++++++------- src/types/course.types.ts | 20 +++- 4 files changed, 141 insertions(+), 54 deletions(-) diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index 6cc1d35..aa66250 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -33,6 +33,7 @@ import type { GetQuestionSetsParams, GetQuestionSetDetailResponse, GetQuestionSetQuestionsResponse, + GetPracticeQuestionsByPracticeResponse, CreateQuestionSetRequest, CreateQuestionSetResponse, AddQuestionToSetRequest, @@ -147,6 +148,14 @@ export const deletePractice = (practiceId: number) => export const getPracticeQuestions = (practiceId: number) => http.get(`/question-sets/${practiceId}/questions`) +export const getPracticeQuestionsByPractice = ( + practiceId: number, + params?: { limit?: number; offset?: number }, +) => + http.get(`/practices/${practiceId}/questions`, { + params, + }) + export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) => http.post("/course-management/practice-questions", data) diff --git a/src/pages/content-management/PracticeQuestionsPage.tsx b/src/pages/content-management/PracticeQuestionsPage.tsx index 592e23b..ba3d055 100644 --- a/src/pages/content-management/PracticeQuestionsPage.tsx +++ b/src/pages/content-management/PracticeQuestionsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { Link, useParams } from "react-router-dom" import { ArrowLeft, Plus, Edit, Trash2, X, Check, ChevronDown, ChevronUp, SlidersHorizontal, ArrowUpDown } from "lucide-react" import practiceSrc from "../../assets/Practice.svg" @@ -9,7 +9,7 @@ import { Badge } from "../../components/ui/badge" import { Button } from "../../components/ui/button" import { getQuestionSetById, - getQuestionSetQuestions, + getPracticeQuestionsByPractice, getQuestionById, deleteQuestion, updateQuestion, @@ -21,7 +21,7 @@ import { Select } from "../../components/ui/select" import { Textarea } from "../../components/ui/textarea" import type { PracticeQuestion, QuestionSetQuestion, QuestionDetail } from "../../types/course.types" -type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" +type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" type DifficultyLevel = "EASY" | "MEDIUM" | "HARD" type GroupByOption = "none" | "type" | "difficulty" type PointsSortOption = "asc" | "desc" @@ -47,12 +47,14 @@ const typeLabels: Record = { MCQ: "Multiple Choice", TRUE_FALSE: "True/False", SHORT: "Short Answer", + AUDIO: "Audio", } const typeColors: Record = { MCQ: "bg-blue-100 text-blue-700", TRUE_FALSE: "bg-purple-100 text-purple-700", SHORT: "bg-green-100 text-green-700", + AUDIO: "bg-brand-100 text-brand-700", } export function PracticeQuestionsPage() { @@ -75,6 +77,9 @@ export function PracticeQuestionsPage() { const [loadingDetailIds, setLoadingDetailIds] = useState>({}) const [groupBy, setGroupBy] = useState("none") const [pointsSort, setPointsSort] = useState("desc") + const [pageSize] = useState(10) + const [currentPage, setCurrentPage] = useState(1) + const [totalQuestions, setTotalQuestions] = useState(0) const [draft, setDraft] = useState({ questionText: "", @@ -194,19 +199,22 @@ export function PracticeQuestionsPage() { }) } - const fetchQuestions = async () => { + const fetchQuestions = useCallback(async (page: number = currentPage) => { if (!practiceId) return try { + const safePage = page < 1 ? 1 : page + const offset = (safePage - 1) * pageSize const [detailRes, questionsRes] = await Promise.all([ getQuestionSetById(Number(practiceId)), - getQuestionSetQuestions(Number(practiceId)), + getPracticeQuestionsByPractice(Number(practiceId), { limit: pageSize, offset }), ]) const detail = detailRes.data?.data setPracticeTitle(detail?.title || "Practice Questions") setPracticeDescription(detail?.description || "") - const mappedQuestions: PracticeQuestion[] = (questionsRes.data?.data ?? []).map( + const payload = questionsRes.data?.data + const mappedQuestions: PracticeQuestion[] = (payload?.questions ?? []).map( (question: QuestionSetQuestion) => ({ id: question.question_id || question.id, practice_id: question.set_id, @@ -214,13 +222,14 @@ export function PracticeQuestionsPage() { points: question.points ?? 0, difficulty_level: question.difficulty_level || "", question_voice_prompt: question.voice_prompt || "", - sample_answer_voice_prompt: "", - sample_answer: question.explanation || "", + sample_answer_voice_prompt: question.sample_answer_voice_prompt || "", + sample_answer: question.audio_correct_answer_text || question.explanation || "", tips: question.tips || "", type: question.question_type === "MCQ" || question.question_type === "TRUE_FALSE" || - question.question_type === "SHORT" + question.question_type === "SHORT" || + question.question_type === "AUDIO" ? question.question_type : question.question_type === "SHORT_ANSWER" ? "SHORT" @@ -228,17 +237,19 @@ export function PracticeQuestionsPage() { }), ) setQuestions(mappedQuestions) + setTotalQuestions(payload?.total_count ?? mappedQuestions.length) + setCurrentPage(safePage) } catch (err) { console.error("Failed to fetch questions:", err) setError("Failed to load questions") } finally { setLoading(false) } - } + }, [practiceId, currentPage, pageSize]) useEffect(() => { fetchQuestions() - }, [practiceId]) + }, [fetchQuestions]) const handleAddQuestion = () => { resetDraft() @@ -525,7 +536,10 @@ export function PracticeQuestionsPage() { {practiceDescription && (

{practiceDescription}

)} -

{questions.length} questions available

+

{questions.length} questions on this page

+

+ Total: {totalQuestions} question{totalQuestions === 1 ? "" : "s"} +

+ + + + )} )} diff --git a/src/pages/content-management/SpeakingPage.tsx b/src/pages/content-management/SpeakingPage.tsx index ba5bdae..18ed4df 100644 --- a/src/pages/content-management/SpeakingPage.tsx +++ b/src/pages/content-management/SpeakingPage.tsx @@ -109,6 +109,9 @@ function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: strin export function SpeakingPage() { const [audioQuestions, setAudioQuestions] = useState([]) + const [audioTotalCount, setAudioTotalCount] = useState(0) + const [audioPage, setAudioPage] = useState(1) + const [audioPageSize] = useState(12) const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState>({}) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) @@ -139,7 +142,7 @@ export function SpeakingPage() { const [deleteTarget, setDeleteTarget] = useState<{ id: number; text: string } | null>(null) const [detailForm, setDetailForm] = useState({ question_text: "", - question_type: "AUDIO" as "AUDIO", + question_type: "AUDIO" as const, difficulty_level: "EASY" as "EASY" | "MEDIUM" | "HARD", points: 1, explanation: "", @@ -187,59 +190,49 @@ export function SpeakingPage() { return res.data?.data?.url ?? "" }, []) - const fetchAudioQuestions = useCallback(async () => { + const fetchAudioQuestions = useCallback(async (page: number = audioPage) => { setLoading(true) try { - const batchSize = 100 - let nextOffset = 0 - let expectedTotal = Number.POSITIVE_INFINITY - let allRows: QuestionDetail[] = [] + const safePage = page < 1 ? 1 : page + const offset = (safePage - 1) * audioPageSize + 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 - while (allRows.length < expectedTotal) { - const res = await getQuestions({ - question_type: "AUDIO", - limit: batchSize, - offset: nextOffset, - }) - const payload = res.data?.data as unknown - const meta = res.data?.metadata as { total_count?: number } | null | undefined - - let chunk: QuestionDetail[] = [] - let chunkTotal: number | undefined - if (Array.isArray(payload)) { - chunk = payload as QuestionDetail[] - chunkTotal = meta?.total_count - } else if ( - payload && - typeof payload === "object" && - Array.isArray((payload as { questions?: unknown[] }).questions) - ) { - const data = payload as { questions: QuestionDetail[]; total_count?: number } - chunk = data.questions - chunkTotal = data.total_count ?? meta?.total_count - } - - allRows = [...allRows, ...chunk] - if (typeof chunkTotal === "number" && Number.isFinite(chunkTotal)) { - expectedTotal = chunkTotal - } - - if (chunk.length < batchSize) break - nextOffset += chunk.length + let rows: QuestionDetail[] = [] + let total = 0 + 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 } - setAudioQuestions(allRows) + setAudioQuestions(rows) + setAudioTotalCount(total) + setAudioPage(safePage) } catch (error) { console.error("Failed to fetch audio questions:", error) setAudioQuestions([]) + setAudioTotalCount(0) } finally { setLoading(false) } - }, []) + }, [audioPage, audioPageSize]) useEffect(() => { fetchAudioQuestions() - }, [fetchAudioQuestions]) + }, [fetchAudioQuestions, audioPageSize]) useEffect(() => { let cancelled = false @@ -988,6 +981,9 @@ export function SpeakingPage() {

Tap a row to view details. Speaking practices create AUDIO question sets linked to a sub-course.

+

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

{loading ? ( @@ -1060,6 +1056,33 @@ export function SpeakingPage() { ) : null} ))} + {audioTotalCount > audioPageSize ? ( +
+

+ Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} +

+
+ + +
+
+ ) : null} )}
diff --git a/src/types/course.types.ts b/src/types/course.types.ts index 6fa575c..7dbc57e 100644 --- a/src/types/course.types.ts +++ b/src/types/course.types.ts @@ -344,7 +344,7 @@ export interface PracticeQuestion { sample_answer_voice_prompt: string sample_answer: string tips: string - type: "MCQ" | "TRUE_FALSE" | "SHORT" + type: "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" } export interface GetPracticeQuestionsResponse { @@ -455,12 +455,15 @@ export interface QuestionSetQuestion { question_id: number display_order: number question_text: string - question_type: "MCQ" | "TRUE_FALSE" | "SHORT" | string + question_type: "MCQ" | "TRUE_FALSE" | "SHORT" | "SHORT_ANSWER" | "AUDIO" | string difficulty_level?: string | null points?: number explanation?: string | null tips?: string | null voice_prompt?: string | null + sample_answer_voice_prompt?: string | null + image_url?: string | null + audio_correct_answer_text?: string | null question_status?: string } @@ -472,6 +475,19 @@ export interface GetQuestionSetQuestionsResponse { metadata: unknown } +export interface GetPracticeQuestionsByPracticeResponse { + message: string + data: { + questions: QuestionSetQuestion[] + total_count: number + limit: number + offset: number + } + success: boolean + status_code: number + metadata: unknown +} + /** POST /question-sets — practices use set_type: "PRACTICE", owner_type: "SUB_COURSE", owner_id: sub-course id */ export interface CreateQuestionSetRequest { title: string