add speaking list grouping, search, and bulk actions
Group audio questions under practices on the Speaking tab, add client-side search and image previews, and support multi-select bulk deletion of audio questions. Made-with: Cursor
This commit is contained in:
parent
c648c6668b
commit
e8fc835d51
|
|
@ -71,6 +71,11 @@ type PracticeFilterOption = {
|
||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AudioListQuestion = QuestionDetail & {
|
||||||
|
practice_id: number | null
|
||||||
|
practice_title: string | null
|
||||||
|
}
|
||||||
|
|
||||||
const createEmptyDraft = (): AudioQuestionDraft => ({
|
const createEmptyDraft = (): AudioQuestionDraft => ({
|
||||||
questionText: "",
|
questionText: "",
|
||||||
difficulty: "EASY",
|
difficulty: "EASY",
|
||||||
|
|
@ -138,13 +143,17 @@ function toVimeoEmbedUrl(rawUrl: string): string | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SpeakingPage() {
|
export function SpeakingPage() {
|
||||||
const [audioQuestions, setAudioQuestions] = useState<QuestionDetail[]>([])
|
const [audioQuestions, setAudioQuestions] = useState<AudioListQuestion[]>([])
|
||||||
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 [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
|
||||||
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
|
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
|
||||||
const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState<Record<number, string>>({})
|
const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState<Record<number, string>>({})
|
||||||
|
const [imagePreviewByQuestionId, setImagePreviewByQuestionId] = useState<Record<number, string>>({})
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const [selectedQuestionIds, setSelectedQuestionIds] = useState<number[]>([])
|
||||||
|
const [bulkDeleting, setBulkDeleting] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [openCreate, setOpenCreate] = useState(false)
|
const [openCreate, setOpenCreate] = useState(false)
|
||||||
|
|
@ -228,17 +237,18 @@ export function SpeakingPage() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const safePage = page < 1 ? 1 : page
|
const safePage = page < 1 ? 1 : page
|
||||||
const offset = (safePage - 1) * audioPageSize
|
let rows: AudioListQuestion[] = []
|
||||||
let rows: QuestionDetail[] = []
|
|
||||||
let total = 0
|
let total = 0
|
||||||
|
|
||||||
if (selectedPracticeId) {
|
if (selectedPracticeId) {
|
||||||
|
const offset = (safePage - 1) * audioPageSize
|
||||||
const practiceRes = await getPracticeQuestionsByPractice(Number(selectedPracticeId), {
|
const practiceRes = await getPracticeQuestionsByPractice(Number(selectedPracticeId), {
|
||||||
limit: audioPageSize,
|
limit: audioPageSize,
|
||||||
offset,
|
offset,
|
||||||
question_type: "AUDIO",
|
question_type: "AUDIO",
|
||||||
})
|
})
|
||||||
const practiceData = practiceRes.data?.data
|
const practiceData = practiceRes.data?.data
|
||||||
|
const selectedPractice = practiceOptions.find((p) => p.id === Number(selectedPracticeId))
|
||||||
rows = (practiceData?.questions ?? []).map((q) => ({
|
rows = (practiceData?.questions ?? []).map((q) => ({
|
||||||
id: q.question_id || q.id,
|
id: q.question_id || q.id,
|
||||||
question_text: q.question_text,
|
question_text: q.question_text,
|
||||||
|
|
@ -253,28 +263,62 @@ export function SpeakingPage() {
|
||||||
status: q.question_status ?? "DRAFT",
|
status: q.question_status ?? "DRAFT",
|
||||||
created_at: "",
|
created_at: "",
|
||||||
audio_correct_answer_text: q.audio_correct_answer_text ?? undefined,
|
audio_correct_answer_text: q.audio_correct_answer_text ?? undefined,
|
||||||
|
practice_id: Number(selectedPracticeId),
|
||||||
|
practice_title: selectedPractice?.title ?? `Practice #${selectedPracticeId}`,
|
||||||
}))
|
}))
|
||||||
total = practiceData?.total_count ?? rows.length
|
const q = searchQuery.trim().toLowerCase()
|
||||||
} else {
|
if (q) {
|
||||||
const res = await getQuestions({
|
rows = rows.filter((question) => {
|
||||||
question_type: "AUDIO",
|
const haystack =
|
||||||
limit: audioPageSize,
|
`${question.question_text} ${question.audio_correct_answer_text ?? ""} ${question.practice_title ?? ""}`.toLowerCase()
|
||||||
offset,
|
return haystack.includes(q)
|
||||||
})
|
})
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
total = searchQuery.trim() ? rows.length : (practiceData?.total_count ?? rows.length)
|
||||||
|
} else {
|
||||||
|
const groupedRows = await Promise.all(
|
||||||
|
practiceOptions.map(async (practice) => {
|
||||||
|
try {
|
||||||
|
const res = await getPracticeQuestionsByPractice(practice.id, {
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
question_type: "AUDIO",
|
||||||
|
})
|
||||||
|
const questions = res.data?.data?.questions ?? []
|
||||||
|
return 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,
|
||||||
|
practice_id: practice.id,
|
||||||
|
practice_title: practice.title,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
rows = groupedRows.flat()
|
||||||
|
const q = searchQuery.trim().toLowerCase()
|
||||||
|
if (q) {
|
||||||
|
rows = rows.filter((question) => {
|
||||||
|
const haystack =
|
||||||
|
`${question.question_text} ${question.audio_correct_answer_text ?? ""} ${question.practice_title ?? ""}`.toLowerCase()
|
||||||
|
return haystack.includes(q)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
total = rows.length
|
||||||
|
const offset = (safePage - 1) * audioPageSize
|
||||||
|
rows = rows.slice(offset, offset + audioPageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
setAudioQuestions(rows)
|
setAudioQuestions(rows)
|
||||||
|
|
@ -287,7 +331,7 @@ export function SpeakingPage() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [audioPage, audioPageSize, selectedPracticeId])
|
}, [audioPage, audioPageSize, selectedPracticeId, practiceOptions, searchQuery])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAudioQuestions()
|
fetchAudioQuestions()
|
||||||
|
|
@ -446,6 +490,40 @@ export function SpeakingPage() {
|
||||||
}
|
}
|
||||||
}, [audioQuestions, resolvePreviewUrl])
|
}, [audioQuestions, resolvePreviewUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const withImages = audioQuestions.filter((q) => Boolean(q.image_url))
|
||||||
|
if (withImages.length === 0) {
|
||||||
|
setImagePreviewByQuestionId({})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAll = async () => {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
withImages.map(async (question) => {
|
||||||
|
try {
|
||||||
|
const url = await resolvePreviewUrl(question.image_url ?? "")
|
||||||
|
return [question.id, url] as const
|
||||||
|
} catch {
|
||||||
|
return [question.id, ""] as const
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!cancelled) {
|
||||||
|
setImagePreviewByQuestionId(Object.fromEntries(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveAll()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [audioQuestions, resolvePreviewUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedQuestionIds([])
|
||||||
|
}, [selectedPracticeId, audioPage, searchQuery])
|
||||||
|
|
||||||
const resetCreateForm = () => {
|
const resetCreateForm = () => {
|
||||||
setSetTitle("")
|
setSetTitle("")
|
||||||
setSetDescription("")
|
setSetDescription("")
|
||||||
|
|
@ -1111,6 +1189,54 @@ export function SpeakingPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleQuestionSelection = (questionId: number) => {
|
||||||
|
setSelectedQuestionIds((prev) =>
|
||||||
|
prev.includes(questionId) ? prev.filter((id) => id !== questionId) : [...prev, questionId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSelectAllCurrent = () => {
|
||||||
|
const currentIds = audioQuestions.map((q) => q.id)
|
||||||
|
if (currentIds.length === 0) return
|
||||||
|
const allSelected = currentIds.every((id) => selectedQuestionIds.includes(id))
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedQuestionIds((prev) => prev.filter((id) => !currentIds.includes(id)))
|
||||||
|
} else {
|
||||||
|
setSelectedQuestionIds((prev) => Array.from(new Set([...prev, ...currentIds])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkDeleteSelected = async () => {
|
||||||
|
if (selectedQuestionIds.length === 0) return
|
||||||
|
setBulkDeleting(true)
|
||||||
|
try {
|
||||||
|
for (const id of selectedQuestionIds) {
|
||||||
|
await deleteQuestion(id)
|
||||||
|
}
|
||||||
|
setSelectedQuestionIds([])
|
||||||
|
await fetchAudioQuestions()
|
||||||
|
toast.success(`Deleted ${selectedQuestionIds.length} AUDIO question(s)`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete selected AUDIO questions:", error)
|
||||||
|
toast.error("Failed to delete selected AUDIO questions")
|
||||||
|
} finally {
|
||||||
|
setBulkDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedAudioQuestions = useMemo(() => {
|
||||||
|
const groups = new Map<string, { practiceId: number | null; practiceTitle: string; questions: AudioListQuestion[] }>()
|
||||||
|
for (const q of audioQuestions) {
|
||||||
|
const key = q.practice_id ? String(q.practice_id) : "unassigned"
|
||||||
|
const title = q.practice_title || "Unknown practice"
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, { practiceId: q.practice_id, practiceTitle: title, questions: [] })
|
||||||
|
}
|
||||||
|
groups.get(key)?.questions.push(q)
|
||||||
|
}
|
||||||
|
return Array.from(groups.values())
|
||||||
|
}, [audioQuestions])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-7xl space-y-6 pb-10 sm:space-y-8 sm:pb-12">
|
<div className="mx-auto w-full max-w-7xl space-y-6 pb-10 sm:space-y-8 sm:pb-12">
|
||||||
<div className="flex flex-col gap-4 border-b border-grayScale-100 pb-6 sm:flex-row sm:items-end sm:justify-between sm:pb-8">
|
<div className="flex flex-col gap-4 border-b border-grayScale-100 pb-6 sm:flex-row sm:items-end sm:justify-between sm:pb-8">
|
||||||
|
|
@ -1164,6 +1290,18 @@ export function SpeakingPage() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 max-w-md space-y-1.5">
|
||||||
|
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Search</label>
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value)
|
||||||
|
setAudioPage(1)
|
||||||
|
}}
|
||||||
|
placeholder="Search question text, answer text, or practice..."
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
@ -1186,7 +1324,34 @@ export function SpeakingPage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{audioQuestions.map((question, idx) => (
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-grayScale-200 bg-grayScale-50/40 px-3 py-2">
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-grayScale-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={
|
||||||
|
audioQuestions.length > 0 && audioQuestions.every((question) => selectedQuestionIds.includes(question.id))
|
||||||
|
}
|
||||||
|
onChange={toggleSelectAllCurrent}
|
||||||
|
/>
|
||||||
|
Select all on this page
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
disabled={selectedQuestionIds.length === 0 || bulkDeleting}
|
||||||
|
onClick={handleBulkDeleteSelected}
|
||||||
|
>
|
||||||
|
{bulkDeleting ? "Deleting..." : `Delete selected (${selectedQuestionIds.length})`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{groupedAudioQuestions.map((group) => (
|
||||||
|
<div key={group.practiceId ?? "unknown"} className="space-y-2">
|
||||||
|
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50 px-3 py-2 text-sm font-semibold text-grayScale-700">
|
||||||
|
{group.practiceTitle} {group.practiceId ? `(#${group.practiceId})` : ""}
|
||||||
|
</div>
|
||||||
|
{group.questions.map((question, idx) => (
|
||||||
<div
|
<div
|
||||||
key={question.id}
|
key={question.id}
|
||||||
className={`cursor-pointer rounded-xl border px-4 py-3.5 transition-all sm:px-5 ${
|
className={`cursor-pointer rounded-xl border px-4 py-3.5 transition-all sm:px-5 ${
|
||||||
|
|
@ -1195,7 +1360,16 @@ export function SpeakingPage() {
|
||||||
onClick={() => handleOpenQuestionDetail(question.id)}
|
onClick={() => handleOpenQuestionDetail(question.id)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedQuestionIds.includes(question.id)}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onChange={() => toggleQuestionSelection(question.id)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
<p className="text-sm font-medium leading-snug text-grayScale-800">{question.question_text}</p>
|
<p className="text-sm font-medium leading-snug text-grayScale-800">{question.question_text}</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -1221,6 +1395,16 @@ export function SpeakingPage() {
|
||||||
Status: {question.status || "—"}
|
Status: {question.status || "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{question.image_url && imagePreviewByQuestionId[question.id] ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="mb-2 text-xs font-medium text-grayScale-500">Image preview</p>
|
||||||
|
<img
|
||||||
|
src={imagePreviewByQuestionId[question.id]}
|
||||||
|
alt="Question visual"
|
||||||
|
className="h-20 w-32 rounded-md border border-grayScale-200 object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{question.voice_prompt ? (
|
{question.voice_prompt ? (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
<p className="text-xs font-medium text-grayScale-500">Voice prompt preview</p>
|
<p className="text-xs font-medium text-grayScale-500">Voice prompt preview</p>
|
||||||
|
|
@ -1239,6 +1423,8 @@ export function SpeakingPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
{audioTotalCount > audioPageSize ? (
|
{audioTotalCount > audioPageSize ? (
|
||||||
<div className="mt-4 flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 py-2">
|
<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">
|
<p className="text-xs text-grayScale-500 sm:text-sm">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user