Compare commits
No commits in common. "53a72bef2dbd2ded5ece486cacbffbd34e040d8d" and "dbfb4307fbcbb3cc32f18b87e71ef4bc4adc2c67" have entirely different histories.
53a72bef2d
...
dbfb4307fb
|
|
@ -31,7 +31,6 @@ import { PracticeDetailsPage } from "../pages/content-management/PracticeDetails
|
||||||
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage"
|
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage"
|
||||||
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
|
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
|
||||||
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
|
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
|
||||||
import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage"
|
|
||||||
import { UserLogPage } from "../pages/user-log/UserLogPage"
|
import { UserLogPage } from "../pages/user-log/UserLogPage"
|
||||||
import { IssuesPage } from "../pages/issues/IssuesPage"
|
import { IssuesPage } from "../pages/issues/IssuesPage"
|
||||||
import { ProfilePage } from "../pages/ProfilePage"
|
import { ProfilePage } from "../pages/ProfilePage"
|
||||||
|
|
@ -77,7 +76,6 @@ export function AppRoutes() {
|
||||||
<Route index element={<CourseCategoryPage />} />
|
<Route index element={<CourseCategoryPage />} />
|
||||||
<Route path="courses" element={<AllCoursesPage />} />
|
<Route path="courses" element={<AllCoursesPage />} />
|
||||||
<Route path="flows" element={<CourseFlowBuilderPage />} />
|
<Route path="flows" element={<CourseFlowBuilderPage />} />
|
||||||
<Route path="human-language" element={<HumanLanguagePage />} />
|
|
||||||
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
|
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
|
||||||
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
|
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
|
||||||
{/* Course → Sub-course → Video/Practice */}
|
{/* Course → Sub-course → Video/Practice */}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { cn } from "../../lib/utils"
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ label: "Overview", to: "/content" },
|
{ label: "Overview", to: "/content" },
|
||||||
{ label: "Courses", to: "/content/courses" },
|
{ label: "Courses", to: "/content/courses" },
|
||||||
{ label: "Human Language", to: "/content/human-language" },
|
|
||||||
{ label: "Flows", to: "/content/flows" },
|
{ label: "Flows", to: "/content/flows" },
|
||||||
{ label: "Speaking", to: "/content/speaking" },
|
{ label: "Speaking", to: "/content/speaking" },
|
||||||
{ label: "Practice", to: "/content/practices" },
|
{ label: "Practice", to: "/content/practices" },
|
||||||
|
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { ArrowLeft, ChevronDown, ChevronRight, Image as ImageIcon, Loader2, Mic, Plus, Trash2, Upload } from "lucide-react"
|
import { ArrowLeft, ChevronDown, Image as ImageIcon, Loader2, Mic, Plus, Trash2, Upload } from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
getSubCoursesByCourse,
|
getSubCoursesByCourse,
|
||||||
createQuestion,
|
createQuestion,
|
||||||
createQuestionSet,
|
createQuestionSet,
|
||||||
// getQuestions,
|
getQuestions,
|
||||||
getPracticeQuestionsByPractice,
|
getPracticeQuestionsByPractice,
|
||||||
getQuestionSets,
|
getQuestionSets,
|
||||||
updateQuestion,
|
updateQuestion,
|
||||||
|
|
@ -69,14 +69,6 @@ type AudioQuestionDraft = {
|
||||||
type PracticeFilterOption = {
|
type PracticeFilterOption = {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
|
||||||
persona?: string
|
|
||||||
status?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type AudioListQuestion = QuestionDetail & {
|
|
||||||
practice_id: number | null
|
|
||||||
practice_title: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const createEmptyDraft = (): AudioQuestionDraft => ({
|
const createEmptyDraft = (): AudioQuestionDraft => ({
|
||||||
|
|
@ -122,44 +114,14 @@ function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: strin
|
||||||
return pageUrl || null
|
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() {
|
export function SpeakingPage() {
|
||||||
const [audioQuestions, setAudioQuestions] = useState<AudioListQuestion[]>([])
|
const [audioQuestions, setAudioQuestions] = useState<QuestionDetail[]>([])
|
||||||
const [audioTotalCount, setAudioTotalCount] = useState(0)
|
const [audioTotalCount, setAudioTotalCount] = useState(0)
|
||||||
const [audioPage, setAudioPage] = useState(1)
|
const [audioPage, setAudioPage] = useState(1)
|
||||||
const [audioPageSize] = useState(12)
|
const [audioPageSize] = useState(12)
|
||||||
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
|
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
|
||||||
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
|
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
|
||||||
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false)
|
|
||||||
const [practiceFilterSearch, setPracticeFilterSearch] = useState("")
|
|
||||||
const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState<Record<number, string>>({})
|
const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState<Record<number, string>>({})
|
||||||
const [imagePreviewByQuestionId, setImagePreviewByQuestionId] = useState<Record<number, string>>({})
|
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
|
||||||
const [selectedQuestionIds, setSelectedQuestionIds] = useState<number[]>([])
|
|
||||||
const [bulkDeleting, setBulkDeleting] = useState(false)
|
|
||||||
const [collapsedPracticeIds, setCollapsedPracticeIds] = useState<number[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [openCreate, setOpenCreate] = useState(false)
|
const [openCreate, setOpenCreate] = useState(false)
|
||||||
|
|
@ -174,9 +136,7 @@ export function SpeakingPage() {
|
||||||
const [subCourseLoading, setSubCourseLoading] = useState(false)
|
const [subCourseLoading, setSubCourseLoading] = useState(false)
|
||||||
const [subCourseSearch, setSubCourseSearch] = useState("")
|
const [subCourseSearch, setSubCourseSearch] = useState("")
|
||||||
const [subCourseMenuOpen, setSubCourseMenuOpen] = useState(false)
|
const [subCourseMenuOpen, setSubCourseMenuOpen] = useState(false)
|
||||||
const [setStatus, setSetStatus] = useState<"DRAFT" | "PUBLISHED">("PUBLISHED")
|
const [setStatus, setSetStatus] = useState<"DRAFT" | "PUBLISHED">("DRAFT")
|
||||||
const [createdSetId, setCreatedSetId] = useState<number | null>(null)
|
|
||||||
const [creatingSet, setCreatingSet] = useState(false)
|
|
||||||
const [currentStep, setCurrentStep] = useState(1)
|
const [currentStep, setCurrentStep] = useState(1)
|
||||||
const [detailOpen, setDetailOpen] = useState(false)
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
const [detailLoading, setDetailLoading] = useState(false)
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
|
@ -243,18 +203,17 @@ export function SpeakingPage() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const safePage = page < 1 ? 1 : page
|
const safePage = page < 1 ? 1 : page
|
||||||
let rows: AudioListQuestion[] = []
|
const offset = (safePage - 1) * audioPageSize
|
||||||
|
let rows: QuestionDetail[] = []
|
||||||
let total = 0
|
let total = 0
|
||||||
|
|
||||||
if (selectedPracticeId) {
|
if (selectedPracticeId) {
|
||||||
const offset = (safePage - 1) * audioPageSize
|
|
||||||
const practiceRes = await getPracticeQuestionsByPractice(Number(selectedPracticeId), {
|
const practiceRes = await getPracticeQuestionsByPractice(Number(selectedPracticeId), {
|
||||||
limit: audioPageSize,
|
limit: audioPageSize,
|
||||||
offset,
|
offset,
|
||||||
question_type: "AUDIO",
|
question_type: "AUDIO",
|
||||||
})
|
})
|
||||||
const practiceData = practiceRes.data?.data
|
const practiceData = practiceRes.data?.data
|
||||||
const selectedPractice = practiceOptions.find((p) => p.id === Number(selectedPracticeId))
|
|
||||||
rows = (practiceData?.questions ?? []).map((q) => ({
|
rows = (practiceData?.questions ?? []).map((q) => ({
|
||||||
id: q.question_id || q.id,
|
id: q.question_id || q.id,
|
||||||
question_text: q.question_text,
|
question_text: q.question_text,
|
||||||
|
|
@ -269,62 +228,28 @@ export function SpeakingPage() {
|
||||||
status: q.question_status ?? "DRAFT",
|
status: q.question_status ?? "DRAFT",
|
||||||
created_at: "",
|
created_at: "",
|
||||||
audio_correct_answer_text: q.audio_correct_answer_text ?? undefined,
|
audio_correct_answer_text: q.audio_correct_answer_text ?? undefined,
|
||||||
practice_id: Number(selectedPracticeId),
|
|
||||||
practice_title: selectedPractice?.title ?? `Practice #${selectedPracticeId}`,
|
|
||||||
}))
|
}))
|
||||||
const q = searchQuery.trim().toLowerCase()
|
total = practiceData?.total_count ?? rows.length
|
||||||
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 {
|
} else {
|
||||||
const groupedRows = await Promise.all(
|
const res = await getQuestions({
|
||||||
practiceOptions.map(async (practice) => {
|
question_type: "AUDIO",
|
||||||
try {
|
limit: audioPageSize,
|
||||||
const res = await getPracticeQuestionsByPractice(practice.id, {
|
offset,
|
||||||
limit: 100,
|
})
|
||||||
offset: 0,
|
const payload = res.data?.data as unknown
|
||||||
question_type: "AUDIO",
|
const meta = res.data?.metadata as { total_count?: number } | null | undefined
|
||||||
})
|
if (Array.isArray(payload)) {
|
||||||
const questions = res.data?.data?.questions ?? []
|
rows = payload as QuestionDetail[]
|
||||||
return questions.map((q) => ({
|
total = meta?.total_count ?? rows.length
|
||||||
id: q.question_id || q.id,
|
} else if (
|
||||||
question_text: q.question_text,
|
payload &&
|
||||||
question_type: q.question_type,
|
typeof payload === "object" &&
|
||||||
difficulty_level: q.difficulty_level ?? undefined,
|
Array.isArray((payload as { questions?: unknown[] }).questions)
|
||||||
points: q.points ?? 0,
|
) {
|
||||||
explanation: q.explanation ?? undefined,
|
const data = payload as { questions: QuestionDetail[]; total_count?: number }
|
||||||
tips: q.tips ?? undefined,
|
rows = data.questions
|
||||||
voice_prompt: q.voice_prompt ?? undefined,
|
total = data.total_count ?? meta?.total_count ?? rows.length
|
||||||
sample_answer_voice_prompt: q.sample_answer_voice_prompt ?? undefined,
|
|
||||||
image_url: q.image_url ?? undefined,
|
|
||||||
status: q.question_status ?? "DRAFT",
|
|
||||||
created_at: "",
|
|
||||||
audio_correct_answer_text: q.audio_correct_answer_text ?? undefined,
|
|
||||||
practice_id: practice.id,
|
|
||||||
practice_title: practice.title,
|
|
||||||
}))
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
rows = groupedRows.flat()
|
|
||||||
const q = searchQuery.trim().toLowerCase()
|
|
||||||
if (q) {
|
|
||||||
rows = rows.filter((question) => {
|
|
||||||
const haystack =
|
|
||||||
`${question.question_text} ${question.audio_correct_answer_text ?? ""} ${question.practice_title ?? ""}`.toLowerCase()
|
|
||||||
return haystack.includes(q)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
total = rows.length
|
|
||||||
const offset = (safePage - 1) * audioPageSize
|
|
||||||
rows = rows.slice(offset, offset + audioPageSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAudioQuestions(rows)
|
setAudioQuestions(rows)
|
||||||
|
|
@ -337,82 +262,59 @@ export function SpeakingPage() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [audioPage, audioPageSize, selectedPracticeId, practiceOptions, searchQuery])
|
}, [audioPage, audioPageSize, selectedPracticeId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAudioQuestions()
|
fetchAudioQuestions()
|
||||||
}, [fetchAudioQuestions, audioPageSize, selectedPracticeId])
|
}, [fetchAudioQuestions, audioPageSize, selectedPracticeId])
|
||||||
|
|
||||||
const fetchPracticeOptions = useCallback(async () => {
|
useEffect(() => {
|
||||||
const batchSize = 100
|
let cancelled = false
|
||||||
let offset = 0
|
const fetchPractices = async () => {
|
||||||
let total = Number.POSITIVE_INFINITY
|
try {
|
||||||
const all: QuestionSet[] = []
|
const batchSize = 100
|
||||||
while (all.length < total) {
|
let offset = 0
|
||||||
const res = await getQuestionSets({
|
let total = Number.POSITIVE_INFINITY
|
||||||
set_type: "PRACTICE",
|
const all: QuestionSet[] = []
|
||||||
limit: batchSize,
|
while (all.length < total) {
|
||||||
offset,
|
const res = await getQuestionSets({
|
||||||
})
|
set_type: "PRACTICE",
|
||||||
const payload = res.data?.data
|
limit: batchSize,
|
||||||
let chunk: QuestionSet[] = []
|
offset,
|
||||||
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 payload = res.data?.data
|
||||||
const hasAudioQuestion = questions.some(
|
let chunk: QuestionSet[] = []
|
||||||
(question) => (question.question_type ?? "").toUpperCase() === "AUDIO",
|
let chunkTotal = 0
|
||||||
)
|
if (Array.isArray(payload)) {
|
||||||
return hasAudioQuestion ? practice : null
|
chunk = payload
|
||||||
} catch {
|
chunkTotal = payload.length
|
||||||
return null
|
} 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) => ({
|
||||||
const speakingPractices = checks.filter((p): p is QuestionSet => p !== null)
|
id: p.id,
|
||||||
setPracticeOptions(
|
title: p.title,
|
||||||
speakingPractices.map((p) => ({
|
})),
|
||||||
id: p.id,
|
)
|
||||||
title: p.title,
|
}
|
||||||
description: p.description ?? "",
|
} catch {
|
||||||
persona: p.persona ?? "",
|
if (!cancelled) setPracticeOptions([])
|
||||||
status: p.status ?? "",
|
}
|
||||||
})),
|
}
|
||||||
)
|
fetchPractices()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPracticeOptions().catch(() => {
|
|
||||||
setPracticeOptions([])
|
|
||||||
})
|
|
||||||
}, [fetchPracticeOptions])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedPracticeId) return
|
|
||||||
const exists = practiceOptions.some((option) => option.id === Number(selectedPracticeId))
|
|
||||||
if (!exists) setSelectedPracticeId("")
|
|
||||||
}, [practiceOptions, selectedPracticeId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const fetchSubCourseOptions = async () => {
|
const fetchSubCourseOptions = async () => {
|
||||||
|
|
@ -499,87 +401,15 @@ export function SpeakingPage() {
|
||||||
}
|
}
|
||||||
}, [audioQuestions, resolvePreviewUrl])
|
}, [audioQuestions, resolvePreviewUrl])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
const withImages = audioQuestions.filter((q) => Boolean(q.image_url))
|
|
||||||
if (withImages.length === 0) {
|
|
||||||
setImagePreviewByQuestionId({})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveAll = async () => {
|
|
||||||
const entries = await Promise.all(
|
|
||||||
withImages.map(async (question) => {
|
|
||||||
try {
|
|
||||||
const url = await resolvePreviewUrl(question.image_url ?? "")
|
|
||||||
return [question.id, url] as const
|
|
||||||
} catch {
|
|
||||||
return [question.id, ""] as const
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (!cancelled) {
|
|
||||||
setImagePreviewByQuestionId(Object.fromEntries(entries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveAll()
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [audioQuestions, resolvePreviewUrl])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedQuestionIds([])
|
|
||||||
}, [selectedPracticeId, audioPage, searchQuery])
|
|
||||||
|
|
||||||
const resetCreateForm = () => {
|
const resetCreateForm = () => {
|
||||||
setSetTitle("")
|
setSetTitle("")
|
||||||
setSetDescription("")
|
setSetDescription("")
|
||||||
setIntroVideoUrl("")
|
setIntroVideoUrl("")
|
||||||
setSubCourseId("")
|
setSubCourseId("")
|
||||||
setSetStatus("PUBLISHED")
|
setSetStatus("DRAFT")
|
||||||
setCreatedSetId(null)
|
|
||||||
setQuestionDrafts([createEmptyDraft()])
|
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 handleIntroVideoFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
event.target.value = ""
|
event.target.value = ""
|
||||||
|
|
@ -628,21 +458,6 @@ export function SpeakingPage() {
|
||||||
return haystack.includes(q)
|
return haystack.includes(q)
|
||||||
})
|
})
|
||||||
}, [subCourseOptions, subCourseSearch])
|
}, [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) => {
|
const updateDraft = (index: number, updater: (draft: AudioQuestionDraft) => AudioQuestionDraft) => {
|
||||||
setQuestionDrafts((prev) => prev.map((draft, idx) => (idx === index ? updater(draft) : draft)))
|
setQuestionDrafts((prev) => prev.map((draft, idx) => (idx === index ? updater(draft) : draft)))
|
||||||
|
|
@ -1036,7 +851,18 @@ export function SpeakingPage() {
|
||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const setId = createdSetId
|
// 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
|
||||||
if (!setId) throw new Error("Question set creation failed: missing set ID")
|
if (!setId) throw new Error("Question set creation failed: missing set ID")
|
||||||
|
|
||||||
// 2) Create all AUDIO questions then attach in sequence.
|
// 2) Create all AUDIO questions then attach in sequence.
|
||||||
|
|
@ -1068,8 +894,6 @@ export function SpeakingPage() {
|
||||||
setOpenCreate(false)
|
setOpenCreate(false)
|
||||||
setCurrentStep(1)
|
setCurrentStep(1)
|
||||||
resetCreateForm()
|
resetCreateForm()
|
||||||
await fetchPracticeOptions()
|
|
||||||
setSelectedPracticeId(String(setId))
|
|
||||||
toast.success(`Speaking practice created with ${draftsToCreate.length} AUDIO question(s)`)
|
toast.success(`Speaking practice created with ${draftsToCreate.length} AUDIO question(s)`)
|
||||||
await fetchAudioQuestions()
|
await fetchAudioQuestions()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -1206,71 +1030,6 @@ 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 (
|
return (
|
||||||
<div className="mx-auto w-full max-w-7xl space-y-6 pb-10 sm:space-y-8 sm:pb-12">
|
<div className="mx-auto w-full max-w-7xl space-y-6 pb-10 sm:space-y-8 sm:pb-12">
|
||||||
<div className="flex flex-col gap-4 border-b border-grayScale-100 pb-6 sm:flex-row sm:items-end sm:justify-between sm:pb-8">
|
<div className="flex flex-col gap-4 border-b border-grayScale-100 pb-6 sm:flex-row sm:items-end sm:justify-between sm:pb-8">
|
||||||
|
|
@ -1285,7 +1044,6 @@ export function SpeakingPage() {
|
||||||
<Button
|
<Button
|
||||||
className="h-11 w-full shrink-0 bg-brand-500 px-5 shadow-sm hover:bg-brand-600 sm:w-auto"
|
className="h-11 w-full shrink-0 bg-brand-500 px-5 shadow-sm hover:bg-brand-600 sm:w-auto"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
resetCreateForm()
|
|
||||||
setOpenCreate(true)
|
setOpenCreate(true)
|
||||||
setCurrentStep(1)
|
setCurrentStep(1)
|
||||||
}}
|
}}
|
||||||
|
|
@ -1308,59 +1066,21 @@ export function SpeakingPage() {
|
||||||
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">
|
<label className="text-xs font-medium uppercase tracking-wide text-grayScale-500">
|
||||||
Filter by practice
|
Filter by practice
|
||||||
</label>
|
</label>
|
||||||
<DropdownMenu open={practiceFilterOpen} onOpenChange={setPracticeFilterOpen}>
|
<select
|
||||||
<DropdownMenuTrigger asChild>
|
value={selectedPracticeId}
|
||||||
<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) => {
|
onChange={(e) => {
|
||||||
setSearchQuery(e.target.value)
|
setSelectedPracticeId(e.target.value)
|
||||||
setAudioPage(1)
|
setAudioPage(1)
|
||||||
}}
|
}}
|
||||||
placeholder="Search question text, answer text, or practice..."
|
className="h-10 w-full rounded-md border border-grayScale-200 bg-white px-3 text-sm text-grayScale-700"
|
||||||
className="h-10"
|
>
|
||||||
/>
|
<option value="">All practices</option>
|
||||||
|
{practiceOptions.map((practice) => (
|
||||||
|
<option key={practice.id} value={String(practice.id)}>
|
||||||
|
{practice.title} (#{practice.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs font-normal text-grayScale-400 sm:text-sm">
|
<p className="mt-1 text-xs font-normal text-grayScale-400 sm:text-sm">
|
||||||
Showing page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} ({audioTotalCount} total)
|
Showing page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} ({audioTotalCount} total)
|
||||||
|
|
@ -1384,130 +1104,57 @@ export function SpeakingPage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2.5">
|
<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">
|
{audioQuestions.map((question, idx) => (
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-grayScale-700">
|
<div
|
||||||
<input
|
key={question.id}
|
||||||
type="checkbox"
|
className={`cursor-pointer rounded-xl border px-4 py-3.5 transition-all sm:px-5 ${
|
||||||
checked={
|
idx % 2 === 0 ? "border-grayScale-200 bg-white" : "border-grayScale-100 bg-grayScale-50/80"
|
||||||
audioQuestions.length > 0 && audioQuestions.every((question) => selectedQuestionIds.includes(question.id))
|
} hover:border-brand-300 hover:bg-brand-50/40 hover:shadow-sm`}
|
||||||
}
|
onClick={() => handleOpenQuestionDetail(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})`}
|
<div className="flex items-start justify-between gap-3">
|
||||||
</Button>
|
<p className="text-sm font-medium leading-snug text-grayScale-800">{question.question_text}</p>
|
||||||
</div>
|
<Button
|
||||||
{groupedAudioQuestions.map((group) => (
|
variant="ghost"
|
||||||
<div key={group.practiceId ?? "unknown"} className="space-y-2">
|
size="icon"
|
||||||
<div className="rounded-lg border border-grayScale-200 bg-gradient-to-r from-grayScale-50 to-white px-3 py-2">
|
className="h-8 w-8 shrink-0 text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
onClick={(event) => {
|
||||||
<div className="inline-flex items-center gap-2">
|
event.stopPropagation()
|
||||||
<button
|
setDeleteTarget({ id: question.id, text: question.question_text })
|
||||||
type="button"
|
setConfirmDeleteOpen(true)
|
||||||
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">
|
<Trash2 className="h-4 w-4" />
|
||||||
<div className="flex items-start gap-3">
|
</Button>
|
||||||
<input
|
</div>
|
||||||
type="checkbox"
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||||
checked={selectedQuestionIds.includes(question.id)}
|
<span className="rounded-md bg-brand-100 px-2 py-0.5 font-medium text-brand-800">AUDIO</span>
|
||||||
onClick={(event) => event.stopPropagation()}
|
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
|
||||||
onChange={() => toggleQuestionSelection(question.id)}
|
Difficulty: {question.difficulty_level || "—"}
|
||||||
className="mt-1"
|
</span>
|
||||||
/>
|
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
|
||||||
<p className="text-sm font-medium leading-snug text-grayScale-800">{question.question_text}</p>
|
Points: {question.points ?? 0}
|
||||||
</div>
|
</span>
|
||||||
<Button
|
<span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
|
||||||
variant="ghost"
|
Status: {question.status || "—"}
|
||||||
size="icon"
|
</span>
|
||||||
className="h-8 w-8 shrink-0 text-red-500 hover:bg-red-50 hover:text-red-600"
|
</div>
|
||||||
onClick={(event) => {
|
{question.voice_prompt ? (
|
||||||
event.stopPropagation()
|
<div className="mt-3 space-y-2">
|
||||||
setDeleteTarget({ id: question.id, text: question.question_text })
|
<p className="text-xs font-medium text-grayScale-500">Voice prompt preview</p>
|
||||||
setConfirmDeleteOpen(true)
|
{audioPreviewByQuestionId[question.id] ? (
|
||||||
}}
|
<audio controls src={audioPreviewByQuestionId[question.id]} className="h-10 w-full max-w-sm" />
|
||||||
>
|
) : (
|
||||||
<Trash2 className="h-4 w-4" />
|
<p className="text-xs text-grayScale-400">Unable to resolve audio URL.</p>
|
||||||
</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>
|
||||||
))}
|
) : 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 ? (
|
{audioTotalCount > audioPageSize ? (
|
||||||
|
|
@ -1845,10 +1492,7 @@ export function SpeakingPage() {
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-10 w-full shrink-0 border-grayScale-200 text-grayScale-700 sm:w-auto"
|
className="h-10 w-full shrink-0 border-grayScale-200 text-grayScale-700 sm:w-auto"
|
||||||
onClick={() => {
|
onClick={() => setOpenCreate(false)}
|
||||||
resetCreateForm()
|
|
||||||
setOpenCreate(false)
|
|
||||||
}}
|
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
|
@ -1941,22 +1585,6 @@ export function SpeakingPage() {
|
||||||
<p className="text-xs leading-relaxed text-grayScale-500">
|
<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.
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<aside className="space-y-4 lg:col-span-5">
|
<aside className="space-y-4 lg:col-span-5">
|
||||||
|
|
@ -2018,7 +1646,7 @@ export function SpeakingPage() {
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-grayScale-700">Status</label>
|
<label className="text-sm font-medium text-grayScale-700">Set status</label>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -2046,23 +1674,15 @@ export function SpeakingPage() {
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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">
|
<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
|
<Button variant="outline" onClick={() => setOpenCreate(false)} disabled={saving} className="sm:w-auto">
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
resetCreateForm()
|
|
||||||
setOpenCreate(false)
|
|
||||||
}}
|
|
||||||
disabled={saving}
|
|
||||||
className="sm:w-auto"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="bg-brand-500 hover:bg-brand-600 sm:min-w-[180px]"
|
className="bg-brand-500 hover:bg-brand-600 sm:min-w-[180px]"
|
||||||
onClick={handleProceedToQuestions}
|
onClick={() => setCurrentStep(2)}
|
||||||
disabled={!canProceedToQuestions || creatingSet}
|
disabled={!canProceedToQuestions}
|
||||||
>
|
>
|
||||||
{creatingSet ? "Creating..." : "Next: Questions"}
|
Next: Questions
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user