diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index aa66250..a9687fe 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -150,7 +150,7 @@ export const getPracticeQuestions = (practiceId: number) => export const getPracticeQuestionsByPractice = ( practiceId: number, - params?: { limit?: number; offset?: number }, + params?: { limit?: number; offset?: number; question_type?: string }, ) => http.get(`/practices/${practiceId}/questions`, { params, diff --git a/src/pages/content-management/SpeakingPage.tsx b/src/pages/content-management/SpeakingPage.tsx index 18ed4df..a99d353 100644 --- a/src/pages/content-management/SpeakingPage.tsx +++ b/src/pages/content-management/SpeakingPage.tsx @@ -15,6 +15,8 @@ import { createQuestion, createQuestionSet, getQuestions, + getPracticeQuestionsByPractice, + getQuestionSets, updateQuestion, } from "../../api/courses.api" import { resolveFileUrl, uploadAudioFile, uploadImageFile, uploadVideoFile } from "../../api/files.api" @@ -26,7 +28,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "../../components/ui/dropdown-menu" -import type { Course, CourseCategory, QuestionDetail, SubCourse } from "../../types/course.types" +import type { Course, CourseCategory, QuestionDetail, QuestionSet, SubCourse } from "../../types/course.types" import { toast } from "sonner" const MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024 @@ -64,6 +66,11 @@ type AudioQuestionDraft = { recordingSamplePrompt: boolean } +type PracticeFilterOption = { + id: number + title: string +} + const createEmptyDraft = (): AudioQuestionDraft => ({ questionText: "", difficulty: "EASY", @@ -112,6 +119,8 @@ export function SpeakingPage() { 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 [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) @@ -195,27 +204,52 @@ export function SpeakingPage() { try { 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 - 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 + + if (selectedPracticeId) { + const practiceRes = await getPracticeQuestionsByPractice(Number(selectedPracticeId), { + limit: audioPageSize, + offset, + question_type: "AUDIO", + }) + const practiceData = practiceRes.data?.data + rows = (practiceData?.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, + })) + 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 + } } setAudioQuestions(rows) @@ -228,11 +262,58 @@ export function SpeakingPage() { } finally { setLoading(false) } - }, [audioPage, audioPageSize]) + }, [audioPage, audioPageSize, selectedPracticeId]) useEffect(() => { fetchAudioQuestions() - }, [fetchAudioQuestions, audioPageSize]) + }, [fetchAudioQuestions, audioPageSize, selectedPracticeId]) + + useEffect(() => { + let cancelled = false + const fetchPractices = async () => { + try { + const batchSize = 100 + let offset = 0 + let total = Number.POSITIVE_INFINITY + const all: QuestionSet[] = [] + while (all.length < total) { + const res = await getQuestionSets({ + set_type: "PRACTICE", + limit: batchSize, + offset, + }) + const payload = res.data?.data + let chunk: QuestionSet[] = [] + let chunkTotal = 0 + if (Array.isArray(payload)) { + chunk = payload + chunkTotal = payload.length + } else if (payload && typeof payload === "object") { + chunk = payload.question_sets ?? [] + chunkTotal = payload.total_count ?? chunk.length + } + all.push(...chunk) + total = chunkTotal + if (chunk.length < batchSize) break + offset += chunk.length + } + if (!cancelled) { + setPracticeOptions( + all.map((p) => ({ + id: p.id, + title: p.title, + })), + ) + } + } catch { + if (!cancelled) setPracticeOptions([]) + } + } + fetchPractices() + return () => { + cancelled = true + } + }, []) useEffect(() => { let cancelled = false @@ -981,6 +1062,26 @@ 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)