speaking and questions content filtering

This commit is contained in:
Yared Yemane 2026-04-07 03:22:19 -07:00
parent 21a23d9a88
commit e8c601985b
2 changed files with 124 additions and 23 deletions

View File

@ -150,7 +150,7 @@ export const getPracticeQuestions = (practiceId: number) =>
export const getPracticeQuestionsByPractice = ( export const getPracticeQuestionsByPractice = (
practiceId: number, practiceId: number,
params?: { limit?: number; offset?: number }, params?: { limit?: number; offset?: number; question_type?: string },
) => ) =>
http.get<GetPracticeQuestionsByPracticeResponse>(`/practices/${practiceId}/questions`, { http.get<GetPracticeQuestionsByPracticeResponse>(`/practices/${practiceId}/questions`, {
params, params,

View File

@ -15,6 +15,8 @@ import {
createQuestion, createQuestion,
createQuestionSet, createQuestionSet,
getQuestions, getQuestions,
getPracticeQuestionsByPractice,
getQuestionSets,
updateQuestion, updateQuestion,
} from "../../api/courses.api" } from "../../api/courses.api"
import { resolveFileUrl, uploadAudioFile, uploadImageFile, uploadVideoFile } from "../../api/files.api" import { resolveFileUrl, uploadAudioFile, uploadImageFile, uploadVideoFile } from "../../api/files.api"
@ -26,7 +28,7 @@ import {
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../../components/ui/dropdown-menu" } 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" import { toast } from "sonner"
const MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024 const MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024
@ -64,6 +66,11 @@ type AudioQuestionDraft = {
recordingSamplePrompt: boolean recordingSamplePrompt: boolean
} }
type PracticeFilterOption = {
id: number
title: string
}
const createEmptyDraft = (): AudioQuestionDraft => ({ const createEmptyDraft = (): AudioQuestionDraft => ({
questionText: "", questionText: "",
difficulty: "EASY", difficulty: "EASY",
@ -112,6 +119,8 @@ export function SpeakingPage() {
const [audioTotalCount, setAudioTotalCount] = useState(0) const [audioTotalCount, setAudioTotalCount] = useState(0)
const [audioPage, setAudioPage] = useState(1) const [audioPage, setAudioPage] = useState(1)
const [audioPageSize] = useState(12) const [audioPageSize] = useState(12)
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState<Record<number, string>>({}) const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState<Record<number, string>>({})
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -195,27 +204,52 @@ export function SpeakingPage() {
try { try {
const safePage = page < 1 ? 1 : page const safePage = page < 1 ? 1 : page
const offset = (safePage - 1) * audioPageSize 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 rows: QuestionDetail[] = []
let total = 0 let total = 0
if (Array.isArray(payload)) {
rows = payload as QuestionDetail[] if (selectedPracticeId) {
total = meta?.total_count ?? rows.length const practiceRes = await getPracticeQuestionsByPractice(Number(selectedPracticeId), {
} else if ( limit: audioPageSize,
payload && offset,
typeof payload === "object" && question_type: "AUDIO",
Array.isArray((payload as { questions?: unknown[] }).questions) })
) { const practiceData = practiceRes.data?.data
const data = payload as { questions: QuestionDetail[]; total_count?: number } rows = (practiceData?.questions ?? []).map((q) => ({
rows = data.questions id: q.question_id || q.id,
total = data.total_count ?? meta?.total_count ?? rows.length 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) setAudioQuestions(rows)
@ -228,11 +262,58 @@ export function SpeakingPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [audioPage, audioPageSize]) }, [audioPage, audioPageSize, selectedPracticeId])
useEffect(() => { useEffect(() => {
fetchAudioQuestions() 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -981,6 +1062,26 @@ export function SpeakingPage() {
<p className="mt-1 text-xs font-normal text-grayScale-500 sm:text-sm"> <p className="mt-1 text-xs font-normal text-grayScale-500 sm:text-sm">
Tap a row to view details. Speaking practices create AUDIO question sets linked to a sub-course. Tap a row to view details. Speaking practices create AUDIO question sets linked to a sub-course.
</p> </p>
<div className="mt-3 max-w-md space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">
Filter by practice
</label>
<select
value={selectedPracticeId}
onChange={(e) => {
setSelectedPracticeId(e.target.value)
setAudioPage(1)
}}
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm text-grayScale-700"
>
<option value="">All practices</option>
{practiceOptions.map((practice) => (
<option key={practice.id} value={String(practice.id)}>
{practice.title} (#{practice.id})
</option>
))}
</select>
</div>
<p className="mt-1 text-xs font-normal text-grayScale-400 sm:text-sm"> <p className="mt-1 text-xs font-normal text-grayScale-400 sm:text-sm">
Showing page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} ({audioTotalCount} total) Showing page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} ({audioTotalCount} total)
</p> </p>