speaking and questions content filtering
This commit is contained in:
parent
21a23d9a88
commit
e8c601985b
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user