speaking and questions content filtering
This commit is contained in:
parent
6910fb55a4
commit
21a23d9a88
|
|
@ -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<GetQuestionSetQuestionsResponse>(`/question-sets/${practiceId}/questions`)
|
||||
|
||||
export const getPracticeQuestionsByPractice = (
|
||||
practiceId: number,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) =>
|
||||
http.get<GetPracticeQuestionsByPracticeResponse>(`/practices/${practiceId}/questions`, {
|
||||
params,
|
||||
})
|
||||
|
||||
export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) =>
|
||||
http.post("/course-management/practice-questions", data)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<QuestionType, string> = {
|
|||
MCQ: "Multiple Choice",
|
||||
TRUE_FALSE: "True/False",
|
||||
SHORT: "Short Answer",
|
||||
AUDIO: "Audio",
|
||||
}
|
||||
|
||||
const typeColors: Record<QuestionType, string> = {
|
||||
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<Record<number, boolean>>({})
|
||||
const [groupBy, setGroupBy] = useState<GroupByOption>("none")
|
||||
const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc")
|
||||
const [pageSize] = useState(10)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalQuestions, setTotalQuestions] = useState(0)
|
||||
|
||||
const [draft, setDraft] = useState<QuestionDraft>({
|
||||
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 && (
|
||||
<p className="mt-0.5 text-sm text-grayScale-500">{practiceDescription}</p>
|
||||
)}
|
||||
<p className="mt-0.5 text-sm text-grayScale-500">{questions.length} questions available</p>
|
||||
<p className="mt-0.5 text-sm text-grayScale-500">{questions.length} questions on this page</p>
|
||||
<p className="mt-0.5 text-xs text-grayScale-400">
|
||||
Total: {totalQuestions} question{totalQuestions === 1 ? "" : "s"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddQuestion}>
|
||||
|
|
@ -707,6 +721,31 @@ export function PracticeQuestionsPage() {
|
|||
))}
|
||||
</div>
|
||||
))}
|
||||
{totalQuestions > pageSize && (
|
||||
<div className="flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-4 py-3">
|
||||
<p className="text-sm text-grayScale-500">
|
||||
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => void fetchQuestions(currentPage - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)}
|
||||
onClick={() => void fetchQuestions(currentPage + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: strin
|
|||
|
||||
export function SpeakingPage() {
|
||||
const [audioQuestions, setAudioQuestions] = useState<QuestionDetail[]>([])
|
||||
const [audioTotalCount, setAudioTotalCount] = useState(0)
|
||||
const [audioPage, setAudioPage] = useState(1)
|
||||
const [audioPageSize] = useState(12)
|
||||
const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState<Record<number, string>>({})
|
||||
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() {
|
|||
<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>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-6 pt-5 sm:px-6">
|
||||
{loading ? (
|
||||
|
|
@ -1060,6 +1056,33 @@ export function SpeakingPage() {
|
|||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{audioTotalCount > audioPageSize ? (
|
||||
<div className="mt-4 flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 py-2">
|
||||
<p className="text-xs text-grayScale-500 sm:text-sm">
|
||||
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={audioPage <= 1 || loading}
|
||||
onClick={() => fetchAudioQuestions(audioPage - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={audioPage >= Math.ceil(audioTotalCount / audioPageSize) || loading}
|
||||
onClick={() => fetchAudioQuestions(audioPage + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user