Replace ad-hoc Loader2 loading indicators with SpinnerIcon so loading states across content and notifications pages use the same Circular-indeterminate progress indicator. Made-with: Cursor
2545 lines
112 KiB
TypeScript
2545 lines
112 KiB
TypeScript
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, 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"
|
|
import { Textarea } from "../../components/ui/textarea"
|
|
import { Stepper } from "../../components/ui/stepper"
|
|
import {
|
|
addQuestionToSet,
|
|
deleteQuestion,
|
|
getCourseCategories,
|
|
getCoursesByCategory,
|
|
getQuestionById,
|
|
getSubModulesByCourse,
|
|
createQuestion,
|
|
createQuestionSet,
|
|
// getQuestions,
|
|
getPracticeQuestionsByPractice,
|
|
getQuestionSets,
|
|
updateQuestion,
|
|
} from "../../api/courses.api"
|
|
import { resolveFileUrl, uploadAudioFile, uploadImageFile, uploadVideoFile } from "../../api/files.api"
|
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuRadioGroup,
|
|
DropdownMenuRadioItem,
|
|
DropdownMenuTrigger,
|
|
} from "../../components/ui/dropdown-menu"
|
|
import type { Course, CourseCategory, QuestionDetail, QuestionSet, SubCourse } from "../../types/course.types"
|
|
import { toast } from "sonner"
|
|
|
|
const MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024
|
|
const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"])
|
|
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024
|
|
const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "webp", "gif"])
|
|
const SPEAKING_STEPS = ["Practice", "Questions", "Review"]
|
|
|
|
type SubCourseOption = {
|
|
id: number
|
|
title: string
|
|
courseTitle: string
|
|
categoryName: string
|
|
}
|
|
|
|
type RecordingField = "voice_prompt" | "sample_answer_voice_prompt"
|
|
|
|
type AudioQuestionDraft = {
|
|
questionText: string
|
|
difficulty: "EASY" | "MEDIUM" | "HARD"
|
|
points: number
|
|
voicePrompt: string
|
|
sampleAnswerVoicePrompt: string
|
|
audioCorrectAnswerText: string
|
|
imageUrl: string
|
|
tips: string
|
|
explanation: string
|
|
voicePromptPreviewUrl: string
|
|
samplePromptPreviewUrl: string
|
|
imagePreviewUrl: string
|
|
uploadingVoicePrompt: boolean
|
|
uploadingSamplePrompt: boolean
|
|
uploadingImage: boolean
|
|
recordingVoicePrompt: boolean
|
|
recordingSamplePrompt: boolean
|
|
}
|
|
|
|
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 => ({
|
|
questionText: "",
|
|
difficulty: "EASY",
|
|
points: 1,
|
|
voicePrompt: "",
|
|
sampleAnswerVoicePrompt: "",
|
|
audioCorrectAnswerText: "",
|
|
imageUrl: "",
|
|
tips: "",
|
|
explanation: "",
|
|
voicePromptPreviewUrl: "",
|
|
samplePromptPreviewUrl: "",
|
|
imagePreviewUrl: "",
|
|
uploadingVoicePrompt: false,
|
|
uploadingSamplePrompt: false,
|
|
uploadingImage: false,
|
|
recordingVoicePrompt: false,
|
|
recordingSamplePrompt: false,
|
|
})
|
|
|
|
function normalizeObjectKey(value: string) {
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return ""
|
|
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed
|
|
const protocolMatch = trimmed.match(/^[a-z]+:\/\//i)
|
|
if (protocolMatch) {
|
|
return trimmed.replace(/^[a-z]+:\/\//i, "")
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
/** Prefer direct storage URL; for Vimeo pipeline match SubCourseContentPage player URL shape. */
|
|
function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: string } | undefined): string | null {
|
|
if (!data) return null
|
|
const pageUrl = data.url?.trim()
|
|
const embedUrl = data.embed_url?.trim()
|
|
if (embedUrl) {
|
|
const hashFromUrl = pageUrl ? pageUrl.split("/").filter(Boolean).at(-1) : undefined
|
|
return hashFromUrl ? `${embedUrl}?h=${hashFromUrl}` : embedUrl
|
|
}
|
|
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<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)
|
|
|
|
const [setTitle, setSetTitle] = useState("")
|
|
const [setDescription, setSetDescription] = useState("")
|
|
const [introVideoUrl, setIntroVideoUrl] = useState("")
|
|
const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false)
|
|
const introVideoFileInputRef = useRef<HTMLInputElement>(null)
|
|
const [subCourseId, setSubCourseId] = useState("")
|
|
const [subCourseOptions, setSubCourseOptions] = useState<SubCourseOption[]>([])
|
|
const [subCourseLoading, setSubCourseLoading] = useState(false)
|
|
const [subCourseSearch, setSubCourseSearch] = useState("")
|
|
const [subCourseMenuOpen, setSubCourseMenuOpen] = useState(false)
|
|
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)
|
|
const [selectedQuestionDetail, setSelectedQuestionDetail] = useState<QuestionDetail | null>(null)
|
|
const [detailVoiceUrl, setDetailVoiceUrl] = useState("")
|
|
const [detailSampleVoiceUrl, setDetailSampleVoiceUrl] = useState("")
|
|
const [detailImageUrl, setDetailImageUrl] = useState("")
|
|
const [detailEditing, setDetailEditing] = useState(false)
|
|
const [detailSaving, setDetailSaving] = useState(false)
|
|
const [detailDeleting, setDetailDeleting] = useState(false)
|
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
|
const [deleteTarget, setDeleteTarget] = useState<{ id: number; text: string } | null>(null)
|
|
const [detailForm, setDetailForm] = useState({
|
|
question_text: "",
|
|
question_type: "AUDIO" as const,
|
|
difficulty_level: "EASY" as "EASY" | "MEDIUM" | "HARD",
|
|
points: 1,
|
|
explanation: "",
|
|
tips: "",
|
|
voice_prompt: "",
|
|
sample_answer_voice_prompt: "",
|
|
image_url: "",
|
|
status: "DRAFT" as "DRAFT" | "PUBLISHED" | "INACTIVE",
|
|
audio_correct_answer_text: "",
|
|
})
|
|
|
|
const [questionDrafts, setQuestionDrafts] = useState<AudioQuestionDraft[]>([createEmptyDraft()])
|
|
const [recordingModal, setRecordingModal] = useState<{
|
|
draftIndex: number
|
|
field: RecordingField
|
|
levels: number[]
|
|
label: string
|
|
elapsedSeconds: number
|
|
isPaused: boolean
|
|
} | null>(null)
|
|
const activeRecordingRef = useRef<{
|
|
recorder: MediaRecorder
|
|
stream: MediaStream
|
|
chunks: BlobPart[]
|
|
audioContext: AudioContext
|
|
analyser: AnalyserNode
|
|
rafId: number | null
|
|
draftIndex: number
|
|
field: RecordingField
|
|
shouldUpload: boolean
|
|
isPaused: boolean
|
|
startedAtMs: number
|
|
pausedTotalMs: number
|
|
pauseStartedAtMs: number | null
|
|
} | null>(null)
|
|
|
|
const resolvePreviewUrl = useCallback(async (value: string) => {
|
|
if (!value.trim()) return ""
|
|
if (value.startsWith("http://") || value.startsWith("https://")) return value
|
|
|
|
const key = normalizeObjectKey(value)
|
|
if (!key) return ""
|
|
|
|
const res = await resolveFileUrl(key)
|
|
return res.data?.data?.url ?? ""
|
|
}, [])
|
|
|
|
const fetchAudioQuestions = useCallback(async (page: number = audioPage) => {
|
|
setLoading(true)
|
|
try {
|
|
const safePage = page < 1 ? 1 : page
|
|
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,
|
|
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: Number(selectedPracticeId),
|
|
practice_title: selectedPractice?.title ?? `Practice #${selectedPracticeId}`,
|
|
}))
|
|
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)
|
|
setAudioTotalCount(total)
|
|
setAudioPage(safePage)
|
|
} catch (error) {
|
|
console.error("Failed to fetch audio questions:", error)
|
|
setAudioQuestions([])
|
|
setAudioTotalCount(0)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [audioPage, audioPageSize, selectedPracticeId, practiceOptions, searchQuery])
|
|
|
|
useEffect(() => {
|
|
fetchAudioQuestions()
|
|
}, [fetchAudioQuestions, audioPageSize, selectedPracticeId])
|
|
|
|
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
|
|
}
|
|
// 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 () => {
|
|
setSubCourseLoading(true)
|
|
try {
|
|
const categoriesRes = await getCourseCategories()
|
|
const categories = categoriesRes.data?.data?.categories ?? []
|
|
if (categories.length === 0) {
|
|
if (!cancelled) setSubCourseOptions([])
|
|
return
|
|
}
|
|
|
|
const coursesByCategoryResponses = await Promise.all(
|
|
categories.map(async (category: CourseCategory) => {
|
|
const res = await getCoursesByCategory(category.id)
|
|
return {
|
|
category,
|
|
courses: res.data?.data?.courses ?? [],
|
|
}
|
|
}),
|
|
)
|
|
|
|
const courseRecords = coursesByCategoryResponses.flatMap(({ category, courses }) =>
|
|
courses.map((course: Course) => ({ category, course })),
|
|
)
|
|
|
|
const subCourseResponses = await Promise.all(
|
|
courseRecords.map(async ({ category, course }) => {
|
|
const res = await getSubModulesByCourse(course.id)
|
|
const subCourses = res.data?.data?.sub_courses ?? []
|
|
return subCourses.map((subCourse: SubCourse) => ({
|
|
id: subCourse.id,
|
|
title: subCourse.title,
|
|
courseTitle: course.title,
|
|
categoryName: category.name,
|
|
}))
|
|
}),
|
|
)
|
|
|
|
const options = subCourseResponses
|
|
.flat()
|
|
.sort((a, b) => a.title.localeCompare(b.title))
|
|
if (!cancelled) setSubCourseOptions(options)
|
|
} catch (error) {
|
|
console.error("Failed to load course options:", error)
|
|
if (!cancelled) setSubCourseOptions([])
|
|
} finally {
|
|
if (!cancelled) setSubCourseLoading(false)
|
|
}
|
|
}
|
|
fetchSubCourseOptions()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
const withVoice = audioQuestions.filter((q) => Boolean(q.voice_prompt))
|
|
if (withVoice.length === 0) {
|
|
setAudioPreviewByQuestionId({})
|
|
return
|
|
}
|
|
|
|
const resolveAll = async () => {
|
|
const entries = await Promise.all(
|
|
withVoice.map(async (question) => {
|
|
try {
|
|
const url = await resolvePreviewUrl(question.voice_prompt ?? "")
|
|
return [question.id, url] as const
|
|
} catch {
|
|
return [question.id, ""] as const
|
|
}
|
|
}),
|
|
)
|
|
if (!cancelled) {
|
|
setAudioPreviewByQuestionId(Object.fromEntries(entries))
|
|
}
|
|
}
|
|
|
|
resolveAll()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [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("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 = ""
|
|
if (!file) return
|
|
|
|
setUploadingIntroVideo(true)
|
|
try {
|
|
const uploadRes = await uploadVideoFile(file, {
|
|
title: setTitle.trim() || file.name.replace(/\.[^.]+$/, "") || "Speaking intro",
|
|
description: setDescription.trim() || undefined,
|
|
})
|
|
const finalUrl = introVideoUrlFromUploadResponse(uploadRes.data?.data)
|
|
if (!finalUrl) throw new Error("Missing uploaded video url")
|
|
setIntroVideoUrl(finalUrl)
|
|
toast.success("Intro video uploaded", { description: "The URL has been filled in for you." })
|
|
} catch (error) {
|
|
console.error("Failed to upload intro video:", error)
|
|
toast.error("Failed to upload intro video")
|
|
} finally {
|
|
setUploadingIntroVideo(false)
|
|
}
|
|
}
|
|
|
|
const canCreate = useMemo(() => {
|
|
const hasQuestionWithText = questionDrafts.some((draft) => draft.questionText.trim().length > 0)
|
|
return setTitle.trim().length > 0 && subCourseId.trim().length > 0 && hasQuestionWithText
|
|
}, [setTitle, subCourseId, questionDrafts])
|
|
const canProceedToQuestions = useMemo(
|
|
() => setTitle.trim().length > 0 && subCourseId.trim().length > 0,
|
|
[setTitle, subCourseId],
|
|
)
|
|
const questionsWithText = useMemo(
|
|
() => questionDrafts.filter((draft) => draft.questionText.trim().length > 0),
|
|
[questionDrafts],
|
|
)
|
|
const canProceedToReview = questionsWithText.length > 0
|
|
const selectedSubCourseOption = useMemo(
|
|
() => subCourseOptions.find((option) => option.id === Number(subCourseId)),
|
|
[subCourseId, subCourseOptions],
|
|
)
|
|
const filteredSubCourseOptions = useMemo(() => {
|
|
const q = subCourseSearch.trim().toLowerCase()
|
|
if (!q) return subCourseOptions
|
|
return subCourseOptions.filter((option) => {
|
|
const haystack = `${option.title} ${option.courseTitle} ${option.categoryName} ${option.id}`.toLowerCase()
|
|
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)))
|
|
}
|
|
|
|
const uploadAudioForDraft = useCallback(
|
|
async (file: File, field: "voice_prompt" | "sample_answer_voice_prompt", draftIndex: number) => {
|
|
const extension = file.name.split(".").pop()?.toLowerCase() ?? ""
|
|
if (!ALLOWED_AUDIO_EXTENSIONS.has(extension)) {
|
|
toast.error("Unsupported audio format")
|
|
return
|
|
}
|
|
if (file.size > MAX_AUDIO_SIZE_BYTES) {
|
|
toast.error("Audio file must be 50MB or less")
|
|
return
|
|
}
|
|
|
|
updateDraft(draftIndex, (draft) =>
|
|
field === "voice_prompt"
|
|
? { ...draft, uploadingVoicePrompt: true }
|
|
: { ...draft, uploadingSamplePrompt: true },
|
|
)
|
|
|
|
try {
|
|
const res = await uploadAudioFile(file)
|
|
const objectKey = res.data?.data?.object_key?.trim()
|
|
const immediateUrl = res.data?.data?.url?.trim() ?? ""
|
|
if (!objectKey) throw new Error("Missing uploaded audio object key")
|
|
|
|
if (field === "voice_prompt") {
|
|
updateDraft(draftIndex, (draft) => ({
|
|
...draft,
|
|
voicePrompt: objectKey,
|
|
voicePromptPreviewUrl: immediateUrl || draft.voicePromptPreviewUrl,
|
|
}))
|
|
if (!immediateUrl) {
|
|
const resolvedUrl = await resolvePreviewUrl(objectKey)
|
|
updateDraft(draftIndex, (draft) => ({ ...draft, voicePromptPreviewUrl: resolvedUrl }))
|
|
}
|
|
} else {
|
|
updateDraft(draftIndex, (draft) => ({
|
|
...draft,
|
|
sampleAnswerVoicePrompt: objectKey,
|
|
samplePromptPreviewUrl: immediateUrl || draft.samplePromptPreviewUrl,
|
|
}))
|
|
if (!immediateUrl) {
|
|
const resolvedUrl = await resolvePreviewUrl(objectKey)
|
|
updateDraft(draftIndex, (draft) => ({ ...draft, samplePromptPreviewUrl: resolvedUrl }))
|
|
}
|
|
}
|
|
toast.success("Audio uploaded successfully")
|
|
} catch (error) {
|
|
console.error("Failed to upload audio:", error)
|
|
toast.error("Failed to upload audio file")
|
|
} finally {
|
|
updateDraft(draftIndex, (draft) =>
|
|
field === "voice_prompt"
|
|
? { ...draft, uploadingVoicePrompt: false }
|
|
: { ...draft, uploadingSamplePrompt: false },
|
|
)
|
|
}
|
|
},
|
|
[resolvePreviewUrl],
|
|
)
|
|
|
|
const handleAudioUpload = async (
|
|
event: ChangeEvent<HTMLInputElement>,
|
|
field: "voice_prompt" | "sample_answer_voice_prompt",
|
|
draftIndex: number,
|
|
) => {
|
|
const file = event.target.files?.[0]
|
|
if (!file) return
|
|
await uploadAudioForDraft(file, field, draftIndex)
|
|
event.target.value = ""
|
|
}
|
|
|
|
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>, draftIndex: number) => {
|
|
const file = event.target.files?.[0]
|
|
if (!file) return
|
|
|
|
const extension = file.name.split(".").pop()?.toLowerCase() ?? ""
|
|
if (!ALLOWED_IMAGE_EXTENSIONS.has(extension)) {
|
|
toast.error("Unsupported image format")
|
|
event.target.value = ""
|
|
return
|
|
}
|
|
if (file.size > MAX_IMAGE_SIZE_BYTES) {
|
|
toast.error("Image file must be 10MB or less")
|
|
event.target.value = ""
|
|
return
|
|
}
|
|
|
|
updateDraft(draftIndex, (draft) => ({ ...draft, uploadingImage: true }))
|
|
try {
|
|
const res = await uploadImageFile(file)
|
|
const uploadedUrl = res.data?.data?.url?.trim()
|
|
if (!uploadedUrl) throw new Error("Missing uploaded image url")
|
|
const resolved = uploadedUrl
|
|
updateDraft(draftIndex, (draft) => ({
|
|
...draft,
|
|
imageUrl: uploadedUrl,
|
|
imagePreviewUrl: resolved,
|
|
}))
|
|
toast.success("Image uploaded successfully")
|
|
} catch (error) {
|
|
console.error("Failed to upload image:", error)
|
|
toast.error("Failed to upload image")
|
|
} finally {
|
|
updateDraft(draftIndex, (draft) => ({ ...draft, uploadingImage: false }))
|
|
event.target.value = ""
|
|
}
|
|
}
|
|
|
|
const handleResolveManualAudioPreview = async (
|
|
field: "voice_prompt" | "sample_answer_voice_prompt",
|
|
draftIndex: number,
|
|
) => {
|
|
const selectedDraft = questionDrafts[draftIndex]
|
|
if (!selectedDraft) return
|
|
const rawValue = field === "voice_prompt" ? selectedDraft.voicePrompt : selectedDraft.sampleAnswerVoicePrompt
|
|
if (!rawValue.trim()) {
|
|
updateDraft(draftIndex, (draft) =>
|
|
field === "voice_prompt"
|
|
? { ...draft, voicePromptPreviewUrl: "" }
|
|
: { ...draft, samplePromptPreviewUrl: "" },
|
|
)
|
|
return
|
|
}
|
|
try {
|
|
const trimmedValue = rawValue.trim()
|
|
const isURL = /^https?:\/\//i.test(trimmedValue)
|
|
|
|
if (isURL) {
|
|
updateDraft(draftIndex, (draft) =>
|
|
field === "voice_prompt"
|
|
? { ...draft, uploadingVoicePrompt: true }
|
|
: { ...draft, uploadingSamplePrompt: true },
|
|
)
|
|
|
|
const res = await uploadAudioFile(trimmedValue)
|
|
const objectKey = res.data?.data?.object_key?.trim()
|
|
const immediateUrl = res.data?.data?.url?.trim() ?? ""
|
|
if (!objectKey) throw new Error("Missing uploaded audio object key")
|
|
|
|
if (field === "voice_prompt") {
|
|
updateDraft(draftIndex, (draft) => ({
|
|
...draft,
|
|
voicePrompt: objectKey,
|
|
voicePromptPreviewUrl: immediateUrl || draft.voicePromptPreviewUrl,
|
|
}))
|
|
if (!immediateUrl) {
|
|
const resolvedUrl = await resolvePreviewUrl(objectKey)
|
|
updateDraft(draftIndex, (draft) => ({ ...draft, voicePromptPreviewUrl: resolvedUrl }))
|
|
}
|
|
} else {
|
|
updateDraft(draftIndex, (draft) => ({
|
|
...draft,
|
|
sampleAnswerVoicePrompt: objectKey,
|
|
samplePromptPreviewUrl: immediateUrl || draft.samplePromptPreviewUrl,
|
|
}))
|
|
if (!immediateUrl) {
|
|
const resolvedUrl = await resolvePreviewUrl(objectKey)
|
|
updateDraft(draftIndex, (draft) => ({ ...draft, samplePromptPreviewUrl: resolvedUrl }))
|
|
}
|
|
}
|
|
toast.success("Audio URL imported successfully")
|
|
return
|
|
}
|
|
|
|
const url = await resolvePreviewUrl(rawValue)
|
|
updateDraft(draftIndex, (draft) =>
|
|
field === "voice_prompt"
|
|
? { ...draft, voicePromptPreviewUrl: url }
|
|
: { ...draft, samplePromptPreviewUrl: url },
|
|
)
|
|
} catch (error) {
|
|
console.error("Failed to resolve audio preview URL:", error)
|
|
toast.error("Could not import/resolve audio URL")
|
|
} finally {
|
|
updateDraft(draftIndex, (draft) =>
|
|
field === "voice_prompt"
|
|
? { ...draft, uploadingVoicePrompt: false }
|
|
: { ...draft, uploadingSamplePrompt: false },
|
|
)
|
|
}
|
|
}
|
|
|
|
const handleResolveManualImagePreview = async (draftIndex: number) => {
|
|
const draft = questionDrafts[draftIndex]
|
|
if (!draft) return
|
|
if (!draft.imageUrl.trim()) {
|
|
updateDraft(draftIndex, (current) => ({ ...current, imagePreviewUrl: "" }))
|
|
return
|
|
}
|
|
try {
|
|
const resolved = await resolvePreviewUrl(draft.imageUrl)
|
|
updateDraft(draftIndex, (current) => ({ ...current, imagePreviewUrl: resolved }))
|
|
} catch (error) {
|
|
console.error("Failed to resolve image preview URL:", error)
|
|
toast.error("Could not resolve image preview URL")
|
|
}
|
|
}
|
|
|
|
const stopActiveRecording = async (saveRecording: boolean) => {
|
|
const active = activeRecordingRef.current
|
|
if (!active) return
|
|
active.shouldUpload = saveRecording
|
|
if (active.recorder.state !== "inactive") {
|
|
active.recorder.stop()
|
|
return
|
|
}
|
|
if (!saveRecording) {
|
|
active.stream.getTracks().forEach((track) => track.stop())
|
|
if (active.rafId) cancelAnimationFrame(active.rafId)
|
|
await active.audioContext.close().catch(() => undefined)
|
|
activeRecordingRef.current = null
|
|
setRecordingModal(null)
|
|
}
|
|
}
|
|
|
|
const togglePauseRecording = () => {
|
|
const active = activeRecordingRef.current
|
|
if (!active) return
|
|
if (active.recorder.state === "recording") {
|
|
active.recorder.pause()
|
|
active.isPaused = true
|
|
active.pauseStartedAtMs = Date.now()
|
|
setRecordingModal((prev) => (prev ? { ...prev, isPaused: true } : prev))
|
|
return
|
|
}
|
|
if (active.recorder.state === "paused") {
|
|
active.recorder.resume()
|
|
if (active.pauseStartedAtMs) {
|
|
active.pausedTotalMs += Date.now() - active.pauseStartedAtMs
|
|
}
|
|
active.pauseStartedAtMs = null
|
|
active.isPaused = false
|
|
setRecordingModal((prev) => (prev ? { ...prev, isPaused: false } : prev))
|
|
}
|
|
}
|
|
|
|
const startRecording = async (field: RecordingField, draftIndex: number) => {
|
|
if (activeRecordingRef.current) {
|
|
toast.error("Finish current recording first")
|
|
return
|
|
}
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
const recorder = new MediaRecorder(stream)
|
|
const chunks: BlobPart[] = []
|
|
const audioContext = new AudioContext()
|
|
const analyser = audioContext.createAnalyser()
|
|
analyser.fftSize = 256
|
|
const source = audioContext.createMediaStreamSource(stream)
|
|
source.connect(analyser)
|
|
const dataArray = new Uint8Array(analyser.frequencyBinCount)
|
|
const label = field === "voice_prompt" ? "Voice Prompt" : "Sample Answer"
|
|
const animateLevels = () => {
|
|
const active = activeRecordingRef.current
|
|
if (!active) return
|
|
const now = Date.now()
|
|
const effectiveElapsedMs =
|
|
now -
|
|
active.startedAtMs -
|
|
active.pausedTotalMs -
|
|
(active.isPaused && active.pauseStartedAtMs ? now - active.pauseStartedAtMs : 0)
|
|
const elapsedSeconds = Math.max(0, Math.floor(effectiveElapsedMs / 1000))
|
|
|
|
if (!active.isPaused) {
|
|
analyser.getByteFrequencyData(dataArray)
|
|
const barCount = 32
|
|
const binsPerBar = Math.max(1, Math.floor(dataArray.length / barCount))
|
|
const nextLevels = Array.from({ length: barCount }, (_, barIdx) => {
|
|
const start = barIdx * binsPerBar
|
|
const end = Math.min(dataArray.length, start + binsPerBar)
|
|
if (start >= end) return 0.05
|
|
let sum = 0
|
|
for (let i = start; i < end; i += 1) sum += dataArray[i]
|
|
const avg = sum / (end - start)
|
|
// Slight boost curve for clearer realtime movement.
|
|
const normalized = Math.min(1, Math.pow(avg / 180, 0.85))
|
|
return Math.max(0.04, normalized)
|
|
})
|
|
setRecordingModal((prev) => {
|
|
if (!prev || prev.draftIndex !== draftIndex || prev.field !== field) return prev
|
|
return { ...prev, levels: nextLevels, elapsedSeconds }
|
|
})
|
|
} else {
|
|
setRecordingModal((prev) => {
|
|
if (!prev || prev.draftIndex !== draftIndex || prev.field !== field) return prev
|
|
return { ...prev, elapsedSeconds }
|
|
})
|
|
}
|
|
|
|
if (activeRecordingRef.current) {
|
|
activeRecordingRef.current.rafId = requestAnimationFrame(animateLevels)
|
|
}
|
|
}
|
|
|
|
recorder.ondataavailable = (event) => {
|
|
if (event.data.size > 0) chunks.push(event.data)
|
|
}
|
|
|
|
recorder.onstop = async () => {
|
|
const active = activeRecordingRef.current
|
|
if (!active) return
|
|
const shouldUpload = active.shouldUpload
|
|
active.stream.getTracks().forEach((track) => track.stop())
|
|
if (active.rafId) cancelAnimationFrame(active.rafId)
|
|
await active.audioContext.close().catch(() => undefined)
|
|
activeRecordingRef.current = null
|
|
setRecordingModal(null)
|
|
|
|
if (shouldUpload) {
|
|
const blob = new Blob(chunks, { type: "audio/webm" })
|
|
const fileName =
|
|
field === "voice_prompt"
|
|
? `voice-prompt-${Date.now()}.webm`
|
|
: `sample-answer-${Date.now()}.webm`
|
|
const recordedFile = new File([blob], fileName, { type: "audio/webm" })
|
|
await uploadAudioForDraft(recordedFile, field, draftIndex)
|
|
}
|
|
|
|
updateDraft(draftIndex, (draft) =>
|
|
field === "voice_prompt"
|
|
? { ...draft, recordingVoicePrompt: false }
|
|
: { ...draft, recordingSamplePrompt: false },
|
|
)
|
|
}
|
|
|
|
setRecordingModal({
|
|
draftIndex,
|
|
field,
|
|
levels: Array.from({ length: 32 }, () => 0.05),
|
|
label,
|
|
elapsedSeconds: 0,
|
|
isPaused: false,
|
|
})
|
|
recorder.start()
|
|
activeRecordingRef.current = {
|
|
recorder,
|
|
stream,
|
|
chunks,
|
|
audioContext,
|
|
analyser,
|
|
rafId: null,
|
|
draftIndex,
|
|
field,
|
|
shouldUpload: true,
|
|
isPaused: false,
|
|
startedAtMs: Date.now(),
|
|
pausedTotalMs: 0,
|
|
pauseStartedAtMs: null,
|
|
}
|
|
animateLevels()
|
|
updateDraft(draftIndex, (draft) =>
|
|
field === "voice_prompt"
|
|
? { ...draft, recordingVoicePrompt: true }
|
|
: { ...draft, recordingSamplePrompt: true },
|
|
)
|
|
} catch (error) {
|
|
console.error("Microphone access failed:", error)
|
|
toast.error("Unable to access microphone")
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
const active = activeRecordingRef.current
|
|
if (!active) return
|
|
active.shouldUpload = false
|
|
if (active.recorder.state !== "inactive") active.recorder.stop()
|
|
active.stream.getTracks().forEach((track) => track.stop())
|
|
if (active.rafId) cancelAnimationFrame(active.rafId)
|
|
active.audioContext.close().catch(() => undefined)
|
|
}
|
|
}, [])
|
|
|
|
const handleCreateSpeakingPractice = async () => {
|
|
if (!canCreate) return
|
|
const parsedSubCourseId = Number(subCourseId)
|
|
if (!Number.isFinite(parsedSubCourseId) || parsedSubCourseId <= 0) {
|
|
toast.error("Please enter a valid Course ID")
|
|
return
|
|
}
|
|
const draftsToCreate = questionDrafts.filter((draft) => draft.questionText.trim().length > 0)
|
|
if (draftsToCreate.length === 0) {
|
|
toast.error("Add at least one AUDIO question")
|
|
return
|
|
}
|
|
|
|
setSaving(true)
|
|
try {
|
|
const setId = createdSetId
|
|
if (!setId) throw new Error("Question set creation failed: missing set ID")
|
|
|
|
// 2) Create all AUDIO questions then attach in sequence.
|
|
for (const [idx, draft] of draftsToCreate.entries()) {
|
|
const questionRes = await createQuestion({
|
|
question_text: draft.questionText.trim(),
|
|
question_type: "AUDIO",
|
|
status: setStatus,
|
|
difficulty_level: draft.difficulty || undefined,
|
|
points: Number.isFinite(draft.points) && draft.points > 0 ? draft.points : 1,
|
|
voice_prompt: draft.voicePrompt.trim() || undefined,
|
|
sample_answer_voice_prompt: draft.sampleAnswerVoicePrompt.trim() || undefined,
|
|
audio_correct_answer_text: draft.audioCorrectAnswerText.trim() || undefined,
|
|
image_url: draft.imageUrl.trim() || undefined,
|
|
explanation: draft.explanation.trim() || undefined,
|
|
tips: draft.tips.trim() || undefined,
|
|
})
|
|
|
|
const questionId = questionRes.data?.data?.id
|
|
if (!questionId) throw new Error("Question creation failed: missing question ID")
|
|
|
|
// 3) Attach each question to created set with display order.
|
|
await addQuestionToSet(setId, {
|
|
question_id: questionId,
|
|
display_order: idx + 1,
|
|
})
|
|
}
|
|
|
|
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) {
|
|
console.error("Failed to create speaking practice:", error)
|
|
toast.error("Failed to create speaking practice")
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleOpenQuestionDetail = async (questionId: number) => {
|
|
setDetailOpen(true)
|
|
setDetailLoading(true)
|
|
setSelectedQuestionDetail(null)
|
|
setDetailVoiceUrl("")
|
|
setDetailSampleVoiceUrl("")
|
|
setDetailImageUrl("")
|
|
try {
|
|
const res = await getQuestionById(questionId)
|
|
const detail = res.data?.data ?? null
|
|
setSelectedQuestionDetail(detail)
|
|
if (detail) {
|
|
setDetailForm({
|
|
question_text: detail.question_text ?? "",
|
|
question_type: "AUDIO",
|
|
difficulty_level:
|
|
(detail.difficulty_level as "EASY" | "MEDIUM" | "HARD") || "EASY",
|
|
points: Number(detail.points) > 0 ? Number(detail.points) : 1,
|
|
explanation: detail.explanation ?? "",
|
|
tips: detail.tips ?? "",
|
|
voice_prompt: detail.voice_prompt ?? "",
|
|
sample_answer_voice_prompt: detail.sample_answer_voice_prompt ?? "",
|
|
image_url: detail.image_url ?? "",
|
|
status:
|
|
(detail.status as "DRAFT" | "PUBLISHED" | "INACTIVE") || "DRAFT",
|
|
audio_correct_answer_text: detail.audio_correct_answer_text ?? "",
|
|
})
|
|
setDetailEditing(false)
|
|
const [voice, sample, image] = await Promise.all([
|
|
resolvePreviewUrl(detail.voice_prompt ?? ""),
|
|
resolvePreviewUrl(detail.sample_answer_voice_prompt ?? ""),
|
|
resolvePreviewUrl(detail.image_url ?? ""),
|
|
])
|
|
setDetailVoiceUrl(voice)
|
|
setDetailSampleVoiceUrl(sample)
|
|
setDetailImageUrl(image)
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to fetch question detail:", error)
|
|
toast.error("Failed to load question detail")
|
|
setDetailOpen(false)
|
|
} finally {
|
|
setDetailLoading(false)
|
|
}
|
|
}
|
|
|
|
const refreshSelectedQuestionDetail = async (questionId: number) => {
|
|
const res = await getQuestionById(questionId)
|
|
const detail = res.data?.data ?? null
|
|
setSelectedQuestionDetail(detail)
|
|
if (detail) {
|
|
const [voice, sample, image] = await Promise.all([
|
|
resolvePreviewUrl(detail.voice_prompt ?? ""),
|
|
resolvePreviewUrl(detail.sample_answer_voice_prompt ?? ""),
|
|
resolvePreviewUrl(detail.image_url ?? ""),
|
|
])
|
|
setDetailVoiceUrl(voice)
|
|
setDetailSampleVoiceUrl(sample)
|
|
setDetailImageUrl(image)
|
|
setDetailForm({
|
|
question_text: detail.question_text ?? "",
|
|
question_type: "AUDIO",
|
|
difficulty_level:
|
|
(detail.difficulty_level as "EASY" | "MEDIUM" | "HARD") || "EASY",
|
|
points: Number(detail.points) > 0 ? Number(detail.points) : 1,
|
|
explanation: detail.explanation ?? "",
|
|
tips: detail.tips ?? "",
|
|
voice_prompt: detail.voice_prompt ?? "",
|
|
sample_answer_voice_prompt: detail.sample_answer_voice_prompt ?? "",
|
|
image_url: detail.image_url ?? "",
|
|
status:
|
|
(detail.status as "DRAFT" | "PUBLISHED" | "INACTIVE") || "DRAFT",
|
|
audio_correct_answer_text: detail.audio_correct_answer_text ?? "",
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleUpdateQuestionDetail = async () => {
|
|
if (!selectedQuestionDetail) return
|
|
setDetailSaving(true)
|
|
try {
|
|
await updateQuestion(selectedQuestionDetail.id, {
|
|
question_text: detailForm.question_text.trim(),
|
|
question_type: "AUDIO",
|
|
difficulty_level: detailForm.difficulty_level,
|
|
points: Number.isFinite(detailForm.points) && detailForm.points > 0 ? detailForm.points : 1,
|
|
explanation: detailForm.explanation.trim() || undefined,
|
|
tips: detailForm.tips.trim() || undefined,
|
|
voice_prompt: detailForm.voice_prompt.trim() || undefined,
|
|
sample_answer_voice_prompt: detailForm.sample_answer_voice_prompt.trim() || undefined,
|
|
image_url: detailForm.image_url.trim() || undefined,
|
|
status: detailForm.status,
|
|
audio_correct_answer_text: detailForm.audio_correct_answer_text.trim() || undefined,
|
|
})
|
|
await refreshSelectedQuestionDetail(selectedQuestionDetail.id)
|
|
await fetchAudioQuestions()
|
|
setDetailEditing(false)
|
|
toast.success("AUDIO question updated")
|
|
} catch (error) {
|
|
console.error("Failed to update AUDIO question:", error)
|
|
toast.error("Failed to update AUDIO question")
|
|
} finally {
|
|
setDetailSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleDeleteQuestionDetail = async () => {
|
|
const targetId = deleteTarget?.id ?? selectedQuestionDetail?.id
|
|
if (!targetId) return
|
|
setDetailDeleting(true)
|
|
try {
|
|
await deleteQuestion(targetId)
|
|
setConfirmDeleteOpen(false)
|
|
setDeleteTarget(null)
|
|
setDetailOpen(false)
|
|
setSelectedQuestionDetail(null)
|
|
await fetchAudioQuestions()
|
|
toast.success("AUDIO question deleted")
|
|
} catch (error) {
|
|
console.error("Failed to delete AUDIO question:", error)
|
|
toast.error("Failed to delete AUDIO question")
|
|
} finally {
|
|
setDetailDeleting(false)
|
|
}
|
|
}
|
|
|
|
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">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-3xl">
|
|
Speaking
|
|
</h1>
|
|
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-grayScale-500 sm:text-[15px]">
|
|
Create and manage speaking practice sessions for your learners.
|
|
</p>
|
|
</div>
|
|
<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)
|
|
}}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add New Speaking Practice
|
|
</Button>
|
|
</div>
|
|
|
|
{!openCreate && (
|
|
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
|
<CardHeader className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-4 sm:px-6 sm:py-5">
|
|
<CardTitle className="text-base font-semibold text-grayScale-900 sm:text-lg">
|
|
AUDIO questions
|
|
</CardTitle>
|
|
<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>
|
|
<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) => {
|
|
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">
|
|
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 ? (
|
|
<div className="flex flex-col items-center gap-2 py-14 text-center text-sm text-grayScale-500">
|
|
<SpinnerIcon className="h-5 w-5" />
|
|
Loading audio questions...
|
|
</div>
|
|
) : audioQuestions.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/30 py-16 text-center">
|
|
<div className="mb-5 grid h-16 w-16 place-items-center rounded-2xl bg-gradient-to-br from-brand-100 to-brand-200 shadow-sm ring-4 ring-brand-500/10">
|
|
<Mic className="h-8 w-8 text-brand-600" />
|
|
</div>
|
|
<h3 className="text-base font-semibold text-grayScale-800">No audio questions yet</h3>
|
|
<p className="mt-2 max-w-md text-sm leading-relaxed text-grayScale-500">
|
|
Create a speaking practice to automatically create and attach an AUDIO question.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2.5">
|
|
<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-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>
|
|
</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 ? (
|
|
<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>
|
|
</Card>
|
|
)}
|
|
|
|
{detailOpen && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
|
|
onClick={() => setDetailOpen(false)}
|
|
>
|
|
<div
|
|
className="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-grayScale-200/80 bg-white shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex shrink-0 items-center justify-between border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/90 to-white px-5 py-4 sm:px-6">
|
|
<h3 className="text-base font-semibold text-grayScale-900 sm:text-lg">AUDIO question</h3>
|
|
<div className="flex items-center gap-2">
|
|
{!detailLoading && selectedQuestionDetail && !detailEditing ? (
|
|
<Button variant="outline" size="sm" onClick={() => setDetailEditing(true)}>
|
|
Edit
|
|
</Button>
|
|
) : null}
|
|
{!detailLoading && selectedQuestionDetail ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700"
|
|
onClick={() => {
|
|
setDeleteTarget({
|
|
id: selectedQuestionDetail.id,
|
|
text: selectedQuestionDetail.question_text,
|
|
})
|
|
setConfirmDeleteOpen(true)
|
|
}}
|
|
>
|
|
Delete
|
|
</Button>
|
|
) : null}
|
|
<Button variant="outline" size="sm" onClick={() => setDetailOpen(false)}>
|
|
Close
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-5 sm:px-6">
|
|
{detailLoading ? (
|
|
<div className="flex items-center justify-center py-10">
|
|
<SpinnerIcon className="h-5 w-5" />
|
|
</div>
|
|
) : selectedQuestionDetail && detailEditing ? (
|
|
<>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-grayScale-500">Question Text</label>
|
|
<Textarea
|
|
value={detailForm.question_text}
|
|
onChange={(e) =>
|
|
setDetailForm((prev) => ({ ...prev, question_text: e.target.value }))
|
|
}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-grayScale-500">Difficulty</label>
|
|
<select
|
|
value={detailForm.difficulty_level}
|
|
onChange={(e) =>
|
|
setDetailForm((prev) => ({
|
|
...prev,
|
|
difficulty_level: e.target.value as "EASY" | "MEDIUM" | "HARD",
|
|
}))
|
|
}
|
|
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
|
|
>
|
|
<option value="EASY">EASY</option>
|
|
<option value="MEDIUM">MEDIUM</option>
|
|
<option value="HARD">HARD</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-grayScale-500">Points</label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={detailForm.points}
|
|
onChange={(e) =>
|
|
setDetailForm((prev) => ({ ...prev, points: Number(e.target.value) || 1 }))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-grayScale-500">Voice Prompt URL</label>
|
|
<Input
|
|
value={detailForm.voice_prompt}
|
|
onChange={(e) =>
|
|
setDetailForm((prev) => ({ ...prev, voice_prompt: e.target.value }))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-grayScale-500">Sample Answer Voice Prompt URL</label>
|
|
<Input
|
|
value={detailForm.sample_answer_voice_prompt}
|
|
onChange={(e) =>
|
|
setDetailForm((prev) => ({
|
|
...prev,
|
|
sample_answer_voice_prompt: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-grayScale-500">Image URL</label>
|
|
<Input
|
|
value={detailForm.image_url}
|
|
onChange={(e) =>
|
|
setDetailForm((prev) => ({ ...prev, image_url: e.target.value }))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-grayScale-500">Audio Correct Answer Text</label>
|
|
<Textarea
|
|
value={detailForm.audio_correct_answer_text}
|
|
onChange={(e) =>
|
|
setDetailForm((prev) => ({
|
|
...prev,
|
|
audio_correct_answer_text: e.target.value,
|
|
}))
|
|
}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-grayScale-500">Explanation</label>
|
|
<Textarea
|
|
value={detailForm.explanation}
|
|
onChange={(e) =>
|
|
setDetailForm((prev) => ({ ...prev, explanation: e.target.value }))
|
|
}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-grayScale-500">Tips</label>
|
|
<Textarea
|
|
value={detailForm.tips}
|
|
onChange={(e) =>
|
|
setDetailForm((prev) => ({ ...prev, tips: e.target.value }))
|
|
}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-grayScale-500">Status</label>
|
|
<select
|
|
value={detailForm.status}
|
|
onChange={(e) =>
|
|
setDetailForm((prev) => ({
|
|
...prev,
|
|
status: e.target.value as "DRAFT" | "PUBLISHED" | "INACTIVE",
|
|
}))
|
|
}
|
|
className="h-10 w-full rounded-md border border-grayScale-200 px-3 text-sm"
|
|
>
|
|
<option value="DRAFT">DRAFT</option>
|
|
<option value="PUBLISHED">PUBLISHED</option>
|
|
<option value="INACTIVE">INACTIVE</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex justify-end gap-2 border-t border-grayScale-100 pt-3">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDetailEditing(false)}
|
|
disabled={detailSaving}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
className="bg-brand-500 hover:bg-brand-600"
|
|
onClick={handleUpdateQuestionDetail}
|
|
disabled={detailSaving || !detailForm.question_text.trim()}
|
|
>
|
|
{detailSaving ? "Saving..." : "Save Changes"}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
) : selectedQuestionDetail ? (
|
|
<>
|
|
<div>
|
|
<p className="text-xs font-medium text-grayScale-500">Question Text</p>
|
|
<p className="mt-1 text-sm text-grayScale-700">{selectedQuestionDetail.question_text}</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<p className="text-xs font-medium text-grayScale-500">Status</p>
|
|
<p className="mt-1 text-grayScale-700">{selectedQuestionDetail.status || "—"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-medium text-grayScale-500">Points</p>
|
|
<p className="mt-1 text-grayScale-700">{selectedQuestionDetail.points ?? "—"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-medium text-grayScale-500">Difficulty</p>
|
|
<p className="mt-1 text-grayScale-700">{selectedQuestionDetail.difficulty_level || "—"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-medium text-grayScale-500">Type</p>
|
|
<p className="mt-1 text-grayScale-700">{selectedQuestionDetail.question_type || "—"}</p>
|
|
</div>
|
|
</div>
|
|
{selectedQuestionDetail.audio_correct_answer_text ? (
|
|
<div>
|
|
<p className="text-xs font-medium text-grayScale-500">Audio Correct Answer Text</p>
|
|
<p className="mt-1 text-sm text-grayScale-700">
|
|
{selectedQuestionDetail.audio_correct_answer_text}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
{selectedQuestionDetail.explanation ? (
|
|
<div>
|
|
<p className="text-xs font-medium text-grayScale-500">Explanation</p>
|
|
<p className="mt-1 text-sm text-grayScale-700">{selectedQuestionDetail.explanation}</p>
|
|
</div>
|
|
) : null}
|
|
{selectedQuestionDetail.tips ? (
|
|
<div>
|
|
<p className="text-xs font-medium text-grayScale-500">Tips</p>
|
|
<p className="mt-1 text-sm text-grayScale-700">{selectedQuestionDetail.tips}</p>
|
|
</div>
|
|
) : null}
|
|
{detailVoiceUrl ? (
|
|
<div>
|
|
<p className="text-xs font-medium text-grayScale-500">Voice Prompt</p>
|
|
<audio controls src={detailVoiceUrl} className="mt-1 h-10 w-full" />
|
|
</div>
|
|
) : null}
|
|
{detailSampleVoiceUrl ? (
|
|
<div>
|
|
<p className="text-xs font-medium text-grayScale-500">Sample Answer Voice Prompt</p>
|
|
<audio controls src={detailSampleVoiceUrl} className="mt-1 h-10 w-full" />
|
|
</div>
|
|
) : null}
|
|
{detailImageUrl ? (
|
|
<div>
|
|
<p className="text-xs font-medium text-grayScale-500">Image</p>
|
|
<img
|
|
src={detailImageUrl}
|
|
alt="Question reference"
|
|
className="mt-1 h-32 w-32 rounded-md border border-grayScale-200 object-cover"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<p className="py-6 text-sm text-grayScale-500">Question details unavailable.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{confirmDeleteOpen && (deleteTarget || selectedQuestionDetail) && (
|
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm">
|
|
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-grayScale-200/80 bg-white shadow-2xl">
|
|
<div className="border-b border-grayScale-100 bg-gradient-to-r from-red-50/50 to-white px-5 py-4">
|
|
<h3 className="text-base font-semibold text-grayScale-900">Delete AUDIO question</h3>
|
|
</div>
|
|
<div className="space-y-2 px-5 py-4">
|
|
<p className="text-sm leading-relaxed text-grayScale-600">
|
|
This action cannot be undone. The question will be removed permanently.
|
|
</p>
|
|
<p className="line-clamp-3 rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-600">
|
|
{deleteTarget?.text ?? selectedQuestionDetail?.question_text}
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-end gap-2 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setConfirmDeleteOpen(false)
|
|
setDeleteTarget(null)
|
|
}}
|
|
disabled={detailDeleting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
className="bg-red-500 hover:bg-red-600"
|
|
onClick={handleDeleteQuestionDetail}
|
|
disabled={detailDeleting}
|
|
>
|
|
{detailDeleting ? "Deleting..." : "Delete"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{openCreate && (
|
|
<div className="space-y-6">
|
|
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
|
<div className="flex flex-col gap-4 border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 sm:py-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-10 w-full shrink-0 border-grayScale-200 text-grayScale-700 sm:w-auto"
|
|
onClick={() => {
|
|
resetCreateForm()
|
|
setOpenCreate(false)
|
|
}}
|
|
disabled={saving}
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Back to list
|
|
</Button>
|
|
<p className="text-center text-xs text-grayScale-500 sm:text-left sm:text-sm">
|
|
New speaking practice · {SPEAKING_STEPS[currentStep - 1] ?? ""}
|
|
</p>
|
|
</div>
|
|
<div className="px-4 py-5 sm:px-6 sm:py-6">
|
|
<div className="rounded-2xl border border-grayScale-100 bg-grayScale-50/40 px-3 py-4 sm:px-5">
|
|
<Stepper steps={SPEAKING_STEPS} currentStep={currentStep} />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{currentStep === 1 && (
|
|
<Card className="w-full overflow-hidden border-grayScale-200/80 shadow-sm">
|
|
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
|
|
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 1: Practice context</h2>
|
|
<p className="mt-1.5 max-w-3xl text-sm text-grayScale-500">
|
|
Title, description, optional intro video, sub-course, and publish status for this speaking practice.
|
|
</p>
|
|
</div>
|
|
<div className="p-5 sm:p-8 lg:p-10">
|
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12 lg:gap-10">
|
|
<div className="space-y-5 lg:col-span-7">
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-grayScale-700">Practice title</label>
|
|
<Input
|
|
value={setTitle}
|
|
onChange={(e) => setSetTitle(e.target.value)}
|
|
placeholder="Speaking practice title"
|
|
className="h-11"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-grayScale-700">Description (optional)</label>
|
|
<Textarea
|
|
value={setDescription}
|
|
onChange={(e) => setSetDescription(e.target.value)}
|
|
rows={3}
|
|
placeholder="Brief description"
|
|
className="min-h-[88px]"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-grayScale-700">
|
|
Intro video URL <span className="font-normal text-grayScale-400">(optional)</span>
|
|
</label>
|
|
<Input
|
|
value={introVideoUrl}
|
|
onChange={(e) => setIntroVideoUrl(e.target.value)}
|
|
placeholder="https://…"
|
|
type="url"
|
|
inputMode="url"
|
|
autoComplete="off"
|
|
className="h-11 font-mono text-[13px]"
|
|
/>
|
|
<input
|
|
ref={introVideoFileInputRef}
|
|
type="file"
|
|
accept="video/*"
|
|
className="hidden"
|
|
onChange={handleIntroVideoFileChange}
|
|
disabled={uploadingIntroVideo}
|
|
/>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={uploadingIntroVideo}
|
|
onClick={() => introVideoFileInputRef.current?.click()}
|
|
className="gap-1.5"
|
|
>
|
|
{uploadingIntroVideo ? (
|
|
<SpinnerIcon className="h-4 w-4" alt="" />
|
|
) : (
|
|
<Upload className="h-4 w-4" />
|
|
)}
|
|
{uploadingIntroVideo ? "Uploading…" : "Upload video from computer"}
|
|
</Button>
|
|
{introVideoUrl.trim() ? (
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => setIntroVideoUrl("")}>
|
|
Clear URL
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
<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">
|
|
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/40 p-5 shadow-sm ring-1 ring-grayScale-100/80">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Sub-course & status</h3>
|
|
<p className="mt-1 text-xs text-grayScale-400">Choose where this practice is attached.</p>
|
|
<div className="mt-4 space-y-4">
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-grayScale-700">Sub-course</label>
|
|
<DropdownMenu open={subCourseMenuOpen} onOpenChange={setSubCourseMenuOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-11 w-full justify-between rounded-xl border-grayScale-200 bg-white px-3 text-sm font-normal text-grayScale-600 hover:bg-grayScale-50"
|
|
>
|
|
<span className="truncate">
|
|
{selectedSubCourseOption
|
|
? `${selectedSubCourseOption.title} (#${selectedSubCourseOption.id})`
|
|
: subCourseLoading
|
|
? "Loading courses..."
|
|
: "Select course"}
|
|
</span>
|
|
<ChevronDown className="h-4 w-4 text-grayScale-400" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-[420px] max-w-[90vw] p-2">
|
|
<Input
|
|
value={subCourseSearch}
|
|
onChange={(e) => setSubCourseSearch(e.target.value)}
|
|
placeholder="Search course, course, category..."
|
|
className="mb-2 h-9"
|
|
/>
|
|
<div className="max-h-64 overflow-auto">
|
|
{filteredSubCourseOptions.length === 0 ? (
|
|
<p className="px-2 py-2 text-sm text-grayScale-400">No courses found</p>
|
|
) : (
|
|
<DropdownMenuRadioGroup
|
|
value={subCourseId}
|
|
onValueChange={(value) => {
|
|
setSubCourseId(value)
|
|
setSubCourseMenuOpen(false)
|
|
}}
|
|
>
|
|
{filteredSubCourseOptions.map((option) => (
|
|
<DropdownMenuRadioItem key={option.id} value={String(option.id)}>
|
|
<div className="flex min-w-0 flex-col">
|
|
<span className="truncate">{option.title}</span>
|
|
<span className="text-xs text-grayScale-400">
|
|
{option.categoryName} / {option.courseTitle} / ID: {option.id}
|
|
</span>
|
|
</div>
|
|
</DropdownMenuRadioItem>
|
|
))}
|
|
</DropdownMenuRadioGroup>
|
|
)}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-grayScale-700">Status</label>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-11 w-full justify-between rounded-xl border-grayScale-200 bg-white px-3 text-sm font-normal text-grayScale-600 hover:bg-grayScale-50"
|
|
>
|
|
<span>{setStatus}</span>
|
|
<ChevronDown className="h-4 w-4 text-grayScale-400" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-[220px]">
|
|
<DropdownMenuRadioGroup
|
|
value={setStatus}
|
|
onValueChange={(value) => setSetStatus(value as "DRAFT" | "PUBLISHED")}
|
|
>
|
|
<DropdownMenuRadioItem value="PUBLISHED">PUBLISHED</DropdownMenuRadioItem>
|
|
<DropdownMenuRadioItem value="DRAFT">DRAFT</DropdownMenuRadioItem>
|
|
</DropdownMenuRadioGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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={() => {
|
|
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={handleProceedToQuestions}
|
|
disabled={!canProceedToQuestions || creatingSet}
|
|
>
|
|
{creatingSet ? "Creating..." : "Next: Questions"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{currentStep === 2 && (
|
|
<Card className="w-full overflow-hidden border-grayScale-200/80 shadow-sm">
|
|
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
|
|
<div>
|
|
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 2: AUDIO questions</h2>
|
|
<p className="mt-1 max-w-3xl text-sm text-grayScale-500">
|
|
Build each question with voice prompts, optional media, and answer guidance.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="p-5 sm:p-8">
|
|
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/30 p-4 sm:p-5">
|
|
<div className="space-y-5">
|
|
{questionDrafts.map((draft, draftIndex) => (
|
|
<div key={draftIndex} className="rounded-2xl border border-grayScale-200 border-l-4 border-l-brand-500 bg-white p-4 shadow-sm transition-shadow hover:shadow-md sm:p-6">
|
|
<div className="mb-4 flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="rounded-md bg-brand-100 px-2 py-1 text-xs font-semibold tracking-wide text-brand-700">
|
|
AUDIO
|
|
</span>
|
|
<p className="text-sm font-semibold text-grayScale-900">Question {draftIndex + 1}</p>
|
|
</div>
|
|
{questionDrafts.length > 1 ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-red-600 hover:text-red-700"
|
|
onClick={() =>
|
|
setQuestionDrafts((prev) => prev.filter((_, idx) => idx !== draftIndex))
|
|
}
|
|
disabled={saving}
|
|
>
|
|
Remove
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<label className="text-sm font-medium text-grayScale-600">Question Text</label>
|
|
<Textarea
|
|
value={draft.questionText}
|
|
onChange={(e) =>
|
|
updateDraft(draftIndex, (prev) => ({ ...prev, questionText: e.target.value }))
|
|
}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-grayScale-600">Difficulty</label>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-11 w-full justify-between rounded-xl border-grayScale-200 bg-white px-3 text-sm font-normal text-grayScale-600 hover:bg-grayScale-50"
|
|
>
|
|
<span>{draft.difficulty}</span>
|
|
<ChevronDown className="h-4 w-4 text-grayScale-400" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-[220px]">
|
|
<DropdownMenuRadioGroup
|
|
value={draft.difficulty}
|
|
onValueChange={(value) =>
|
|
updateDraft(draftIndex, (prev) => ({
|
|
...prev,
|
|
difficulty: value as "EASY" | "MEDIUM" | "HARD",
|
|
}))
|
|
}
|
|
>
|
|
<DropdownMenuRadioItem value="EASY">EASY</DropdownMenuRadioItem>
|
|
<DropdownMenuRadioItem value="MEDIUM">MEDIUM</DropdownMenuRadioItem>
|
|
<DropdownMenuRadioItem value="HARD">HARD</DropdownMenuRadioItem>
|
|
</DropdownMenuRadioGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-grayScale-600">Points</label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={draft.points}
|
|
onChange={(e) =>
|
|
updateDraft(draftIndex, (prev) => ({
|
|
...prev,
|
|
points: Number(e.target.value) || 1,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<label className="text-sm font-medium text-grayScale-600">Voice Prompt (optional)</label>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-sm text-grayScale-600 hover:bg-grayScale-50">
|
|
{draft.uploadingVoicePrompt ? (
|
|
<SpinnerIcon className="h-4 w-4" />
|
|
) : (
|
|
<Upload className="h-4 w-4" />
|
|
)}
|
|
Upload Voice Prompt
|
|
<input
|
|
type="file"
|
|
accept=".mp3,.wav,.ogg,.m4a,.aac,.webm,.flac,audio/*"
|
|
className="hidden"
|
|
onChange={(event) => handleAudioUpload(event, "voice_prompt", draftIndex)}
|
|
disabled={draft.uploadingVoicePrompt || saving}
|
|
/>
|
|
</label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-9 px-3 text-xs"
|
|
onClick={() => startRecording("voice_prompt", draftIndex)}
|
|
disabled={draft.uploadingVoicePrompt || saving || Boolean(recordingModal)}
|
|
>
|
|
{draft.recordingVoicePrompt ? "Recording..." : "Record Voice Prompt"}
|
|
</Button>
|
|
<span className="text-xs text-grayScale-400">Max 50MB</span>
|
|
</div>
|
|
<Input
|
|
value={draft.voicePrompt}
|
|
onChange={(e) =>
|
|
updateDraft(draftIndex, (prev) => ({ ...prev, voicePrompt: e.target.value }))
|
|
}
|
|
onBlur={() => handleResolveManualAudioPreview("voice_prompt", draftIndex)}
|
|
placeholder="Audio object_key (or URL)"
|
|
/>
|
|
{draft.voicePromptPreviewUrl ? (
|
|
<audio controls src={draft.voicePromptPreviewUrl} className="h-10 w-full max-w-md" />
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<label className="text-sm font-medium text-grayScale-600">Sample Answer Voice (optional)</label>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-sm text-grayScale-600 hover:bg-grayScale-50">
|
|
{draft.uploadingSamplePrompt ? (
|
|
<SpinnerIcon className="h-4 w-4" />
|
|
) : (
|
|
<Upload className="h-4 w-4" />
|
|
)}
|
|
Upload Sample Answer
|
|
<input
|
|
type="file"
|
|
accept=".mp3,.wav,.ogg,.m4a,.aac,.webm,.flac,audio/*"
|
|
className="hidden"
|
|
onChange={(event) =>
|
|
handleAudioUpload(event, "sample_answer_voice_prompt", draftIndex)
|
|
}
|
|
disabled={draft.uploadingSamplePrompt || saving}
|
|
/>
|
|
</label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-9 px-3 text-xs"
|
|
onClick={() => startRecording("sample_answer_voice_prompt", draftIndex)}
|
|
disabled={draft.uploadingSamplePrompt || saving || Boolean(recordingModal)}
|
|
>
|
|
{draft.recordingSamplePrompt ? "Recording..." : "Record Sample Answer"}
|
|
</Button>
|
|
<span className="text-xs text-grayScale-400">Max 50MB</span>
|
|
</div>
|
|
<Input
|
|
value={draft.sampleAnswerVoicePrompt}
|
|
onChange={(e) =>
|
|
updateDraft(draftIndex, (prev) => ({
|
|
...prev,
|
|
sampleAnswerVoicePrompt: e.target.value,
|
|
}))
|
|
}
|
|
onBlur={() =>
|
|
handleResolveManualAudioPreview("sample_answer_voice_prompt", draftIndex)
|
|
}
|
|
placeholder="Audio object_key (or URL)"
|
|
/>
|
|
{draft.samplePromptPreviewUrl ? (
|
|
<audio controls src={draft.samplePromptPreviewUrl} className="h-10 w-full max-w-md" />
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<label className="text-sm font-medium text-grayScale-600">Correct Answer Text (optional)</label>
|
|
<Textarea
|
|
value={draft.audioCorrectAnswerText}
|
|
onChange={(e) =>
|
|
updateDraft(draftIndex, (prev) => ({
|
|
...prev,
|
|
audioCorrectAnswerText: e.target.value,
|
|
}))
|
|
}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<label className="text-sm font-medium text-grayScale-600">Image URL (optional)</label>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-grayScale-200 px-3 py-2 text-sm text-grayScale-600 hover:bg-grayScale-50">
|
|
{draft.uploadingImage ? (
|
|
<SpinnerIcon className="h-4 w-4" />
|
|
) : (
|
|
<ImageIcon className="h-4 w-4" />
|
|
)}
|
|
Upload Image
|
|
<input
|
|
type="file"
|
|
accept=".jpg,.jpeg,.png,.webp,.gif,image/*"
|
|
className="hidden"
|
|
onChange={(event) => handleImageUpload(event, draftIndex)}
|
|
disabled={draft.uploadingImage || saving}
|
|
/>
|
|
</label>
|
|
<span className="text-xs text-grayScale-400">Max 10MB</span>
|
|
</div>
|
|
<Input
|
|
value={draft.imageUrl}
|
|
onChange={(e) =>
|
|
updateDraft(draftIndex, (prev) => ({ ...prev, imageUrl: e.target.value }))
|
|
}
|
|
onBlur={() => handleResolveManualImagePreview(draftIndex)}
|
|
placeholder="Image URL (https://...)"
|
|
/>
|
|
{draft.imagePreviewUrl ? (
|
|
<img
|
|
src={draft.imagePreviewUrl}
|
|
alt="Uploaded preview"
|
|
className="h-28 w-28 rounded-md border border-grayScale-200 object-cover"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<label className="text-sm font-medium text-grayScale-600">Tips (optional)</label>
|
|
<Textarea
|
|
value={draft.tips}
|
|
onChange={(e) =>
|
|
updateDraft(draftIndex, (prev) => ({ ...prev, tips: e.target.value }))
|
|
}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<label className="text-sm font-medium text-grayScale-600">Explanation (optional)</label>
|
|
<Textarea
|
|
value={draft.explanation}
|
|
onChange={(e) =>
|
|
updateDraft(draftIndex, (prev) => ({ ...prev, explanation: e.target.value }))
|
|
}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="border-t border-grayScale-100 bg-white px-5 py-4 sm:px-8">
|
|
<div className="flex flex-col gap-3 rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/40 p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4">
|
|
<p className="text-xs text-grayScale-500 sm:text-sm">
|
|
Need another item? Add a new AUDIO question to the end of the list.
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="shrink-0 border-brand-200 text-brand-700 hover:bg-brand-50 hover:text-brand-800"
|
|
onClick={() => setQuestionDrafts((prev) => [...prev, createEmptyDraft()])}
|
|
disabled={saving}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add question
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:justify-end sm:px-8 sm:py-5">
|
|
<Button variant="outline" onClick={() => setCurrentStep(1)} disabled={saving} className="sm:w-auto">
|
|
Back
|
|
</Button>
|
|
<Button
|
|
className="bg-brand-500 hover:bg-brand-600 sm:min-w-[180px]"
|
|
onClick={() => setCurrentStep(3)}
|
|
disabled={!canProceedToReview}
|
|
>
|
|
Next: Review
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{currentStep === 3 && (
|
|
<Card className="w-full overflow-hidden border-grayScale-200/80 shadow-sm">
|
|
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
|
|
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Review & publish</h2>
|
|
<p className="mt-1.5 max-w-3xl text-sm text-grayScale-500">
|
|
Confirm practice metadata and each AUDIO question before publishing.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-6 p-5 sm:p-8">
|
|
<div className="grid gap-6 lg:grid-cols-2 lg:items-start lg:gap-8">
|
|
<div className="rounded-xl border border-grayScale-200 bg-white p-5 shadow-sm">
|
|
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-grayScale-500">Practice</h3>
|
|
<div className="grid grid-cols-1 gap-3 text-sm text-grayScale-600 sm:grid-cols-2">
|
|
<p><span className="font-medium">Title:</span> {setTitle || "—"}</p>
|
|
<p><span className="font-medium">Course ID:</span> {subCourseId || "—"}</p>
|
|
<p className="sm:col-span-2"><span className="font-medium">Description:</span> {setDescription || "—"}</p>
|
|
<p className="sm:col-span-2 break-all">
|
|
<span className="font-medium">Intro video URL:</span> {introVideoUrl.trim() || "—"}
|
|
</p>
|
|
<p><span className="font-medium">Status:</span> {setStatus}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex max-h-[min(70vh,48rem)] min-h-0 flex-col rounded-xl border border-grayScale-200 bg-grayScale-50/20 p-4 shadow-sm sm:p-5">
|
|
<h3 className="mb-3 shrink-0 text-sm font-semibold uppercase tracking-wide text-grayScale-500">
|
|
Questions ({questionsWithText.length})
|
|
</h3>
|
|
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
|
{questionsWithText.map((draft, idx) => (
|
|
<div key={idx} className="rounded-lg border border-grayScale-200 bg-white p-3 text-sm shadow-sm">
|
|
<p className="font-medium text-grayScale-700">
|
|
{idx + 1}. {draft.questionText}
|
|
</p>
|
|
<div className="mt-2 grid grid-cols-1 gap-2 text-xs text-grayScale-600 sm:grid-cols-2">
|
|
<p><span className="font-medium text-grayScale-700">Question Type:</span> AUDIO</p>
|
|
<p><span className="font-medium text-grayScale-700">Difficulty:</span> {draft.difficulty}</p>
|
|
<p><span className="font-medium text-grayScale-700">Points:</span> {draft.points}</p>
|
|
<p><span className="font-medium text-grayScale-700">Status:</span> {setStatus}</p>
|
|
<p className="sm:col-span-2">
|
|
<span className="font-medium text-grayScale-700">Voice Prompt:</span>{" "}
|
|
{draft.voicePrompt.trim() || "—"}
|
|
</p>
|
|
<p className="sm:col-span-2">
|
|
<span className="font-medium text-grayScale-700">Sample Answer Voice Prompt:</span>{" "}
|
|
{draft.sampleAnswerVoicePrompt.trim() || "—"}
|
|
</p>
|
|
<p className="sm:col-span-2">
|
|
<span className="font-medium text-grayScale-700">Image URL:</span>{" "}
|
|
{draft.imageUrl.trim() || "—"}
|
|
</p>
|
|
<p className="sm:col-span-2">
|
|
<span className="font-medium text-grayScale-700">Audio Correct Answer Text:</span>{" "}
|
|
{draft.audioCorrectAnswerText.trim() || "—"}
|
|
</p>
|
|
<p className="sm:col-span-2">
|
|
<span className="font-medium text-grayScale-700">Tips:</span>{" "}
|
|
{draft.tips.trim() || "—"}
|
|
</p>
|
|
<p className="sm:col-span-2">
|
|
<span className="font-medium text-grayScale-700">Explanation:</span>{" "}
|
|
{draft.explanation.trim() || "—"}
|
|
</p>
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap gap-3">
|
|
{draft.voicePromptPreviewUrl ? (
|
|
<audio controls src={draft.voicePromptPreviewUrl} className="h-9 w-full max-w-sm" />
|
|
) : null}
|
|
{draft.samplePromptPreviewUrl ? (
|
|
<audio controls src={draft.samplePromptPreviewUrl} className="h-9 w-full max-w-sm" />
|
|
) : null}
|
|
{draft.imagePreviewUrl ? (
|
|
<img
|
|
src={draft.imagePreviewUrl}
|
|
alt="Question preview"
|
|
className="h-20 w-20 rounded-md border border-grayScale-200 object-cover"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:justify-end sm:px-8 sm:py-5">
|
|
<Button variant="outline" onClick={() => setCurrentStep(2)} disabled={saving} className="sm:w-auto">
|
|
Back
|
|
</Button>
|
|
<Button className="bg-brand-500 hover:bg-brand-600 sm:min-w-[200px]" disabled={!canCreate || saving} onClick={handleCreateSpeakingPractice}>
|
|
{saving ? "Publishing..." : "Publish speaking practice"}
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{recordingModal ? (
|
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
|
<div className="mx-4 w-full max-w-md overflow-hidden rounded-2xl border border-grayScale-200/80 bg-white p-6 shadow-2xl">
|
|
<p className="text-center text-base font-semibold text-grayScale-900">
|
|
Recording {recordingModal.label}
|
|
</p>
|
|
<p className="mt-1 text-center text-xs text-grayScale-500">
|
|
Speak clearly. The bars reflect your input level in real time.
|
|
</p>
|
|
<div className="mt-3 flex justify-center">
|
|
<div className="inline-flex items-center gap-2 rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-xs font-semibold text-brand-800">
|
|
<span className="h-2 w-2 animate-pulse rounded-full bg-brand-500" />
|
|
REC{" "}
|
|
{`${Math.floor(recordingModal.elapsedSeconds / 60)
|
|
.toString()
|
|
.padStart(2, "0")}:${(recordingModal.elapsedSeconds % 60)
|
|
.toString()
|
|
.padStart(2, "0")}`}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 flex items-center gap-4 rounded-xl border border-brand-100 bg-brand-50/50 px-4 py-4">
|
|
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-700 shadow-sm">
|
|
<Mic className="h-5 w-5" />
|
|
</div>
|
|
<div className="grid h-16 w-full grid-cols-32 items-end gap-[3px] overflow-hidden">
|
|
{recordingModal.levels.map((level, idx) => {
|
|
const segmentCount = 9
|
|
const activeSegments = Math.max(1, Math.min(segmentCount, Math.round(level * segmentCount)))
|
|
return (
|
|
<div key={idx} className="flex h-full w-full flex-col-reverse gap-[2px]">
|
|
{Array.from({ length: segmentCount }, (_, segmentIdx) => (
|
|
<div
|
|
key={segmentIdx}
|
|
className="h-[5px] rounded-[2px]"
|
|
style={{
|
|
background:
|
|
segmentIdx < activeSegments
|
|
? "linear-gradient(180deg, #9E2891 0%, #6A1B9A 100%)"
|
|
: "rgba(189, 189, 189, 0.35)",
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 flex flex-wrap justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="border-grayScale-200 text-grayScale-700 hover:bg-grayScale-50"
|
|
onClick={() => void stopActiveRecording(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="border-grayScale-200 text-grayScale-700 hover:bg-grayScale-50"
|
|
onClick={togglePauseRecording}
|
|
>
|
|
{recordingModal.isPaused ? "Continue" : "Pause"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
className="bg-brand-500 text-white hover:bg-brand-600"
|
|
onClick={() => void stopActiveRecording(true)}
|
|
>
|
|
Stop & Save
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|