UI and integration adjustment for create practice

This commit is contained in:
Yared Yemane 2026-03-29 02:32:35 -07:00
parent 4d5d4f0d15
commit f2bf172fbb
7 changed files with 603 additions and 322 deletions

View File

@ -118,14 +118,21 @@ export const deleteSubCourseVideo = (videoId: number) =>
http.delete(`/course-management/sub-course-videos/${videoId}`) http.delete(`/course-management/sub-course-videos/${videoId}`)
// Practice APIs - for SubCourse practices (New Hierarchy) // Practice APIs - for SubCourse practices (New Hierarchy)
// Practices are sourced from question sets by owner_type=SUB_COURSE. // Practices are question sets: POST /question-sets with set_type: "PRACTICE", owner_type: "SUB_COURSE".
export const getPracticesBySubCourse = (subCourseId: number) => export const getPracticesBySubCourse = (subCourseId: number) =>
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", { http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
params: { owner_type: "SUB_COURSE", owner_id: subCourseId }, params: { owner_type: "SUB_COURSE", owner_id: subCourseId },
}) })
export const createPractice = (data: CreatePracticeRequest) => export const createPractice = (data: CreatePracticeRequest) =>
http.post("/course-management/practices", data) http.post<CreateQuestionSetResponse>("/question-sets", {
title: data.title,
set_type: "PRACTICE",
owner_type: "SUB_COURSE",
owner_id: data.sub_course_id,
...(data.description?.trim() ? { description: data.description.trim() } : {}),
...(data.persona ? { persona: data.persona } : {}),
})
export const updatePractice = (practiceId: number, data: UpdatePracticeRequest) => export const updatePractice = (practiceId: number, data: UpdatePracticeRequest) =>
http.put(`/course-management/practices/${practiceId}`, data) http.put(`/course-management/practices/${practiceId}`, data)

View File

