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 = (
practiceId: number,
params?: { limit?: number; offset?: number },
params?: { limit?: number; offset?: number; question_type?: string },
) =>
http.get<GetPracticeQuestionsByPracticeResponse>(`/practices/${practiceId}/questions`, {
params,

View File

@ -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<PracticeFilterOption[]>([])
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState<Record<number, string>>({})
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() {
<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.
</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">
Showing page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} ({audioTotalCount} total)
</p>