UI and integration adjustment for create practice
This commit is contained in:
parent
4d5d4f0d15
commit
f2bf172fbb
|
|
@ -118,14 +118,21 @@ export const deleteSubCourseVideo = (videoId: number) =>
|
|||
http.delete(`/course-management/sub-course-videos/${videoId}`)
|
||||
|
||||
// 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) =>
|
||||
http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
|
||||
params: { owner_type: "SUB_COURSE", owner_id: subCourseId },
|
||||
})
|
||||
|
||||
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) =>
|
||||
http.put(`/course-management/practices/${practiceId}`, data)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { useState } from "react"
|
||||
import { useRef, useState, type ChangeEvent } from "react"
|
||||
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 { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { createQuestionSet, createQuestion, addQuestionToSet } from "../../api/courses.api"
|
||||
import { uploadVideoFile } from "../../api/files.api"
|
||||
import { Select } from "../../components/ui/select"
|
||||
import type { QuestionOption } from "../../types/course.types"
|
||||
|
||||
|
|
@ -56,6 +58,18 @@ const STEPS = [
|
|||
{ 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 {
|
||||
return {
|
||||
id,
|
||||
|
|
@ -89,6 +103,9 @@ export function AddNewPracticePage() {
|
|||
const [selectedCourse] = useState("B2")
|
||||
const [practiceTitle, setPracticeTitle] = 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 [passingScore, setPassingScore] = useState(50)
|
||||
const [timeLimitMinutes, setTimeLimitMinutes] = useState(60)
|
||||
|
|
@ -120,6 +137,29 @@ export function AddNewPracticePage() {
|
|||
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 = () => {
|
||||
setQuestions([...questions, createEmptyQuestion(String(Date.now()))])
|
||||
}
|
||||
|
|
@ -170,15 +210,16 @@ export function AddNewPracticePage() {
|
|||
const persona = PERSONAS.find(p => p.id === selectedPersona)
|
||||
const setRes = await createQuestionSet({
|
||||
title: practiceTitle || "Untitled Practice",
|
||||
description: practiceDescription,
|
||||
set_type: "PRACTICE",
|
||||
owner_type: "SUB_COURSE",
|
||||
owner_id: Number(subCourseId),
|
||||
persona: persona?.name,
|
||||
...(practiceDescription.trim() ? { description: practiceDescription.trim() } : {}),
|
||||
...(persona?.name ? { persona: persona.name } : {}),
|
||||
shuffle_questions: shuffleQuestions,
|
||||
status,
|
||||
passing_score: passingScore,
|
||||
time_limit_minutes: timeLimitMinutes,
|
||||
...(introVideoUrl.trim() ? { intro_video_url: introVideoUrl.trim() } : {}),
|
||||
})
|
||||
|
||||
const questionSetId = setRes.data?.data?.id
|
||||
|
|
@ -250,7 +291,8 @@ export function AddNewPracticePage() {
|
|||
}
|
||||
|
||||
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 && (
|
||||
<>
|
||||
{/* Back Link */}
|
||||
|
|
@ -263,9 +305,9 @@ export function AddNewPracticePage() {
|
|||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900">Add New Practice</h1>
|
||||
<p className="mt-1.5 text-sm text-grayScale-500">
|
||||
<div className="border-b border-grayScale-100 pb-6 sm:pb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-3xl">Add New Practice</h1>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -274,12 +316,12 @@ export function AddNewPracticePage() {
|
|||
|
||||
{/* Step Tracker */}
|
||||
{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) => (
|
||||
<div key={step.number} className="flex items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<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
|
||||
? "bg-brand-500 text-white ring-4 ring-brand-100"
|
||||
: currentStep > step.number
|
||||
|
|
@ -290,7 +332,7 @@ export function AddNewPracticePage() {
|
|||
{currentStep > step.number ? <Check className="h-4 w-4" /> : step.number}
|
||||
</div>
|
||||
<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
|
||||
? "text-brand-600"
|
||||
: currentStep > step.number
|
||||
|
|
@ -303,7 +345,7 @@ export function AddNewPracticePage() {
|
|||
</div>
|
||||
{index < STEPS.length - 1 && (
|
||||
<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"
|
||||
}`}
|
||||
/>
|
||||
|
|
@ -315,111 +357,170 @@ export function AddNewPracticePage() {
|
|||
|
||||
{/* Step Content */}
|
||||
{currentStep === 1 && (
|
||||
<Card className="mx-auto max-w-2xl p-6 sm:p-10">
|
||||
<h2 className="text-lg font-semibold text-grayScale-900">Step 1: Context Definition</h2>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-500">
|
||||
Define the educational level and curriculum module for this practice.
|
||||
</p>
|
||||
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 1: Context</h2>
|
||||
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
|
||||
Define details and rules for this practice. Curriculum context is shown on the right.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-7">
|
||||
{/* Practice Title */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">Practice Title</label>
|
||||
<Input
|
||||
value={practiceTitle}
|
||||
onChange={(e) => setPracticeTitle(e.target.value)}
|
||||
placeholder="Enter practice title"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-5 sm:p-8 lg:p-10">
|
||||
<div className="grid gap-8 lg:grid-cols-12 lg:gap-10">
|
||||
<div className="space-y-6 lg:col-span-7">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">Practice Title</label>
|
||||
<Input
|
||||
value={practiceTitle}
|
||||
onChange={(e) => setPracticeTitle(e.target.value)}
|
||||
placeholder="Enter practice title"
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Practice Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
||||
<textarea
|
||||
value={practiceDescription}
|
||||
onChange={(e) => setPracticeDescription(e.target.value)}
|
||||
placeholder="Enter practice description"
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">Description</label>
|
||||
<textarea
|
||||
value={practiceDescription}
|
||||
onChange={(e) => setPracticeDescription(e.target.value)}
|
||||
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"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{/* Passing Score */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">Passing Score</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={passingScore}
|
||||
onChange={(e) => setPassingScore(Number(e.target.value))}
|
||||
placeholder="50"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<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 go through the file service (optional, not tied to sub-course video rows).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Limit */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">Time Limit (minutes)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={timeLimitMinutes}
|
||||
onChange={(e) => setTimeLimitMinutes(Number(e.target.value))}
|
||||
placeholder="60"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="space-y-5 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">Scoring & behavior</h3>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">Passing score</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={passingScore}
|
||||
onChange={(e) => setPassingScore(Number(e.target.value))}
|
||||
placeholder="50"
|
||||
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="flex items-center gap-3 rounded-lg bg-grayScale-50 px-4 py-3">
|
||||
<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>
|
||||
<label className="text-sm font-medium text-grayScale-700">Shuffle Questions</label>
|
||||
</div>
|
||||
|
||||
{/* Program */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Program <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">{selectedProgram}</span>
|
||||
<ChevronDown className="h-5 w-5 text-grayScale-300" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 className="rounded-xl border border-grayScale-200 bg-white p-5 shadow-sm ring-1 ring-grayScale-100/80">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-grayScale-500">Curriculum context</h3>
|
||||
<p className="mt-1 text-xs text-grayScale-400">Read-only for this flow.</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Program{" "}
|
||||
<span className="rounded bg-brand-50 px-1.5 py-0.5 text-xs font-medium text-brand-500">Auto</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/50 px-3 py-2.5">
|
||||
<Grid3X3 className="h-4 w-4 shrink-0 text-grayScale-400" />
|
||||
<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>
|
||||
</div>
|
||||
<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</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/50 px-3 py-2.5">
|
||||
<Grid3X3 className="h-4 w-4 shrink-0 text-grayScale-400" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-grayScale-700">{selectedCourse}</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-grayScale-300" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<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}>
|
||||
<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">
|
||||
<Button variant="ghost" onClick={handleCancel} className="sm:w-auto">
|
||||
Cancel
|
||||
</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()}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -428,13 +529,16 @@ export function AddNewPracticePage() {
|
|||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Select Personas</h2>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-500">
|
||||
Choose the characters that will participate in this practice scenario. Students will interact with these personas.
|
||||
</p>
|
||||
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 2: Persona</h2>
|
||||
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
|
||||
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) => (
|
||||
<button
|
||||
key={persona.id}
|
||||
|
|
@ -465,30 +569,32 @@ export function AddNewPracticePage() {
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<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">
|
||||
<Button variant="outline" onClick={handleBack} className="sm:w-auto">
|
||||
Back
|
||||
</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()}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Create Practice Questions</h2>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-500">
|
||||
Add questions to your practice. Support for MCQ, True/False, and Short Answer types.
|
||||
</p>
|
||||
<div className="w-full space-y-6">
|
||||
<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">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Questions</h2>
|
||||
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
|
||||
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) => (
|
||||
<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-center gap-3">
|
||||
<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 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 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||
|
|
@ -643,7 +749,7 @@ export function AddNewPracticePage() {
|
|||
</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 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||
|
|
@ -669,7 +775,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">
|
||||
{/* Voice Prompt */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||
|
|
@ -699,23 +805,22 @@ export function AddNewPracticePage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Button */}
|
||||
<div className="mt-5">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
Add New Question
|
||||
Add another question
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<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">
|
||||
<Button variant="outline" onClick={handleBack} className="sm:w-auto">
|
||||
Back
|
||||
</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()}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -724,14 +829,17 @@ export function AddNewPracticePage() {
|
|||
)}
|
||||
|
||||
{currentStep === 4 && (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h2 className="text-xl font-semibold tracking-tight text-grayScale-900">Review & Publish</h2>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-grayScale-500">
|
||||
Review your practice details before saving.
|
||||
</p>
|
||||
<div className="w-full space-y-6">
|
||||
<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">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 4: Review & publish</h2>
|
||||
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
|
||||
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 */}
|
||||
<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">
|
||||
<h3 className="font-semibold text-grayScale-900">Basic Information</h3>
|
||||
<button
|
||||
|
|
@ -749,21 +857,27 @@ export function AddNewPracticePage() {
|
|||
</div>
|
||||
<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="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 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 font-medium text-grayScale-900">{passingScore}%</span>
|
||||
</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 font-medium text-grayScale-900">{timeLimitMinutes} minutes</span>
|
||||
</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 font-medium text-grayScale-900">{shuffleQuestions ? "Yes" : "No"}</span>
|
||||
</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>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedPersona && (
|
||||
|
|
@ -784,7 +898,7 @@ export function AddNewPracticePage() {
|
|||
</Card>
|
||||
|
||||
{/* 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 gap-2.5">
|
||||
<h3 className="font-semibold text-grayScale-900">Questions</h3>
|
||||
|
|
@ -800,9 +914,9 @@ export function AddNewPracticePage() {
|
|||
Edit
|
||||
</button>
|
||||
</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) => (
|
||||
<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">
|
||||
<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}
|
||||
|
|
@ -845,23 +959,23 @@ export function AddNewPracticePage() {
|
|||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="mt-10 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<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">
|
||||
<Button variant="outline" onClick={handleBack} className="sm:w-auto">
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row">
|
||||
<Button variant="outline" onClick={handleSaveAsDraft} disabled={saving}>
|
||||
<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} className="sm:min-w-[140px]">
|
||||
{saving ? "Saving..." : "Save as Draft"}
|
||||
</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" />
|
||||
{saving ? "Publishing..." : "Publish Now"}
|
||||
</Button>
|
||||
|
|
@ -898,6 +1012,7 @@ export function AddNewPracticePage() {
|
|||
setCurrentStep(1)
|
||||
setPracticeTitle("")
|
||||
setPracticeDescription("")
|
||||
setIntroVideoUrl("")
|
||||
setShuffleQuestions(false)
|
||||
setPassingScore(50)
|
||||
setTimeLimitMinutes(60)
|
||||
|
|
@ -940,6 +1055,7 @@ export function AddNewPracticePage() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,41 +136,46 @@ export function AddPracticePage() {
|
|||
}
|
||||
|
||||
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 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<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-start gap-3 sm:items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">Add New Practice</h1>
|
||||
<p className="text-sm text-grayScale-400">Create a new practice session with questions</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-3xl">Add practice</h1>
|
||||
<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>
|
||||
<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" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<Card className="border-grayScale-200 bg-white/80 p-5 shadow-sm sm:p-6">
|
||||
<Stepper steps={STEPS} currentStep={currentStep} />
|
||||
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<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>
|
||||
|
||||
{/* Step 1: Details */}
|
||||
{currentStep === 1 && (
|
||||
<Card className="mx-auto max-w-3xl border-grayScale-200 p-6 shadow-sm sm:p-8">
|
||||
<h2 className="mb-6 text-lg font-semibold tracking-tight text-grayScale-600">
|
||||
Practice Details
|
||||
</h2>
|
||||
<div className="space-y-5">
|
||||
<Card className="w-full overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 1: Details</h2>
|
||||
<p className="mt-1.5 text-sm text-grayScale-500">Basics for this practice session.</p>
|
||||
</div>
|
||||
<div className="space-y-5 p-5 sm:p-8 lg:p-10">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
||||
Practice Title
|
||||
|
|
@ -256,11 +261,11 @@ export function AddPracticePage() {
|
|||
</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
|
||||
onClick={() => setCurrentStep(2)}
|
||||
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
|
||||
</Button>
|
||||
|
|
@ -272,10 +277,12 @@ export function AddPracticePage() {
|
|||
{currentStep === 2 && (
|
||||
<div className="space-y-6">
|
||||
{/* Select Participants Section */}
|
||||
<Card className="border-grayScale-200 p-6 shadow-sm">
|
||||
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
|
||||
Select Participants
|
||||
</h2>
|
||||
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-4 sm:px-8 sm:py-5">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 2: Participants</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">
|
||||
{mockParticipants.map((participant) => {
|
||||
const isSelected = formData.participants.includes(participant.id)
|
||||
|
|
@ -315,13 +322,16 @@ export function AddPracticePage() {
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Add Questions Section */}
|
||||
<Card className="border-grayScale-200 p-6 shadow-sm">
|
||||
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
|
||||
General Practice Questions
|
||||
</h2>
|
||||
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-4 sm:px-8 sm:py-5">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Questions</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 */}
|
||||
{formData.questions.map((q) => (
|
||||
|
|
@ -459,16 +469,17 @@ export function AddPracticePage() {
|
|||
Add New Question
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={() => setCurrentStep(1)} className="px-6">
|
||||
<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 sm:w-auto">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCurrentStep(3)}
|
||||
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
|
||||
</Button>
|
||||
|
|
@ -478,11 +489,13 @@ export function AddPracticePage() {
|
|||
|
||||
{/* Step 3: Review */}
|
||||
{currentStep === 3 && (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<Card className="border-grayScale-200 p-6 shadow-sm sm:p-8">
|
||||
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
|
||||
Practice Details
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-4 sm:px-8 sm:py-5">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Review</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">
|
||||
{[
|
||||
{ label: "Title", value: formData.title },
|
||||
|
|
@ -505,12 +518,14 @@ export function AddPracticePage() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="border-grayScale-200 p-6 shadow-sm sm:p-8">
|
||||
<h2 className="mb-5 text-lg font-semibold tracking-tight text-grayScale-600">
|
||||
Questions
|
||||
</h2>
|
||||
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-4 sm:px-8 sm:py-5">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Questions</h2>
|
||||
</div>
|
||||
<div className="p-5 sm:p-8">
|
||||
<div className="space-y-4">
|
||||
{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">
|
||||
|
|
@ -549,14 +564,15 @@ export function AddPracticePage() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={() => setCurrentStep(2)} className="px-6">
|
||||
<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 sm:w-auto">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} className="bg-brand-500 px-6 shadow-sm hover:bg-brand-600 transition-colors">
|
||||
Create Practice
|
||||
<Button onClick={handleSubmit} className="min-w-[160px] bg-brand-500 px-6 shadow-sm hover:bg-brand-600">
|
||||
Create practice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,16 +12,16 @@ const tabs = [
|
|||
|
||||
export function ContentManagementLayout() {
|
||||
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 */}
|
||||
<div className="mb-8">
|
||||
<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>
|
||||
<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
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from "../../components/ui/button"
|
|||
import { Input } from "../../components/ui/input"
|
||||
import { getCourseCategories } from "../../api/courses.api"
|
||||
import type { CourseCategory } from "../../types/course.types"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const contentSections = [
|
||||
{
|
||||
|
|
@ -29,8 +30,8 @@ const contentSections = [
|
|||
action: "Manage Speaking",
|
||||
count: 8,
|
||||
countLabel: "sessions",
|
||||
gradient: "from-purple-500/10 via-purple-400/5 to-transparent",
|
||||
accentBorder: "group-hover:border-purple-400",
|
||||
gradient: "from-brand-500/12 via-brand-400/6 to-transparent",
|
||||
accentBorder: "group-hover:border-brand-400",
|
||||
},
|
||||
{
|
||||
key: "practices",
|
||||
|
|
@ -334,7 +335,7 @@ export function ContentOverviewPage() {
|
|||
step.type === "course" &&
|
||||
"bg-violet-50 text-violet-700 ring-1 ring-inset ring-violet-200",
|
||||
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" &&
|
||||
"bg-indigo-50 text-indigo-700 ring-1 ring-inset ring-indigo-200",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
|
|
@ -17,7 +17,7 @@ import {
|
|||
getQuestions,
|
||||
updateQuestion,
|
||||
} 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 {
|
||||
DropdownMenu,
|
||||
|
|
@ -95,6 +95,18 @@ function normalizeObjectKey(value: string) {
|
|||
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() {
|
||||
const [audioQuestions, setAudioQuestions] = useState<QuestionDetail[]>([])
|
||||
const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState<Record<number, string>>({})
|
||||
|
|
@ -104,6 +116,9 @@ export function SpeakingPage() {
|
|||
|
||||
const [setTitle, setSetTitle] = useState("")
|
||||
const [setDescription, setSetDescription] = useState("")
|
||||
const [introVideoUrl, setIntroVideoUrl] = useState("")
|
||||
const [uploadingIntroVideo, setUploadingIntroVideo] = useState(false)
|
||||
const introVideoFileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [subCourseId, setSubCourseId] = useState("")
|
||||
const [subCourseOptions, setSubCourseOptions] = useState<SubCourseOption[]>([])
|
||||
const [subCourseLoading, setSubCourseLoading] = useState(false)
|
||||
|
|
@ -315,11 +330,35 @@ export function SpeakingPage() {
|
|||
const resetCreateForm = () => {
|
||||
setSetTitle("")
|
||||
setSetDescription("")
|
||||
setIntroVideoUrl("")
|
||||
setSubCourseId("")
|
||||
setSetStatus("DRAFT")
|
||||
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 hasQuestionWithText = questionDrafts.some((draft) => draft.questionText.trim().length > 0)
|
||||
return setTitle.trim().length > 0 && subCourseId.trim().length > 0 && hasQuestionWithText
|
||||
|
|
@ -695,11 +734,12 @@ export function SpeakingPage() {
|
|||
// 1) Create speaking practice set.
|
||||
const setRes = await createQuestionSet({
|
||||
title: setTitle.trim(),
|
||||
description: setDescription.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
|
||||
|
|
@ -871,18 +911,18 @@ export function SpeakingPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="mx-auto w-full max-w-7xl space-y-6 pb-10 sm:space-y-8 sm:pb-12">
|
||||
<div className="flex flex-col gap-4 border-b border-grayScale-100 pb-6 sm:flex-row sm:items-end sm:justify-between sm:pb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-3xl">
|
||||
Speaking
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
<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={() => {
|
||||
setOpenCreate(true)
|
||||
setCurrentStep(1)
|
||||
|
|
@ -894,40 +934,43 @@ export function SpeakingPage() {
|
|||
</div>
|
||||
|
||||
{!openCreate && (
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
AUDIO Questions
|
||||
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<CardHeader className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-4 sm:px-6 sm:py-5">
|
||||
<CardTitle className="text-base font-semibold text-grayScale-900 sm:text-lg">
|
||||
AUDIO questions
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs font-normal text-grayScale-500 sm:text-sm">
|
||||
Tap a row to view details. Speaking practices create AUDIO question sets linked to a sub-course.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-5">
|
||||
<CardContent className="px-4 pb-6 pt-5 sm:px-6">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center gap-2 py-14 text-center text-sm text-grayScale-500">
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
Loading audio questions...
|
||||
</div>
|
||||
) : audioQuestions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-14 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">
|
||||
<Mic className="h-8 w-8 text-brand-500" />
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/30 py-16 text-center">
|
||||
<div className="mb-5 grid h-16 w-16 place-items-center rounded-2xl bg-gradient-to-br from-brand-100 to-brand-200 shadow-sm ring-4 ring-brand-500/10">
|
||||
<Mic className="h-8 w-8 text-brand-600" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-grayScale-600">No audio questions yet</h3>
|
||||
<p className="mt-2 max-w-md text-sm leading-relaxed text-grayScale-400">
|
||||
<h3 className="text-base font-semibold text-grayScale-800">No audio questions yet</h3>
|
||||
<p className="mt-2 max-w-md text-sm leading-relaxed text-grayScale-500">
|
||||
Create a speaking practice to automatically create and attach an AUDIO question.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
{audioQuestions.map((question, idx) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className={`rounded-lg border px-4 py-3 ${
|
||||
idx % 2 === 0 ? "border-grayScale-200 bg-white" : "border-grayScale-100 bg-grayScale-50"
|
||||
} cursor-pointer transition-colors hover:border-brand-300 hover:bg-brand-50/30`}
|
||||
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">
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -942,7 +985,7 @@ export function SpeakingPage() {
|
|||
</Button>
|
||||
</div>
|
||||
<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">
|
||||
Difficulty: {question.difficulty_level || "—"}
|
||||
</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"
|
||||
onClick={() => setDetailOpen(false)}
|
||||
>
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-5 py-4">
|
||||
<h3 className="text-base font-semibold text-grayScale-700">AUDIO Question Detail</h3>
|
||||
<div
|
||||
className="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-grayScale-200/80 bg-white shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/90 to-white px-5 py-4 sm:px-6">
|
||||
<h3 className="text-base font-semibold text-grayScale-900 sm:text-lg">AUDIO question</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{!detailLoading && selectedQuestionDetail && !detailEditing ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setDetailEditing(true)}>
|
||||
|
|
@ -1012,7 +1058,7 @@ export function SpeakingPage() {
|
|||
</Button>
|
||||
</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 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
|
|
@ -1233,19 +1279,19 @@ export function SpeakingPage() {
|
|||
|
||||
{confirmDeleteOpen && (deleteTarget || selectedQuestionDetail) && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white shadow-2xl">
|
||||
<div className="border-b border-grayScale-100 px-5 py-4">
|
||||
<h3 className="text-base font-semibold text-grayScale-700">Delete AUDIO Question</h3>
|
||||
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-grayScale-200/80 bg-white shadow-2xl">
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-red-50/50 to-white px-5 py-4">
|
||||
<h3 className="text-base font-semibold text-grayScale-900">Delete AUDIO question</h3>
|
||||
</div>
|
||||
<div className="space-y-2 px-5 py-4">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
Are you sure you want to delete this question?
|
||||
<p className="text-sm leading-relaxed text-grayScale-600">
|
||||
This action cannot be undone. The question will be removed permanently.
|
||||
</p>
|
||||
<p className="line-clamp-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}
|
||||
</p>
|
||||
</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
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
|
|
@ -1270,45 +1316,114 @@ export function SpeakingPage() {
|
|||
|
||||
{openCreate && (
|
||||
<div className="space-y-6">
|
||||
<Card className="border-grayScale-200 bg-white/80 p-5 shadow-sm sm:p-6">
|
||||
<div className="mb-4">
|
||||
<Card className="overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<div className="flex flex-col gap-4 border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 sm:py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-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)}
|
||||
disabled={saving}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to AUDIO Questions
|
||||
Back to list
|
||||
</Button>
|
||||
<p className="text-center text-xs text-grayScale-500 sm:text-left sm:text-sm">
|
||||
New speaking practice · {SPEAKING_STEPS[currentStep - 1] ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-5 sm:px-6 sm:py-6">
|
||||
<div className="rounded-2xl border border-grayScale-100 bg-grayScale-50/40 px-3 py-4 sm:px-5">
|
||||
<Stepper steps={SPEAKING_STEPS} currentStep={currentStep} />
|
||||
</div>
|
||||
</div>
|
||||
<Stepper steps={SPEAKING_STEPS} currentStep={currentStep} />
|
||||
</Card>
|
||||
|
||||
{currentStep === 1 && (
|
||||
<Card className="mx-auto max-w-3xl border-grayScale-200 p-6 shadow-sm sm:p-8">
|
||||
<h2 className="mb-6 text-lg font-semibold text-grayScale-700">Step 1: Practice Context</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<label className="text-sm font-medium text-grayScale-600">Practice Title</label>
|
||||
<Input
|
||||
value={setTitle}
|
||||
onChange={(e) => setSetTitle(e.target.value)}
|
||||
placeholder="Speaking practice title"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<label className="text-sm font-medium text-grayScale-600">Practice Description (Optional)</label>
|
||||
<Textarea
|
||||
value={setDescription}
|
||||
onChange={(e) => setSetDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Brief description"
|
||||
/>
|
||||
</div>
|
||||
<Card className="w-full overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 1: Practice context</h2>
|
||||
<p className="mt-1.5 max-w-3xl text-sm text-grayScale-500">
|
||||
Title, description, optional intro video, sub-course, and publish status for this speaking practice.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 sm:p-8 lg:p-10">
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12 lg:gap-10">
|
||||
<div className="space-y-5 lg:col-span-7">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-grayScale-700">Practice title</label>
|
||||
<Input
|
||||
value={setTitle}
|
||||
onChange={(e) => setSetTitle(e.target.value)}
|
||||
placeholder="Speaking practice title"
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-grayScale-700">Description (optional)</label>
|
||||
<Textarea
|
||||
value={setDescription}
|
||||
onChange={(e) => setSetDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Brief description"
|
||||
className="min-h-[88px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">
|
||||
Intro video URL <span className="font-normal text-grayScale-400">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
value={introVideoUrl}
|
||||
onChange={(e) => setIntroVideoUrl(e.target.value)}
|
||||
placeholder="https://…"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
className="h-11 font-mono text-[13px]"
|
||||
/>
|
||||
<input
|
||||
ref={introVideoFileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={handleIntroVideoFileChange}
|
||||
disabled={uploadingIntroVideo}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploadingIntroVideo}
|
||||
onClick={() => introVideoFileInputRef.current?.click()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{uploadingIntroVideo ? (
|
||||
<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">
|
||||
<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}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -1361,7 +1476,7 @@ export function SpeakingPage() {
|
|||
</DropdownMenu>
|
||||
</div>
|
||||
<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>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -1384,45 +1499,56 @@ export function SpeakingPage() {
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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">
|
||||
<Button variant="outline" onClick={() => setOpenCreate(false)} disabled={saving}>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div className="mt-8 flex flex-col-reverse gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-0 py-4 sm:flex-row sm:justify-end sm:px-0 sm:py-5">
|
||||
<Button variant="outline" onClick={() => setOpenCreate(false)} disabled={saving} className="sm:w-auto">
|
||||
Cancel
|
||||
</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)}
|
||||
disabled={!canProceedToQuestions}
|
||||
>
|
||||
Next: Questions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<Card className="mx-auto max-w-4xl border-grayScale-200 p-6 shadow-sm sm:p-8">
|
||||
<h2 className="mb-4 text-lg font-semibold text-grayScale-700">Step 2: AUDIO Questions</h2>
|
||||
<div className="rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-grayScale-700">AUDIO Questions</p>
|
||||
<Card className="w-full overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 2: AUDIO questions</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm text-grayScale-500">
|
||||
Upload or record prompts, add reference image, and set optional tips.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setQuestionDrafts((prev) => [...prev, createEmptyDraft()])}
|
||||
disabled={saving}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Question
|
||||
Add question
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
</div>
|
||||
<div className="p-5 sm:p-8">
|
||||
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/30 p-4 sm:p-5">
|
||||
<div className="space-y-5">
|
||||
{questionDrafts.map((draft, draftIndex) => (
|
||||
<div key={draftIndex} className="rounded-lg border border-grayScale-200 bg-white p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-grayScale-700">Question {draftIndex + 1}</p>
|
||||
<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-4 flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-grayScale-900">Question {draftIndex + 1}</p>
|
||||
{questionDrafts.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -1663,12 +1789,13 @@ export function SpeakingPage() {
|
|||
))}
|
||||
</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">
|
||||
<Button variant="outline" onClick={() => setCurrentStep(1)} disabled={saving}>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:justify-end sm:px-8 sm:py-5">
|
||||
<Button variant="outline" onClick={() => setCurrentStep(1)} disabled={saving} className="sm:w-auto">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-brand-500 hover:bg-brand-600"
|
||||
className="bg-brand-500 hover:bg-brand-600 sm:min-w-[180px]"
|
||||
onClick={() => setCurrentStep(3)}
|
||||
disabled={!canProceedToReview}
|
||||
>
|
||||
|
|
@ -1679,26 +1806,35 @@ export function SpeakingPage() {
|
|||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<Card className="mx-auto max-w-4xl border-grayScale-200 p-6 shadow-sm sm:p-8">
|
||||
<h2 className="mb-4 text-lg font-semibold text-grayScale-700">Step 3: Review & Publish</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-grayScale-200 bg-white p-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-grayScale-700">Practice</h3>
|
||||
<Card className="w-full overflow-hidden border-grayScale-200/80 shadow-sm">
|
||||
<div className="border-b border-grayScale-100 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 sm:px-8 sm:py-6">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">Step 3: Review & publish</h2>
|
||||
<p className="mt-1.5 max-w-3xl text-sm text-grayScale-500">
|
||||
Confirm practice metadata and each AUDIO question before publishing.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-6 p-5 sm:p-8">
|
||||
<div className="grid gap-6 lg:grid-cols-2 lg:items-start lg:gap-8">
|
||||
<div className="rounded-xl border border-grayScale-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-grayScale-500">Practice</h3>
|
||||
<div className="grid grid-cols-1 gap-3 text-sm text-grayScale-600 sm:grid-cols-2">
|
||||
<p><span className="font-medium">Title:</span> {setTitle || "—"}</p>
|
||||
<p><span className="font-medium">Course ID:</span> {subCourseId || "—"}</p>
|
||||
<p className="sm:col-span-2"><span className="font-medium">Description:</span> {setDescription || "—"}</p>
|
||||
<p className="sm:col-span-2 break-all">
|
||||
<span className="font-medium">Intro video URL:</span> {introVideoUrl.trim() || "—"}
|
||||
</p>
|
||||
<p><span className="font-medium">Status:</span> {setStatus}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-grayScale-200 bg-white p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-grayScale-700">
|
||||
Questions to Publish ({questionsWithText.length})
|
||||
<div className="flex max-h-[min(70vh,48rem)] min-h-0 flex-col rounded-xl border border-grayScale-200 bg-grayScale-50/20 p-4 shadow-sm sm:p-5">
|
||||
<h3 className="mb-3 shrink-0 text-sm font-semibold uppercase tracking-wide text-grayScale-500">
|
||||
Questions ({questionsWithText.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
{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">
|
||||
{idx + 1}. {draft.questionText}
|
||||
</p>
|
||||
|
|
@ -1751,13 +1887,14 @@ 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">
|
||||
<Button variant="outline" onClick={() => setCurrentStep(2)} 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(2)} disabled={saving} className="sm:w-auto">
|
||||
Back
|
||||
</Button>
|
||||
<Button className="bg-brand-500 hover:bg-brand-600" disabled={!canCreate || saving} onClick={handleCreateSpeakingPractice}>
|
||||
{saving ? "Publishing..." : "Publish Speaking Practice"}
|
||||
<Button className="bg-brand-500 hover:bg-brand-600 sm:min-w-[200px]" disabled={!canCreate || saving} onClick={handleCreateSpeakingPractice}>
|
||||
{saving ? "Publishing..." : "Publish speaking practice"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -1766,17 +1903,17 @@ export function SpeakingPage() {
|
|||
)}
|
||||
|
||||
{recordingModal ? (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/35 backdrop-blur-[2px]">
|
||||
<div className="mx-4 w-full max-w-md rounded-2xl border border-[#eee2f7] bg-[#fcf9ff] p-6 shadow-2xl">
|
||||
<p className="text-center text-base font-semibold text-grayScale-700">
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-md overflow-hidden rounded-2xl border border-grayScale-200/80 bg-white p-6 shadow-2xl">
|
||||
<p className="text-center text-base font-semibold text-grayScale-900">
|
||||
Recording {recordingModal.label}
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-grayScale-400">
|
||||
Speak now. The visualizer reacts to your voice volume in real time.
|
||||
<p className="mt-1 text-center text-xs text-grayScale-500">
|
||||
Speak clearly. The bars reflect your input level in real time.
|
||||
</p>
|
||||
<div className="mt-3 flex justify-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-[#ddc8ee] bg-[#f6ecff] px-3 py-1 text-xs font-medium text-[#7b3ca6]">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[#a742d5]" />
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-xs font-semibold text-brand-800">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-brand-500" />
|
||||
REC{" "}
|
||||
{`${Math.floor(recordingModal.elapsedSeconds / 60)
|
||||
.toString()
|
||||
|
|
@ -1786,8 +1923,8 @@ export function SpeakingPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center gap-4 rounded-xl border border-[#e3cff3] bg-[#f2e8f8] 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="mt-5 flex items-center gap-4 rounded-xl border border-brand-100 bg-brand-50/50 px-4 py-4">
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-brand-100 text-brand-700 shadow-sm">
|
||||
<Mic className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="grid h-16 w-full grid-cols-32 items-end gap-[3px] overflow-hidden">
|
||||
|
|
@ -1803,8 +1940,8 @@ export function SpeakingPage() {
|
|||
style={{
|
||||
background:
|
||||
segmentIdx < activeSegments
|
||||
? "linear-gradient(180deg, rgba(142,55,184,0.95) 0%, rgba(206,92,235,0.92) 100%)"
|
||||
: "rgba(207,177,230,0.38)",
|
||||
? "linear-gradient(180deg, #9E2891 0%, #6A1B9A 100%)"
|
||||
: "rgba(189, 189, 189, 0.35)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -1814,11 +1951,11 @@ export function SpeakingPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
Cancel
|
||||
|
|
@ -1826,14 +1963,14 @@ export function SpeakingPage() {
|
|||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-[#ddc8ee] text-[#7a6792] hover:bg-[#f4ecfb]"
|
||||
className="border-grayScale-200 text-grayScale-700 hover:bg-grayScale-50"
|
||||
onClick={togglePauseRecording}
|
||||
>
|
||||
{recordingModal.isPaused ? "Continue" : "Pause"}
|
||||
</Button>
|
||||
<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)}
|
||||
>
|
||||
Stop & Save
|
||||
|
|
|
|||
|
|
@ -436,6 +436,7 @@ export interface QuestionSetDetail {
|
|||
shuffle_questions?: boolean
|
||||
status: string
|
||||
sub_course_video_id?: number | null
|
||||
intro_video_url?: string | null
|
||||
created_at: string
|
||||
question_count: number
|
||||
}
|
||||
|
|
@ -471,18 +472,21 @@ export interface GetQuestionSetQuestionsResponse {
|
|||
metadata: unknown
|
||||
}
|
||||
|
||||
/** POST /question-sets — practices use set_type: "PRACTICE", owner_type: "SUB_COURSE", owner_id: sub-course id */
|
||||
export interface CreateQuestionSetRequest {
|
||||
title: string
|
||||
description: string
|
||||
set_type: string
|
||||
owner_type: string
|
||||
owner_id: number
|
||||
persona?: string
|
||||
shuffle_questions?: boolean
|
||||
status?: string
|
||||
banner_image?: string
|
||||
passing_score?: number
|
||||
time_limit_minutes?: number
|
||||
description?: string | null
|
||||
owner_type?: string | null
|
||||
owner_id?: number | null
|
||||
banner_image?: string | null
|
||||
persona?: string | null
|
||||
time_limit_minutes?: number | null
|
||||
passing_score?: number | null
|
||||
shuffle_questions?: boolean | null
|
||||
status?: string | null
|
||||
sub_course_video_id?: number | null
|
||||
intro_video_url?: string | null
|
||||
}
|
||||
|
||||
export interface AddQuestionToSetRequest {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user