feat(content): lesson practices page, dynamic question schema, and practice flow updates
- Add LessonPracticesPage with GET /lessons/:id/practices and polished UI - Route and module lesson navigation; view practices icon on VideoCard hover - Question type definitions API, DynamicSchemaSlotField, definition helpers - AddPracticeFlow and practice steps; AddQuestionPage and PracticeQuestionEditorFields Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f1b6172f91
commit
2b556d9d09
|
|
@ -217,7 +217,9 @@ export function normalizeTypeDefinitionFromApi(raw: unknown): QuestionTypeDefini
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
key: asStr(o.Key ?? o.key),
|
key: asStr(o.Key ?? o.key),
|
||||||
display_name: asStr(o.DisplayName ?? o.display_name),
|
display_name: asStr(
|
||||||
|
o.DisplayName ?? o.display_name ?? o.displayName ?? o.Display_Name,
|
||||||
|
),
|
||||||
description: (() => {
|
description: (() => {
|
||||||
const d = o.Description ?? o.description
|
const d = o.Description ?? o.description
|
||||||
if (d == null) return null
|
if (d == null) return null
|
||||||
|
|
@ -235,6 +237,15 @@ export function normalizeTypeDefinitionFromApi(raw: unknown): QuestionTypeDefini
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Label for selects: API `DisplayName` (stored as `display_name`), then key, then id. */
|
||||||
|
export function questionTypeDefinitionListLabel(def: QuestionTypeDefinition): string {
|
||||||
|
const name = def.display_name?.trim()
|
||||||
|
if (name) return name
|
||||||
|
const k = def.key?.trim()
|
||||||
|
if (k) return k
|
||||||
|
return `Type #${def.id}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Definition id from POST create or PUT update (`data.ID`, `data.id`, or PascalCase `Id`).
|
* Definition id from POST create or PUT update (`data.ID`, `data.id`, or PascalCase `Id`).
|
||||||
* Example update: `{ "data": { "id": 6 } }`.
|
* Example update: `{ "data": { "id": 6 } }`.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { NewContentPage } from "../pages/content-management/NewContentPage";
|
||||||
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
|
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
|
||||||
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
|
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
|
||||||
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
|
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
|
||||||
|
import { LessonPracticesPage } from "../pages/content-management/LessonPracticesPage";
|
||||||
import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
|
import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
|
||||||
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
|
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
|
||||||
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
|
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
|
||||||
|
|
@ -222,6 +223,10 @@ export function AppRoutes() {
|
||||||
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/add-video"
|
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/add-video"
|
||||||
element={<AddVideoFlow />}
|
element={<AddVideoFlow />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/lessons/:lessonId/practices"
|
||||||
|
element={<LessonPracticesPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/new-content/learn-english/:level/courses/add-practice"
|
path="/new-content/learn-english/:level/courses/add-practice"
|
||||||
element={<AddPracticeFlow />}
|
element={<AddPracticeFlow />}
|
||||||
|
|
|
||||||
593
src/components/content-management/DynamicSchemaSlotField.tsx
Normal file
593
src/components/content-management/DynamicSchemaSlotField.tsx
Normal file
|
|
@ -0,0 +1,593 @@
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type DragEvent,
|
||||||
|
} from "react"
|
||||||
|
import { CloudUpload, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { uploadAudioFile, uploadImageFile } from "../../api/files.api"
|
||||||
|
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
|
||||||
|
import { Input } from "../ui/input"
|
||||||
|
import { Textarea } from "../ui/textarea"
|
||||||
|
import { SpinnerIcon } from "../ui/spinner-icon"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
import { ResolvedImage } from "../media/ResolvedImage"
|
||||||
|
|
||||||
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||||
|
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
|
||||||
|
const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "webp", "gif"])
|
||||||
|
const AUDIO_EXT = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"])
|
||||||
|
|
||||||
|
export interface DynamicSchemaSlotRow {
|
||||||
|
id: string
|
||||||
|
kind: string
|
||||||
|
label?: string
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotMediaMode(kind: string): "image" | "audio" | "text" {
|
||||||
|
const u = kind.trim().toUpperCase()
|
||||||
|
if (u === "IMAGE") return "image"
|
||||||
|
if (u.startsWith("AUDIO")) return "audio"
|
||||||
|
return "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(s: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(s.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileLabelFromValue(raw: string): string {
|
||||||
|
const t = raw.trim()
|
||||||
|
if (!t) return "No audio"
|
||||||
|
try {
|
||||||
|
if (isHttpUrl(t)) {
|
||||||
|
const path = new URL(t).pathname.split("/").filter(Boolean)
|
||||||
|
const last = path[path.length - 1]
|
||||||
|
return last ? decodeURIComponent(last) : "Audio clip"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const parts = t.split("/").filter(Boolean)
|
||||||
|
const last = parts[parts.length - 1]
|
||||||
|
return last ? decodeURIComponent(last) : "Audio clip"
|
||||||
|
}
|
||||||
|
|
||||||
|
function DynamicImageSlot({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
slotLabel,
|
||||||
|
slotMeta,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (next: string) => void
|
||||||
|
disabled: boolean
|
||||||
|
slotLabel: string
|
||||||
|
slotMeta: string
|
||||||
|
}) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const valueAtFocusRef = useRef("")
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
|
||||||
|
const processFile = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
if (disabled || uploading) return
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
|
||||||
|
if (!IMAGE_EXT.has(ext)) {
|
||||||
|
toast.error("Unsupported image format", {
|
||||||
|
description: "Use JPG, PNG, WEBP, or GIF.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > MAX_IMAGE_BYTES) {
|
||||||
|
toast.error("Image is too large", { description: "Maximum size is 10 MB." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const res = await uploadImageFile(file)
|
||||||
|
const url = res.data?.data?.url?.trim()
|
||||||
|
if (!url) throw new Error("Upload did not return a URL")
|
||||||
|
onChange(url)
|
||||||
|
toast.success("Image uploaded")
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
toast.error("Failed to upload image")
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, uploading, onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const importUrl = useCallback(async () => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed || !isHttpUrl(trimmed)) return
|
||||||
|
if (trimmed === valueAtFocusRef.current) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const res = await uploadImageFile(trimmed)
|
||||||
|
const url = res.data?.data?.url?.trim()
|
||||||
|
if (!url) throw new Error("Import did not return a URL")
|
||||||
|
onChange(url)
|
||||||
|
toast.success("Image URL imported to storage")
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
toast.error("Could not import image from URL")
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}, [value, onChange])
|
||||||
|
|
||||||
|
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = ""
|
||||||
|
if (file) void processFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoneDisabled = disabled || uploading
|
||||||
|
const hasImage = Boolean(value.trim())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
||||||
|
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
|
||||||
|
<div
|
||||||
|
className="min-h-[100px] space-y-2 rounded-lg border border-grayScale-200 bg-grayScale-50/40 p-2.5 lg:min-h-[140px]"
|
||||||
|
onDragOver={
|
||||||
|
hasImage
|
||||||
|
? (e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!zoneDisabled) setDragActive(true)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDragLeave={
|
||||||
|
hasImage
|
||||||
|
? (e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragActive(false)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDrop={
|
||||||
|
hasImage
|
||||||
|
? (e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragActive(false)
|
||||||
|
if (zoneDisabled) return
|
||||||
|
const file = e.dataTransfer.files?.[0]
|
||||||
|
if (file) void processFile(file)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasImage ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative mx-auto aspect-video max-h-48 w-full max-w-lg overflow-hidden rounded-lg border bg-white shadow-sm transition-colors",
|
||||||
|
dragActive ? "border-[#9E2891] ring-2 ring-[#9E289133]" : "border-grayScale-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ResolvedImage src={value} alt="" className="h-full w-full object-contain" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-1.5 top-1.5 rounded-full bg-white/95 p-1.5 text-[#9E2891] shadow-md hover:bg-white"
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
disabled={zoneDisabled}
|
||||||
|
aria-label="Remove image"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full min-h-[88px] flex-col items-center justify-center rounded-lg border border-dashed border-grayScale-200 bg-white px-3 py-5 text-center text-xs text-grayScale-500 sm:text-sm lg:min-h-[120px]">
|
||||||
|
<ImageIcon className="mb-2 h-8 w-8 text-grayScale-300" strokeWidth={1.25} aria-hidden />
|
||||||
|
<p>Preview appears here after you upload or paste a URL on the right.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.webp,.gif,image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={zoneDisabled}
|
||||||
|
/>
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Upload file</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={zoneDisabled}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={(e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!zoneDisabled) setDragActive(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={(e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragActive(false)
|
||||||
|
}}
|
||||||
|
onDrop={(e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragActive(false)
|
||||||
|
if (zoneDisabled) return
|
||||||
|
const file = e.dataTransfer.files?.[0]
|
||||||
|
if (file) void processFile(file)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-[#9E289133] bg-white p-4 text-center transition-colors",
|
||||||
|
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
|
||||||
|
dragActive && "border-[#9E2891] bg-[#9E289108]",
|
||||||
|
zoneDisabled && "cursor-not-allowed opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<p className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
|
||||||
|
<SpinnerIcon className="h-4 w-4" /> Uploading…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CloudUpload className="mb-3 h-9 w-9 text-[#9E2891]" strokeWidth={1.5} aria-hidden />
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
|
||||||
|
<span className="text-grayScale-500">or paste a URL below</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
||||||
|
JPG, PNG, WebP, GIF (max 10 MB)
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onFocus={(e) => {
|
||||||
|
valueAtFocusRef.current = e.currentTarget.value.trim()
|
||||||
|
}}
|
||||||
|
onBlur={() => void importUrl()}
|
||||||
|
placeholder="https://…"
|
||||||
|
title="Leave the field after pasting a public URL to import to storage"
|
||||||
|
className="h-12 rounded-xl border-grayScale-200 font-mono text-sm"
|
||||||
|
disabled={disabled || uploading}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WaveformDecor({ active }: { active: boolean }) {
|
||||||
|
const heights = [0.35, 0.55, 0.42, 0.7, 0.5, 0.62, 0.4, 0.58, 0.48, 0.66, 0.44, 0.52, 0.38, 0.6, 0.46, 0.54]
|
||||||
|
return (
|
||||||
|
<div className="flex h-7 flex-1 items-end justify-center gap-[2px] overflow-hidden px-0.5">
|
||||||
|
{heights.map((h, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"w-[3px] min-w-[2px] rounded-full transition-colors",
|
||||||
|
active ? "bg-brand-500/80" : "bg-grayScale-300/90",
|
||||||
|
)}
|
||||||
|
style={{ height: `${Math.round(h * 100)}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DynamicAudioSlot({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
slotLabel,
|
||||||
|
slotMeta,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (next: string) => void
|
||||||
|
disabled: boolean
|
||||||
|
slotLabel: string
|
||||||
|
slotMeta: string
|
||||||
|
}) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
const valueAtFocusRef = useRef("")
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
const [resolvedSrc, setResolvedSrc] = useState("")
|
||||||
|
const [playing, setPlaying] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
const raw = value.trim()
|
||||||
|
if (!raw) {
|
||||||
|
setResolvedSrc("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = await resolveMediaPreviewUrl(raw)
|
||||||
|
if (!cancelled) setResolvedSrc(url || raw)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setResolvedSrc(raw)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const processFile = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
if (disabled || uploading) return
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
|
||||||
|
if (!AUDIO_EXT.has(ext)) {
|
||||||
|
toast.error("Unsupported audio format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > MAX_AUDIO_BYTES) {
|
||||||
|
toast.error("Audio file must be 50MB or less")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const res = await uploadAudioFile(file)
|
||||||
|
const url = res.data?.data?.url?.trim()
|
||||||
|
const objectKey = res.data?.data?.object_key?.trim()
|
||||||
|
const stored = url || objectKey
|
||||||
|
if (!stored) throw new Error("Upload did not return a URL or key")
|
||||||
|
onChange(stored)
|
||||||
|
toast.success("Audio uploaded")
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
toast.error("Failed to upload audio")
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, uploading, onChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const importUrl = useCallback(async () => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed || !isHttpUrl(trimmed)) return
|
||||||
|
if (trimmed === valueAtFocusRef.current) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const res = await uploadAudioFile(trimmed)
|
||||||
|
const url = res.data?.data?.url?.trim()
|
||||||
|
const objectKey = res.data?.data?.object_key?.trim()
|
||||||
|
const stored = url || objectKey
|
||||||
|
if (!stored) throw new Error("Import did not return a URL or key")
|
||||||
|
onChange(stored)
|
||||||
|
toast.success("Audio URL imported to storage")
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
toast.error("Could not import audio from URL")
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}, [value, onChange])
|
||||||
|
|
||||||
|
const togglePlay = async () => {
|
||||||
|
const el = audioRef.current
|
||||||
|
if (!el || !resolvedSrc) return
|
||||||
|
if (playing) {
|
||||||
|
el.pause()
|
||||||
|
setPlaying(false)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await el.play()
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not play audio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = ""
|
||||||
|
if (file) void processFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoneDisabled = disabled || uploading
|
||||||
|
const hasMedia = Boolean(value.trim() && resolvedSrc)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
||||||
|
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
|
||||||
|
<div className="min-h-[100px] space-y-2 rounded-lg border border-grayScale-200 bg-grayScale-50/40 p-2.5 lg:min-h-[140px]">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".mp3,.wav,.ogg,.m4a,.aac,.webm,.flac,audio/*"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={zoneDisabled}
|
||||||
|
/>
|
||||||
|
{hasMedia ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-[#9E289140] bg-[#FAF5FF]/80 px-2 py-2 shadow-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void togglePlay()}
|
||||||
|
disabled={!resolvedSrc || zoneDisabled}
|
||||||
|
className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-[#9E2891] text-white shadow-sm transition hover:bg-[#8a217d] disabled:opacity-40"
|
||||||
|
aria-label={playing ? "Pause" : "Play"}
|
||||||
|
>
|
||||||
|
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<WaveformDecor active={playing} />
|
||||||
|
<p className="mt-0.5 truncate text-[11px] font-medium leading-tight text-[#9E2891]">
|
||||||
|
{fileLabelFromValue(value)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded-md p-1.5 text-[#9E2891] transition hover:bg-white/80"
|
||||||
|
onClick={() => {
|
||||||
|
audioRef.current?.pause()
|
||||||
|
setPlaying(false)
|
||||||
|
onChange("")
|
||||||
|
}}
|
||||||
|
disabled={zoneDisabled}
|
||||||
|
aria-label="Remove audio"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={resolvedSrc}
|
||||||
|
className="hidden"
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onEnded={() => setPlaying(false)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full min-h-[80px] flex-col items-center justify-center rounded-lg border border-dashed border-grayScale-200 bg-white px-3 py-4 text-center text-xs text-grayScale-500 sm:text-sm">
|
||||||
|
<Mic className="mb-2 h-8 w-8 text-grayScale-300" strokeWidth={1.25} aria-hidden />
|
||||||
|
<p>Playback preview appears here after you upload or paste a URL on the right.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">Upload file</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={zoneDisabled}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={(e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!zoneDisabled) setDragActive(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={(e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragActive(false)
|
||||||
|
}}
|
||||||
|
onDrop={(e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragActive(false)
|
||||||
|
if (zoneDisabled) return
|
||||||
|
const file = e.dataTransfer.files?.[0]
|
||||||
|
if (file) void processFile(file)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-[#9E289133] bg-white p-4 text-center transition-colors",
|
||||||
|
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
|
||||||
|
dragActive && "border-[#9E2891] bg-[#9E289108]",
|
||||||
|
zoneDisabled && "cursor-not-allowed opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<p className="flex items-center gap-2 text-sm font-medium text-grayScale-600">
|
||||||
|
<SpinnerIcon className="h-4 w-4" /> Uploading…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CloudUpload className="mb-3 h-9 w-9 text-[#9E2891]" strokeWidth={1.5} aria-hidden />
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
|
||||||
|
<span className="text-grayScale-500">or paste a URL below</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
||||||
|
MP3, WAV, OGG, M4A, AAC, WebM, FLAC (max 50 MB)
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onFocus={(e) => {
|
||||||
|
valueAtFocusRef.current = e.currentTarget.value.trim()
|
||||||
|
}}
|
||||||
|
onBlur={() => void importUrl()}
|
||||||
|
placeholder="https://…"
|
||||||
|
title="Leave the field after pasting a public URL to import to storage"
|
||||||
|
className="h-12 rounded-xl border-grayScale-200 font-mono text-sm"
|
||||||
|
disabled={disabled || uploading}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DynamicSchemaSlotFieldProps {
|
||||||
|
row: DynamicSchemaSlotRow
|
||||||
|
value: string
|
||||||
|
onChange: (next: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicSchemaSlotField({
|
||||||
|
row,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}: DynamicSchemaSlotFieldProps) {
|
||||||
|
const mode = slotMediaMode(row.kind)
|
||||||
|
const baseLabel =
|
||||||
|
row.label?.trim() ||
|
||||||
|
(mode === "image" ? "Image" : mode === "audio" ? "Audio" : row.kind)
|
||||||
|
const slotLabel = `${baseLabel}${row.required ? " *" : ""}`
|
||||||
|
const slotMeta = `${row.id} · ${row.kind}`
|
||||||
|
|
||||||
|
if (mode === "text") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
||||||
|
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="URL, plain text, or JSON object"
|
||||||
|
className="min-h-[88px] resize-y rounded-lg border-grayScale-200 font-mono text-sm"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "image") {
|
||||||
|
return (
|
||||||
|
<DynamicImageSlot
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
slotLabel={slotLabel}
|
||||||
|
slotMeta={slotMeta}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DynamicAudioSlot
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
slotLabel={slotLabel}
|
||||||
|
slotMeta={slotMeta}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import { uploadAudioFile, uploadImageFile } from "../../api/files.api"
|
||||||
import {
|
import {
|
||||||
getQuestionTypeDefinitionById,
|
getQuestionTypeDefinitionById,
|
||||||
getQuestionTypeDefinitions,
|
getQuestionTypeDefinitions,
|
||||||
|
questionTypeDefinitionListLabel,
|
||||||
} from "../../api/questionTypeDefinitions.api"
|
} from "../../api/questionTypeDefinitions.api"
|
||||||
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
|
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
|
|
@ -22,6 +23,7 @@ import { SpinnerIcon } from "../ui/spinner-icon"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { ResolvedAudio } from "../media/ResolvedAudio"
|
import { ResolvedAudio } from "../media/ResolvedAudio"
|
||||||
import { ResolvedImage } from "../media/ResolvedImage"
|
import { ResolvedImage } from "../media/ResolvedImage"
|
||||||
|
import { DynamicSchemaSlotField } from "./DynamicSchemaSlotField"
|
||||||
|
|
||||||
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC"
|
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC"
|
||||||
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
|
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
|
||||||
|
|
@ -712,9 +714,9 @@ export function PracticeQuestionEditorFields({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-5 space-y-5">
|
<div className="mt-3 space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Question Text</label>
|
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Question Text</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={value.questionText}
|
value={value.questionText}
|
||||||
onChange={(e) => patch({ questionText: e.target.value })}
|
onChange={(e) => patch({ questionText: e.target.value })}
|
||||||
|
|
@ -731,10 +733,10 @@ export function PracticeQuestionEditorFields({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 lg:gap-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Type</label>
|
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Type</label>
|
||||||
<Select value={value.questionType} onChange={(e) => setType(e.target.value as PracticeQuestionEditorType)}>
|
<Select value={value.questionType} onChange={(e) => setType(e.target.value as PracticeQuestionEditorType)} className="h-9 text-sm">
|
||||||
<option value="MCQ">Multiple Choice</option>
|
<option value="MCQ">Multiple Choice</option>
|
||||||
<option value="TRUE_FALSE">True/False</option>
|
<option value="TRUE_FALSE">True/False</option>
|
||||||
<option value="SHORT">Short Answer</option>
|
<option value="SHORT">Short Answer</option>
|
||||||
|
|
@ -742,25 +744,29 @@ export function PracticeQuestionEditorFields({
|
||||||
<option value="DYNAMIC">Dynamic (schema-driven)</option>
|
<option value="DYNAMIC">Dynamic (schema-driven)</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Difficulty</label>
|
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Difficulty</label>
|
||||||
<Select
|
<Select
|
||||||
value={value.difficultyLevel}
|
value={value.difficultyLevel}
|
||||||
onChange={(e) => patch({ difficultyLevel: e.target.value as PracticeQuestionEditorDifficulty })}
|
onChange={(e) => patch({ difficultyLevel: e.target.value as PracticeQuestionEditorDifficulty })}
|
||||||
|
className="h-9 text-sm"
|
||||||
>
|
>
|
||||||
<option value="EASY">Easy</option>
|
<option value="EASY">Easy</option>
|
||||||
<option value="MEDIUM">Medium</option>
|
<option value="MEDIUM">Medium</option>
|
||||||
<option value="HARD">Hard</option>
|
<option value="HARD">Hard</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Points</label>
|
<label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Points</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={value.points}
|
value={value.points}
|
||||||
onChange={(e) => patch({ points: Number(e.target.value) || 1 })}
|
onChange={(e) => patch({ points: Number(e.target.value) || 1 })}
|
||||||
min={1}
|
min={1}
|
||||||
className={cn(showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined)}
|
className={cn(
|
||||||
|
"h-9 text-sm",
|
||||||
|
showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined,
|
||||||
|
)}
|
||||||
aria-invalid={Boolean(showFieldErrors && fieldErrors.points)}
|
aria-invalid={Boolean(showFieldErrors && fieldErrors.points)}
|
||||||
/>
|
/>
|
||||||
{showFieldErrors && fieldErrors.points ? (
|
{showFieldErrors && fieldErrors.points ? (
|
||||||
|
|
@ -770,12 +776,11 @@ export function PracticeQuestionEditorFields({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{value.questionType === "DYNAMIC" && (
|
{value.questionType === "DYNAMIC" && (
|
||||||
<div className="space-y-5 rounded-xl border border-violet-200 bg-violet-50/50 p-4 sm:p-5">
|
<div className="space-y-2 rounded-lg border border-violet-200 bg-violet-50/50 p-2.5 sm:p-3">
|
||||||
<p className="text-sm leading-relaxed text-grayScale-600">
|
<p className="text-xs leading-snug text-grayScale-600 sm:text-sm">
|
||||||
Pick a question type definition, then fill each stimulus/response slot. Element{" "}
|
<span className="font-medium text-grayScale-800">Image / Audio</span> slots: drop file or paste URL
|
||||||
<code className="rounded bg-white px-1 text-xs">id</code> and{" "}
|
(imports via <code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>). Other
|
||||||
<code className="rounded bg-white px-1 text-xs">kind</code> must match the definition schema. Use JSON
|
slots: text or JSON.
|
||||||
for object values (e.g. <code className="text-xs">{"{\"placeholder\":\"Type here\"}"}</code>).
|
|
||||||
</p>
|
</p>
|
||||||
<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">
|
||||||
|
|
@ -785,11 +790,12 @@ export function PracticeQuestionEditorFields({
|
||||||
value={value.questionTypeDefinitionId != null ? String(value.questionTypeDefinitionId) : ""}
|
value={value.questionTypeDefinitionId != null ? String(value.questionTypeDefinitionId) : ""}
|
||||||
onChange={(e) => void handleDynamicDefinitionChange(e.target.value)}
|
onChange={(e) => void handleDynamicDefinitionChange(e.target.value)}
|
||||||
disabled={definitionsLoading || definitionDetailLoading}
|
disabled={definitionsLoading || definitionDetailLoading}
|
||||||
|
className="h-9 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">{definitionsLoading ? "Loading definitions…" : "Select definition…"}</option>
|
<option value="">{definitionsLoading ? "Loading definitions…" : "Select definition…"}</option>
|
||||||
{typeDefinitions.map((d) => (
|
{typeDefinitions.map((d) => (
|
||||||
<option key={d.id} value={String(d.id)}>
|
<option key={d.id} value={String(d.id)}>
|
||||||
#{d.id} — {d.display_name} ({d.key})
|
{questionTypeDefinitionListLabel(d)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -798,52 +804,36 @@ export function PracticeQuestionEditorFields({
|
||||||
<p className="text-sm font-medium text-grayScale-500">Loading schema…</p>
|
<p className="text-sm font-medium text-grayScale-500">Loading schema…</p>
|
||||||
) : null}
|
) : null}
|
||||||
{value.dynamicStimulusRows.length > 0 ? (
|
{value.dynamicStimulusRows.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-violet-800">Stimulus</p>
|
<p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Stimulus</p>
|
||||||
{value.dynamicStimulusRows.map((row) => (
|
{value.dynamicStimulusRows.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={`stimulus-${row.id}`}
|
key={`stimulus-${row.id}`}
|
||||||
className="space-y-2 rounded-lg border border-grayScale-200 bg-white p-3 shadow-sm"
|
className="rounded-lg border border-grayScale-200 bg-white p-2.5 shadow-sm sm:p-3"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
<DynamicSchemaSlotField
|
||||||
<span className="text-sm font-semibold text-grayScale-900">{row.label || row.id}</span>
|
row={row}
|
||||||
<span className="text-[11px] font-mono text-grayScale-500">
|
|
||||||
{row.id} · {row.kind}
|
|
||||||
{row.required ? <span className="text-red-500"> *</span> : null}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
rows={3}
|
|
||||||
value={value.dynamicFieldValues[`stimulus:${row.id}`] ?? ""}
|
value={value.dynamicFieldValues[`stimulus:${row.id}`] ?? ""}
|
||||||
onChange={(e) => setDynamicField(`stimulus:${row.id}`, e.target.value)}
|
onChange={(next) => setDynamicField(`stimulus:${row.id}`, next)}
|
||||||
placeholder="URL, plain text, or JSON object"
|
disabled={controlsDisabled}
|
||||||
className="min-h-[72px] resize-y font-mono text-[13px]"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{value.dynamicResponseRows.length > 0 ? (
|
{value.dynamicResponseRows.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-violet-800">Response</p>
|
<p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Response</p>
|
||||||
{value.dynamicResponseRows.map((row) => (
|
{value.dynamicResponseRows.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={`response-${row.id}`}
|
key={`response-${row.id}`}
|
||||||
className="space-y-2 rounded-lg border border-grayScale-200 bg-white p-3 shadow-sm"
|
className="rounded-lg border border-grayScale-200 bg-white p-2.5 shadow-sm sm:p-3"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
<DynamicSchemaSlotField
|
||||||
<span className="text-sm font-semibold text-grayScale-900">{row.label || row.id}</span>
|
row={row}
|
||||||
<span className="text-[11px] font-mono text-grayScale-500">
|
|
||||||
{row.id} · {row.kind}
|
|
||||||
{row.required ? <span className="text-red-500"> *</span> : null}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
rows={3}
|
|
||||||
value={value.dynamicFieldValues[`response:${row.id}`] ?? ""}
|
value={value.dynamicFieldValues[`response:${row.id}`] ?? ""}
|
||||||
onChange={(e) => setDynamicField(`response:${row.id}`, e.target.value)}
|
onChange={(next) => setDynamicField(`response:${row.id}`, next)}
|
||||||
placeholder="URL, plain text, or JSON object"
|
disabled={controlsDisabled}
|
||||||
className="min-h-[72px] resize-y font-mono text-[13px]"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -853,7 +843,7 @@ export function PracticeQuestionEditorFields({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{value.questionType === "MCQ" && (
|
{value.questionType === "MCQ" && (
|
||||||
<div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4">
|
<div className="space-y-2 rounded-lg bg-grayScale-50/50 p-3">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label>
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{value.options.map((option, optIdx) => (
|
{value.options.map((option, optIdx) => (
|
||||||
|
|
@ -966,7 +956,7 @@ export function PracticeQuestionEditorFields({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
|
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
175
src/lib/learnEnglishDefinitionQuestion.ts
Normal file
175
src/lib/learnEnglishDefinitionQuestion.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import type { CreateQuestionRequest, QuestionOption } from "../types/course.types"
|
||||||
|
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
||||||
|
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
|
||||||
|
|
||||||
|
export function definitionUsesDynamicPayload(def: QuestionTypeDefinition): boolean {
|
||||||
|
return def.stimulus_schema.length > 0 || def.response_schema.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyDynamicFieldValuesForDefinition(
|
||||||
|
def: QuestionTypeDefinition,
|
||||||
|
): Record<string, string> {
|
||||||
|
const o: Record<string, string> = {}
|
||||||
|
for (const r of def.stimulus_schema) o[`stimulus:${r.id}`] = ""
|
||||||
|
for (const r of def.response_schema) o[`response:${r.id}`] = ""
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System definitions with empty schema map to classic POST /questions types.
|
||||||
|
* Returns null when the payload must be DYNAMIC (schema-driven or unknown).
|
||||||
|
*/
|
||||||
|
export function legacyQuestionTypeFromDefinition(
|
||||||
|
def: QuestionTypeDefinition,
|
||||||
|
): "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | null {
|
||||||
|
if (definitionUsesDynamicPayload(def)) return null
|
||||||
|
const k = def.key.toLowerCase()
|
||||||
|
if (k === "multiple_choice") return "MCQ"
|
||||||
|
if (k === "true_false") return "TRUE_FALSE"
|
||||||
|
if (k === "short_answer" || k === "fill_in_the_blank") return "SHORT_ANSWER"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LearnEnglishDefinitionQuestionInput {
|
||||||
|
questionText: string
|
||||||
|
questionTypeDefinitionId: number
|
||||||
|
dynamicFieldValues: Record<string, string>
|
||||||
|
mcqOptions?: { option_text: string; is_correct: boolean }[]
|
||||||
|
trueFalseAnswerIsTrue?: boolean
|
||||||
|
shortAnswers?: string[]
|
||||||
|
voicePromptUrl?: string
|
||||||
|
sampleAnswerVoiceUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCreateQuestionFromDefinition(
|
||||||
|
def: QuestionTypeDefinition,
|
||||||
|
q: LearnEnglishDefinitionQuestionInput,
|
||||||
|
status: "DRAFT" | "PUBLISHED",
|
||||||
|
): CreateQuestionRequest {
|
||||||
|
const difficulty = "EASY"
|
||||||
|
const points = 1
|
||||||
|
const question_text = q.questionText.trim()
|
||||||
|
|
||||||
|
if (definitionUsesDynamicPayload(def)) {
|
||||||
|
const payload = buildDynamicQuestionPayload({
|
||||||
|
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
|
||||||
|
responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })),
|
||||||
|
fieldValues: q.dynamicFieldValues ?? {},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
question_text,
|
||||||
|
question_type: "DYNAMIC",
|
||||||
|
question_type_definition_id: def.id,
|
||||||
|
difficulty_level: difficulty,
|
||||||
|
points,
|
||||||
|
status,
|
||||||
|
dynamic_payload: payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacy = legacyQuestionTypeFromDefinition(def)
|
||||||
|
if (legacy === "MCQ") {
|
||||||
|
const options: QuestionOption[] = (q.mcqOptions ?? [])
|
||||||
|
.filter((o) => o.option_text.trim())
|
||||||
|
.map((o, idx) => ({
|
||||||
|
option_order: idx + 1,
|
||||||
|
option_text: o.option_text.trim(),
|
||||||
|
is_correct: o.is_correct,
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
question_text,
|
||||||
|
question_type: "MCQ",
|
||||||
|
difficulty_level: difficulty,
|
||||||
|
points,
|
||||||
|
status,
|
||||||
|
options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (legacy === "TRUE_FALSE") {
|
||||||
|
const trueCorrect = q.trueFalseAnswerIsTrue !== false
|
||||||
|
const options: QuestionOption[] = [
|
||||||
|
{ option_order: 1, option_text: "True", is_correct: trueCorrect },
|
||||||
|
{ option_order: 2, option_text: "False", is_correct: !trueCorrect },
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
question_text,
|
||||||
|
question_type: "TRUE_FALSE",
|
||||||
|
difficulty_level: difficulty,
|
||||||
|
points,
|
||||||
|
status,
|
||||||
|
options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (legacy === "SHORT_ANSWER") {
|
||||||
|
const short_answers = (q.shortAnswers ?? [])
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((acceptable_answer) => ({
|
||||||
|
acceptable_answer,
|
||||||
|
match_type: "CASE_INSENSITIVE" as const,
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
question_text,
|
||||||
|
question_type: "SHORT_ANSWER",
|
||||||
|
difficulty_level: difficulty,
|
||||||
|
points,
|
||||||
|
status,
|
||||||
|
short_answers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No schema and no legacy key mapping: still create as DYNAMIC with empty payload + definition id
|
||||||
|
return {
|
||||||
|
question_text,
|
||||||
|
question_type: "DYNAMIC",
|
||||||
|
question_type_definition_id: def.id,
|
||||||
|
difficulty_level: difficulty,
|
||||||
|
points,
|
||||||
|
status,
|
||||||
|
dynamic_payload: { stimulus: [], response: [] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDefinitionQuestion(
|
||||||
|
def: QuestionTypeDefinition,
|
||||||
|
q: LearnEnglishDefinitionQuestionInput,
|
||||||
|
index1Based: number,
|
||||||
|
): string | null {
|
||||||
|
const n = index1Based
|
||||||
|
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
|
||||||
|
|
||||||
|
if (definitionUsesDynamicPayload(def)) {
|
||||||
|
for (const row of def.stimulus_schema) {
|
||||||
|
if (!row.required) continue
|
||||||
|
const v = (q.dynamicFieldValues ?? {})[`stimulus:${row.id}`]?.trim()
|
||||||
|
if (!v)
|
||||||
|
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
|
||||||
|
}
|
||||||
|
for (const row of def.response_schema) {
|
||||||
|
if (!row.required) continue
|
||||||
|
const v = (q.dynamicFieldValues ?? {})[`response:${row.id}`]?.trim()
|
||||||
|
if (!v)
|
||||||
|
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacy = legacyQuestionTypeFromDefinition(def)
|
||||||
|
if (legacy === "MCQ") {
|
||||||
|
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim())
|
||||||
|
if (opts.length < 2)
|
||||||
|
return `Question ${n} (${def.display_name}): add at least two choices with text.`
|
||||||
|
if (!opts.some((o) => o.is_correct))
|
||||||
|
return `Question ${n} (${def.display_name}): mark one correct choice.`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (legacy === "TRUE_FALSE") return null
|
||||||
|
if (legacy === "SHORT_ANSWER") {
|
||||||
|
const answers = (q.shortAnswers ?? []).map((s) => s.trim()).filter(Boolean)
|
||||||
|
if (answers.length < 1)
|
||||||
|
return `Question ${n} (${def.display_name}): add at least one acceptable answer.`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
135
src/lib/learnEnglishPracticePublish.ts
Normal file
135
src/lib/learnEnglishPracticePublish.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import type { AxiosError } from "axios"
|
||||||
|
import {
|
||||||
|
addQuestionToSet,
|
||||||
|
createParentLinkedPractice,
|
||||||
|
createQuestion,
|
||||||
|
createQuestionSet,
|
||||||
|
} from "../api/courses.api"
|
||||||
|
import type { PracticeParentKind } from "../types/course.types"
|
||||||
|
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
||||||
|
import {
|
||||||
|
buildCreateQuestionFromDefinition,
|
||||||
|
validateDefinitionQuestion,
|
||||||
|
type LearnEnglishDefinitionQuestionInput,
|
||||||
|
} from "./learnEnglishDefinitionQuestion"
|
||||||
|
|
||||||
|
export type { LearnEnglishDefinitionQuestionInput } from "./learnEnglishDefinitionQuestion"
|
||||||
|
|
||||||
|
export function learnEnglishPracticeApiErrorMessage(err: unknown): string {
|
||||||
|
const ax = err as AxiosError<{ message?: string; error?: string }>
|
||||||
|
const data = ax.response?.data
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
const m = data.message ?? data.error
|
||||||
|
if (typeof m === "string" && m.trim()) return m.trim()
|
||||||
|
}
|
||||||
|
if (err instanceof Error && err.message) return err.message
|
||||||
|
return "Request failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLearnEnglishQuestionsWithDefinitions(
|
||||||
|
questions: LearnEnglishDefinitionQuestionInput[],
|
||||||
|
definitions: QuestionTypeDefinition[],
|
||||||
|
): string | null {
|
||||||
|
const filled = questions.filter((q) => q.questionText.trim())
|
||||||
|
if (filled.length === 0) return "Add at least one question with prompt text."
|
||||||
|
const byId = new Map(definitions.map((d) => [d.id, d]))
|
||||||
|
for (let i = 0; i < filled.length; i++) {
|
||||||
|
const q = filled[i]
|
||||||
|
if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) {
|
||||||
|
return `Question ${i + 1}: select a question type from the list.`
|
||||||
|
}
|
||||||
|
const def = byId.get(q.questionTypeDefinitionId)
|
||||||
|
if (!def) {
|
||||||
|
return `Question ${i + 1}: type definition #${q.questionTypeDefinitionId} was not found. Refresh and try again.`
|
||||||
|
}
|
||||||
|
const err = validateDefinitionQuestion(def, q, i + 1)
|
||||||
|
if (err) return err
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Learn English parent-linked practice: create PRACTICE question set,
|
||||||
|
* create questions from GET /questions/type-definitions entries, attach them, POST /practices.
|
||||||
|
*/
|
||||||
|
export async function executeLearnEnglishPracticeCreation(opts: {
|
||||||
|
parentKind: PracticeParentKind
|
||||||
|
parentId: number
|
||||||
|
status: "DRAFT" | "PUBLISHED"
|
||||||
|
questionSetTitle: string
|
||||||
|
questionSetDescription?: string | null
|
||||||
|
shuffleQuestions: boolean
|
||||||
|
practiceTitle: string
|
||||||
|
storyDescription: string
|
||||||
|
storyImage: string
|
||||||
|
quickTips: string
|
||||||
|
questions: LearnEnglishDefinitionQuestionInput[]
|
||||||
|
definitions: QuestionTypeDefinition[]
|
||||||
|
}): Promise<{ questionSetId: number; practiceId: number }> {
|
||||||
|
const err = validateLearnEnglishQuestionsWithDefinitions(
|
||||||
|
opts.questions,
|
||||||
|
opts.definitions,
|
||||||
|
)
|
||||||
|
if (err) throw new Error(err)
|
||||||
|
|
||||||
|
const byId = new Map(opts.definitions.map((d) => [d.id, d]))
|
||||||
|
|
||||||
|
const setRes = await createQuestionSet({
|
||||||
|
title: opts.questionSetTitle.trim() || "Practice question set",
|
||||||
|
description: opts.questionSetDescription?.trim() || null,
|
||||||
|
set_type: "PRACTICE",
|
||||||
|
owner_type: opts.parentKind,
|
||||||
|
owner_id: opts.parentId,
|
||||||
|
shuffle_questions: opts.shuffleQuestions,
|
||||||
|
status: opts.status,
|
||||||
|
})
|
||||||
|
|
||||||
|
const setId = setRes.data?.data?.id
|
||||||
|
if (!setId) {
|
||||||
|
throw new Error(
|
||||||
|
(setRes.data as { message?: string } | undefined)?.message ??
|
||||||
|
"Could not create question set",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCreate = opts.questions.filter((q) => q.questionText.trim())
|
||||||
|
let displayOrder = 0
|
||||||
|
for (const q of toCreate) {
|
||||||
|
const def = byId.get(q.questionTypeDefinitionId)
|
||||||
|
if (!def) throw new Error(`Missing definition #${q.questionTypeDefinitionId}`)
|
||||||
|
displayOrder += 1
|
||||||
|
const payload = buildCreateQuestionFromDefinition(def, q, opts.status)
|
||||||
|
const qRes = await createQuestion(payload)
|
||||||
|
const questionId = qRes.data?.data?.id
|
||||||
|
if (!questionId) {
|
||||||
|
throw new Error(
|
||||||
|
(qRes.data as { message?: string } | undefined)?.message ??
|
||||||
|
"Could not create question",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await addQuestionToSet(setId, {
|
||||||
|
question_id: questionId,
|
||||||
|
display_order: displayOrder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const practiceRes = await createParentLinkedPractice({
|
||||||
|
parent_kind: opts.parentKind,
|
||||||
|
parent_id: opts.parentId,
|
||||||
|
title: opts.practiceTitle.trim(),
|
||||||
|
story_description: opts.storyDescription.trim(),
|
||||||
|
story_image: opts.storyImage.trim(),
|
||||||
|
question_set_id: setId,
|
||||||
|
quick_tips: opts.quickTips.trim(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const practiceId = practiceRes.data?.data?.id
|
||||||
|
if (!practiceId) {
|
||||||
|
throw new Error(
|
||||||
|
(practiceRes.data as { message?: string } | undefined)?.message ??
|
||||||
|
"Could not create practice",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { questionSetId: setId, practiceId }
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Link,
|
Link,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
|
|
@ -6,16 +6,27 @@ import {
|
||||||
useSearchParams,
|
useSearchParams,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Stepper } from "../../components/ui/stepper";
|
import { Stepper } from "../../components/ui/stepper";
|
||||||
import successIcon from "../../assets/success.svg";
|
import successIcon from "../../assets/success.svg";
|
||||||
|
import type { PracticeParentKind } from "../../types/course.types";
|
||||||
|
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types";
|
||||||
|
import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api";
|
||||||
|
import { emptyDynamicFieldValuesForDefinition } from "../../lib/learnEnglishDefinitionQuestion";
|
||||||
|
import {
|
||||||
|
executeLearnEnglishPracticeCreation,
|
||||||
|
learnEnglishPracticeApiErrorMessage,
|
||||||
|
validateLearnEnglishQuestionsWithDefinitions,
|
||||||
|
} from "../../lib/learnEnglishPracticePublish";
|
||||||
|
|
||||||
import { ContextStep } from "./components/practice-steps/ContextStep";
|
import { ContextStep } from "./components/practice-steps/ContextStep";
|
||||||
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
|
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
|
||||||
import { PersonaStep } from "./components/practice-steps/PersonaStep";
|
|
||||||
import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
|
import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
|
||||||
import { ReviewStep } from "./components/practice-steps/ReviewStep";
|
import { ReviewStep } from "./components/practice-steps/ReviewStep";
|
||||||
|
|
||||||
|
const STEP_LABELS = ["Practice", "Questions", "Review"] as const;
|
||||||
|
|
||||||
export function AddPracticeFlow() {
|
export function AddPracticeFlow() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { level } = useParams<{ level: string }>();
|
const { level } = useParams<{ level: string }>();
|
||||||
|
|
@ -38,6 +49,36 @@ export function AddPracticeFlow() {
|
||||||
const isModuleContext = backTo === "module";
|
const isModuleContext = backTo === "module";
|
||||||
const isCourseContext = backTo === "modules";
|
const isCourseContext = backTo === "modules";
|
||||||
|
|
||||||
|
const parentContext = useMemo((): {
|
||||||
|
kind: PracticeParentKind;
|
||||||
|
id: number;
|
||||||
|
} | null => {
|
||||||
|
const lid = lessonId ? Number(lessonId) : NaN;
|
||||||
|
if (Number.isFinite(lid) && lid > 0) return { kind: "LESSON", id: lid };
|
||||||
|
const mid = moduleId ? Number(moduleId) : NaN;
|
||||||
|
if (isModuleContext && Number.isFinite(mid) && mid > 0)
|
||||||
|
return { kind: "MODULE", id: mid };
|
||||||
|
const cid = courseId ? Number(courseId) : NaN;
|
||||||
|
if (isCourseContext && Number.isFinite(cid) && cid > 0)
|
||||||
|
return { kind: "COURSE", id: cid };
|
||||||
|
return null;
|
||||||
|
}, [lessonId, moduleId, courseId, isModuleContext, isCourseContext]);
|
||||||
|
|
||||||
|
const parentSummary = useMemo(() => {
|
||||||
|
if (lessonId)
|
||||||
|
return `Lesson #${lessonId}${lessonTitleDisplay ? ` — ${lessonTitleDisplay}` : ""}`;
|
||||||
|
if (isModuleContext && moduleId) return `Module #${moduleId}`;
|
||||||
|
if (isCourseContext && courseId) return `Course #${courseId}`;
|
||||||
|
return null;
|
||||||
|
}, [
|
||||||
|
lessonId,
|
||||||
|
lessonTitleDisplay,
|
||||||
|
isModuleContext,
|
||||||
|
isCourseContext,
|
||||||
|
moduleId,
|
||||||
|
courseId,
|
||||||
|
]);
|
||||||
|
|
||||||
const backLabel =
|
const backLabel =
|
||||||
backTo === "module"
|
backTo === "module"
|
||||||
? "Back to Module"
|
? "Back to Module"
|
||||||
|
|
@ -51,36 +92,155 @@ export function AddPracticeFlow() {
|
||||||
? `/new-content/learn-english/${level}/courses/${courseId}`
|
? `/new-content/learn-english/${level}/courses/${courseId}`
|
||||||
: `/new-content/learn-english/${level}/courses`;
|
: `/new-content/learn-english/${level}/courses`;
|
||||||
|
|
||||||
const flowSteps = isModuleContext
|
|
||||||
? ["Context", "Persona", "Questions", "Review"]
|
|
||||||
: ["Context", "Scenario", "Persona", "Questions", "Review"];
|
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [selectedPersona, setSelectedPersona] = useState<string | null>(
|
|
||||||
"dawit",
|
|
||||||
);
|
|
||||||
const [isPublished, setIsPublished] = useState(false);
|
const [isPublished, setIsPublished] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
program: "Intermediate",
|
|
||||||
course: "A2",
|
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
selectedVideo: "",
|
storyImageUrl: "",
|
||||||
tips: "Focus on using the present perfect continuous tense to describe an action that started in the past and continues now.",
|
shuffleQuestions: false,
|
||||||
|
tips: "",
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
text: "How long have you been studying English?",
|
questionTypeDefinitionId: null as number | null,
|
||||||
type: "Speaking",
|
text: "",
|
||||||
voicePrompt: "prompt_q1_en.mp3",
|
dynamicFieldValues: {} as Record<string, string>,
|
||||||
sampleAnswer: "prompt_q1_en.mp3",
|
mcqOptions: [
|
||||||
|
{ text: "", isCorrect: true },
|
||||||
|
{ text: "", isCorrect: false },
|
||||||
|
{ text: "", isCorrect: false },
|
||||||
|
{ text: "", isCorrect: false },
|
||||||
|
],
|
||||||
|
trueFalseCorrect: true,
|
||||||
|
shortAnswers: [""],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [definitionsLoading, setDefinitionsLoading] = useState(true);
|
||||||
|
const [definitionsError, setDefinitionsError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setDefinitionsLoading(true);
|
||||||
|
setDefinitionsError(null);
|
||||||
|
try {
|
||||||
|
const list = await getQuestionTypeDefinitions({
|
||||||
|
include_system: true,
|
||||||
|
status: "ACTIVE",
|
||||||
|
});
|
||||||
|
if (!cancelled) setTypeDefinitions(list);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setDefinitionsError(learnEnglishPracticeApiErrorMessage(e));
|
||||||
|
setTypeDefinitions([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setDefinitionsLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeDefinitions.length === 0) return;
|
||||||
|
setFormData((fd) => ({
|
||||||
|
...fd,
|
||||||
|
questions: fd.questions.map((q) => {
|
||||||
|
if (q.questionTypeDefinitionId != null) return q;
|
||||||
|
const def = typeDefinitions[0];
|
||||||
|
return {
|
||||||
|
...q,
|
||||||
|
questionTypeDefinitionId: def.id,
|
||||||
|
dynamicFieldValues: emptyDynamicFieldValuesForDefinition(def),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}, [typeDefinitions]);
|
||||||
|
|
||||||
|
const submitPractice = async (status: "DRAFT" | "PUBLISHED") => {
|
||||||
|
if (!parentContext) {
|
||||||
|
toast.error("Missing practice parent", {
|
||||||
|
description:
|
||||||
|
"Open this screen from a course, module, or lesson so the API receives parent_kind and parent_id.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.title.trim() || !formData.description.trim()) {
|
||||||
|
toast.error("Title and story description are required", {
|
||||||
|
description: "Complete the first step before publishing.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mappedQuestions = formData.questions
|
||||||
|
.filter((q) => String(q.text ?? "").trim())
|
||||||
|
.map((q) => ({
|
||||||
|
questionText: String(q.text ?? "").trim(),
|
||||||
|
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
|
||||||
|
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
|
||||||
|
mcqOptions: (q.mcqOptions ?? []).map(
|
||||||
|
(o: { text?: string; isCorrect?: boolean }) => ({
|
||||||
|
option_text: String(o.text ?? "").trim(),
|
||||||
|
is_correct: Boolean(o.isCorrect),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
trueFalseAnswerIsTrue: q.trueFalseCorrect !== false,
|
||||||
|
shortAnswers: (q.shortAnswers ?? []).map((s: string) => String(s)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const validationMsg = validateLearnEnglishQuestionsWithDefinitions(
|
||||||
|
mappedQuestions,
|
||||||
|
typeDefinitions,
|
||||||
|
);
|
||||||
|
if (validationMsg) {
|
||||||
|
toast.error("Check your questions", { description: validationMsg });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await executeLearnEnglishPracticeCreation({
|
||||||
|
parentKind: parentContext.kind,
|
||||||
|
parentId: parentContext.id,
|
||||||
|
status,
|
||||||
|
questionSetTitle: formData.title.trim() || "Practice set",
|
||||||
|
questionSetDescription: formData.description.trim() || null,
|
||||||
|
shuffleQuestions: formData.shuffleQuestions,
|
||||||
|
practiceTitle: formData.title.trim() || "Untitled practice",
|
||||||
|
storyDescription: formData.description.trim(),
|
||||||
|
storyImage: formData.storyImageUrl.trim(),
|
||||||
|
quickTips: formData.tips.trim(),
|
||||||
|
questions: mappedQuestions,
|
||||||
|
definitions: typeDefinitions,
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
status === "PUBLISHED" ? "Practice published" : "Draft saved",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Question set, questions, and parent-linked practice were created.",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setIsPublished(true);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not save practice", {
|
||||||
|
description: learnEnglishPracticeApiErrorMessage(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const nextStep = () =>
|
const nextStep = () =>
|
||||||
setCurrentStep((prev) => Math.min(prev + 1, flowSteps.length));
|
setCurrentStep((prev) => Math.min(prev + 1, STEP_LABELS.length));
|
||||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||||
|
|
||||||
if (isPublished) {
|
if (isPublished) {
|
||||||
|
|
@ -98,23 +258,46 @@ export function AddPracticeFlow() {
|
||||||
Practice Published Successfully!
|
Practice Published Successfully!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
|
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
|
||||||
Your speaking practice is now active and available inside the module.
|
{lessonId
|
||||||
|
? "Your speaking practice is saved and linked to this lesson’s question set."
|
||||||
|
: "Your speaking practice is saved for the linked course or module."}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate(backPath)}
|
onClick={() => navigate(backPath)}
|
||||||
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
|
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
|
||||||
>
|
>
|
||||||
Go back to Module
|
{backLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsPublished(false);
|
setIsPublished(false);
|
||||||
setCurrentStep(1);
|
setCurrentStep(1);
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
storyImageUrl: "",
|
||||||
|
shuffleQuestions: false,
|
||||||
|
tips: "",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
questionTypeDefinitionId:
|
||||||
|
typeDefinitions[0]?.id ?? (null as number | null),
|
||||||
|
text: "",
|
||||||
|
dynamicFieldValues: typeDefinitions[0]
|
||||||
|
? emptyDynamicFieldValuesForDefinition(typeDefinitions[0])
|
||||||
|
: {},
|
||||||
|
mcqOptions: [
|
||||||
|
{ text: "", isCorrect: true },
|
||||||
|
{ text: "", isCorrect: false },
|
||||||
|
{ text: "", isCorrect: false },
|
||||||
|
{ text: "", isCorrect: false },
|
||||||
|
],
|
||||||
|
trueFalseCorrect: true,
|
||||||
|
shortAnswers: [""],
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -127,9 +310,8 @@ export function AddPracticeFlow() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to map currentStep to the actual component for the module flow
|
|
||||||
const renderStep = () => {
|
const renderStep = () => {
|
||||||
if (!isModuleContext) {
|
if (isModuleContext) {
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 1:
|
case 1:
|
||||||
return (
|
return (
|
||||||
|
|
@ -139,102 +321,80 @@ export function AddPracticeFlow() {
|
||||||
nextStep={nextStep}
|
nextStep={nextStep}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
level={level!}
|
level={level!}
|
||||||
isModuleContext={isModuleContext}
|
|
||||||
isCourseContext={isCourseContext}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return (
|
|
||||||
<ScenarioStep
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
nextStep={nextStep}
|
|
||||||
prevStep={prevStep}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 3:
|
|
||||||
return (
|
|
||||||
<PersonaStep
|
|
||||||
selectedPersona={selectedPersona}
|
|
||||||
setSelectedPersona={setSelectedPersona}
|
|
||||||
nextStep={nextStep}
|
|
||||||
prevStep={prevStep}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 4:
|
|
||||||
return (
|
return (
|
||||||
<QuestionsStep
|
<QuestionsStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
setFormData={setFormData}
|
setFormData={setFormData}
|
||||||
nextStep={nextStep}
|
nextStep={nextStep}
|
||||||
prevStep={prevStep}
|
prevStep={prevStep}
|
||||||
/>
|
typeDefinitions={typeDefinitions}
|
||||||
);
|
definitionsLoading={definitionsLoading}
|
||||||
case 5:
|
definitionsError={definitionsError}
|
||||||
return (
|
|
||||||
<ReviewStep
|
|
||||||
formData={formData}
|
|
||||||
selectedPersona={selectedPersona}
|
|
||||||
prevStep={prevStep}
|
|
||||||
setIsPublished={setIsPublished}
|
|
||||||
isModuleContext={isModuleContext}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Module Context Flow (Skips Scenario)
|
|
||||||
switch (currentStep) {
|
|
||||||
case 1:
|
|
||||||
return (
|
|
||||||
<ContextStep
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
nextStep={nextStep}
|
|
||||||
navigate={navigate}
|
|
||||||
level={level!}
|
|
||||||
isModuleContext={isModuleContext}
|
|
||||||
isCourseContext={isCourseContext}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 2:
|
|
||||||
return (
|
|
||||||
<PersonaStep
|
|
||||||
selectedPersona={selectedPersona}
|
|
||||||
setSelectedPersona={setSelectedPersona}
|
|
||||||
nextStep={nextStep}
|
|
||||||
prevStep={prevStep}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
return (
|
|
||||||
<QuestionsStep
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
nextStep={nextStep}
|
|
||||||
prevStep={prevStep}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 4:
|
|
||||||
return (
|
return (
|
||||||
<ReviewStep
|
<ReviewStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
selectedPersona={selectedPersona}
|
|
||||||
prevStep={prevStep}
|
prevStep={prevStep}
|
||||||
setIsPublished={setIsPublished}
|
parentSummary={parentSummary}
|
||||||
isModuleContext={isModuleContext}
|
typeDefinitions={typeDefinitions}
|
||||||
|
canPublish={parentContext !== null}
|
||||||
|
submitting={submitting}
|
||||||
|
onSaveDraft={() => void submitPractice("DRAFT")}
|
||||||
|
onPublish={() => void submitPractice("PUBLISHED")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<ScenarioStep
|
||||||
|
formData={formData}
|
||||||
|
setFormData={setFormData}
|
||||||
|
nextStep={nextStep}
|
||||||
|
cancelHref={backPath}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<QuestionsStep
|
||||||
|
formData={formData}
|
||||||
|
setFormData={setFormData}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
typeDefinitions={typeDefinitions}
|
||||||
|
definitionsLoading={definitionsLoading}
|
||||||
|
definitionsError={definitionsError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<ReviewStep
|
||||||
|
formData={formData}
|
||||||
|
prevStep={prevStep}
|
||||||
|
parentSummary={parentSummary}
|
||||||
|
typeDefinitions={typeDefinitions}
|
||||||
|
canPublish={parentContext !== null}
|
||||||
|
submitting={submitting}
|
||||||
|
onSaveDraft={() => void submitPractice("DRAFT")}
|
||||||
|
onPublish={() => void submitPractice("PUBLISHED")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
|
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
|
||||||
{/* Header */}
|
|
||||||
<div className="mx-auto max-w-7xl w-full">
|
<div className="mx-auto max-w-7xl w-full">
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -260,33 +420,36 @@ export function AddPracticeFlow() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-grayScale-400 text-base">
|
<p className="text-grayScale-400 text-base">
|
||||||
Create a new immersive practice session for students.
|
Create a practice: question types from{" "}
|
||||||
|
<code className="text-xs">GET /questions/type-definitions</code>, then
|
||||||
|
question set and POST /practices.
|
||||||
</p>
|
</p>
|
||||||
{lessonId ? (
|
{lessonId ? (
|
||||||
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950">
|
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950">
|
||||||
<p className="font-semibold text-violet-900">Practice for this lesson</p>
|
<p className="font-semibold text-violet-900">Lesson practice</p>
|
||||||
<p className="mt-1 text-violet-800/90">
|
<p className="mt-1 text-violet-800/90">
|
||||||
This session will be associated with lesson{" "}
|
Linked to lesson{" "}
|
||||||
<span className="font-mono font-bold text-violet-950">#{lessonId}</span>
|
<span className="font-mono font-bold text-violet-950">
|
||||||
|
#{lessonId}
|
||||||
|
</span>
|
||||||
{lessonTitleDisplay ? (
|
{lessonTitleDisplay ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
— <span className="font-medium">{lessonTitleDisplay}</span>
|
— <span className="font-medium">{lessonTitleDisplay}</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
. The module-level flow still uses the same steps; use this context when naming and
|
.
|
||||||
configuring the practice.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto w-[70%] mb-12">
|
<div className="mx-auto w-[70%] mb-12">
|
||||||
<Stepper steps={flowSteps} currentStep={currentStep} />
|
<Stepper steps={[...STEP_LABELS]} currentStep={currentStep} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`mx-auto ${(!isModuleContext && currentStep === 3) || (isModuleContext && currentStep === 2) || currentStep === 5 ? "max-w-6xl" : "max-w-4xl"}`}
|
className={`mx-auto ${currentStep === 2 ? "max-w-6xl" : "max-w-4xl"}`}
|
||||||
>
|
>
|
||||||
{renderStep()}
|
{renderStep()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ import { Input } from "../../components/ui/input"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
import { Select } from "../../components/ui/select"
|
import { Select } from "../../components/ui/select"
|
||||||
import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api"
|
import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api"
|
||||||
import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api"
|
import {
|
||||||
|
getQuestionTypeDefinitions,
|
||||||
|
questionTypeDefinitionListLabel,
|
||||||
|
} from "../../api/questionTypeDefinitions.api"
|
||||||
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
|
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
|
||||||
|
|
||||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | "DYNAMIC"
|
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | "DYNAMIC"
|
||||||
|
|
@ -343,47 +346,46 @@ export function AddQuestionPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-4 pb-6">
|
||||||
{/* Page Header */}
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate("/content/questions")}
|
onClick={() => navigate("/content/questions")}
|
||||||
className="rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500 transition-colors"
|
className="h-9 w-9 shrink-0 rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600">
|
<h1 className="text-lg font-bold tracking-tight text-grayScale-800 sm:text-xl">
|
||||||
{isEditing ? "Edit Question" : "Add New Question"}
|
{isEditing ? "Edit Question" : "Add New Question"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
<p className="mt-0.5 text-xs text-grayScale-500 sm:text-sm">
|
||||||
{isEditing ? "Update the question details below" : "Fill in the details to create a new question"}
|
{isEditing ? "Update fields below" : "Create a bank question"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="mx-auto max-w-2xl">
|
||||||
{loading && (
|
{loading && (
|
||||||
<Card className="mb-4 border border-grayScale-200">
|
<Card className="mb-2 border border-grayScale-200">
|
||||||
<CardContent className="py-4 text-sm text-grayScale-500">Loading question details...</CardContent>
|
<CardContent className="py-2.5 text-xs text-grayScale-500">Loading…</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Card className="shadow-sm border border-grayScale-100 rounded-xl">
|
<Card className="rounded-lg border border-grayScale-100 shadow-sm">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="space-y-0 px-4 py-3 sm:px-5">
|
||||||
<CardTitle className="text-lg font-semibold text-grayScale-600">Question Details</CardTitle>
|
<CardTitle className="text-base font-semibold text-grayScale-700">Question details</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-7">
|
<CardContent className="space-y-3 px-4 pb-4 pt-0 sm:px-5 sm:pb-5">
|
||||||
{/* Question Type */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||||
Question Type
|
Question Type
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
|
onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
|
||||||
|
className="h-9 text-sm"
|
||||||
>
|
>
|
||||||
<option value="MCQ">Multiple Choice</option>
|
<option value="MCQ">Multiple Choice</option>
|
||||||
<option value="TRUE_FALSE">True/False</option>
|
<option value="TRUE_FALSE">True/False</option>
|
||||||
|
|
@ -396,7 +398,7 @@ export function AddQuestionPage() {
|
||||||
{formData.type === "DYNAMIC" && (
|
{formData.type === "DYNAMIC" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||||
Question type definition <span className="text-red-500">*</span>
|
Question type definition <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -405,49 +407,44 @@ export function AddQuestionPage() {
|
||||||
setFormData((prev) => ({ ...prev, questionTypeDefinitionId: e.target.value }))
|
setFormData((prev) => ({ ...prev, questionTypeDefinitionId: e.target.value }))
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
|
className="h-9 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">Select definition…</option>
|
<option value="">Select definition…</option>
|
||||||
{typeDefinitions.map((d) => (
|
{typeDefinitions.map((d) => (
|
||||||
<option key={d.id} value={String(d.id)}>
|
<option key={d.id} value={String(d.id)}>
|
||||||
{d.display_name} ({d.key})
|
{questionTypeDefinitionListLabel(d)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<p className="mt-1 text-xs text-grayScale-400">
|
|
||||||
Loaded from GET /questions/type-definitions?include_system=true&status=ACTIVE
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||||
dynamic_payload (JSON) <span className="text-red-500">*</span>
|
dynamic_payload (JSON) <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={formData.dynamicPayloadJson}
|
value={formData.dynamicPayloadJson}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, dynamicPayloadJson: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, dynamicPayloadJson: e.target.value }))}
|
||||||
rows={12}
|
rows={7}
|
||||||
className="font-mono text-xs"
|
className="min-h-0 font-mono text-[11px] leading-snug"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-grayScale-400">
|
|
||||||
Must match the selected definition's stimulus/response schema (see integration guide).
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<hr className="border-grayScale-100" />
|
<hr className="border-grayScale-100" />
|
||||||
|
|
||||||
{/* Question Text */}
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="question" className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label htmlFor="question" className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||||
{formData.type === "DYNAMIC" ? "Question title / stem" : "Question"}
|
{formData.type === "DYNAMIC" ? "Title / stem" : "Question"}
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="question"
|
id="question"
|
||||||
placeholder="Enter your question here..."
|
placeholder="Enter your question here..."
|
||||||
value={formData.question}
|
value={formData.question}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, question: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, question: e.target.value }))}
|
||||||
rows={3}
|
rows={2}
|
||||||
|
className="min-h-[72px] text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -455,13 +452,11 @@ export function AddQuestionPage() {
|
||||||
{/* Options for Multiple Choice */}
|
{/* Options for Multiple Choice */}
|
||||||
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
|
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label className="mb-1 block text-xs font-medium text-grayScale-600">Options</label>
|
||||||
Options
|
<div className="space-y-1.5">
|
||||||
</label>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{formData.options.map((option, index) => (
|
{formData.options.map((option, index) => (
|
||||||
<div key={index} className="flex items-center gap-2 group">
|
<div key={index} className="group flex items-center gap-1.5">
|
||||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-grayScale-50 text-grayScale-400 text-xs font-medium flex items-center justify-center">
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-grayScale-100 text-[10px] font-medium text-grayScale-500">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -469,6 +464,7 @@ export function AddQuestionPage() {
|
||||||
onChange={(e) => handleOptionChange(index, e.target.value)}
|
onChange={(e) => handleOptionChange(index, e.target.value)}
|
||||||
placeholder={`Option ${index + 1}`}
|
placeholder={`Option ${index + 1}`}
|
||||||
disabled={formData.type === "TRUE_FALSE"}
|
disabled={formData.type === "TRUE_FALSE"}
|
||||||
|
className="h-9 text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{formData.type === "MCQ" && formData.options.length > 2 && (
|
{formData.type === "MCQ" && formData.options.length > 2 && (
|
||||||
|
|
@ -477,17 +473,22 @@ export function AddQuestionPage() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => removeOption(index)}
|
onClick={() => removeOption(index)}
|
||||||
className="opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-500 transition-all"
|
className="h-8 w-8 shrink-0 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-50 hover:text-red-500"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{formData.type === "MCQ" && (
|
{formData.type === "MCQ" && (
|
||||||
<Button type="button" variant="outline" onClick={addOption} className="w-full mt-1 border-dashed border-grayScale-200 text-grayScale-400 hover:text-brand-500 hover:border-brand-500/30">
|
<Button
|
||||||
<Plus className="h-4 w-4" />
|
type="button"
|
||||||
Add Option
|
variant="outline"
|
||||||
|
onClick={addOption}
|
||||||
|
className="mt-0.5 h-9 w-full border-dashed border-grayScale-200 text-xs text-grayScale-500 hover:border-brand-500/30 hover:text-brand-500"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Add option
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -499,8 +500,8 @@ export function AddQuestionPage() {
|
||||||
{/* Correct Answer */}
|
{/* Correct Answer */}
|
||||||
{formData.type !== "DYNAMIC" && (
|
{formData.type !== "DYNAMIC" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||||
{formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"}
|
{formData.type === "AUDIO" ? "Audio correct answer" : "Correct answer"}
|
||||||
</label>
|
</label>
|
||||||
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
|
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -508,6 +509,7 @@ export function AddQuestionPage() {
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
|
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
|
||||||
}
|
}
|
||||||
|
className="h-9 text-sm"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select correct answer</option>
|
<option value="">Select correct answer</option>
|
||||||
|
|
@ -519,7 +521,7 @@ export function AddQuestionPage() {
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={formData.type === "AUDIO" ? "Enter audio correct answer text..." : "Enter the correct answer..."}
|
placeholder={formData.type === "AUDIO" ? "Expected spoken answer…" : "Correct answer…"}
|
||||||
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
|
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) =>
|
setFormData((prev) =>
|
||||||
|
|
@ -529,6 +531,7 @@ export function AddQuestionPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
rows={2}
|
rows={2}
|
||||||
|
className="min-h-[60px] text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -538,16 +541,16 @@ export function AddQuestionPage() {
|
||||||
<hr className="border-grayScale-100" />
|
<hr className="border-grayScale-100" />
|
||||||
|
|
||||||
{/* Points and Difficulty side by side */}
|
{/* Points and Difficulty side by side */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||||
{/* Points */}
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="points" className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label htmlFor="points" className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||||
Points
|
Points
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="points"
|
id="points"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
className="h-9 text-sm"
|
||||||
value={formData.points}
|
value={formData.points}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 }))
|
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 }))
|
||||||
|
|
@ -556,14 +559,12 @@ export function AddQuestionPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Difficulty */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label className="mb-1 block text-xs font-medium text-grayScale-600">Difficulty</label>
|
||||||
Difficulty (Optional)
|
|
||||||
</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={formData.difficulty}
|
value={formData.difficulty}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))}
|
||||||
|
className="h-9 text-sm"
|
||||||
>
|
>
|
||||||
<option value="EASY">Easy</option>
|
<option value="EASY">Easy</option>
|
||||||
<option value="MEDIUM">Medium</option>
|
<option value="MEDIUM">Medium</option>
|
||||||
|
|
@ -574,12 +575,11 @@ export function AddQuestionPage() {
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label className="mb-1 block text-xs font-medium text-grayScale-600">Status</label>
|
||||||
Status
|
|
||||||
</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={formData.status}
|
value={formData.status}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as QuestionStatus }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as QuestionStatus }))}
|
||||||
|
className="h-9 text-sm"
|
||||||
>
|
>
|
||||||
<option value="DRAFT">Draft</option>
|
<option value="DRAFT">Draft</option>
|
||||||
<option value="PUBLISHED">Published</option>
|
<option value="PUBLISHED">Published</option>
|
||||||
|
|
@ -588,58 +588,71 @@ export function AddQuestionPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(formData.type === "AUDIO" || formData.type === "SHORT_ANSWER") && (
|
{(formData.type === "AUDIO" || formData.type === "SHORT_ANSWER") && (
|
||||||
<>
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||||
Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
|
Voice prompt{formData.type === "AUDIO" ? "" : " (opt.)"}
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={formData.voicePrompt}
|
value={formData.voicePrompt}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="Please say your answer..."
|
placeholder="URL or key…"
|
||||||
|
className="min-h-[60px] text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">
|
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
||||||
Sample Answer Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"}
|
Sample answer (voice){formData.type === "AUDIO" ? "" : " (opt.)"}
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={formData.sampleAnswerVoicePrompt}
|
value={formData.sampleAnswerVoicePrompt}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="Sample spoken answer..."
|
placeholder="URL or key…"
|
||||||
|
className="min-h-[60px] text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Tips (Optional)</label>
|
<div>
|
||||||
<Input
|
<label className="mb-1 block text-xs font-medium text-grayScale-600">Tips (opt.)</label>
|
||||||
value={formData.tips}
|
<Input
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
|
value={formData.tips}
|
||||||
placeholder="Helpful tip for learners"
|
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
|
||||||
/>
|
placeholder="Short tip"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-grayScale-600">Explanation (opt.)</label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.explanation}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Why this answer"
|
||||||
|
className="min-h-[60px] text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="flex flex-col-reverse gap-2 border-t border-grayScale-100 pt-3 sm:flex-row sm:justify-end">
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Explanation (Optional)</label>
|
<Button
|
||||||
<Textarea
|
type="button"
|
||||||
value={formData.explanation}
|
variant="outline"
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))}
|
onClick={() => navigate("/content/questions")}
|
||||||
rows={2}
|
className="h-9 w-full text-sm sm:w-auto"
|
||||||
placeholder="Explain why the answer is correct"
|
>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex flex-col-reverse sm:flex-row justify-end gap-3 pt-6 border-t border-grayScale-100">
|
|
||||||
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")} className="w-full sm:w-auto hover:bg-grayScale-50">
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={submitting || loading} className="bg-brand-500 hover:bg-brand-600 text-white w-full sm:w-auto shadow-sm hover:shadow-md transition-all">
|
<Button
|
||||||
{isEditing ? "Update Question" : "Create Question"}
|
type="submit"
|
||||||
|
disabled={submitting || loading}
|
||||||
|
className="h-9 w-full bg-brand-500 text-sm text-white hover:bg-brand-600 sm:w-auto"
|
||||||
|
>
|
||||||
|
{isEditing ? "Update" : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
379
src/pages/content-management/LessonPracticesPage.tsx
Normal file
379
src/pages/content-management/LessonPracticesPage.tsx
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowLeft,
|
||||||
|
BookOpen,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Hash,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getPracticesByParentLesson } from "../../api/courses.api";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { Card, CardContent } from "../../components/ui/card";
|
||||||
|
import type {
|
||||||
|
GetPracticesByParentContextResponse,
|
||||||
|
ParentContextPractice,
|
||||||
|
} from "../../types/course.types";
|
||||||
|
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
function unwrapPracticesEnvelope(
|
||||||
|
res: { data?: GetPracticesByParentContextResponse & { Data?: GetPracticesByParentContextResponse["data"] } },
|
||||||
|
): GetPracticesByParentContextResponse["data"] | null {
|
||||||
|
const b = res.data;
|
||||||
|
if (!b) return null;
|
||||||
|
return b.data ?? b.Data ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPracticeDate(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime())
|
||||||
|
? iso
|
||||||
|
: d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function PracticeCard({
|
||||||
|
practice,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
practice: ParentContextPractice;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
}) {
|
||||||
|
const [imgFailed, setImgFailed] = useState(false);
|
||||||
|
const thumb = resolveThumbnailForPreview(practice.story_image);
|
||||||
|
const showThumb = Boolean(thumb) && !imgFailed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden border-grayScale-100/90 bg-white shadow-sm transition-all duration-300",
|
||||||
|
"hover:border-brand-200/60 hover:shadow-md hover:shadow-brand-500/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-stretch">
|
||||||
|
<div className="relative shrink-0 lg:w-[280px]">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative aspect-[16/10] w-full overflow-hidden bg-gradient-to-br from-grayScale-100 to-grayScale-50 lg:aspect-auto lg:h-full lg:min-h-[220px]",
|
||||||
|
!showThumb && "grid min-h-[180px] place-items-center lg:min-h-[220px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showThumb ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={thumb!}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={() => setImgFailed(true)}
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/25 via-transparent to-black/10" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2 text-grayScale-400">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/80 shadow-inner ring-1 ring-grayScale-200/80">
|
||||||
|
<BookOpen className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider">
|
||||||
|
No cover image
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col p-6 sm:p-7">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-brand-500">
|
||||||
|
Practice {index + 1} of {total}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="font-mono text-[10px] font-semibold">
|
||||||
|
ID {practice.id}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-semibold leading-snug tracking-tight text-grayScale-900 sm:text-[1.35rem]">
|
||||||
|
{practice.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{practice.story_description?.trim() ? (
|
||||||
|
<div className="mt-4 rounded-xl border border-grayScale-100 bg-grayScale-50/80 px-4 py-3.5">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||||
|
Story & instructions
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 whitespace-pre-line text-[14px] leading-relaxed text-grayScale-700">
|
||||||
|
{practice.story_description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{practice.quick_tips?.trim() ? (
|
||||||
|
<div className="mt-4 border-l-[3px] border-amber-400 bg-gradient-to-r from-amber-50/90 to-amber-50/30 py-3 pl-4 pr-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-amber-900/75">
|
||||||
|
Quick tips
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 whitespace-pre-line text-[13px] leading-relaxed text-grayScale-800">
|
||||||
|
{practice.quick_tips}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-2 border-t border-grayScale-100 pt-5">
|
||||||
|
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
|
||||||
|
<Hash className="h-3 w-3 opacity-70" aria-hidden />
|
||||||
|
Question set {practice.question_set_id}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
|
||||||
|
<Clock className="h-3 w-3 opacity-70" aria-hidden />
|
||||||
|
{formatPracticeDate(practice.created_at)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LessonPracticesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { level, courseId, moduleId, lessonId } = useParams<{
|
||||||
|
level: string;
|
||||||
|
courseId: string;
|
||||||
|
moduleId: string;
|
||||||
|
lessonId: string;
|
||||||
|
}>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const lessonTitle = searchParams.get("lessonTitle")?.trim() || "";
|
||||||
|
|
||||||
|
const backHref = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
||||||
|
|
||||||
|
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const lid = lessonId ? Number(lessonId) : NaN;
|
||||||
|
const validLesson = Number.isFinite(lid) && lid > 0;
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!validLesson) {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadError("Invalid lesson.");
|
||||||
|
setPractices([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
try {
|
||||||
|
const res = await getPracticesByParentLesson(lid, { limit: 100, offset: 0 });
|
||||||
|
const envelope = unwrapPracticesEnvelope(res);
|
||||||
|
const list = Array.isArray(envelope?.practices) ? envelope.practices : [];
|
||||||
|
setPractices(list);
|
||||||
|
setTotalCount(
|
||||||
|
typeof envelope?.total_count === "number"
|
||||||
|
? envelope.total_count
|
||||||
|
: list.length,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setPractices([]);
|
||||||
|
setTotalCount(0);
|
||||||
|
setLoadError("Could not load practices for this lesson.");
|
||||||
|
toast.error("Failed to load practices");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [lid, validLesson]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const displayTitle =
|
||||||
|
lessonTitle || (validLesson ? `Lesson #${lid}` : "Lesson practices");
|
||||||
|
|
||||||
|
const addPracticeHref = `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-[#F4F6FB] via-white to-[#F8FAFC]">
|
||||||
|
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
||||||
|
<div className="absolute -right-24 -top-24 h-72 w-72 rounded-full bg-brand-500/[0.06] blur-3xl" />
|
||||||
|
<div className="absolute -bottom-32 -left-20 h-80 w-80 rounded-full bg-violet-500/[0.05] blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-4xl px-4 pb-24 pt-8 sm:px-6 lg:px-8">
|
||||||
|
<div className="animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
|
<Link
|
||||||
|
to={backHref}
|
||||||
|
className="group mb-6 inline-flex items-center gap-2 rounded-full border border-transparent px-1 py-1 text-[14px] font-medium text-grayScale-600 transition-colors hover:border-grayScale-200 hover:bg-white/80 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-grayScale-100 transition-transform group-hover:-translate-x-0.5">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
Back to module
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Card className="mb-10 border-grayScale-100/80 bg-white/90 shadow-md shadow-grayScale-200/40 backdrop-blur-sm">
|
||||||
|
<CardContent className="p-6 sm:p-8">
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="flex min-w-0 gap-4">
|
||||||
|
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-lg shadow-brand-500/25">
|
||||||
|
<BookOpen className="h-7 w-7" strokeWidth={1.75} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-brand-500/90">
|
||||||
|
Lesson practices
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1.5 text-2xl font-semibold tracking-tight text-grayScale-900 sm:text-3xl">
|
||||||
|
{displayTitle}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 max-w-xl text-[15px] leading-relaxed text-grayScale-500">
|
||||||
|
Review speaking practices linked to this lesson. Thumbnails
|
||||||
|
and copy come from your published practice content.
|
||||||
|
</p>
|
||||||
|
{!loading && !loadError ? (
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="default" className="px-3 py-1 text-xs font-semibold">
|
||||||
|
{practices.length}{" "}
|
||||||
|
{practices.length === 1 ? "practice" : "practices"}
|
||||||
|
</Badge>
|
||||||
|
{totalCount > practices.length ? (
|
||||||
|
<span className="text-[12px] text-grayScale-500">
|
||||||
|
Showing {practices.length} of {totalCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-col gap-2 sm:flex-row lg:flex-col">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="h-11 rounded-xl bg-brand-500 px-6 font-semibold shadow-md shadow-brand-500/20 hover:bg-brand-600"
|
||||||
|
onClick={() => void navigate(addPracticeHref)}
|
||||||
|
>
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
Add practice
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 rounded-xl border-grayScale-200 font-semibold text-grayScale-700 hover:bg-grayScale-50"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => void load()}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn("mr-2 h-4 w-4", loading && "animate-spin")}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card className="border-grayScale-100 bg-white/95 py-20 shadow-sm">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center gap-4 pt-6">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-50 ring-1 ring-brand-100">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-[16px] font-semibold text-grayScale-800">
|
||||||
|
Loading practices
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[14px] text-grayScale-500">
|
||||||
|
Fetching content for this lesson…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : loadError ? (
|
||||||
|
<Card className="border-red-100 bg-gradient-to-br from-red-50/90 to-white shadow-sm">
|
||||||
|
<CardContent className="flex flex-col items-center gap-5 py-14 text-center sm:py-16">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100 text-red-600">
|
||||||
|
<AlertCircle className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-grayScale-900">
|
||||||
|
Something went wrong
|
||||||
|
</p>
|
||||||
|
<p className="mx-auto mt-2 max-w-md text-[14px] leading-relaxed text-grayScale-600">
|
||||||
|
{loadError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl border-grayScale-300 font-semibold"
|
||||||
|
onClick={() => void load()}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : practices.length === 0 ? (
|
||||||
|
<Card className="border-dashed border-grayScale-200 bg-white/90 shadow-sm">
|
||||||
|
<CardContent className="flex flex-col items-center px-6 py-16 text-center sm:py-20">
|
||||||
|
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-3xl bg-gradient-to-br from-violet-50 to-brand-50 ring-1 ring-brand-100/60">
|
||||||
|
<Sparkles className="h-9 w-9 text-brand-500" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-semibold text-grayScale-900">
|
||||||
|
No practices yet
|
||||||
|
</p>
|
||||||
|
<p className="mx-auto mt-3 max-w-md text-[15px] leading-relaxed text-grayScale-500">
|
||||||
|
This lesson does not have any linked practices. Create one to
|
||||||
|
give learners a structured speaking activity after the video.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="h-11 rounded-xl bg-brand-500 px-8 font-semibold shadow-md shadow-brand-500/15 hover:bg-brand-600"
|
||||||
|
onClick={() => void navigate(addPracticeHref)}
|
||||||
|
>
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
Create practice
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 rounded-xl border-grayScale-200 px-8 font-semibold"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link to={backHref}>Return to module</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{practices.map((p, i) => (
|
||||||
|
<PracticeCard
|
||||||
|
key={p.id}
|
||||||
|
practice={p}
|
||||||
|
index={i}
|
||||||
|
total={practices.length}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<p className="px-1 text-center text-[11px] text-grayScale-400">
|
||||||
|
Source:{" "}
|
||||||
|
<code className="rounded-md bg-grayScale-100 px-1.5 py-0.5 font-mono text-[10px] text-grayScale-500">
|
||||||
|
GET /lessons/{lid}/practices
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -404,6 +404,11 @@ export function ModuleDetailPage() {
|
||||||
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`,
|
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onViewPractices={() =>
|
||||||
|
navigate(
|
||||||
|
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}/practices?lessonTitle=${encodeURIComponent(lesson.title ?? "")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { MoreVertical, Edit2, Play, Pencil, Trash2, Calendar } from "lucide-react";
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Calendar,
|
||||||
|
Edit2,
|
||||||
|
MoreVertical,
|
||||||
|
Pencil,
|
||||||
|
Play,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -32,14 +40,16 @@ interface VideoCardProps {
|
||||||
*/
|
*/
|
||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
/**
|
/**
|
||||||
* When true, shows edit/delete in the top-right of the thumbnail (same
|
* When true, shows edit/delete (and optional view practices) in the top-right
|
||||||
* hover pattern as module cards) and removes the footer + overflow menu.
|
* of the thumbnail on hover, and removes the footer + overflow menu.
|
||||||
*/
|
*/
|
||||||
hoverModuleActions?: boolean;
|
hoverModuleActions?: boolean;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
/** When set (e.g. on module lesson cards), shows an "Add practice" control scoped to this lesson. */
|
/** When set (e.g. on module lesson cards), shows an "Add practice" control scoped to this lesson. */
|
||||||
onAddPractice?: () => void;
|
onAddPractice?: () => void;
|
||||||
|
/** When set with hoverModuleActions, shows a book icon next to edit/delete on thumbnail hover. */
|
||||||
|
onViewPractices?: () => void;
|
||||||
onPublish?: () => void;
|
onPublish?: () => void;
|
||||||
/** Shown under title on module lesson cards; reserved height keeps grid rows even. */
|
/** Shown under title on module lesson cards; reserved height keeps grid rows even. */
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
@ -56,6 +66,7 @@ export function VideoCard({
|
||||||
onDelete,
|
onDelete,
|
||||||
onPublish,
|
onPublish,
|
||||||
onAddPractice,
|
onAddPractice,
|
||||||
|
onViewPractices,
|
||||||
hoverModuleActions = false,
|
hoverModuleActions = false,
|
||||||
description,
|
description,
|
||||||
}: VideoCardProps) {
|
}: VideoCardProps) {
|
||||||
|
|
@ -128,10 +139,25 @@ export function VideoCard({
|
||||||
!useGradient && "bg-grayScale-100",
|
!useGradient && "bg-grayScale-100",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{hoverModuleActions && (onEdit || onDelete) ? (
|
{hoverModuleActions && (onEdit || onDelete || onViewPractices) ? (
|
||||||
<div
|
<div
|
||||||
className="absolute right-2 top-2 z-20 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
|
className="absolute right-2 top-2 z-20 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"
|
||||||
>
|
>
|
||||||
|
{onViewPractices ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-md bg-white/95 text-brand-600 shadow-sm transition-colors hover:bg-brand-50"
|
||||||
|
aria-label={`View practices for ${title}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewPractices();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BookOpen className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{onEdit ? (
|
{onEdit ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { GraduationCap, ArrowRight, LayoutGrid, Monitor } from "lucide-react";
|
import { useRef, useState, type ChangeEvent } from "react";
|
||||||
|
import { ArrowRight, Loader2, Upload } from "lucide-react";
|
||||||
import { Button } from "../../../../components/ui/button";
|
import { Button } from "../../../../components/ui/button";
|
||||||
import { Card } from "../../../../components/ui/card";
|
import { Card } from "../../../../components/ui/card";
|
||||||
import { Select } from "../../../../components/ui/select";
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
import { Textarea } from "../../../../components/ui/textarea";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { uploadImageFile } from "../../../../api/files.api";
|
||||||
|
|
||||||
interface ContextStepProps {
|
interface ContextStepProps {
|
||||||
formData: any;
|
formData: any;
|
||||||
|
|
@ -9,27 +13,51 @@ interface ContextStepProps {
|
||||||
nextStep: () => void;
|
nextStep: () => void;
|
||||||
navigate: (path: string) => void;
|
navigate: (path: string) => void;
|
||||||
level: string;
|
level: string;
|
||||||
isModuleContext?: boolean;
|
|
||||||
isCourseContext?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module / lesson entry: fields that map to POST /practices and POST /question-sets.
|
||||||
|
*/
|
||||||
export function ContextStep({
|
export function ContextStep({
|
||||||
formData,
|
formData,
|
||||||
setFormData,
|
setFormData,
|
||||||
nextStep,
|
nextStep,
|
||||||
navigate,
|
navigate,
|
||||||
level,
|
level,
|
||||||
isModuleContext,
|
|
||||||
isCourseContext,
|
|
||||||
}: ContextStepProps) {
|
}: ContextStepProps) {
|
||||||
|
const storyFileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploadingStory, setUploadingStory] = useState(false);
|
||||||
|
|
||||||
|
const handleStoryImageFile = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
setUploadingStory(true);
|
||||||
|
try {
|
||||||
|
const res = await uploadImageFile(file);
|
||||||
|
const url = res.data?.data?.url?.trim();
|
||||||
|
if (!url) throw new Error("Missing image URL from upload");
|
||||||
|
setFormData({ ...formData, storyImageUrl: url });
|
||||||
|
toast.success("Story image uploaded");
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not upload story image");
|
||||||
|
} finally {
|
||||||
|
setUploadingStory(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canContinue =
|
||||||
|
Boolean(formData.title?.trim()) && Boolean(formData.description?.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden border-grayScale-300 rounded-2xl bg-white animate-in fade-in duration-500">
|
<Card className="overflow-hidden border-grayScale-300 rounded-2xl bg-white animate-in fade-in duration-500">
|
||||||
<div className="border-b border-grayScale-50 px-8 pt-8 pb-4">
|
<div className="border-b border-grayScale-50 px-8 pt-8 pb-4">
|
||||||
<h2 className="text-xl font-bold text-grayScale-900 leading-none">
|
<h2 className="text-xl font-bold text-grayScale-900 leading-none">
|
||||||
Step 1: Context Definition
|
Practice details
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-grayScale-600 text-base mt-3">
|
<p className="text-grayScale-600 text-base mt-3">
|
||||||
Define the educational level and curriculum module for this practice.
|
Title, story, optional image, shuffle, and quick tips match the create
|
||||||
|
practice and question set APIs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -40,110 +68,111 @@ export function ContextStep({
|
||||||
<div className="relative flex justify-center">
|
<div className="relative flex justify-center">
|
||||||
<div
|
<div
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
className="h-[0.5px] w-full opacity-20 rounded-full"
|
||||||
style={{
|
style={{ background: "gray" }}
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-10 p-10">
|
<div className="space-y-8 p-10">
|
||||||
{/* Program Field */}
|
<div className="space-y-2">
|
||||||
<div className="space-y-3">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
<label className="text-[16px] text-grayScale-700 ml-1">
|
Practice title <span className="text-red-500">*</span>
|
||||||
Program{" "}
|
|
||||||
<span className="text-grayScale-300 font-medium">
|
|
||||||
(Auto-selected)
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<Input
|
||||||
<div className="absolute left-6 top-1/2 -translate-y-1/2">
|
value={formData.title ?? ""}
|
||||||
<GraduationCap className="h-6 w-6 text-grayScale-600" />
|
onChange={(e) =>
|
||||||
</div>
|
setFormData({ ...formData, title: e.target.value })
|
||||||
<Select
|
}
|
||||||
className="h-12 w-full rounded-[6px] border-grayScale-400 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all cursor-default"
|
placeholder="e.g. Lesson 12 conversation drill"
|
||||||
disabled
|
className="h-11 rounded-xl border-grayScale-200"
|
||||||
>
|
/>
|
||||||
<option>{formData.program || "Intermediate"}</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Course Field */}
|
<div className="space-y-2">
|
||||||
<div className="space-y-3">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
|
Story description <span className="text-red-500">*</span>
|
||||||
Course{" "}
|
|
||||||
<span className="text-grayScale-300 font-medium">
|
|
||||||
(Auto-selected)
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<Textarea
|
||||||
<div className="absolute left-6 top-1/2 -translate-y-1/2">
|
value={formData.description ?? ""}
|
||||||
<GraduationCap className="h-6 w-6 text-grayScale-600" />
|
onChange={(e) =>
|
||||||
</div>
|
setFormData({ ...formData, description: e.target.value })
|
||||||
<Select
|
}
|
||||||
className="h-12 w-full rounded-[6px] border-grayScale-400 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all cursor-default"
|
placeholder="Short scenario for learners…"
|
||||||
disabled
|
className="min-h-[120px] rounded-xl border-grayScale-200"
|
||||||
>
|
maxLength={2000}
|
||||||
<option>{formData.course || "B2"}</option>
|
/>
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Select Module Field */}
|
<div className="space-y-2">
|
||||||
{(isModuleContext || isCourseContext) && (
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
<div className="space-y-3">
|
Quick tips <span className="text-grayScale-400">(optional)</span>
|
||||||
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
|
</label>
|
||||||
Select Module
|
<Textarea
|
||||||
</label>
|
value={formData.tips ?? ""}
|
||||||
<div className="relative">
|
onChange={(e) =>
|
||||||
<div className="absolute left-6 top-1/2 -translate-y-1/2">
|
setFormData({ ...formData, tips: e.target.value })
|
||||||
<LayoutGrid className="h-6 w-6 text-grayScale-400" />
|
}
|
||||||
</div>
|
placeholder="Learner-facing tips (quick_tips on POST /practices)"
|
||||||
<Select className="h-12 w-full rounded-[6px] border-grayScale-300 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all">
|
className="min-h-[80px] rounded-xl border-grayScale-200"
|
||||||
<option value="">Choose a module...</option>
|
maxLength={1000}
|
||||||
<option value="m1">Introduction Basics</option>
|
/>
|
||||||
<option value="m2">Daily Routines</option>
|
</div>
|
||||||
<option value="m3">Travel Essentials</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] text-grayScale-400 font-medium px-2">
|
|
||||||
Select the specific learning module this practice will reinforce.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Select Video Field (Conditional) */}
|
<div className="space-y-2">
|
||||||
{isModuleContext && (
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
|
Story image <span className="text-grayScale-400">(optional)</span>
|
||||||
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
|
</label>
|
||||||
Select Video
|
<Input
|
||||||
</label>
|
value={formData.storyImageUrl ?? ""}
|
||||||
<div className="relative">
|
onChange={(e) =>
|
||||||
<div className="absolute left-6 top-1/2 -translate-y-1/2">
|
setFormData({ ...formData, storyImageUrl: e.target.value })
|
||||||
<Monitor className="h-6 w-6 text-grayScale-400" />
|
}
|
||||||
</div>
|
placeholder="https://… or upload"
|
||||||
<Select
|
className="h-11 rounded-xl border-grayScale-200 font-mono text-[13px]"
|
||||||
className="h-12 w-full rounded-[6px] border-grayScale-300 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all"
|
/>
|
||||||
value={formData.selectedVideo}
|
<input
|
||||||
onChange={(e) =>
|
ref={storyFileRef}
|
||||||
setFormData({ ...formData, selectedVideo: e.target.value })
|
type="file"
|
||||||
}
|
accept="image/*"
|
||||||
>
|
className="hidden"
|
||||||
<option value="">Choose a video</option>
|
onChange={handleStoryImageFile}
|
||||||
<option value="v1">Intro to Greetings</option>
|
/>
|
||||||
<option value="v2">Advanced Grammar</option>
|
<Button
|
||||||
</Select>
|
type="button"
|
||||||
</div>
|
variant="outline"
|
||||||
<p className="text-[13px] text-grayScale-400 font-medium px-2">
|
size="sm"
|
||||||
Select the specific learning module this practice will reinforce.
|
disabled={uploadingStory}
|
||||||
</p>
|
onClick={() => storyFileRef.current?.click()}
|
||||||
</div>
|
className="gap-2"
|
||||||
)}
|
>
|
||||||
|
{uploadingStory ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Upload image
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-center gap-3 text-sm text-grayScale-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
|
||||||
|
checked={Boolean(formData.shuffleQuestions)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
shuffleQuestions: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Shuffle questions in the set</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12">
|
<div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="text-[14px] font-bold text-grayScale-500 transition-colors hover:text-grayScale-700"
|
className="text-[14px] font-bold text-grayScale-500 transition-colors hover:text-grayScale-700"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/new-content/learn-english/${level}/courses`)
|
navigate(`/new-content/learn-english/${level}/courses`)
|
||||||
|
|
@ -152,11 +181,12 @@ export function ContextStep({
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
className="h-10 px-10 rounded-[6px] bg-brand-500 text-[14px] font-bold text-white transition-all active:scale-95 flex items-center gap-2"
|
disabled={!canContinue}
|
||||||
|
className="h-10 px-10 rounded-[6px] bg-brand-500 text-[14px] font-bold text-white transition-all active:scale-95 flex items-center gap-2 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Next: {isModuleContext ? "Persona" : "Scenario"}{" "}
|
Next: Questions <ArrowRight className="h-5 w-5" />
|
||||||
<ArrowRight className="h-5 w-5" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,45 @@
|
||||||
import { GripVertical, Trash2, Plus, ArrowRight } from "lucide-react";
|
import { Trash2, Plus, ArrowRight } from "lucide-react";
|
||||||
import { Button } from "../../../../components/ui/button";
|
import { Button } from "../../../../components/ui/button";
|
||||||
import { Card } from "../../../../components/ui/card";
|
import { Card } from "../../../../components/ui/card";
|
||||||
import { Input } from "../../../../components/ui/input";
|
import { Input } from "../../../../components/ui/input";
|
||||||
import { VoicePrompt } from "./VoicePrompt";
|
import { DynamicSchemaSlotField } from "../../../../components/content-management/DynamicSchemaSlotField";
|
||||||
|
import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
|
||||||
|
import { questionTypeDefinitionListLabel } from "../../../../api/questionTypeDefinitions.api";
|
||||||
|
import {
|
||||||
|
definitionUsesDynamicPayload,
|
||||||
|
emptyDynamicFieldValuesForDefinition,
|
||||||
|
legacyQuestionTypeFromDefinition,
|
||||||
|
} from "../../../../lib/learnEnglishDefinitionQuestion";
|
||||||
|
|
||||||
|
function defaultMcqOptions() {
|
||||||
|
return [
|
||||||
|
{ text: "", isCorrect: true },
|
||||||
|
{ text: "", isCorrect: false },
|
||||||
|
{ text: "", isCorrect: false },
|
||||||
|
{ text: "", isCorrect: false },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyQuestionRow(id: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
questionTypeDefinitionId: null as number | null,
|
||||||
|
text: "",
|
||||||
|
dynamicFieldValues: {} as Record<string, string>,
|
||||||
|
mcqOptions: defaultMcqOptions(),
|
||||||
|
trueFalseCorrect: true,
|
||||||
|
shortAnswers: [""],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface QuestionsStepProps {
|
interface QuestionsStepProps {
|
||||||
formData: any;
|
formData: any;
|
||||||
setFormData: (data: any) => void;
|
setFormData: (data: any) => void;
|
||||||
nextStep: () => void;
|
nextStep: () => void;
|
||||||
prevStep: () => void;
|
prevStep: () => void;
|
||||||
|
typeDefinitions: QuestionTypeDefinition[];
|
||||||
|
definitionsLoading: boolean;
|
||||||
|
definitionsError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionsStep({
|
export function QuestionsStep({
|
||||||
|
|
@ -16,64 +47,365 @@ export function QuestionsStep({
|
||||||
setFormData,
|
setFormData,
|
||||||
nextStep,
|
nextStep,
|
||||||
prevStep,
|
prevStep,
|
||||||
|
typeDefinitions,
|
||||||
|
definitionsLoading,
|
||||||
|
definitionsError,
|
||||||
}: QuestionsStepProps) {
|
}: QuestionsStepProps) {
|
||||||
const addQuestion = () => {
|
const applyDefinitionToQuestion = (
|
||||||
const newQuestion = {
|
index: number,
|
||||||
id: `q${formData.questions.length + 1}`,
|
definitionId: number,
|
||||||
text: "",
|
defs: QuestionTypeDefinition[],
|
||||||
type: "Speaking",
|
) => {
|
||||||
voicePrompt: "upload_audio.mp3",
|
const def = defs.find((d) => d.id === definitionId);
|
||||||
sampleAnswer: "upload_audio.mp3",
|
const newQuestions = [...formData.questions];
|
||||||
|
const row = { ...newQuestions[index], questionTypeDefinitionId: definitionId };
|
||||||
|
if (def) {
|
||||||
|
row.dynamicFieldValues = emptyDynamicFieldValuesForDefinition(def);
|
||||||
|
}
|
||||||
|
newQuestions[index] = row;
|
||||||
|
setFormData({ ...formData, questions: newQuestions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDynamicValue = (qIndex: number, key: string, value: string) => {
|
||||||
|
const newQuestions = [...formData.questions];
|
||||||
|
newQuestions[qIndex] = {
|
||||||
|
...newQuestions[qIndex],
|
||||||
|
dynamicFieldValues: {
|
||||||
|
...(newQuestions[qIndex].dynamicFieldValues ?? {}),
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
setFormData({ ...formData, questions: newQuestions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addQuestion = () => {
|
||||||
|
const id = `q${Date.now()}`;
|
||||||
|
const row = createEmptyQuestionRow(id);
|
||||||
|
if (typeDefinitions[0]) {
|
||||||
|
row.questionTypeDefinitionId = typeDefinitions[0].id;
|
||||||
|
row.dynamicFieldValues = emptyDynamicFieldValuesForDefinition(
|
||||||
|
typeDefinitions[0],
|
||||||
|
);
|
||||||
|
}
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
questions: [...formData.questions, newQuestion],
|
questions: [...formData.questions, row],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderTypeSpecificFields = (q: any, i: number, def: QuestionTypeDefinition) => {
|
||||||
|
if (definitionUsesDynamicPayload(def)) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-lg border border-violet-200 bg-violet-50/40 p-3">
|
||||||
|
<p className="text-xs leading-snug text-grayScale-600">
|
||||||
|
<span className="font-medium text-grayScale-800">Image / Audio</span> slots use upload or URL import (
|
||||||
|
<code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>
|
||||||
|
). Others: URL, text, or JSON.
|
||||||
|
</p>
|
||||||
|
{def.stimulus_schema.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Stimulus</p>
|
||||||
|
{def.stimulus_schema.map((row) => (
|
||||||
|
<div
|
||||||
|
key={`stimulus-${row.id}`}
|
||||||
|
className="rounded-lg border border-grayScale-200 bg-white p-2.5"
|
||||||
|
>
|
||||||
|
<DynamicSchemaSlotField
|
||||||
|
row={row}
|
||||||
|
value={q.dynamicFieldValues?.[`stimulus:${row.id}`] ?? ""}
|
||||||
|
onChange={(next) =>
|
||||||
|
setDynamicValue(i, `stimulus:${row.id}`, next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{def.response_schema.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Response</p>
|
||||||
|
{def.response_schema.map((row) => (
|
||||||
|
<div
|
||||||
|
key={`response-${row.id}`}
|
||||||
|
className="rounded-lg border border-grayScale-200 bg-white p-2.5"
|
||||||
|
>
|
||||||
|
<DynamicSchemaSlotField
|
||||||
|
row={row}
|
||||||
|
value={q.dynamicFieldValues?.[`response:${row.id}`] ?? ""}
|
||||||
|
onChange={(next) =>
|
||||||
|
setDynamicValue(i, `response:${row.id}`, next)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacy = legacyQuestionTypeFromDefinition(def);
|
||||||
|
if (legacy === "MCQ") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
||||||
|
Choices (mark one correct)
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(q.mcqOptions ?? defaultMcqOptions()).map(
|
||||||
|
(opt: { text: string; isCorrect: boolean }, j: number) => (
|
||||||
|
<div
|
||||||
|
key={j}
|
||||||
|
className="flex flex-wrap items-center gap-2 sm:flex-nowrap"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={opt.text}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newQuestions = [...formData.questions];
|
||||||
|
const opts = [
|
||||||
|
...(newQuestions[i].mcqOptions ?? defaultMcqOptions()),
|
||||||
|
];
|
||||||
|
opts[j] = { ...opts[j], text: e.target.value };
|
||||||
|
newQuestions[i].mcqOptions = opts;
|
||||||
|
setFormData({ ...formData, questions: newQuestions });
|
||||||
|
}}
|
||||||
|
className="min-w-0 flex-1 rounded-lg border-grayScale-200"
|
||||||
|
placeholder={`Option ${j + 1}`}
|
||||||
|
/>
|
||||||
|
<label className="flex shrink-0 items-center gap-2 text-sm text-grayScale-600">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`mcq-correct-${q.id}`}
|
||||||
|
checked={opt.isCorrect}
|
||||||
|
onChange={() => {
|
||||||
|
const newQuestions = [...formData.questions];
|
||||||
|
const opts = (
|
||||||
|
newQuestions[i].mcqOptions ?? defaultMcqOptions()
|
||||||
|
).map((o: { text: string; isCorrect: boolean }, k: number) => ({
|
||||||
|
...o,
|
||||||
|
isCorrect: k === j,
|
||||||
|
}));
|
||||||
|
newQuestions[i].mcqOptions = opts;
|
||||||
|
setFormData({ ...formData, questions: newQuestions });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Correct
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacy === "TRUE_FALSE") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
||||||
|
Correct answer
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-grayScale-700">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`tf-${q.id}`}
|
||||||
|
checked={q.trueFalseCorrect !== false}
|
||||||
|
onChange={() => {
|
||||||
|
const newQuestions = [...formData.questions];
|
||||||
|
newQuestions[i].trueFalseCorrect = true;
|
||||||
|
setFormData({ ...formData, questions: newQuestions });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
True
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-grayScale-700">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`tf-${q.id}`}
|
||||||
|
checked={q.trueFalseCorrect === false}
|
||||||
|
onChange={() => {
|
||||||
|
const newQuestions = [...formData.questions];
|
||||||
|
newQuestions[i].trueFalseCorrect = false;
|
||||||
|
setFormData({ ...formData, questions: newQuestions });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
False
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legacy === "SHORT_ANSWER") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
||||||
|
Acceptable answers
|
||||||
|
</label>
|
||||||
|
{(q.shortAnswers ?? [""]).map((line: string, j: number) => (
|
||||||
|
<div key={j} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={line}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newQuestions = [...formData.questions];
|
||||||
|
const lines = [...(newQuestions[i].shortAnswers ?? [""])];
|
||||||
|
lines[j] = e.target.value;
|
||||||
|
newQuestions[i].shortAnswers = lines;
|
||||||
|
setFormData({ ...formData, questions: newQuestions });
|
||||||
|
}}
|
||||||
|
className="rounded-lg border-grayScale-200"
|
||||||
|
placeholder="Acceptable wording"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newQuestions = [...formData.questions];
|
||||||
|
const lines = [...(newQuestions[i].shortAnswers ?? [""])];
|
||||||
|
lines.splice(j, 1);
|
||||||
|
newQuestions[i].shortAnswers =
|
||||||
|
lines.length > 0 ? lines : [""];
|
||||||
|
setFormData({ ...formData, questions: newQuestions });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newQuestions = [...formData.questions];
|
||||||
|
newQuestions[i].shortAnswers = [
|
||||||
|
...(newQuestions[i].shortAnswers ?? [""]),
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
setFormData({ ...formData, questions: newQuestions });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add acceptable answer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="rounded-lg border border-amber-200 bg-amber-50/80 px-3 py-2 text-sm text-amber-900">
|
||||||
|
This definition has no schema rows and is not mapped to MCQ / True‑False /
|
||||||
|
Short answer. It will be submitted as{" "}
|
||||||
|
<span className="font-mono">DYNAMIC</span> with an empty payload.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-1 px-2">
|
<div className="space-y-1 px-2">
|
||||||
<h2 className="text-2xl font-bold text-grayScale-700">
|
<h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
|
||||||
Create Practice Questions
|
|
||||||
</h2>
|
|
||||||
<p className="text-grayScale-400 text-lg">
|
<p className="text-grayScale-400 text-lg">
|
||||||
Define the dialogue flow and interactions for this scenario.
|
Question types are loaded from{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 text-sm">
|
||||||
|
GET /questions/type-definitions
|
||||||
|
</code>
|
||||||
|
. Pick a type per row, then fill the fields required for that definition.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{definitionsError ? (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{definitionsError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{definitionsLoading ? (
|
||||||
|
<p className="px-2 text-sm text-grayScale-500">Loading question types…</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{formData.questions.map((q: any, i: number) => (
|
{formData.questions.map((q: any, i: number) => {
|
||||||
<Card
|
const def = typeDefinitions.find(
|
||||||
key={q.id}
|
(d) => d.id === q.questionTypeDefinitionId,
|
||||||
className="overflow-hidden border-grayScale-50 shadow-soft rounded-2xl bg-white relative"
|
);
|
||||||
>
|
return (
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-[5px] bg-brand-500" />
|
<Card
|
||||||
<div className="px-5 pb-7 pt-2 space-y-6">
|
key={q.id}
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-50 pb-4 mb-4">
|
className="relative overflow-hidden rounded-2xl border border-grayScale-50 bg-white shadow-soft"
|
||||||
<div className="flex items-center gap-3">
|
>
|
||||||
<GripVertical className="h-5 w-5 text-brand-500 cursor-grab" />
|
<div className="absolute bottom-0 left-0 top-0 w-[5px] bg-brand-500" />
|
||||||
<span className="font-bold text-grayScale-500 text-base">
|
<div className="space-y-6 px-5 pb-7 pt-4 pl-7">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-grayScale-50 pb-4">
|
||||||
|
<span className="text-base font-bold text-grayScale-500">
|
||||||
Question {i + 1}
|
Question {i + 1}
|
||||||
</span>
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
className="text-brand-500 hover:bg-brand-50 rounded-lg"
|
||||||
|
onClick={() => {
|
||||||
|
const newQuestions = formData.questions.filter(
|
||||||
|
(item: any) => item.id !== q.id,
|
||||||
|
);
|
||||||
|
if (newQuestions.length > 0) {
|
||||||
|
setFormData({ ...formData, questions: newQuestions });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = createEmptyQuestionRow("q1");
|
||||||
|
if (typeDefinitions[0]) {
|
||||||
|
row.questionTypeDefinitionId = typeDefinitions[0].id;
|
||||||
|
row.dynamicFieldValues =
|
||||||
|
emptyDynamicFieldValuesForDefinition(
|
||||||
|
typeDefinitions[0],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setFormData({ ...formData, questions: [row] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
<div className="space-y-2">
|
||||||
size="icon"
|
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
||||||
className="text-brand-500 hover:bg-brand-50 rounded-lg"
|
Question type
|
||||||
onClick={() => {
|
</label>
|
||||||
const newQuestions = formData.questions.filter(
|
<select
|
||||||
(item: any) => item.id !== q.id,
|
className="h-11 w-full max-w-xl rounded-lg border border-grayScale-200 bg-white px-3 text-sm font-medium text-grayScale-800"
|
||||||
);
|
disabled={definitionsLoading || typeDefinitions.length === 0}
|
||||||
setFormData({ ...formData, questions: newQuestions });
|
value={
|
||||||
}}
|
q.questionTypeDefinitionId != null
|
||||||
>
|
? String(q.questionTypeDefinitionId)
|
||||||
<Trash2 className="h-4 w-4" />
|
: ""
|
||||||
</Button>
|
}
|
||||||
</div>
|
onChange={(e) => {
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
|
const v = e.target.value;
|
||||||
<div className="md:col-span-8 space-y-3">
|
if (!v) return;
|
||||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
applyDefinitionToQuestion(i, Number(v), typeDefinitions);
|
||||||
QUESTION PROMPT
|
}}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{definitionsLoading
|
||||||
|
? "Loading…"
|
||||||
|
: "Select question type…"}
|
||||||
|
</option>
|
||||||
|
{typeDefinitions.map((d) => (
|
||||||
|
<option key={d.id} value={String(d.id)}>
|
||||||
|
{questionTypeDefinitionListLabel(d)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{def?.description ? (
|
||||||
|
<p className="text-xs text-grayScale-500">{def.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
||||||
|
Question text
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={q.text}
|
value={q.text}
|
||||||
|
|
@ -82,62 +414,35 @@ export function QuestionsStep({
|
||||||
newQuestions[i].text = e.target.value;
|
newQuestions[i].text = e.target.value;
|
||||||
setFormData({ ...formData, questions: newQuestions });
|
setFormData({ ...formData, questions: newQuestions });
|
||||||
}}
|
}}
|
||||||
className="h-16 rounded-xl border-grayScale-200 font-medium px-6 text-base placeholder:text-grayScale-400 bg-white text-grayScale-700"
|
className="min-h-[52px] rounded-xl border-grayScale-200 px-4 py-3 text-base font-medium text-grayScale-700"
|
||||||
placeholder="e.g. How long have you been studying English?"
|
placeholder="Question prompt for learners"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-4 space-y-3">
|
|
||||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
|
||||||
VOICE PROMPT
|
|
||||||
</label>
|
|
||||||
<VoicePrompt
|
|
||||||
src={q.voicePrompt}
|
|
||||||
filename={q.voicePrompt}
|
|
||||||
onRemove={() => {
|
|
||||||
const newQuestions = [...formData.questions];
|
|
||||||
newQuestions[i].voicePrompt = "";
|
|
||||||
setFormData({ ...formData, questions: newQuestions });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{def ? renderTypeSpecificFields(q, i, def) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="md:w-1/3 space-y-3">
|
</Card>
|
||||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
);
|
||||||
SAMPLE ANSWER PROMPT
|
})}
|
||||||
</label>
|
|
||||||
<VoicePrompt
|
|
||||||
src={q.sampleAnswer}
|
|
||||||
filename={q.sampleAnswer}
|
|
||||||
onRemove={() => {
|
|
||||||
const newQuestions = [...formData.questions];
|
|
||||||
newQuestions[i].sampleAnswer = "";
|
|
||||||
setFormData({ ...formData, questions: newQuestions });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
<div className="flex items-center gap-8 pt-4">
|
<div className="flex items-center gap-8 pt-4">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={addQuestion}
|
onClick={addQuestion}
|
||||||
className="flex items-center gap-3 text-brand-500 font-bold text-base hover:opacity-80 transition-all"
|
disabled={definitionsLoading || typeDefinitions.length === 0}
|
||||||
|
className="flex items-center gap-3 text-base font-bold text-brand-500 transition-all hover:opacity-80 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
|
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
|
||||||
<Plus className="h-3 w-3 stroke-[4]" />
|
<Plus className="h-3 w-3 stroke-[4]" />
|
||||||
</div>{" "}
|
</div>
|
||||||
Add New Question
|
Add question
|
||||||
</button>
|
|
||||||
<button className="flex items-center gap-3 text-brand-500 font-bold text-base hover:opacity-80 transition-all">
|
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
|
|
||||||
<Plus className="h-3 w-3 stroke-[4]" />
|
|
||||||
</div>{" "}
|
|
||||||
Add Tips
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-8">
|
<div className="flex items-center justify-between pt-8">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={prevStep}
|
onClick={prevStep}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-10 w-20 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 shadow-sm"
|
className="h-10 w-20 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 shadow-sm"
|
||||||
|
|
@ -145,8 +450,10 @@ export function QuestionsStep({
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold "
|
disabled={definitionsLoading || !!definitionsError || typeDefinitions.length === 0}
|
||||||
|
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Next: Review <ArrowRight className="ml-2 h-4 w-4" />
|
Next: Review <ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,58 @@
|
||||||
import { Edit2, GripVertical, Trash2, Rocket, Info } from "lucide-react";
|
import { Rocket, Info, Loader2 } from "lucide-react";
|
||||||
import { Button } from "../../../../components/ui/button";
|
import { Button } from "../../../../components/ui/button";
|
||||||
import { Card } from "../../../../components/ui/card";
|
import { Card } from "../../../../components/ui/card";
|
||||||
import { Input } from "../../../../components/ui/input";
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
definitionUsesDynamicPayload,
|
||||||
AvatarFallback,
|
legacyQuestionTypeFromDefinition,
|
||||||
AvatarImage,
|
} from "../../../../lib/learnEnglishDefinitionQuestion";
|
||||||
} from "../../../../components/ui/avatar";
|
|
||||||
import { PERSONAS } from "./constants";
|
|
||||||
import { VoicePrompt } from "./VoicePrompt";
|
|
||||||
|
|
||||||
interface ReviewStepProps {
|
interface ReviewStepProps {
|
||||||
formData: any;
|
formData: any;
|
||||||
selectedPersona: string | null;
|
|
||||||
prevStep: () => void;
|
prevStep: () => void;
|
||||||
setIsPublished: (val: boolean) => void;
|
parentSummary: string | null;
|
||||||
isModuleContext?: boolean;
|
typeDefinitions: QuestionTypeDefinition[];
|
||||||
|
canPublish: boolean;
|
||||||
|
submitting: boolean;
|
||||||
|
onSaveDraft: () => void;
|
||||||
|
onPublish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewStep({
|
export function ReviewStep({
|
||||||
formData,
|
formData,
|
||||||
selectedPersona,
|
|
||||||
prevStep,
|
prevStep,
|
||||||
setIsPublished,
|
parentSummary,
|
||||||
isModuleContext,
|
typeDefinitions,
|
||||||
|
canPublish,
|
||||||
|
submitting,
|
||||||
|
onSaveDraft,
|
||||||
|
onPublish,
|
||||||
}: ReviewStepProps) {
|
}: ReviewStepProps) {
|
||||||
const persona = PERSONAS.find((p) => p.id === selectedPersona);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10 animate-in fade-in duration-700">
|
<div className="space-y-10 animate-in fade-in duration-700">
|
||||||
<div className="flex items-center justify-between px-2">
|
<div className="flex items-center justify-between px-2">
|
||||||
<h2 className="text-2xl font-bold text-grayScale-900 tracking-tight">
|
<h2 className="text-2xl font-bold text-grayScale-900 tracking-tight">
|
||||||
Review Practice Questions
|
Review
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 1. Basic Info Card (Image 1436.1) */}
|
{!canPublish && (
|
||||||
<Card className="overflow-hidden border border-grayScale-200 rounded-2xl bg-white ">
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||||
<div className="border-b border-grayScale-50 p-4 px-5 flex justify-between items-center bg-white">
|
<p className="font-semibold">Missing parent for the API</p>
|
||||||
<h3 className="text-[17px] font-extrabold text-grayScale-900">
|
<p className="mt-1 text-amber-900/90">
|
||||||
Basic Information
|
Open Add Practice from a course, module, or lesson so parent IDs are
|
||||||
</h3>
|
in the URL.
|
||||||
<Button
|
</p>
|
||||||
variant="ghost"
|
</div>
|
||||||
size="sm"
|
)}
|
||||||
className="text-brand-500 font-bold hover:bg-brand-50 gap-2 h-9"
|
|
||||||
>
|
<Card className="overflow-hidden border border-grayScale-200 rounded-2xl bg-white">
|
||||||
<Edit2 className="h-4 w-4" />
|
<div className="border-b border-grayScale-50 px-5 py-4">
|
||||||
Edit
|
<h3 className="text-[17px] font-extrabold text-grayScale-900">
|
||||||
</Button>
|
Practice
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{/* Gradient Divider */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center"
|
className="absolute inset-0 flex items-center"
|
||||||
|
|
@ -58,186 +60,99 @@ export function ReviewStep({
|
||||||
>
|
>
|
||||||
<div className="w-full border-t border-grayScale-100" />
|
<div className="w-full border-t border-grayScale-100" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-8 px-5 flex items-center justify-between ">
|
<div className="p-6 sm:p-8">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
|
||||||
<div className="h-[70px] w-[85px] rounded-xl bg-grayScale-100 overflow-hidden shadow-inner flex-shrink-0">
|
<div className="h-[70px] w-[85px] shrink-0 overflow-hidden rounded-xl bg-grayScale-100 shadow-inner">
|
||||||
<img
|
<img
|
||||||
src="https://images.unsplash.com/photo-1558403194-611308249627?auto=format&fit=crop&q=80&w=200"
|
src={
|
||||||
alt="Banner"
|
formData.storyImageUrl?.trim() ||
|
||||||
className="w-full h-full object-cover opacity-80"
|
"https://images.unsplash.com/photo-1558403194-611308249627?auto=format&fit=crop&q=80&w=200"
|
||||||
|
}
|
||||||
|
alt="Story"
|
||||||
|
className="h-full w-full object-cover opacity-80"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<h4 className="text-[22px] font-bold text-grayScale-900 leading-tight">
|
<h4 className="text-xl font-bold leading-tight text-grayScale-900">
|
||||||
{formData.title || "Business English 101: Communication"}
|
{formData.title || "Untitled"}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-center gap-6 text-[14px]">
|
<p className="text-sm text-grayScale-600">
|
||||||
<span className="text-grayScale-900 ">
|
{parentSummary ? (
|
||||||
Program:{" "}
|
<span>
|
||||||
<span className="text-brand-500 ">{formData.program}</span>
|
<span className="font-medium text-grayScale-800">Link:</span>{" "}
|
||||||
</span>
|
{parentSummary}
|
||||||
<span className="text-grayScale-900 ">
|
|
||||||
Course:{" "}
|
|
||||||
<span className="text-brand-500 ">{formData.course}</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-grayScale-900 font-bold">
|
|
||||||
Module:{" "}
|
|
||||||
<span className="text-brand-500 font-extrabold">
|
|
||||||
Module 101
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
) : (
|
||||||
</div>
|
"—"
|
||||||
</div>
|
)}
|
||||||
</div>
|
</p>
|
||||||
<div className="flex flex-col items-center gap-2">
|
{formData.shuffleQuestions ? (
|
||||||
<span className="text-[11px] text-left font-medium text-grayScale-900 ">
|
<p className="text-xs text-grayScale-500">Shuffle questions: on</p>
|
||||||
Persona
|
) : null}
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2 bg-[#FAF5FF] py-1 pl-2.5 pr-4 rounded-full border border-brand-100/30">
|
|
||||||
<Avatar className="h-8 w-8 border-2 border-white shadow-sm font-bold">
|
|
||||||
<AvatarImage src={persona?.avatar} />
|
|
||||||
<AvatarFallback>P</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<span className="text-[14px] text-brand-500 capitalize">
|
|
||||||
{persona?.name || "Alex Johnson"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 2. Tips Section (Image 1436.1) */}
|
|
||||||
<div className="space-y-4 px-2">
|
<div className="space-y-4 px-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-[12px] font-bold text-grayScale-900 uppercase tracking-widest leading-none">
|
<label className="text-[12px] font-bold uppercase tracking-widest text-grayScale-900">
|
||||||
TIPS / GUIDANCE
|
Quick tips
|
||||||
</label>
|
</label>
|
||||||
<Info className="h-4 w-4 text-brand-500" />
|
<Info className="h-4 w-4 text-brand-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 pt-2 pb-8 bg-white border border-[#E2E8F0] shadow-sm rounded-xl">
|
<div className="rounded-xl border border-[#E2E8F0] bg-white px-5 py-4 shadow-sm">
|
||||||
<p className="text-[14px] text-grayScale-500 font-medium leading-relaxed">
|
<p className="text-[14px] font-medium leading-relaxed text-grayScale-600">
|
||||||
{formData.tips ||
|
{formData.tips?.trim() || "—"}
|
||||||
"Focus on using the present perfect continuous tense to describe an action that started in the past and continues now."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isModuleContext ? (
|
<div className="space-y-4">
|
||||||
/* 3. Split Questions & Answers Layout (Image 1413.1) */
|
<h3 className="px-2 text-lg font-bold text-grayScale-900">Questions</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 bg-white rounded-[12px] border border-grayScale-50 shadow-sm overflow-hidden min-h-[600px]">
|
<div className="space-y-4">
|
||||||
{/* Left Column: Questions */}
|
|
||||||
<div className="border-r border-grayScale-200 flex flex-col">
|
|
||||||
<div className="p-4 border-b border-grayScale-50 flex items-center gap-3 bg-white">
|
|
||||||
<h3 className="text-[16px] font-extrabold text-[#0F172A]">
|
|
||||||
Questions
|
|
||||||
</h3>
|
|
||||||
<span className="h-6 w-6 rounded-full bg-grayScale-100 flex items-center justify-center text-[12px] font-extrabold text-grayScale-500">
|
|
||||||
{formData.questions.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 space-y-14">
|
|
||||||
{formData.questions.map((q: any, i: number) => (
|
|
||||||
<div key={q.id} className="relative pl-12">
|
|
||||||
<span className="absolute left-0 top-0 text-[18px] font-bold text-grayScale-400 tracking-tighter opacity-70">
|
|
||||||
{(i + 1).toString().padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<span className="text-[11px] font-extrabold text-grayScale-600 uppercase tracking-[0.1em] block">
|
|
||||||
TEXT PROMPT
|
|
||||||
</span>
|
|
||||||
<p className="text-[16px] font-medium text-grayScale-600 leading-relaxed max-w-[90%]">
|
|
||||||
{q.text}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<span className="text-[11px] font-extrabold text-grayScale-300 uppercase tracking-[0.1em] block">
|
|
||||||
VOICE PROMPT
|
|
||||||
</span>
|
|
||||||
<VoicePrompt
|
|
||||||
filename={q.voicePrompt}
|
|
||||||
className="bg-[#FAF5FF]/60 border-[#F3E8FF] h-[72px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column: Answers */}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="p-4 border-b border-grayScale-50 flex items-center justify-between bg-white">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h3 className="text-[16px] font-extrabold ">Answers</h3>
|
|
||||||
<span className="h-6 w-6 rounded-full bg-grayScale-100 flex items-center justify-center text-[12px] font-extrabold text-grayScale-500">
|
|
||||||
{formData.questions.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button className="flex items-center gap-2 text-brand-500 font-bold text-[15px] hover:opacity-80 transition-opacity">
|
|
||||||
<Edit2 className="h-3 w-3" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 space-y-14">
|
|
||||||
{formData.questions.map((q: any, i: number) => (
|
|
||||||
<div key={q.id + "_ans"} className="relative pl-12">
|
|
||||||
<span className="absolute left-0 top-0 text-[18px] font-bold text-grayScale-400 tracking-tighter opacity-70">
|
|
||||||
{(i + 1).toString().padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<span className="text-[11px] font-extrabold text-grayScale-600 uppercase tracking-[0.1em] block">
|
|
||||||
VOICE PROMPT
|
|
||||||
</span>
|
|
||||||
<VoicePrompt
|
|
||||||
filename={q.sampleAnswer}
|
|
||||||
className="bg-[#FAF5FF]/60 border-[#F3E8FF] h-[60px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Original Non-Module View */
|
|
||||||
<div className="space-y-6">
|
|
||||||
{formData.questions.map((q: any, i: number) => (
|
{formData.questions.map((q: any, i: number) => (
|
||||||
<ReviewItem key={q.id} q={q} index={i} />
|
<QuestionReviewBlock
|
||||||
|
key={q.id}
|
||||||
|
q={q}
|
||||||
|
index={i}
|
||||||
|
typeDefinitions={typeDefinitions}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Action Footer */}
|
|
||||||
<div className="flex items-center justify-between pt-12">
|
<div className="flex items-center justify-between pt-12">
|
||||||
<Button
|
<Button
|
||||||
onClick={prevStep}
|
onClick={prevStep}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-10 px-10 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 bg-white shadow-sm hover:bg-grayScale-50 transition-all text-sm"
|
className="h-10 rounded-[6px] border-grayScale-200 bg-white px-10 text-sm font-bold text-grayScale-600 shadow-sm transition-all hover:bg-grayScale-50"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-10 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 bg-white shadow-sm hover:bg-grayScale-50 transition-all text-sm"
|
disabled={submitting || !canPublish}
|
||||||
|
onClick={onSaveDraft}
|
||||||
|
className="h-10 rounded-[6px] border-grayScale-100 bg-white px-8 text-sm font-bold text-grayScale-600 shadow-sm hover:bg-grayScale-50"
|
||||||
>
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
Save as Draft
|
Save as Draft
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsPublished(true)}
|
disabled={submitting || !canPublish}
|
||||||
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold hover:bg-brand-600 shadow-xl shadow-brand-500/20 gap-3 active:scale-95 transition-all text-white text-sm"
|
onClick={onPublish}
|
||||||
|
className="h-10 gap-3 rounded-[6px] bg-brand-500 px-10 text-sm font-bold text-white shadow-xl shadow-brand-500/20 transition-all hover:bg-brand-600 active:scale-95 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Rocket className="h-4 w-4" />
|
{submitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Rocket className="h-4 w-4" />
|
||||||
|
)}
|
||||||
Publish Now
|
Publish Now
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -246,59 +161,146 @@ export function ReviewStep({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReviewItem({ q, index }: { q: any; index: number }) {
|
function QuestionReviewBlock({
|
||||||
|
q,
|
||||||
|
index,
|
||||||
|
typeDefinitions,
|
||||||
|
}: {
|
||||||
|
q: any;
|
||||||
|
index: number;
|
||||||
|
typeDefinitions: QuestionTypeDefinition[];
|
||||||
|
}) {
|
||||||
|
const def = typeDefinitions.find((d) => d.id === q.questionTypeDefinitionId);
|
||||||
|
const badge =
|
||||||
|
def != null
|
||||||
|
? `${def.display_name}${def.is_system ? "" : ` · ${def.key}`}`
|
||||||
|
: q.questionTypeDefinitionId != null
|
||||||
|
? `Type #${q.questionTypeDefinitionId}`
|
||||||
|
: "No type selected";
|
||||||
|
|
||||||
|
const isDynamic = def != null && definitionUsesDynamicPayload(def);
|
||||||
|
const legacy = def != null ? legacyQuestionTypeFromDefinition(def) : null;
|
||||||
|
|
||||||
|
const schemaRows: { key: string; label: string; value: string }[] = [];
|
||||||
|
if (isDynamic && def) {
|
||||||
|
const vals = (q.dynamicFieldValues ?? {}) as Record<string, string>;
|
||||||
|
for (const r of def.stimulus_schema) {
|
||||||
|
const k = `stimulus:${r.id}`;
|
||||||
|
schemaRows.push({
|
||||||
|
key: k,
|
||||||
|
label: r.label?.trim() || r.kind,
|
||||||
|
value: vals[k] ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const r of def.response_schema) {
|
||||||
|
const k = `response:${r.id}`;
|
||||||
|
schemaRows.push({
|
||||||
|
key: k,
|
||||||
|
label: r.label?.trim() || r.kind,
|
||||||
|
value: vals[k] ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden border-grayScale-50 shadow-soft rounded-2xl bg-white relative">
|
<Card className="relative overflow-hidden rounded-2xl border-grayScale-50 bg-white shadow-soft">
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-[5px] bg-brand-500" />
|
<div className="absolute bottom-0 left-0 top-0 w-[5px] bg-brand-500" />
|
||||||
<div className="px-5 pb-7 pt-2 space-y-6">
|
<div className="space-y-4 px-5 pb-6 pt-4 pl-7">
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-50 pb-4 mb-4">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-grayScale-50 pb-3">
|
||||||
<div className="flex items-center gap-3">
|
<span className="text-base font-bold text-grayScale-500">
|
||||||
<GripVertical className="h-5 w-5 text-brand-500 cursor-grab" />
|
Question {index + 1}
|
||||||
<span className="font-bold text-grayScale-500 text-base">
|
</span>
|
||||||
Question {index + 1}
|
<span className="rounded-md bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700">
|
||||||
</span>
|
{badge}
|
||||||
</div>
|
</span>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-brand-500 hover:bg-brand-50 rounded-lg"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
|
|
||||||
<div className="md:col-span-8 space-y-3">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
|
||||||
QUESTION PROMPT
|
Question text
|
||||||
</label>
|
</span>
|
||||||
<Input
|
<Input
|
||||||
value={q.text}
|
value={q.text}
|
||||||
readOnly
|
readOnly
|
||||||
className="h-16 rounded-xl border-grayScale-200 font-medium px-6 text-base placeholder:text-grayScale-400 bg-white text-grayScale-700"
|
className="min-h-[52px] rounded-xl border-grayScale-200 bg-white px-4 py-3 text-base font-medium text-grayScale-700"
|
||||||
placeholder="e.g. How long have you been studying English?"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-4 space-y-3">
|
|
||||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
|
||||||
VOICE PROMPT
|
|
||||||
</label>
|
|
||||||
<VoicePrompt
|
|
||||||
src={q.voicePrompt}
|
|
||||||
filename={q.voicePrompt}
|
|
||||||
onRemove={() => {}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="md:w-1/3 space-y-3">
|
|
||||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
|
||||||
SAMPLE ANSWER PROMPT
|
|
||||||
</label>
|
|
||||||
<VoicePrompt
|
|
||||||
src={q.sampleAnswer}
|
|
||||||
filename={q.sampleAnswer}
|
|
||||||
onRemove={() => {}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isDynamic && schemaRows.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
|
||||||
|
Stimulus and response fields
|
||||||
|
</span>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{schemaRows.map((row) => (
|
||||||
|
<li key={row.key} className="rounded-lg border border-grayScale-100 bg-grayScale-50/50 px-3 py-2">
|
||||||
|
<span className="block text-[10px] font-bold uppercase tracking-wide text-grayScale-500">
|
||||||
|
{row.label}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block break-all font-mono text-xs text-grayScale-800">
|
||||||
|
{row.value?.trim() ? row.value : "—"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{legacy === "MCQ" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
|
||||||
|
Choices
|
||||||
|
</span>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{(q.mcqOptions ?? []).map(
|
||||||
|
(opt: { text?: string; isCorrect?: boolean }, j: number) =>
|
||||||
|
opt.text?.trim() ? (
|
||||||
|
<li
|
||||||
|
key={j}
|
||||||
|
className={
|
||||||
|
opt.isCorrect
|
||||||
|
? "font-medium text-green-700"
|
||||||
|
: "text-grayScale-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{opt.isCorrect ? "✓ " : ""}
|
||||||
|
{opt.text}
|
||||||
|
</li>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{legacy === "TRUE_FALSE" && (
|
||||||
|
<p className="text-sm text-grayScale-700">
|
||||||
|
<span className="font-semibold">Correct:</span>{" "}
|
||||||
|
{q.trueFalseCorrect !== false ? "True" : "False"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{legacy === "SHORT_ANSWER" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
|
||||||
|
Acceptable answers
|
||||||
|
</span>
|
||||||
|
<ul className="list-disc space-y-1 pl-5 text-sm text-grayScale-700">
|
||||||
|
{(q.shortAnswers ?? [])
|
||||||
|
.filter((s: string) => s?.trim())
|
||||||
|
.map((s: string, j: number) => (
|
||||||
|
<li key={j}>{s}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{def != null && legacy == null && !isDynamic ? (
|
||||||
|
<p className="text-xs text-amber-800">
|
||||||
|
This type has no schema and is not mapped to a classic MCQ / true–false /
|
||||||
|
short-answer form. Publish still sends the best-effort payload from the
|
||||||
|
builder.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,121 @@
|
||||||
import { Upload, ArrowRight } from "lucide-react";
|
import { useRef, useState, type ChangeEvent } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Upload, ArrowRight, Loader2 } from "lucide-react";
|
||||||
import { Button } from "../../../../components/ui/button";
|
import { Button } from "../../../../components/ui/button";
|
||||||
import { Card } from "../../../../components/ui/card";
|
import { Card } from "../../../../components/ui/card";
|
||||||
import { Input } from "../../../../components/ui/input";
|
import { Input } from "../../../../components/ui/input";
|
||||||
import { Textarea } from "../../../../components/ui/textarea";
|
import { Textarea } from "../../../../components/ui/textarea";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { uploadImageFile } from "../../../../api/files.api";
|
||||||
|
|
||||||
interface ScenarioStepProps {
|
interface ScenarioStepProps {
|
||||||
formData: any;
|
formData: any;
|
||||||
setFormData: (data: any) => void;
|
setFormData: (data: any) => void;
|
||||||
nextStep: () => void;
|
nextStep: () => void;
|
||||||
prevStep: () => void;
|
cancelHref: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScenarioStep({
|
export function ScenarioStep({
|
||||||
formData,
|
formData,
|
||||||
setFormData,
|
setFormData,
|
||||||
nextStep,
|
nextStep,
|
||||||
prevStep,
|
cancelHref,
|
||||||
}: ScenarioStepProps) {
|
}: ScenarioStepProps) {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploadingBanner, setUploadingBanner] = useState(false);
|
||||||
|
|
||||||
|
const onBannerFile = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
setUploadingBanner(true);
|
||||||
|
try {
|
||||||
|
const res = await uploadImageFile(file);
|
||||||
|
const url = res.data?.data?.url?.trim();
|
||||||
|
if (!url) throw new Error("Missing URL");
|
||||||
|
setFormData({ ...formData, storyImageUrl: url });
|
||||||
|
toast.success("Story image uploaded");
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not upload image");
|
||||||
|
} finally {
|
||||||
|
setUploadingBanner(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canContinue =
|
||||||
|
Boolean(formData.title?.trim()) && Boolean(formData.description?.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-1 px-2">
|
<div className="space-y-1 px-2">
|
||||||
<h2 className="text-2xl font-extrabold text-grayScale-700">
|
<h2 className="text-2xl font-extrabold text-grayScale-700">
|
||||||
Define Scenario Details
|
Practice details
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-grayScale-400 text-lg">
|
<p className="text-grayScale-400 text-lg">
|
||||||
Set the scene and context for this English practice session.
|
Story fields and question set options used when saving the practice.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-sm text-grayScale-700">
|
|
||||||
Practice Banner Image
|
|
||||||
</label>
|
|
||||||
<p className="text-xs pb-2 text-grayScale-400">
|
|
||||||
This image will appear as the background for the scenario.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-grayScale-200 bg-[#F8F9FA] p-12 hover:bg-grayScale-50 transition-all">
|
|
||||||
<div className="mb-4 rounded-xl border border-grayScale-100 bg-white p-3 text-brand-500 shadow-sm">
|
|
||||||
<Upload className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm">
|
|
||||||
<span className="text-grayScale-700">
|
|
||||||
Click to upload or drag and drop
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-grayScale-400 uppercase tracking-wide ">
|
|
||||||
SVG, PNG, JPG (MAX 5MB)
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="mt-6 h-10 rounded-[6px] border-grayScale-200 bg-white px-8 font-bold text-brand-500 shadow-sm hover:bg-grayScale-50"
|
|
||||||
>
|
|
||||||
Browse Files
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
|
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Practice Title <span className="text-red-500">*</span>
|
Story image <span className="text-grayScale-400">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g., Ordering Coffee at a Cafe"
|
value={formData.storyImageUrl ?? ""}
|
||||||
className="h-12 rounded-xl border-grayScale-200 focus:border-brand-500 placeholder:text-grayScale-500 bg-white"
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, storyImageUrl: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Image URL"
|
||||||
|
className="h-10 rounded-lg border-grayScale-200 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={onBannerFile}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={uploadingBanner}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{uploadingBanner ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Upload image
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-center gap-3 text-sm text-grayScale-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-grayScale-300 text-brand-600 focus:ring-brand-500"
|
||||||
|
checked={Boolean(formData.shuffleQuestions)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
shuffleQuestions: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Shuffle questions in the set</span>
|
||||||
|
</label>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
|
Practice title <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Ordering coffee at a cafe"
|
||||||
|
className="h-12 rounded-xl border-grayScale-200 focus:border-brand-500 placeholder:text-grayScale-500 bg-white"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, title: e.target.value })
|
setFormData({ ...formData, title: e.target.value })
|
||||||
|
|
@ -72,11 +124,11 @@ export function ScenarioStep({
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Scenario Description <span className="text-red-500">*</span>
|
Story description <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Describe the setting..."
|
placeholder="Describe the scenario…"
|
||||||
className="min-h-[160px] rounded-xl resize-none p-4 border-grayScale-200 focus:border-brand-500 leading-relaxed placeholder:text-grayScale-500 bg-white"
|
className="min-h-[160px] rounded-xl resize-none p-4 border-grayScale-200 focus:border-brand-500 leading-relaxed placeholder:text-grayScale-500 bg-white"
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
|
|
@ -91,26 +143,34 @@ export function ScenarioStep({
|
||||||
{formData.description.length} / 1000
|
{formData.description.length} / 1000
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-grayScale-500">
|
</div>
|
||||||
Provide context for the AI and the student. Be specific about the
|
<div className="space-y-2">
|
||||||
location and the goal.
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
</span>
|
Quick tips <span className="text-grayScale-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.tips ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, tips: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Learner-facing tips (quick_tips)"
|
||||||
|
className="min-h-[80px] rounded-xl border-grayScale-200"
|
||||||
|
maxLength={1000}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-4">
|
<div className="flex items-center justify-between pt-4">
|
||||||
<Button
|
<Button variant="outline" className="h-10 px-6" asChild>
|
||||||
onClick={prevStep}
|
<Link to={cancelHref}>Cancel</Link>
|
||||||
variant="outline"
|
|
||||||
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600 shadow-sm"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
disabled={!formData.title || !formData.description}
|
disabled={!canContinue}
|
||||||
className="h-10 rounded-[6px] bg-brand-500 px-8 "
|
className="h-10 rounded-[6px] bg-brand-500 px-8 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Next: Persona <ArrowRight className="ml-2 h-4 w-4" />
|
Next: Questions <ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user