Compare commits

...

9 Commits

Author SHA1 Message Date
53a72bef2d human language learn management adjustment 2026-04-07 05:30:23 -07:00
840a64c4e0 remove speaking practice edit controls
Drop the practice-level edit action and modal from the Speaking page while preserving collapsible groups, searchable practice filtering, and question bulk actions.

Made-with: Cursor
2026-04-07 04:58:35 -07:00
fd0790fe7f add collapsible speaking groups and practice editing
Improve Speaking tab UX with collapsible practice sections, searchable practice filter picker, whole-practice selection controls, and a practice metadata editor wired to the backend practice update API.

Made-with: Cursor
2026-04-07 04:51:50 -07:00
e8fc835d51 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
2026-04-07 04:37:51 -07:00
c648c6668b refine speaking practice filter to inspect audio rows
Filter speaking practice options using returned AUDIO question rows instead of total_count so unrelated practices are excluded reliably.

Made-with: Cursor
2026-04-07 04:28:22 -07:00
e7e64ad2ed filter speaking practice dropdown to audio-only sets
Limit the Speaking page practice filter options to sets that contain AUDIO questions and clear stale selected filter values when unavailable.

Made-with: Cursor
2026-04-07 04:23:19 -07:00
2fcf2b47b0 add intro video preview in speaking create form
Show a live intro video preview from the entered URL, using Vimeo embed playback when applicable and HTML5 video fallback for direct links.

Made-with: Cursor
2026-04-07 03:57:30 -07:00
85df446a66 create speaking set before question step
Ensure the speaking question set is created when moving from setup to questions, rename Set status to Status, and default new set status to PUBLISHED.

Made-with: Cursor
2026-04-07 03:53:13 -07:00
7b08b228df refresh speaking practice filter after create
Re-fetch practice options after creating a speaking practice and auto-select the new set so freshly created practices appear immediately in the filter and question list.

Made-with: Cursor
2026-04-07 03:44:41 -07:00
4 changed files with 756 additions and 150 deletions

View File