@ -1,10 +1,12 @@
import { useState } from "react" import { useRef, useState, type ChangeEvent } from "react"
import { Link, useParams, useNavigate } from "react-router-dom" import { Link, useParams, useNavigate } from "react-router-dom"
import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, X, Edit, Rocket } from "lucide-react" import { ArrowLeft, ArrowRight, ChevronDown, Grid3X3, Check, Plus, Trash2, GripVertical, X, Edit, Rocket, Loader2, Upload } from "lucide-react"
import { toast } from "sonner"
import { Card } from "../../components/ui/card" import { Card } 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"
import { createQuestionSet, createQuestion, addQuestionToSet } from "../../api/courses.api" import { createQuestionSet, createQuestion, addQuestionToSet } from "../../api/courses.api"
import { uploadVideoFile } from "../../api/files.api"
import { Select } from "../../components/ui/select" import { Select } from "../../components/ui/select"
import type { QuestionOption } from "../../types/course.types" import type { QuestionOption } from "../../types/course.types"
@ -56,6 +58,18 @@ const STEPS = [
{ number: 4, label: "Review" }, { number: 4, label: "Review" },
] ]
/** 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 createEmptyQuestion(id: string): Question { function createEmptyQuestion(id: string): Question {
return { return {
id, id,
@ -89,6 +103,9 @@ export function AddNewPracticePage() {
const [selectedCourse] = useState("B2") const [selectedCourse] = useState("B2")
const [practiceTitle, setPracticeTitle] = useState("") const [practiceTitle, setPracticeTitle] = useState("")
const [practiceDescription, setPracticeDescription] = useState("") const [practiceDescription, setPracticeDescription] = useState("")
const [introVideoUrl, setIntroVideoUrl] = useState("")
const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false)
const introVideoFileInputRef = useRef<HTMLInputElement>(null)
const [shuffleQuestions, setShuffleQuestions] = useState(false) const [shuffleQuestions, setShuffleQuestions] = useState(false)
const [passingScore, setPassingScore] = useState(50) const [passingScore, setPassingScore] = useState(50)
const [timeLimitMinutes, setTimeLimitMinutes] = useState(60) const [timeLimitMinutes, setTimeLimitMinutes] = useState(60)
@ -120,6 +137,29 @@ export function AddNewPracticePage() {
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`) navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)
} }
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: practiceTitle.trim() || file.name.replace(/\.[^.]+$/, "") || "Practice intro",
description: practiceDescription.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 addQuestion = () => { const addQuestion = () => {
setQuestions([...questions, createEmptyQuestion(String(Date.now()))]) setQuestions([...questions, createEmptyQuestion(String(Date.now()))])
} }
@ -170,15 +210,16 @@ export function AddNewPracticePage() {
const persona = PERSONAS.find(p => p.id === selectedPersona) const persona = PERSONAS.find(p => p.id === selectedPersona)
const setRes = await createQuestionSet({ const setRes = await createQuestionSet({
title: practiceTitle || "Untitled Practice", title: practiceTitle || "Untitled Practice",
description: practiceDescription,
set_type: "PRACTICE", set_type: "PRACTICE",
owner_type: "SUB_COURSE", owner_type: "SUB_COURSE",
owner_id: Number(subCourseId), owner_id: Number(subCourseId),
persona: persona?.name, ...(practiceDescription.trim() ? { description: practiceDescription.trim() } : {}),
...(persona?.name ? { persona: persona.name } : {}),
shuffle_questions: shuffleQuestions, shuffle_questions: shuffleQuestions,
status, status,
passing_score: passingScore, passing_score: passingScore,
time_limit_minutes: timeLimitMinutes, time_limit_minutes: timeLimitMinutes,
...(introVideoUrl.trim() ? { intro_video_url: introVideoUrl.trim() } : {}),
}) })
const questionSetId = setRes.data?.data?.id const questionSetId = setRes.data?.data?.id
@ -250,7 +291,8 @@ export function AddNewPracticePage() {
} }
return ( return (
<div className="space-y-6"> <div className="mx-auto w-full max-w-7xl px-4 pb-12 sm:px-6 lg:px-8">
<div className="space-y-5 sm:space-y-6">
{currentStep !== 5 && ( {currentStep !== 5 && (
<> <>
{/* Back Link */} {/* Back Link */}
@ -263,9 +305,9 @@ export function AddNewPracticePage() {
</Link> </Link>
{/* Header */} {/* Header */}
<div> <div className="border-b border-grayScale-100 pb-6 sm:pb-8">
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900">Add New Practice</h1> <h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-3xl">Add New Practice</h1>
<p className="mt-1.5 text-sm text-grayScale-500"> <p className="mt-2 max-w-3xl text-sm leading-relaxed text-grayScale-500 sm:text-[15px]">
Create a new immersive practice session for students. Create a new immersive practice session for students.
</p> </p>
</div> </div>
@ -274,12 +316,12 @@ export function AddNewPracticePage() {
{/* Step Tracker */} {/* Step Tracker */}
{currentStep !== 5 && ( {currentStep !== 5 && (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center rounded-2xl border border-grayScale-100 bg-grayScale-50/40 px-3 py-4 sm:px-6 sm:py-5">
{STEPS.map((step, index) => ( {STEPS.map((step, index) => (
<div key={step.number} className="flex items-center"> <div key={step.number} className="flex items-center">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div <div
className={`flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold shadow-sm transition-all duration-300 ${ className={`flex h-9 w-9 items-center justify-center rounded-full text-xs font-semibold shadow-sm transition-all duration-300 sm:h-10 sm:w-10 sm:text-sm ${
currentStep === step.number currentStep === step.number
? "bg-brand-500 text-white ring-4 ring-brand-100" ? "bg-brand-500 text-white ring-4 ring-brand-100"
: currentStep > step.number : currentStep > step.number
@ -290,7 +332,7 @@ export function AddNewPracticePage() {
{currentStep > step.number ? <Check className="h-4 w-4" /> : step.number} {currentStep > step.number ? <Check className="h-4 w-4" /> : step.number}
</div> </div>
<span <span
className={`mt-2.5 text-xs font-semibold tracking-wide ${ className={`mt-2 max-w-[4.5rem] text-center text-[10px] font-semibold uppercase tracking-wide sm:mt-2.5 sm:max-w-none sm:text-xs sm:normal-case sm:tracking-wide ${
currentStep === step.number currentStep === step.number
? "text-brand-600" ? "text-brand-600"
: currentStep > step.number : currentStep > step.number
@ -303,7 +345,7 @@ export function AddNewPracticePage() {
</div> </div>
{index < STEPS.length - 1 && ( {index < STEPS.length - 1 && (
<div <div
className={`mx-3 h-0.5 w-16 rounded-full transition-colors duration-300 sm:mx-5 sm:w-24 md:w-32 ${ className={`mx-2 h-0.5 w-10 shrink-0 rounded-full transition-colors duration-300 sm:mx-4 sm:w-20 md:w-28 lg:w-36 xl:w-44 ${
currentStep > step.number ? "bg-brand-500" : "bg-grayScale-200" currentStep > step.number ? "bg-brand-500" : "bg-grayScale-200"
}`} }`}
/> />
@ -315,111 +357,170 @@ export function AddNewPracticePage() {
{/* Step Content */} {/* Step Content */}
{currentStep === 1 && ( {currentStep === 1 && (
<Card className="mx-auto max-w-2xl p-6 sm:p-10"> <Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
<h2 className="text-lg font-semibold text-grayScale-900">Step 1: Context Definition</h2> <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">
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-500"> <h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 1: Context</h2>
Define the educational level and curriculum module for this practice. <p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
</p> Define details and rules for this practice. Curriculum context is shown on the right.
</p>
</div>
<div className="mt-8 space-y-7"> <div className="p-5 sm:p-8 lg:p-10">
{/* Practice Title */} <div className="grid gap-8 lg:grid-cols-12 lg:gap-10">
<div className="space-y-2"> <div className="space-y-6 lg:col-span-7">
<label className="text-sm font-medium text-grayScale-700">Practice Title</label> <div className="space-y-2">
<Input <label className="text-sm font-medium text-grayScale-700">Practice Title</label>
value={practiceTitle} <Input
onChange={(e) => setPracticeTitle(e.target.value)} value={practiceTitle}
placeholder="Enter practice title" onChange={(e) => setPracticeTitle(e.target.value)}
/> placeholder="Enter practice title"
</div> className="h-11"
/>
</div>
{/* Practice Description */} <div className="space-y-2">
<div className="space-y-2"> <label className="text-sm font-medium text-grayScale-700">Description</label>
<label className="text-sm font-medium text-grayScale-700">Description</label> <textarea
<textarea value={practiceDescription}
value={practiceDescription} onChange={(e) => setPracticeDescription(e.target.value)}
onChange={(e) => setPracticeDescription(e.target.value)} placeholder="Enter practice description"
placeholder="Enter practice description" className="min-h-[88px] w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100"
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-100" rows={3}
rows={3} />
/> </div>
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2"> <div className="space-y-2">
{/* Passing Score */} <label className="text-sm font-medium text-grayScale-700">
<div className="space-y-2"> Intro video URL <span className="font-normal text-grayScale-400">(optional)</span>
<label className="text-sm font-medium text-grayScale-700">Passing Score</label> </label>
<Input <Input
type="number" value={introVideoUrl}
value={passingScore} onChange={(e) => setIntroVideoUrl(e.target.value)}
onChange={(e) => setPassingScore(Number(e.target.value))} placeholder="https://…"
placeholder="50" type="url"
min={0} inputMode="url"
max={100} 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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<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 go through the file service (optional, not tied to sub-course video rows).
</p>
</div>
</div> </div>
{/* Time Limit */} <aside className="space-y-5 lg:col-span-5">
<div className="space-y-2"> <div className="rounded-xl border border-grayScale-200 bg-grayScale-50/40 p-5 shadow-sm ring-1 ring-grayScale-100/80">
<label className="text-sm font-medium text-grayScale-700">Time Limit (minutes)</label> <h3 className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Scoring & behavior</h3>
<Input <div className="mt-4 grid grid-cols-2 gap-4">
type="number" <div className="space-y-2">
value={timeLimitMinutes} <label className="text-sm font-medium text-grayScale-700">Passing score</label>
onChange={(e) => setTimeLimitMinutes(Number(e.target.value))} <Input
placeholder="60" type="number"
min={0} value={passingScore}
/> onChange={(e) => setPassingScore(Number(e.target.value))}
</div> placeholder="50"
</div> min={0}
max={100}
className="h-10"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">Time (min)</label>
<Input
type="number"
value={timeLimitMinutes}
onChange={(e) => setTimeLimitMinutes(Number(e.target.value))}
placeholder="60"
min={0}
className="h-10"
/>
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-grayScale-200/80 bg-white px-4 py-3">
<label className="text-sm font-medium text-grayScale-700">Shuffle questions</label>
<button
type="button"
onClick={() => setShuffleQuestions(!shuffleQuestions)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out ${
shuffleQuestions ? "bg-brand-500" : "bg-grayScale-300"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-md ring-0 transition-transform duration-200 ease-in-out ${
shuffleQuestions ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
</div>
{/* Shuffle Questions */} <div className="rounded-xl border border-grayScale-200 bg-white p-5 shadow-sm ring-1 ring-grayScale-100/80">
<div className="flex items-center gap-3 rounded-lg bg-grayScale-50 px-4 py-3"> <h3 className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Curriculum context</h3>
<button <p className="mt-1 text-xs text-grayScale-400">Read-only for this flow.</p>
type="button" <div className="mt-4 space-y-3">
onClick={() => setShuffleQuestions(!shuffleQuestions)} <div className="space-y-2">
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out ${ <label className="text-sm font-medium text-grayScale-700">
shuffleQuestions ? "bg-brand-500" : "bg-grayScale-300" Program{" "}
}`} <span className="rounded bg-brand-50 px-1.5 py-0.5 text-xs font-medium text-brand-500">Auto</span>
> </label>
<span <div className="flex items-center gap-3 rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/50 px-3 py-2.5">
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-md ring-0 transition-transform duration-200 ease-in-out ${ <Grid3X3 className="h-4 w-4 shrink-0 text-grayScale-400" />
shuffleQuestions ? "translate-x-5" : "translate-x-0" <span className="min-w-0 flex-1 truncate text-sm font-medium text-grayScale-700">{selectedProgram}</span>
}`} <ChevronDown className="h-4 w-4 shrink-0 text-grayScale-300" />
/> </div>
</button> </div>
<label className="text-sm font-medium text-grayScale-700">Shuffle Questions</label> <div className="space-y-2">
</div> <label className="text-sm font-medium text-grayScale-700">
Course{" "}
{/* Program */} <span className="rounded bg-brand-50 px-1.5 py-0.5 text-xs font-medium text-brand-500">Auto</span>
<div className="space-y-2"> </label>
<label className="text-sm font-medium text-grayScale-700"> <div className="flex items-center gap-3 rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/50 px-3 py-2.5">
Program <span className="rounded bg-brand-50 px-1.5 py-0.5 text-xs font-medium text-brand-500">Auto-selected</span> <Grid3X3 className="h-4 w-4 shrink-0 text-grayScale-400" />
</label> <span className="min-w-0 flex-1 truncate text-sm font-medium text-grayScale-700">{selectedCourse}</span>
<div className="flex items-center gap-3 rounded-lg border border-dashed border-grayScale-300 bg-grayScale-50/50 px-4 py-3"> <ChevronDown className="h-4 w-4 shrink-0 text-grayScale-300" />
<Grid3X3 className="h-5 w-5 text-grayScale-400" /> </div>
<span className="flex-1 text-sm font-medium text-grayScale-600">{selectedProgram}</span> </div>
<ChevronDown className="h-5 w-5 text-grayScale-300" /> </div>
</div> </div>
</div> </aside>
{/* Course */}
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">
Course <span className="rounded bg-brand-50 px-1.5 py-0.5 text-xs font-medium text-brand-500">Auto-selected</span>
</label>
<div className="flex items-center gap-3 rounded-lg border border-dashed border-grayScale-300 bg-grayScale-50/50 px-4 py-3">
<Grid3X3 className="h-5 w-5 text-grayScale-400" />
<span className="flex-1 text-sm font-medium text-grayScale-600">{selectedCourse}</span>
<ChevronDown className="h-5 w-5 text-grayScale-300" />
</div>
</div> </div>
</div> </div>
{/* Navigation */} <div className="flex flex-col-reverse items-stretch justify-between gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:items-center sm:px-8 sm:py-5">
<div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 border-t border-grayScale-100 pt-6 sm:flex-row"> <Button variant="ghost" onClick={handleCancel} className="sm:w-auto">
<Button variant="ghost" onClick={handleCancel}>
Cancel Cancel
</Button> </Button>
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto" onClick={handleNext}> <Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto sm:min-w-[180px]" onClick={handleNext}>
{getNextButtonLabel()} {getNextButtonLabel()}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>
@ -428,13 +529,16 @@ export function AddNewPracticePage() {
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<div className="mx-auto max-w-4xl"> <Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
<h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Select Personas</h2> <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">
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-500"> <h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 2: Persona</h2>
Choose the characters that will participate in this practice scenario. Students will interact with these personas. <p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
</p> Choose the character students will interact with in this practice.
</p>
</div>
<div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4"> <div className="p-5 sm:p-8 lg:p-10">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4 lg:gap-5">
{PERSONAS.map((persona) => ( {PERSONAS.map((persona) => (
<button <button
key={persona.id} key={persona.id}
@ -465,30 +569,32 @@ export function AddNewPracticePage() {
</button> </button>
))} ))}
</div> </div>
</div>
{/* Navigation */} <div className="flex flex-col-reverse items-stretch justify-between gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:items-center sm:px-8 sm:py-5">
<div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row"> <Button variant="outline" onClick={handleBack} className="sm:w-auto">
<Button variant="outline" onClick={handleBack}>
Back Back
</Button> </Button>
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto" onClick={handleNext}> <Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto sm:min-w-[180px]" onClick={handleNext}>
{getNextButtonLabel()} {getNextButtonLabel()}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </Card>
)} )}
{currentStep === 3 && ( {currentStep === 3 && (
<div className="mx-auto max-w-4xl"> <div className="w-full space-y-6">
<h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Create Practice Questions</h2> <div className="rounded-2xl border border-grayScale-200/80 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 shadow-sm sm:px-8 sm:py-6">
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-500"> <h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Questions</h2>
Add questions to your practice. Support for MCQ, True/False, and Short Answer types. <p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
</p> Add MCQ, True/False, or Short Answer items. Use the full width for stems and options.
</p>
</div>
<div className="mt-6 space-y-5"> <div className="space-y-4 sm:space-y-5">
{questions.map((question, index) => ( {questions.map((question, index) => (
<Card key={question.id} className="border-l-4 border-l-brand-500 p-5 shadow-sm transition-shadow hover:shadow-md sm:p-7"> <Card key={question.id} className="border border-grayScale-200/90 border-l-4 border-l-brand-500 p-5 shadow-sm transition-shadow hover:shadow-md sm:p-6 lg:p-8">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<GripVertical className="h-5 w-5 cursor-grab text-grayScale-300 transition-colors hover:text-grayScale-500" /> <GripVertical className="h-5 w-5 cursor-grab text-grayScale-300 transition-colors hover:text-grayScale-500" />
@ -517,7 +623,7 @@ export function AddNewPracticePage() {
/> />
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
{/* Question Type */} {/* Question Type */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500"> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
@ -643,7 +749,7 @@ export function AddNewPracticePage() {
</div> </div>
)} )}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
{/* Tips */} {/* Tips */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500"> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
@ -669,7 +775,7 @@ export function AddNewPracticePage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
{/* Voice Prompt */} {/* Voice Prompt */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500"> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
@ -699,23 +805,22 @@ export function AddNewPracticePage() {
))} ))}
</div> </div>
{/* Add Button */} <div>
<div className="mt-5">
<button <button
type="button"
onClick={addQuestion} onClick={addQuestion}
className="inline-flex items-center gap-2 rounded-lg border-2 border-dashed border-brand-200 px-4 py-2.5 text-sm font-semibold text-brand-500 transition-all hover:border-brand-400 hover:bg-brand-50 hover:text-brand-600" className="inline-flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-brand-200/90 bg-brand-50/20 px-4 py-3.5 text-sm font-semibold text-brand-600 transition-all hover:border-brand-300 hover:bg-brand-50/60 hover:text-brand-700 sm:py-3"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add New Question Add another question
</button> </button>
</div> </div>
{/* Navigation */} <div className="flex flex-col-reverse items-stretch justify-between gap-3 rounded-2xl border border-grayScale-200/80 bg-grayScale-50/30 px-4 py-4 sm:flex-row sm:items-center sm:px-6 sm:py-5">
<div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row"> <Button variant="outline" onClick={handleBack} className="sm:w-auto">
<Button variant="outline" onClick={handleBack}>
Back Back
</Button> </Button>
<Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto" onClick={handleNext}> <Button className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto sm:min-w-[180px]" onClick={handleNext}>
{getNextButtonLabel()} {getNextButtonLabel()}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>
@ -724,14 +829,17 @@ export function AddNewPracticePage() {
)} )}
{currentStep === 4 && ( {currentStep === 4 && (
<div className="mx-auto max-w-4xl"> <div className="w-full space-y-6">
<h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Review & Publish</h2> <div className="rounded-2xl border border-grayScale-200/80 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 shadow-sm sm:px-8 sm:py-6">
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-500"> <h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 4: Review & publish</h2>
Review your practice details before saving. <p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
</p> Confirm context, persona, and questions before saving or publishing.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2 lg:items-start lg:gap-8">
{/* Basic Information Card */} {/* Basic Information Card */}
<Card className="mt-6 overflow-hidden p-0"> <Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h3 className="font-semibold text-grayScale-900">Basic Information</h3> <h3 className="font-semibold text-grayScale-900">Basic Information</h3>
<button <button
@ -749,21 +857,27 @@ export function AddNewPracticePage() {
</div> </div>
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5"> <div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
<span className="text-sm text-grayScale-500">Description</span> <span className="text-sm text-grayScale-500">Description</span>
<span className="max-w-sm text-right text-sm text-grayScale-700">{practiceDescription || "—"}</span> <span className="max-w-[min(28rem,55%)] text-right text-sm leading-relaxed text-grayScale-700">{practiceDescription || "—"}</span>
</div> </div>
<div className="flex justify-between px-6 py-3.5"> <div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">Intro video URL</span>
<span className="max-w-[min(28rem,55%)] break-all text-right text-sm text-grayScale-700">
{introVideoUrl.trim() || "—"}
</span>
</div>
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
<span className="text-sm text-grayScale-500">Passing Score</span> <span className="text-sm text-grayScale-500">Passing Score</span>
<span className="text-sm font-medium text-grayScale-900">{passingScore}%</span> <span className="text-sm font-medium text-grayScale-900">{passingScore}%</span>
</div> </div>
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5"> <div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">Time Limit</span> <span className="text-sm text-grayScale-500">Time Limit</span>
<span className="text-sm font-medium text-grayScale-900">{timeLimitMinutes} minutes</span> <span className="text-sm font-medium text-grayScale-900">{timeLimitMinutes} minutes</span>
</div> </div>
<div className="flex justify-between px-6 py-3.5"> <div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
<span className="text-sm text-grayScale-500">Shuffle Questions</span> <span className="text-sm text-grayScale-500">Shuffle Questions</span>
<span className="text-sm font-medium text-grayScale-900">{shuffleQuestions ? "Yes" : "No"}</span> <span className="text-sm font-medium text-grayScale-900">{shuffleQuestions ? "Yes" : "No"}</span>
</div> </div>
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5"> <div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">Persona</span> <span className="text-sm text-grayScale-500">Persona</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedPersona && ( {selectedPersona && (
@ -784,7 +898,7 @@ export function AddNewPracticePage() {
</Card> </Card>
{/* Questions Review */} {/* Questions Review */}
<Card className="mt-6 overflow-hidden p-0"> <Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm lg:min-h-0">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<h3 className="font-semibold text-grayScale-900">Questions</h3> <h3 className="font-semibold text-grayScale-900">Questions</h3>
@ -800,9 +914,9 @@ export function AddNewPracticePage() {
Edit Edit
</button> </button>
</div> </div>
<div className="divide-y divide-grayScale-100 px-6 py-4"> <div className="max-h-[min(70vh,52rem)] space-y-3 overflow-y-auto px-4 py-4 sm:px-6">
{questions.map((question, index) => ( {questions.map((question, index) => (
<div key={question.id} className="rounded-lg border border-grayScale-200 p-4 transition-colors first:mt-0 [&:not(:first-child)]:mt-3 hover:border-grayScale-300"> <div key={question.id} className="rounded-xl border border-grayScale-200 bg-grayScale-50/20 p-4 transition-colors hover:border-grayScale-300 sm:p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-brand-100 text-xs font-bold text-brand-600"> <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-brand-100 text-xs font-bold text-brand-600">
{index + 1} {index + 1}
@ -845,23 +959,23 @@ export function AddNewPracticePage() {
))} ))}
</div> </div>
</Card> </Card>
</div>
{saveError && ( {saveError && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3"> <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p className="text-sm font-medium text-red-600">{saveError}</p> <p className="text-sm font-medium text-red-600">{saveError}</p>
</div> </div>
)} )}
{/* Navigation */} <div className="flex flex-col-reverse items-stretch justify-between gap-3 rounded-2xl border border-grayScale-200/80 bg-grayScale-50/30 px-4 py-4 sm:flex-row sm:items-center sm:px-6 sm:py-5">
<div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row"> <Button variant="outline" onClick={handleBack} className="sm:w-auto">
<Button variant="outline" onClick={handleBack}>
Back Back
</Button> </Button>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row"> <div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:justify-end">
<Button variant="outline" onClick={handleSaveAsDraft} disabled={saving}> <Button variant="outline" onClick={handleSaveAsDraft} disabled={saving} className="sm:min-w-[140px]">
{saving ? "Saving..." : "Save as Draft"} {saving ? "Saving..." : "Save as Draft"}
</Button> </Button>
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handlePublish} disabled={saving}> <Button className="bg-brand-500 hover:bg-brand-600 sm:min-w-[160px]" onClick={handlePublish} disabled={saving}>
<Rocket className="mr-2 h-4 w-4" /> <Rocket className="mr-2 h-4 w-4" />
{saving ? "Publishing..." : "Publish Now"} {saving ? "Publishing..." : "Publish Now"}
</Button> </Button>
@ -898,6 +1012,7 @@ export function AddNewPracticePage() {
setCurrentStep(1) setCurrentStep(1)
setPracticeTitle("") setPracticeTitle("")
setPracticeDescription("") setPracticeDescription("")
setIntroVideoUrl("")
setShuffleQuestions(false) setShuffleQuestions(false)
setPassingScore(50) setPassingScore(50)
setTimeLimitMinutes(60) setTimeLimitMinutes(60)
@ -940,6 +1055,7 @@ export function AddNewPracticePage() {
)} )}
</div> </div>
)} )}
</div>
</div> </div>
) )
} }

View File

@ -136,41 +136,46 @@ export function AddPracticePage() {
} }
return ( return (
<div className="space-y-8"> <div className="mx-auto w-full max-w-7xl space-y-6 pb-10 sm:space-y-8 sm:pb-12">
{/* Header */} {/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <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 items-center gap-3"> <div className="flex items-start gap-3 sm:items-center">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate("/content/speaking")} onClick={() => navigate("/content/speaking")}
className="h-9 w-9 rounded-lg border border-grayScale-200 bg-white shadow-sm transition-colors hover:bg-grayScale-50 hover:border-grayScale-300" className="h-10 w-10 shrink-0 rounded-xl border border-grayScale-200 bg-white shadow-sm transition-colors hover:bg-grayScale-50 hover:border-grayScale-300"
> >
<ArrowLeft className="h-4 w-4 text-grayScale-500" /> <ArrowLeft className="h-4 w-4 text-grayScale-600" />
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Add New Practice</h1> <h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-3xl">Add practice</h1>
<p className="text-sm text-grayScale-400">Create a new practice session with questions</p> <p className="mt-2 max-w-2xl text-sm leading-relaxed text-grayScale-500 sm:text-[15px]">
Draft a practice session with questions (demo flow wire to API when ready).
</p>
</div> </div>
</div> </div>
<Button className="bg-brand-500 shadow-sm hover:bg-brand-600 transition-colors"> <Button className="h-11 w-full shrink-0 bg-brand-500 px-5 shadow-sm hover:bg-brand-600 sm:w-auto">
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
Save Save
</Button> </Button>
</div> </div>
{/* Stepper */} {/* Stepper */}
<Card className="border-grayScale-200 bg-white/80 p-5 shadow-sm sm:p-6"> <Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
<Stepper steps={STEPS} currentStep={currentStep} /> <div className="rounded-2xl border border-grayScale-100 bg-grayScale-50/40 px-4 py-5 sm:px-6">
<Stepper steps={STEPS} currentStep={currentStep} />
</div>
</Card> </Card>
{/* Step 1: Details */} {/* Step 1: Details */}
{currentStep === 1 && ( {currentStep === 1 && (
<Card className="mx-auto max-w-3xl border-grayScale-200 p-6 shadow-sm sm:p-8"> <Card className="w-full overflow-hidden border-grayScale-200/80 shadow-sm">
<h2 className="mb-6 text-lg font-semibold tracking-tight text-grayScale-600"> <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">
Practice Details <h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 1: Details</h2>
</h2> <p className="mt-1.5 text-sm text-grayScale-500">Basics for this practice session.</p>
<div className="space-y-5"> </div>
<div className="space-y-5 p-5 sm:p-8 lg:p-10">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1.5 block text-sm font-medium text-grayScale-500">
Practice Title Practice Title
@ -256,11 +261,11 @@ export function AddPracticePage() {
</div> </div>
</div> </div>
<div className="mt-8 flex justify-end border-t border-grayScale-100 pt-6"> <div className="flex justify-end border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:px-8 sm:py-5">
<Button <Button
onClick={() => setCurrentStep(2)} onClick={() => setCurrentStep(2)}
disabled={!canProceedToStep2()} disabled={!canProceedToStep2()}
className="bg-brand-500 px-6 shadow-sm hover:bg-brand-600 transition-colors" className="min-w-[140px] bg-brand-500 shadow-sm hover:bg-brand-600"
> >
Next Next
</Button> </Button>
@ -272,10 +277,12 @@ export function AddPracticePage() {
{currentStep === 2 && ( {currentStep === 2 && (
<div className="space-y-6"> <div className="space-y-6">
{/* Select Participants Section */} {/* Select Participants Section */}
<Card className="border-grayScale-200 p-6 shadow-sm"> <Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600"> <div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-4 sm:px-8 sm:py-5">
Select Participants <h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 2: Participants</h2>
</h2> <p className="mt-1 text-sm text-grayScale-500">Optional select who appears in this practice.</p>
</div>
<div className="p-5 sm:p-8">
<div className="grid grid-cols-2 gap-5 sm:grid-cols-4 lg:grid-cols-8"> <div className="grid grid-cols-2 gap-5 sm:grid-cols-4 lg:grid-cols-8">
{mockParticipants.map((participant) => { {mockParticipants.map((participant) => {
const isSelected = formData.participants.includes(participant.id) const isSelected = formData.participants.includes(participant.id)
@ -315,13 +322,16 @@ export function AddPracticePage() {
) )
})} })}
</div> </div>
</div>
</Card> </Card>
{/* Add Questions Section */} {/* Add Questions Section */}
<Card className="border-grayScale-200 p-6 shadow-sm"> <Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600"> <div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-4 sm:px-8 sm:py-5">
General Practice Questions <h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Questions</h2>
</h2> <p className="mt-1 text-sm text-grayScale-500">Build your question bank, then add to the practice.</p>
</div>
<div className="p-5 sm:p-8">
{/* Existing Questions */} {/* Existing Questions */}
{formData.questions.map((q) => ( {formData.questions.map((q) => (
@ -459,16 +469,17 @@ export function AddPracticePage() {
Add New Question Add New Question
</Button> </Button>
</div> </div>
</div>
</Card> </Card>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-3 rounded-2xl border border-grayScale-200/80 bg-grayScale-50/30 px-4 py-4 sm:flex-row sm:justify-end sm:px-6 sm:py-5">
<Button variant="outline" onClick={() => setCurrentStep(1)} className="px-6"> <Button variant="outline" onClick={() => setCurrentStep(1)} className="px-6 sm:w-auto">
Back Back
</Button> </Button>
<Button <Button
onClick={() => setCurrentStep(3)} onClick={() => setCurrentStep(3)}
disabled={!canProceedToStep3()} disabled={!canProceedToStep3()}
className="bg-brand-500 px-6 shadow-sm hover:bg-brand-600 transition-colors" className="min-w-[140px] bg-brand-500 px-6 shadow-sm hover:bg-brand-600"
> >
Next Next
</Button> </Button>
@ -478,11 +489,13 @@ export function AddPracticePage() {
{/* Step 3: Review */} {/* Step 3: Review */}
{currentStep === 3 && ( {currentStep === 3 && (
<div className="mx-auto max-w-3xl space-y-6"> <div className="space-y-6">
<Card className="border-grayScale-200 p-6 shadow-sm sm:p-8"> <Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600"> <div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-4 sm:px-8 sm:py-5">
Practice Details <h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Review</h2>
</h2> <p className="mt-1 text-sm text-grayScale-500">Confirm details before creating the practice.</p>
</div>
<div className="p-5 sm:p-8">
<div className="divide-y divide-grayScale-100 overflow-hidden rounded-lg border border-grayScale-200"> <div className="divide-y divide-grayScale-100 overflow-hidden rounded-lg border border-grayScale-200">
{[ {[
{ label: "Title", value: formData.title }, { label: "Title", value: formData.title },
@ -505,12 +518,14 @@ export function AddPracticePage() {
</div> </div>
))} ))}
</div> </div>
</div>
</Card> </Card>
<Card className="border-grayScale-200 p-6 shadow-sm sm:p-8"> <Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600"> <div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-4 sm:px-8 sm:py-5">
Questions <h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Questions</h2>
</h2> </div>
<div className="p-5 sm:p-8">
<div className="space-y-4"> <div className="space-y-4">
{formData.questions.map((q, index) => ( {formData.questions.map((q, index) => (
<div key={q.id} className="rounded-xl border border-grayScale-200 bg-grayScale-50/50 p-5 transition-colors hover:bg-grayScale-50"> <div key={q.id} className="rounded-xl border border-grayScale-200 bg-grayScale-50/50 p-5 transition-colors hover:bg-grayScale-50">
@ -549,14 +564,15 @@ export function AddPracticePage() {
</div> </div>
))} ))}
</div> </div>
</div>
</Card> </Card>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-3 rounded-2xl border border-grayScale-200/80 bg-grayScale-50/30 px-4 py-4 sm:flex-row sm:justify-end sm:px-6 sm:py-5">
<Button variant="outline" onClick={() => setCurrentStep(2)} className="px-6"> <Button variant="outline" onClick={() => setCurrentStep(2)} className="px-6 sm:w-auto">
Back Back
</Button> </Button>
<Button onClick={handleSubmit} className="bg-brand-500 px-6 shadow-sm hover:bg-brand-600 transition-colors"> <Button onClick={handleSubmit} className="min-w-[160px] bg-brand-500 px-6 shadow-sm hover:bg-brand-600">
Create Practice Create practice
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -12,16 +12,16 @@ const tabs = [
export function ContentManagementLayout() { export function ContentManagementLayout() {
return ( return (
<div className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8"> <div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" /> <div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700"> <h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
Content Management Content Management
</h1> </h1>
<p className="mt-0.5 text-sm text-grayScale-400"> <p className="mt-1 max-w-2xl text-sm leading-relaxed text-grayScale-500">
Manage courses, speaking exercises, practices, and questions Manage courses, speaking exercises, practices, and questions
</p> </p>
</div> </div>

View File

@ -6,6 +6,7 @@ import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { getCourseCategories } from "../../api/courses.api" import { getCourseCategories } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types" import type { CourseCategory } from "../../types/course.types"
import { cn } from "../../lib/utils"
const contentSections = [ const contentSections = [
{ {
@ -29,8 +30,8 @@ const contentSections = [
action: "Manage Speaking", action: "Manage Speaking",
count: 8, count: 8,
countLabel: "sessions", countLabel: "sessions",
gradient: "from-purple-500/10 via-purple-400/5 to-transparent", gradient: "from-brand-500/12 via-brand-400/6 to-transparent",
accentBorder: "group-hover:border-purple-400", accentBorder: "group-hover:border-brand-400",
}, },
{ {
key: "practices", key: "practices",
@ -334,7 +335,7 @@ export function ContentOverviewPage() {
step.type === "course" && step.type === "course" &&
"bg-violet-50 text-violet-700 ring-1 ring-inset ring-violet-200", "bg-violet-50 text-violet-700 ring-1 ring-inset ring-violet-200",
step.type === "speaking" && step.type === "speaking" &&
"bg-teal-50 text-teal-700 ring-1 ring-inset ring-teal-200", "bg-brand-50 text-brand-700 ring-1 ring-inset ring-brand-200",
step.type === "new_course" && step.type === "new_course" &&
"bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-200", "bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-200",
)} )}

View File

@ -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, Image as ImageIcon, 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"
@ -17,7 +17,7 @@ import {
getQuestions, getQuestions,
updateQuestion, updateQuestion,
} from "../../api/courses.api" } from "../../api/courses.api"
import { resolveFileUrl, uploadAudioFile, uploadImageFile } from "../../api/files.api" import { resolveFileUrl, uploadAudioFile, uploadImageFile, uploadVideoFile } from "../../api/files.api"
import { SpinnerIcon } from "../../components/ui/spinner-icon" import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { import {
DropdownMenu, DropdownMenu,
@ -95,6 +95,18 @@ function normalizeObjectKey(value: string) {
return trimmed 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
}
export function SpeakingPage() { export function SpeakingPage() {
const [audioQuestions, setAudioQuestions] = useState<QuestionDetail[]>([]) const [audioQuestions, setAudioQuestions] = useState<QuestionDetail[]>([])
const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState<Record<number, string>>({}) const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState<Record<number, string>>({})
@ -104,6 +116,9 @@ export function SpeakingPage() {
const [setTitle, setSetTitle] = useState("") const [setTitle, setSetTitle] = useState("")
const [setDescription, setSetDescription] = useState("") const [setDescription, setSetDescription] = useState("")
const [introVideoUrl, setIntroVideoUrl] = useState("")
const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false)
const introVideoFileInputRef = useRef<HTMLInputElement>(null)
const [subCourseId, setSubCourseId] = useState("") const [subCourseId, setSubCourseId] = useState("")
const [subCourseOptions, setSubCourseOptions] = useState<SubCourseOption[]>([]) const [subCourseOptions, setSubCourseOptions] = useState<SubCourseOption[]>([])
const [subCourseLoading, setSubCourseLoading] = useState(false) const [subCourseLoading, setSubCourseLoading] = useState(false)
@ -315,11 +330,35 @@ export function SpeakingPage() {
const resetCreateForm = () => { const resetCreateForm = () => {
setSetTitle("") setSetTitle("")
setSetDescription("") setSetDescription("")
setIntroVideoUrl("")
setSubCourseId("") setSubCourseId("")
setSetStatus("DRAFT") setSetStatus("DRAFT")
setQuestionDrafts([createEmptyDraft()]) setQuestionDrafts([createEmptyDraft()])
} }
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 canCreate = useMemo(() => {
const hasQuestionWithText = questionDrafts.some((draft) => draft.questionText.trim().length > 0) const hasQuestionWithText = questionDrafts.some((draft) => draft.questionText.trim().length > 0)
return setTitle.trim().length > 0 && subCourseId.trim().length > 0 && hasQuestionWithText return setTitle.trim().length > 0 && subCourseId.trim().length > 0 && hasQuestionWithText
@ -695,11 +734,12 @@ export function SpeakingPage() {
// 1) Create speaking practice set. // 1) Create speaking practice set.
const setRes = await createQuestionSet({ const setRes = await createQuestionSet({
title: setTitle.trim(), title: setTitle.trim(),
description: setDescription.trim(), ...(setDescription.trim() ? { description: setDescription.trim() } : {}),
set_type: "PRACTICE", set_type: "PRACTICE",
owner_type: "SUB_COURSE", owner_type: "SUB_COURSE",
owner_id: parsedSubCourseId, owner_id: parsedSubCourseId,
status: setStatus, status: setStatus,
...(introVideoUrl.trim() ? { intro_video_url: introVideoUrl.trim() } : {}),
}) })
const setId = setRes.data?.data?.id const setId = setRes.data?.data?.id
@ -871,18 +911,18 @@ export function SpeakingPage() {
} }
return ( return (
<div className="space-y-8"> <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 sm:flex-row sm:items-center sm:justify-between"> <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> <div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600"> <h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-3xl">
Speaking Speaking
</h1> </h1>
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-400"> <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. Create and manage speaking practice sessions for your learners.
</p> </p>
</div> </div>
<Button <Button
className="w-full bg-brand-500 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={() => {
setOpenCreate(true) setOpenCreate(true)
setCurrentStep(1) setCurrentStep(1)
@ -894,40 +934,43 @@ export function SpeakingPage() {
</div> </div>
{!openCreate && ( {!openCreate && (
<Card className="shadow-soft"> <Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
<CardHeader className="border-b border-grayScale-200 pb-4"> <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-600"> <CardTitle className="text-base font-semibold text-grayScale-900 sm:text-lg">
AUDIO Questions AUDIO questions
</CardTitle> </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>
</CardHeader> </CardHeader>
<CardContent className="pt-5"> <CardContent className="px-4 pb-6 pt-5 sm:px-6">
{loading ? ( {loading ? (
<div className="flex flex-col items-center gap-2 py-14 text-center text-sm text-grayScale-500"> <div className="flex flex-col items-center gap-2 py-14 text-center text-sm text-grayScale-500">
<SpinnerIcon className="h-5 w-5" /> <SpinnerIcon className="h-5 w-5" />
Loading audio questions... Loading audio questions...
</div> </div>
) : audioQuestions.length === 0 ? ( ) : audioQuestions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-14 text-center"> <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-6 grid h-16 w-16 place-items-center rounded-full bg-gradient-to-br from-brand-100 to-brand-200"> <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-500" /> <Mic className="h-8 w-8 text-brand-600" />
</div> </div>
<h3 className="text-base font-semibold text-grayScale-600">No audio questions yet</h3> <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-400"> <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. Create a speaking practice to automatically create and attach an AUDIO question.
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-2.5">
{audioQuestions.map((question, idx) => ( {audioQuestions.map((question, idx) => (
<div <div
key={question.id} key={question.id}
className={`rounded-lg border px-4 py-3 ${ 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" idx % 2 === 0 ? "border-grayScale-200 bg-white" : "border-grayScale-100 bg-grayScale-50/80"
} cursor-pointer transition-colors hover:border-brand-300 hover:bg-brand-50/30`} } hover:border-brand-300 hover:bg-brand-50/40 hover:shadow-sm`}
onClick={() => handleOpenQuestionDetail(question.id)} onClick={() => handleOpenQuestionDetail(question.id)}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium text-grayScale-700">{question.question_text}</p> <p className="text-sm font-medium leading-snug text-grayScale-800">{question.question_text}</p>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -942,7 +985,7 @@ export function SpeakingPage() {
</Button> </Button>
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs"> <div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-md bg-purple-100 px-2 py-1 text-purple-700">AUDIO</span> <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"> <span className="rounded-md bg-grayScale-100 px-2 py-1 text-grayScale-600">
Difficulty: {question.difficulty_level || "—"} Difficulty: {question.difficulty_level || "—"}
</span> </span>
@ -982,9 +1025,12 @@ export function SpeakingPage() {
className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
onClick={() => setDetailOpen(false)} onClick={() => setDetailOpen(false)}
> >
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl" onClick={(e) => e.stopPropagation()}> <div
<div className="flex items-center justify-between border-b border-grayScale-100 px-5 py-4"> 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"
<h3 className="text-base font-semibold text-grayScale-700">AUDIO Question Detail</h3> 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"> <div className="flex items-center gap-2">
{!detailLoading && selectedQuestionDetail && !detailEditing ? ( {!detailLoading && selectedQuestionDetail && !detailEditing ? (
<Button variant="outline" size="sm" onClick={() => setDetailEditing(true)}> <Button variant="outline" size="sm" onClick={() => setDetailEditing(true)}>
@ -1012,7 +1058,7 @@ export function SpeakingPage() {
</Button> </Button>
</div> </div>
</div> </div>
<div className="max-h-[72vh] space-y-4 overflow-y-auto px-5 py-4"> <div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-5 sm:px-6">
{detailLoading ? ( {detailLoading ? (
<div className="flex items-center justify-center py-10"> <div className="flex items-center justify-center py-10">
<SpinnerIcon className="h-5 w-5" /> <SpinnerIcon className="h-5 w-5" />
@ -1233,19 +1279,19 @@ export function SpeakingPage() {
{confirmDeleteOpen && (deleteTarget || selectedQuestionDetail) && ( {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="fixed inset-0 z-[60] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-2xl bg-white shadow-2xl"> <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 px-5 py-4"> <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-700">Delete AUDIO Question</h3> <h3 className="text-base font-semibold text-grayScale-900">Delete AUDIO question</h3>
</div> </div>
<div className="space-y-2 px-5 py-4"> <div className="space-y-2 px-5 py-4">
<p className="text-sm text-grayScale-600"> <p className="text-sm leading-relaxed text-grayScale-600">
Are you sure you want to delete this question? This action cannot be undone. The question will be removed permanently.
</p> </p>
<p className="line-clamp-2 text-xs text-grayScale-400"> <p className="line-clamp-3 rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-600">
{deleteTarget?.text ?? selectedQuestionDetail?.question_text} {deleteTarget?.text ?? selectedQuestionDetail?.question_text}
</p> </p>
</div> </div>
<div className="flex justify-end gap-2 border-t border-grayScale-100 px-5 py-4"> <div className="flex justify-end gap-2 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4">
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
@ -1270,45 +1316,114 @@ export function SpeakingPage() {
{openCreate && ( {openCreate && (
<div className="space-y-6"> <div className="space-y-6">
<Card className="border-grayScale-200 bg-white/80 p-5 shadow-sm sm:p-6"> <Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
<div className="mb-4"> <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 <Button
type="button" type="button"
variant="outline" variant="outline"
className="h-9 border-grayScale-200 text-grayScale-600" className="h-10 w-full shrink-0 border-grayScale-200 text-grayScale-700 sm:w-auto"
onClick={() => setOpenCreate(false)} onClick={() => setOpenCreate(false)}
disabled={saving} disabled={saving}
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back to AUDIO Questions Back to list
</Button> </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> </div>
<Stepper steps={SPEAKING_STEPS} currentStep={currentStep} />
</Card> </Card>
{currentStep === 1 && ( {currentStep === 1 && (
<Card className="mx-auto max-w-3xl border-grayScale-200 p-6 shadow-sm sm:p-8"> <Card className="w-full overflow-hidden border-grayScale-200/80 shadow-sm">
<h2 className="mb-6 text-lg font-semibold text-grayScale-700">Step 1: Practice Context</h2> <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 className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 1: Practice context</h2>
<div className="space-y-1.5 sm:col-span-2"> <p className="mt-1.5 max-w-3xl text-sm text-grayScale-500">
<label className="text-sm font-medium text-grayScale-600">Practice Title</label> Title, description, optional intro video, sub-course, and publish status for this speaking practice.
<Input </p>
value={setTitle} </div>
onChange={(e) => setSetTitle(e.target.value)} <div className="p-5 sm:p-8 lg:p-10">
placeholder="Speaking practice title" <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> <div className="space-y-1.5">
<div className="space-y-1.5 sm:col-span-2"> <label className="text-sm font-medium text-grayScale-700">Practice title</label>
<label className="text-sm font-medium text-grayScale-600">Practice Description (Optional)</label> <Input
<Textarea value={setTitle}
value={setDescription} onChange={(e) => setSetTitle(e.target.value)}
onChange={(e) => setSetDescription(e.target.value)} placeholder="Speaking practice title"
rows={2} className="h-11"
placeholder="Brief description" />
/> </div>
</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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<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>
</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"> <div className="space-y-1.5">
<label className="text-sm font-medium text-grayScale-600">Course</label> <label className="text-sm font-medium text-grayScale-700">Sub-course</label>
<DropdownMenu open={subCourseMenuOpen} onOpenChange={setSubCourseMenuOpen}> <DropdownMenu open={subCourseMenuOpen} onOpenChange={setSubCourseMenuOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@ -1361,7 +1476,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-600">Set Status</label> <label className="text-sm font-medium text-grayScale-700">Set status</label>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@ -1384,45 +1499,56 @@ export function SpeakingPage() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
<div className="mt-8 flex flex-col-reverse gap-3 border-t border-grayScale-100 pt-6 sm:flex-row sm:justify-end"> </div>
<Button variant="outline" onClick={() => setOpenCreate(false)} disabled={saving}> </aside>
</div>
<div className="mt-8 flex flex-col-reverse gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-0 py-4 sm:flex-row sm:justify-end sm:px-0 sm:py-5">
<Button variant="outline" onClick={() => setOpenCreate(false)} disabled={saving} className="sm:w-auto">
Cancel Cancel
</Button> </Button>
<Button <Button
className="bg-brand-500 hover:bg-brand-600" className="bg-brand-500 hover:bg-brand-600 sm:min-w-[180px]"
onClick={() => setCurrentStep(2)} onClick={() => setCurrentStep(2)}
disabled={!canProceedToQuestions} disabled={!canProceedToQuestions}
> >
Next: Questions Next: Questions
</Button> </Button>
</div> </div>
</div>
</Card> </Card>
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<Card className="mx-auto max-w-4xl border-grayScale-200 p-6 shadow-sm sm:p-8"> <Card className="w-full overflow-hidden border-grayScale-200/80 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-grayScale-700">Step 2: AUDIO Questions</h2> <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 className="rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-4"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="mb-3 flex items-center justify-between"> <div>
<p className="text-sm font-semibold text-grayScale-700">AUDIO Questions</p> <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">
Upload or record prompts, add reference image, and set optional tips.
</p>
</div>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="shrink-0"
onClick={() => setQuestionDrafts((prev) => [...prev, createEmptyDraft()])} onClick={() => setQuestionDrafts((prev) => [...prev, createEmptyDraft()])}
disabled={saving} disabled={saving}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Question Add question
</Button> </Button>
</div> </div>
</div>
<div className="space-y-4"> <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) => ( {questionDrafts.map((draft, draftIndex) => (
<div key={draftIndex} className="rounded-lg border border-grayScale-200 bg-white p-4"> <div key={draftIndex} className="rounded-xl border border-grayScale-200 border-l-4 border-l-brand-500 bg-white p-4 shadow-sm sm:p-6">
<div className="mb-3 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-grayScale-700">Question {draftIndex + 1}</p> <p className="text-sm font-semibold text-grayScale-900">Question {draftIndex + 1}</p>
{questionDrafts.length > 1 ? ( {questionDrafts.length > 1 ? (
<Button <Button
type="button" type="button"
@ -1663,12 +1789,13 @@ export function SpeakingPage() {
))} ))}
</div> </div>
</div> </div>
<div className="mt-8 flex flex-col-reverse gap-3 border-t border-grayScale-100 pt-6 sm:flex-row sm:justify-end"> </div>
<Button variant="outline" onClick={() => setCurrentStep(1)} disabled={saving}> <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 Back
</Button> </Button>
<Button <Button
className="bg-brand-500 hover:bg-brand-600" className="bg-brand-500 hover:bg-brand-600 sm:min-w-[180px]"
onClick={() => setCurrentStep(3)} onClick={() => setCurrentStep(3)}
disabled={!canProceedToReview} disabled={!canProceedToReview}
> >
@ -1679,26 +1806,35 @@ export function SpeakingPage() {
)} )}
{currentStep === 3 && ( {currentStep === 3 && (
<Card className="mx-auto max-w-4xl border-grayScale-200 p-6 shadow-sm sm:p-8"> <Card className="w-full overflow-hidden border-grayScale-200/80 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-grayScale-700">Step 3: Review & Publish</h2> <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 className="space-y-6"> <h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Review & publish</h2>
<div className="rounded-lg border border-grayScale-200 bg-white p-4"> <p className="mt-1.5 max-w-3xl text-sm text-grayScale-500">
<h3 className="mb-2 text-sm font-semibold text-grayScale-700">Practice</h3> 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"> <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">Title:</span> {setTitle || "—"}</p>
<p><span className="font-medium">Course ID:</span> {subCourseId || "—"}</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"><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> <p><span className="font-medium">Status:</span> {setStatus}</p>
</div> </div>
</div> </div>
<div className="rounded-lg border border-grayScale-200 bg-white p-4"> <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 text-sm font-semibold text-grayScale-700"> <h3 className="mb-3 shrink-0 text-sm font-semibold uppercase tracking-wide text-grayScale-500">
Questions to Publish ({questionsWithText.length}) Questions ({questionsWithText.length})
</h3> </h3>
<div className="space-y-3"> <div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
{questionsWithText.map((draft, idx) => ( {questionsWithText.map((draft, idx) => (
<div key={idx} className="rounded-md border border-grayScale-200 bg-grayScale-50 p-3 text-sm"> <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"> <p className="font-medium text-grayScale-700">
{idx + 1}. {draft.questionText} {idx + 1}. {draft.questionText}
</p> </p>
@ -1751,13 +1887,14 @@ export function SpeakingPage() {
))} ))}
</div> </div>
</div> </div>
</div>
</div> </div>
<div className="mt-8 flex flex-col-reverse gap-3 border-t border-grayScale-100 pt-6 sm:flex-row sm:justify-end"> <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}> <Button variant="outline" onClick={() => setCurrentStep(2)} disabled={saving} className="sm:w-auto">
Back Back
</Button> </Button>
<Button className="bg-brand-500 hover:bg-brand-600" disabled={!canCreate || saving} onClick={handleCreateSpeakingPractice}> <Button className="bg-brand-500 hover:bg-brand-600 sm:min-w-[200px]" disabled={!canCreate || saving} onClick={handleCreateSpeakingPractice}>
{saving ? "Publishing..." : "Publish Speaking Practice"} {saving ? "Publishing..." : "Publish speaking practice"}
</Button> </Button>
</div> </div>
</Card> </Card>
@ -1766,17 +1903,17 @@ export function SpeakingPage() {
)} )}
{recordingModal ? ( {recordingModal ? (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/35 backdrop-blur-[2px]"> <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 rounded-2xl border border-[#eee2f7] bg-[#fcf9ff] p-6 shadow-2xl"> <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-700"> <p className="text-center text-base font-semibold text-grayScale-900">
Recording {recordingModal.label} Recording {recordingModal.label}
</p> </p>
<p className="mt-1 text-center text-xs text-grayScale-400"> <p className="mt-1 text-center text-xs text-grayScale-500">
Speak now. The visualizer reacts to your voice volume in real time. Speak clearly. The bars reflect your input level in real time.
</p> </p>
<div className="mt-3 flex justify-center"> <div className="mt-3 flex justify-center">
<div className="inline-flex items-center gap-2 rounded-full border border-[#ddc8ee] bg-[#f6ecff] px-3 py-1 text-xs font-medium text-[#7b3ca6]"> <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-[#a742d5]" /> <span className="h-2 w-2 animate-pulse rounded-full bg-brand-500" />
REC{" "} REC{" "}
{`${Math.floor(recordingModal.elapsedSeconds / 60) {`${Math.floor(recordingModal.elapsedSeconds / 60)
.toString() .toString()
@ -1786,8 +1923,8 @@ export function SpeakingPage() {
</div> </div>
</div> </div>
<div className="mt-5 flex items-center gap-4 rounded-xl border border-[#e3cff3] bg-[#f2e8f8] px-4 py-4"> <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-[#e4d0f3] text-[#8a37b8] shadow-sm"> <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" /> <Mic className="h-5 w-5" />
</div> </div>
<div className="grid h-16 w-full grid-cols-32 items-end gap-[3px] overflow-hidden"> <div className="grid h-16 w-full grid-cols-32 items-end gap-[3px] overflow-hidden">
@ -1803,8 +1940,8 @@ export function SpeakingPage() {
style={{ style={{
background: background:
segmentIdx < activeSegments segmentIdx < activeSegments
? "linear-gradient(180deg, rgba(142,55,184,0.95) 0%, rgba(206,92,235,0.92) 100%)" ? "linear-gradient(180deg, #9E2891 0%, #6A1B9A 100%)"
: "rgba(207,177,230,0.38)", : "rgba(189, 189, 189, 0.35)",
}} }}
/> />
))} ))}
@ -1814,11 +1951,11 @@ export function SpeakingPage() {
</div> </div>
</div> </div>
<div className="mt-6 flex justify-end gap-2"> <div className="mt-6 flex flex-wrap justify-end gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="border-[#ddc8ee] text-[#7a6792] hover:bg-[#f4ecfb]" className="border-grayScale-200 text-grayScale-700 hover:bg-grayScale-50"
onClick={() => void stopActiveRecording(false)} onClick={() => void stopActiveRecording(false)}
> >
Cancel Cancel
@ -1826,14 +1963,14 @@ export function SpeakingPage() {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="border-[#ddc8ee] text-[#7a6792] hover:bg-[#f4ecfb]" className="border-grayScale-200 text-grayScale-700 hover:bg-grayScale-50"
onClick={togglePauseRecording} onClick={togglePauseRecording}
> >
{recordingModal.isPaused ? "Continue" : "Pause"} {recordingModal.isPaused ? "Continue" : "Pause"}
</Button> </Button>
<Button <Button
type="button" type="button"
className="bg-[#8f2bc6] text-white hover:bg-[#7f22b2]" className="bg-brand-500 text-white hover:bg-brand-600"
onClick={() => void stopActiveRecording(true)} onClick={() => void stopActiveRecording(true)}
> >
Stop & Save Stop & Save

View File

@ -436,6 +436,7 @@ export interface QuestionSetDetail {
shuffle_questions?: boolean shuffle_questions?: boolean
status: string status: string
sub_course_video_id?: number | null sub_course_video_id?: number | null
intro_video_url?: string | null
created_at: string created_at: string
question_count: number question_count: number
} }
@ -471,18 +472,21 @@ export interface GetQuestionSetQuestionsResponse {
metadata: unknown metadata: unknown
} }
/** POST /question-sets — practices use set_type: "PRACTICE", owner_type: "SUB_COURSE", owner_id: sub-course id */
export interface CreateQuestionSetRequest { export interface CreateQuestionSetRequest {
title: string title: string
description: string
set_type: string set_type: string
owner_type: string description?: string | null
owner_id: number owner_type?: string | null
persona?: string owner_id?: number | null
shuffle_questions?: boolean banner_image?: string | null
status?: string persona?: string | null
banner_image?: string time_limit_minutes?: number | null
passing_score?: number passing_score?: number | null
time_limit_minutes?: number shuffle_questions?: boolean | null
status?: string | null
sub_course_video_id?: number | null
intro_video_url?: string | null
} }
export interface AddQuestionToSetRequest { export interface AddQuestionToSetRequest {