@ -31,6 +31,7 @@ import { PracticeDetailsPage } from "../pages/content-management/PracticeDetails
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage"
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage"
import { UserLogPage } from "../pages/user-log/UserLogPage"
import { IssuesPage } from "../pages/issues/IssuesPage"
import { ProfilePage } from "../pages/ProfilePage"
@ -76,6 +77,7 @@ export function AppRoutes() {
<Route index element={<CourseCategoryPage />} />
<Route path="courses" element={<AllCoursesPage />} />
<Route path="flows" element={<CourseFlowBuilderPage />} />
<Route path="human-language" element={<HumanLanguagePage />} />
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
{/* Course → Sub-course → Video/Practice */}

View File

@ -4,6 +4,7 @@ import { cn } from "../../lib/utils"
const tabs = [
{ label: "Overview", to: "/content" },
{ label: "Courses", to: "/content/courses" },
{ label: "Human Language", to: "/content/human-language" },
{ label: "Flows", to: "/content/flows" },
{ label: "Speaking", to: "/content/speaking" },
{ label: "Practice", to: "/content/practices" },

View File

@ -0,0 +1,223 @@
import { useEffect, useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { BookOpen, ChevronDown, ChevronRight, Languages } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { getCourseCategories, getCoursesByCategory, getLearningPath } from "../../api/courses.api"
import type { Course, CourseCategory, LearningPathSubCourse } from "../../types/course.types"
const CEFR_LEVELS = ["A1", "A2", "A3", "B1", "B2", "B3", "C1", "C2", "C3"] as const
type CefrLevel = (typeof CEFR_LEVELS)[number]
export function HumanLanguagePage() {
const [loading, setLoading] = useState(false)
const [categories, setCategories] = useState<CourseCategory[]>([])
const [courses, setCourses] = useState<Course[]>([])
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null)
const [selectedCourseId, setSelectedCourseId] = useState<number | null>(null)
const [selectedLevel, setSelectedLevel] = useState<CefrLevel | "ALL">("ALL")
const [collapsedLevels, setCollapsedLevels] = useState<CefrLevel[]>([])
const [subCourses, setSubCourses] = useState<LearningPathSubCourse[]>([])
useEffect(() => {
const loadCategories = async () => {
setLoading(true)
try {
const res = await getCourseCategories()
const items = res.data?.data?.categories ?? []
setCategories(items)
const humanLanguageCategory =
items.find((c) => c.name.toLowerCase().includes("human language")) ??
items.find((c) => c.name.toLowerCase().includes("language")) ??
null
setSelectedCategoryId(humanLanguageCategory?.id ?? (items[0]?.id ?? null))
} finally {
setLoading(false)
}
}
loadCategories().catch(() => undefined)
}, [])
useEffect(() => {
if (!selectedCategoryId) return
const loadCourses = async () => {
setLoading(true)
try {
const res = await getCoursesByCategory(selectedCategoryId)
const items = res.data?.data?.courses ?? []
setCourses(items)
setSelectedCourseId(items[0]?.id ?? null)
} finally {
setLoading(false)
}
}
loadCourses().catch(() => undefined)
}, [selectedCategoryId])
useEffect(() => {
if (!selectedCourseId) return
const loadPath = async () => {
setLoading(true)
try {
const res = await getLearningPath(selectedCourseId)
setSubCourses(res.data?.data?.sub_courses ?? [])
} finally {
setLoading(false)
}
}
loadPath().catch(() => undefined)
}, [selectedCourseId])
const grouped = useMemo(() => {
const base = Object.fromEntries(CEFR_LEVELS.map((level) => [level, [] as LearningPathSubCourse[]])) as Record<
CefrLevel,
LearningPathSubCourse[]
>
for (const subCourse of subCourses) {
const level = (subCourse.sub_level ?? "").toUpperCase() as CefrLevel
if (CEFR_LEVELS.includes(level)) {
base[level].push(subCourse)
}
}
return base
}, [subCourses])
const levelRows = useMemo(
() => (selectedLevel === "ALL" ? CEFR_LEVELS : [selectedLevel]).map((level) => ({ level, rows: grouped[level] })),
[grouped, selectedLevel],
)
const toggleLevel = (level: CefrLevel) => {
setCollapsedLevels((prev) => (prev.includes(level) ? prev.filter((l) => l !== level) : [...prev, level]))
}
return (
<div className="space-y-6">
<div className="rounded-2xl border border-grayScale-200 bg-gradient-to-r from-white to-brand-50/30 p-5 shadow-sm">
<div className="flex items-start gap-3">
<div className="rounded-xl bg-brand-100 p-2 text-brand-700">
<Languages className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold text-grayScale-900">Human Language Content</h2>
<p className="mt-1 text-sm text-grayScale-500">
Dedicated management view for CEFR levels A1 to C3 with no sub-levels.
</p>
</div>
</div>
</div>
<Card className="border-grayScale-200/80 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Filters</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Category</label>
<select
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
value={selectedCategoryId ?? ""}
onChange={(e) => setSelectedCategoryId(Number(e.target.value))}
>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Course</label>
<select
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
value={selectedCourseId ?? ""}
onChange={(e) => setSelectedCourseId(Number(e.target.value))}
>
{courses.map((course) => (
<option key={course.id} value={course.id}>
{course.title}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">Fetch lessons by level</label>
<select
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm"
value={selectedLevel}
onChange={(e) => setSelectedLevel(e.target.value as CefrLevel | "ALL")}
>
<option value="ALL">ALL LEVELS</option>
{CEFR_LEVELS.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</div>
</CardContent>
</Card>
{selectedCategoryId && selectedCourseId ? (
<div className="flex justify-end">
<Link to={`/content/category/${selectedCategoryId}/courses/${selectedCourseId}/sub-courses`}>
<Button variant="outline">Open detailed management</Button>
</Link>
</div>
) : null}
{loading ? (
<div className="flex items-center gap-2 py-8 text-sm text-grayScale-500">
<SpinnerIcon className="h-4 w-4" />
Loading human language lessons...
</div>
) : (
<div className="space-y-3">
{levelRows.map(({ level, rows }) => (
<Card key={level} className="overflow-hidden border-grayScale-200/80 shadow-sm">
<button
type="button"
className="flex w-full items-center justify-between border-b border-grayScale-100 bg-grayScale-50/60 px-4 py-3 text-left"
onClick={() => toggleLevel(level)}
>
<div className="inline-flex items-center gap-2">
{collapsedLevels.includes(level) ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
<span className="text-sm font-semibold text-grayScale-900">{level}</span>
<span className="rounded-md bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">
{rows.length} unit(s)
</span>
</div>
</button>
{!collapsedLevels.includes(level) ? (
<CardContent className="space-y-3 p-4">
{rows.length === 0 ? (
<p className="text-sm text-grayScale-500">No lessons found for this level.</p>
) : (
rows.map((subCourse) => (
<div key={subCourse.id} className="rounded-xl border border-grayScale-200 bg-white p-3">
<p className="text-sm font-semibold text-grayScale-900">{subCourse.title}</p>
<p className="mt-1 text-xs text-grayScale-500">
{subCourse.videos.length} lesson video(s) {subCourse.practices.length} practice(s)
</p>
<div className="mt-2 space-y-1">
{subCourse.videos.map((video) => (
<div key={video.id} className="inline-flex items-center gap-2 rounded-md bg-grayScale-50 px-2 py-1 text-xs text-grayScale-700">
<BookOpen className="h-3.5 w-3.5" />
{video.title}
</div>
))}
</div>
</div>
))
)}
</CardContent>
) : null}
</Card>
))}
</div>
)}
</div>
)
}

View File

@ -1,5 +1,5 @@
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { ArrowLeft, ChevronDown, Image as ImageIcon, Loader2, Mic, Plus, Trash2, Upload } from "lucide-react"
import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Loader2, Mic, Plus, Trash2, Upload } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
@ -14,7 +14,7 @@ import {
getSubCoursesByCourse,
createQuestion,
createQuestionSet,
getQuestions,
// getQuestions,
getPracticeQuestionsByPractice,
getQuestionSets,
updateQuestion,
@ -69,6 +69,14 @@ type AudioQuestionDraft = {
type PracticeFilterOption = {
id: number
title: string
description?: string
persona?: string
status?: string
}
type AudioListQuestion = QuestionDetail & {
practice_id: number | null
practice_title: string | null
}
const createEmptyDraft = (): AudioQuestionDraft => ({
@ -114,14 +122,44 @@ function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: strin
return pageUrl || null
}
function toVimeoEmbedUrl(rawUrl: string): string | null {
try {
const parsed = new URL(rawUrl.trim())
const host = parsed.hostname.toLowerCase()
if (!host.includes("vimeo.com")) return null
if (host.includes("player.vimeo.com") && parsed.pathname.includes("/video/")) {
return parsed.toString()
}
const segments = parsed.pathname.split("/").filter(Boolean)
const videoId = segments.find((segment) => /^\d+$/.test(segment))
if (!videoId) return null
const hash = parsed.searchParams.get("h")
return hash
? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}`
: `https://player.vimeo.com/video/${videoId}`
} catch {
return null
}
}
export function SpeakingPage() {
const [audioQuestions, setAudioQuestions] = useState<QuestionDetail[]>([])
const [audioQuestions, setAudioQuestions] = useState<AudioListQuestion[]>([])
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 [practiceFilterOpen, setPracticeFilterOpen] = useState(false)
const [practiceFilterSearch, setPracticeFilterSearch] = useState("")
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 [collapsedPracticeIds, setCollapsedPracticeIds] = useState<number[]>([])
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [openCreate, setOpenCreate] = useState(false)
@ -136,7 +174,9 @@ export function SpeakingPage() {
const [subCourseLoading, setSubCourseLoading] = useState(false)
const [subCourseSearch, setSubCourseSearch] = useState("")
const [subCourseMenuOpen, setSubCourseMenuOpen] = useState(false)
const [setStatus, setSetStatus] = useState<"DRAFT" | "PUBLISHED">("DRAFT")
const [setStatus, setSetStatus] = useState<"DRAFT" | "PUBLISHED">("PUBLISHED")
const [createdSetId, setCreatedSetId] = useState<number | null>(null)
const [creatingSet, setCreatingSet] = useState(false)
const [currentStep, setCurrentStep] = useState(1)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
@ -203,17 +243,18 @@ export function SpeakingPage() {
setLoading(true)
try {
const safePage = page < 1 ? 1 : page
const offset = (safePage - 1) * audioPageSize
let rows: QuestionDetail[] = []
let rows: AudioListQuestion[] = []
let total = 0
if (selectedPracticeId) {
const offset = (safePage - 1) * audioPageSize
const practiceRes = await getPracticeQuestionsByPractice(Number(selectedPracticeId), {
limit: audioPageSize,
offset,
question_type: "AUDIO",
})
const practiceData = practiceRes.data?.data
const selectedPractice = practiceOptions.find((p) => p.id === Number(selectedPracticeId))
rows = (practiceData?.questions ?? []).map((q) => ({
id: q.question_id || q.id,
question_text: q.question_text,
@ -228,28 +269,62 @@ export function SpeakingPage() {
status: q.question_status ?? "DRAFT",
created_at: "",
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
} 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
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 = 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)
@ -262,59 +337,82 @@ export function SpeakingPage() {
} finally {
setLoading(false)
}
}, [audioPage, audioPageSize, selectedPracticeId])
}, [audioPage, audioPageSize, selectedPracticeId, practiceOptions, searchQuery])
useEffect(() => {
fetchAudioQuestions()
}, [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([])
const fetchPracticeOptions = useCallback(async () => {
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
}
fetchPractices()
return () => {
cancelled = true
}
// Speaking page should only offer practices that already contain AUDIO questions.
const checks = await Promise.all(
all.map(async (practice) => {
try {
const res = await getPracticeQuestionsByPractice(practice.id, {
limit: 20,
offset: 0,
question_type: "AUDIO",
})
const questions = res.data?.data?.questions ?? []
const hasAudioQuestion = questions.some(
(question) => (question.question_type ?? "").toUpperCase() === "AUDIO",
)
return hasAudioQuestion ? practice : null
} catch {
return null
}
}),
)
const speakingPractices = checks.filter((p): p is QuestionSet => p !== null)
setPracticeOptions(
speakingPractices.map((p) => ({
id: p.id,
title: p.title,
description: p.description ?? "",
persona: p.persona ?? "",
status: p.status ?? "",
})),
)
}, [])
useEffect(() => {
fetchPracticeOptions().catch(() => {
setPracticeOptions([])
})
}, [fetchPracticeOptions])
useEffect(() => {
if (!selectedPracticeId) return
const exists = practiceOptions.some((option) => option.id === Number(selectedPracticeId))
if (!exists) setSelectedPracticeId("")
}, [practiceOptions, selectedPracticeId])
useEffect(() => {
let cancelled = false
const fetchSubCourseOptions = async () => {
@ -401,15 +499,87 @@ export function SpeakingPage() {
}
}, [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 = () => {
setSetTitle("")
setSetDescription("")
setIntroVideoUrl("")
setSubCourseId("")
setSetStatus("DRAFT")
setSetStatus("PUBLISHED")
setCreatedSetId(null)
setQuestionDrafts([createEmptyDraft()])
}
const handleProceedToQuestions = async () => {
if (!canProceedToQuestions) return
if (createdSetId) {
setCurrentStep(2)
return
}
const parsedSubCourseId = Number(subCourseId)
if (!Number.isFinite(parsedSubCourseId) || parsedSubCourseId <= 0) {
toast.error("Please select a valid sub-course")
return
}
setCreatingSet(true)
try {
const setRes = await createQuestionSet({
title: setTitle.trim(),
...(setDescription.trim() ? { description: setDescription.trim() } : {}),
set_type: "PRACTICE",
owner_type: "SUB_COURSE",
owner_id: parsedSubCourseId,
status: setStatus,
...(introVideoUrl.trim() ? { intro_video_url: introVideoUrl.trim() } : {}),
})
const setId = setRes.data?.data?.id
if (!setId) throw new Error("Question set creation failed: missing set ID")
setCreatedSetId(setId)
setCurrentStep(2)
toast.success("Practice created. Continue adding questions.")
} catch (error) {
console.error("Failed to create speaking practice set:", error)
toast.error("Failed to create practice set")
} finally {
setCreatingSet(false)
}
}
const handleIntroVideoFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ""
@ -458,6 +628,21 @@ export function SpeakingPage() {
return haystack.includes(q)
})
}, [subCourseOptions, subCourseSearch])
const introVideoPreview = useMemo(() => {
const value = introVideoUrl.trim()
if (!value || !/^https?:\/\//i.test(value)) return null
const vimeoEmbedUrl = toVimeoEmbedUrl(value)
if (vimeoEmbedUrl) return { kind: "iframe" as const, src: vimeoEmbedUrl }
return { kind: "video" as const, src: value }
}, [introVideoUrl])
const filteredPracticeOptions = useMemo(() => {
const query = practiceFilterSearch.trim().toLowerCase()
if (!query) return practiceOptions
return practiceOptions.filter((practice) => {
const haystack = `${practice.title} ${practice.id}`.toLowerCase()
return haystack.includes(query)
})
}, [practiceOptions, practiceFilterSearch])
const updateDraft = (index: number, updater: (draft: AudioQuestionDraft) => AudioQuestionDraft) => {
setQuestionDrafts((prev) => prev.map((draft, idx) => (idx === index ? updater(draft) : draft)))
@ -851,18 +1036,7 @@ export function SpeakingPage() {
setSaving(true)
try {
// 1) Create speaking practice set.
const setRes = await createQuestionSet({
title: setTitle.trim(),
...(setDescription.trim() ? { description: setDescription.trim() } : {}),
set_type: "PRACTICE",
owner_type: "SUB_COURSE",
owner_id: parsedSubCourseId,
status: setStatus,
...(introVideoUrl.trim() ? { intro_video_url: introVideoUrl.trim() } : {}),
})
const setId = setRes.data?.data?.id
const setId = createdSetId
if (!setId) throw new Error("Question set creation failed: missing set ID")
// 2) Create all AUDIO questions then attach in sequence.
@ -894,6 +1068,8 @@ export function SpeakingPage() {
setOpenCreate(false)
setCurrentStep(1)
resetCreateForm()
await fetchPracticeOptions()
setSelectedPracticeId(String(setId))
toast.success(`Speaking practice created with ${draftsToCreate.length} AUDIO question(s)`)
await fetchAudioQuestions()
} catch (error) {
@ -1030,6 +1206,71 @@ 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 togglePracticeCollapsed = (practiceId: number | null) => {
if (!practiceId) return
setCollapsedPracticeIds((prev) =>
prev.includes(practiceId) ? prev.filter((id) => id !== practiceId) : [...prev, practiceId],
)
}
const togglePracticeSelection = (practiceId: number | null) => {
if (!practiceId) return
const questionIds = audioQuestions.filter((q) => q.practice_id === practiceId).map((q) => q.id)
if (questionIds.length === 0) return
const allSelected = questionIds.every((id) => selectedQuestionIds.includes(id))
setSelectedQuestionIds((prev) =>
allSelected ? prev.filter((id) => !questionIds.includes(id)) : Array.from(new Set([...prev, ...questionIds])),
)
}
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 (
<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">
@ -1044,6 +1285,7 @@ export function SpeakingPage() {
<Button
className="h-11 w-full shrink-0 bg-brand-500 px-5 shadow-sm hover:bg-brand-600 sm:w-auto"
onClick={() => {
resetCreateForm()
setOpenCreate(true)
setCurrentStep(1)
}}
@ -1066,21 +1308,59 @@ export function SpeakingPage() {
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">
Filter by practice
</label>
<select
value={selectedPracticeId}
<DropdownMenu open={practiceFilterOpen} onOpenChange={setPracticeFilterOpen}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="h-10 w-full justify-between rounded-md border border-grayScale-200 bg-white px-3 text-sm font-normal text-grayScale-700 hover:bg-grayScale-50"
>
<span className="truncate">
{selectedPracticeId
? `${practiceOptions.find((p) => p.id === Number(selectedPracticeId))?.title ?? "Practice"} (#${selectedPracticeId})`
: "All practices"}
</span>
<ChevronDown className="h-4 w-4 text-grayScale-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[420px] max-w-[92vw] p-2">
<Input
value={practiceFilterSearch}
onChange={(e) => setPracticeFilterSearch(e.target.value)}
placeholder="Search practices..."
className="mb-2 h-9"
/>
<div className="max-h-64 overflow-auto">
<DropdownMenuRadioGroup
value={selectedPracticeId}
onValueChange={(value) => {
setSelectedPracticeId(value)
setAudioPage(1)
setPracticeFilterOpen(false)
}}
>
<DropdownMenuRadioItem value="">All practices</DropdownMenuRadioItem>
{filteredPracticeOptions.map((practice) => (
<DropdownMenuRadioItem key={practice.id} value={String(practice.id)}>
{practice.title} (#{practice.id})
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</div>
</DropdownMenuContent>
</DropdownMenu>
</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) => {
setSelectedPracticeId(e.target.value)
setSearchQuery(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>
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">
Showing page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} ({audioTotalCount} total)
@ -1104,57 +1384,130 @@ export function SpeakingPage() {
</div>
) : (
<div className="space-y-2.5">
{audioQuestions.map((question, idx) => (
<div
key={question.id}
className={`cursor-pointer rounded-xl border px-4 py-3.5 transition-all sm:px-5 ${
idx % 2 === 0 ? "border-grayScale-200 bg-white" : "border-grayScale-100 bg-grayScale-50/80"
} hover:border-brand-300 hover:bg-brand-50/40 hover:shadow-sm`}
onClick={() => handleOpenQuestionDetail(question.id)}
<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}
>
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium leading-snug text-grayScale-800">{question.question_text}</p>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-red-500 hover:bg-red-50 hover:text-red-600"
onClick={(event) => {
event.stopPropagation()
setDeleteTarget({ id: question.id, text: question.question_text })
setConfirmDeleteOpen(true)
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-md bg-brand-100 px-2 py-0.5 font-medium text-brand-800">AUDIO</span>
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
Difficulty: {question.difficulty_level || "—"}
</span>
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
Points: {question.points ?? 0}
</span>
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
Status: {question.status || "—"}
</span>
</div>
{question.voice_prompt ? (
<div className="mt-3 space-y-2">
<p className="text-xs font-medium text-grayScale-500">Voice prompt preview</p>
{audioPreviewByQuestionId[question.id] ? (
<audio controls src={audioPreviewByQuestionId[question.id]} className="h-10 w-full max-w-sm" />
) : (
<p className="text-xs text-grayScale-400">Unable to resolve audio URL.</p>
)}
{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-gradient-to-r from-grayScale-50 to-white px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="inline-flex items-center gap-2">
<button
type="button"
className="rounded-md border border-grayScale-200 bg-white p-1 text-grayScale-600 hover:bg-grayScale-50"
onClick={() => togglePracticeCollapsed(group.practiceId)}
>
{group.practiceId && collapsedPracticeIds.includes(group.practiceId) ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
<label className="inline-flex items-center gap-2 text-sm font-semibold text-grayScale-700">
<input
type="checkbox"
checked={
group.questions.length > 0 &&
group.questions.every((question) => selectedQuestionIds.includes(question.id))
}
onChange={() => togglePracticeSelection(group.practiceId)}
/>
{group.practiceTitle} {group.practiceId ? `(#${group.practiceId})` : ""}
</label>
</div>
</div>
) : null}
{question.audio_correct_answer_text ? (
<p className="mt-2 text-xs text-grayScale-500">
<span className="font-medium text-grayScale-600">Correct answer text:</span>{" "}
{question.audio_correct_answer_text}
</p>
) : null}
</div>
{(group.practiceId && collapsedPracticeIds.includes(group.practiceId) ? [] : group.questions).map((question, idx) => (
<div
key={question.id}
className={`cursor-pointer rounded-xl border px-4 py-3.5 transition-all sm:px-5 ${
idx % 2 === 0 ? "border-grayScale-200 bg-white" : "border-grayScale-100 bg-grayScale-50/80"
} hover:border-brand-300 hover:bg-brand-50/40 hover:shadow-sm`}
onClick={() => handleOpenQuestionDetail(question.id)}
>
<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>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-red-500 hover:bg-red-50 hover:text-red-600"
onClick={(event) => {
event.stopPropagation()
setDeleteTarget({ id: question.id, text: question.question_text })
setConfirmDeleteOpen(true)
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-md bg-brand-100 px-2 py-0.5 font-medium text-brand-800">AUDIO</span>
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
Difficulty: {question.difficulty_level || "—"}
</span>
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
Points: {question.points ?? 0}
</span>
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
Status: {question.status || "—"}
</span>
</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 ? (
<div className="mt-3 space-y-2">
<p className="text-xs font-medium text-grayScale-500">Voice prompt preview</p>
{audioPreviewByQuestionId[question.id] ? (
<audio controls src={audioPreviewByQuestionId[question.id]} className="h-10 w-full max-w-sm" />
) : (
<p className="text-xs text-grayScale-400">Unable to resolve audio URL.</p>
)}
</div>
) : null}
{question.audio_correct_answer_text ? (
<p className="mt-2 text-xs text-grayScale-500">
<span className="font-medium text-grayScale-600">Correct answer text:</span>{" "}
{question.audio_correct_answer_text}
</p>
) : null}
</div>
))}
</div>
))}
{audioTotalCount > audioPageSize ? (
@ -1492,7 +1845,10 @@ export function SpeakingPage() {
type="button"
variant="outline"
className="h-10 w-full shrink-0 border-grayScale-200 text-grayScale-700 sm:w-auto"
onClick={() => setOpenCreate(false)}
onClick={() => {
resetCreateForm()
setOpenCreate(false)
}}
disabled={saving}
>
<ArrowLeft className="h-4 w-4" />
@ -1585,6 +1941,22 @@ export function SpeakingPage() {
<p className="text-xs leading-relaxed text-grayScale-500">
Paste a link or upload from your computer; uploads use the same file service as elsewhere. Optional, not tied to sub-course video rows.
</p>
{introVideoPreview ? (
<div className="rounded-xl border border-grayScale-200 bg-black/95 p-2">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-grayScale-300">Preview</p>
{introVideoPreview.kind === "iframe" ? (
<iframe
src={introVideoPreview.src}
title="Intro video preview"
className="aspect-video w-full rounded-md"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
) : (
<video src={introVideoPreview.src} controls className="aspect-video w-full rounded-md bg-black" />
)}
</div>
) : null}
</div>
</div>
<aside className="space-y-4 lg:col-span-5">
@ -1646,7 +2018,7 @@ export function SpeakingPage() {
</DropdownMenu>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-700">Set status</label>
<label className="text-sm font-medium text-grayScale-700">Status</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@ -1674,15 +2046,23 @@ export function SpeakingPage() {
</aside>
</div>
<div className="mt-8 flex flex-col-reverse gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-0 py-4 sm:flex-row sm:justify-end sm:px-0 sm:py-5">
<Button variant="outline" onClick={() => setOpenCreate(false)} disabled={saving} className="sm:w-auto">
<Button
variant="outline"
onClick={() => {
resetCreateForm()
setOpenCreate(false)
}}
disabled={saving}
className="sm:w-auto"
>
Cancel
</Button>
<Button
className="bg-brand-500 hover:bg-brand-600 sm:min-w-[180px]"
onClick={() => setCurrentStep(2)}
disabled={!canProceedToQuestions}
onClick={handleProceedToQuestions}
disabled={!canProceedToQuestions || creatingSet}
>
Next: Questions
{creatingSet ? "Creating..." : "Next: Questions"}
</Button>
</div>
</div>