From 2b556d9d096d1e14b2b4a7956d629a02e647bdd2 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Wed, 13 May 2026 09:30:53 -0700 Subject: [PATCH] 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 --- src/api/questionTypeDefinitions.api.ts | 13 +- src/app/AppRoutes.tsx | 5 + .../DynamicSchemaSlotField.tsx | 593 ++++++++++++++++++ .../PracticeQuestionEditorFields.tsx | 92 ++- src/lib/learnEnglishDefinitionQuestion.ts | 175 ++++++ src/lib/learnEnglishPracticePublish.ts | 135 ++++ .../content-management/AddPracticeFlow.tsx | 365 ++++++++--- .../content-management/AddQuestionPage.tsx | 187 +++--- .../LessonPracticesPage.tsx | 379 +++++++++++ .../content-management/ModuleDetailPage.tsx | 5 + .../components/VideoCard.tsx | 34 +- .../components/practice-steps/ContextStep.tsx | 232 ++++--- .../practice-steps/QuestionsStep.tsx | 479 +++++++++++--- .../components/practice-steps/ReviewStep.tsx | 444 ++++++------- .../practice-steps/ScenarioStep.tsx | 164 +++-- 15 files changed, 2598 insertions(+), 704 deletions(-) create mode 100644 src/components/content-management/DynamicSchemaSlotField.tsx create mode 100644 src/lib/learnEnglishDefinitionQuestion.ts create mode 100644 src/lib/learnEnglishPracticePublish.ts create mode 100644 src/pages/content-management/LessonPracticesPage.tsx diff --git a/src/api/questionTypeDefinitions.api.ts b/src/api/questionTypeDefinitions.api.ts index 1ac88db..5c3bc00 100644 --- a/src/api/questionTypeDefinitions.api.ts +++ b/src/api/questionTypeDefinitions.api.ts @@ -217,7 +217,9 @@ export function normalizeTypeDefinitionFromApi(raw: unknown): QuestionTypeDefini return { id, 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: (() => { const d = o.Description ?? o.description 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`). * Example update: `{ "data": { "id": 6 } }`. diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index af2a147..bc97bb0 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -18,6 +18,7 @@ import { NewContentPage } from "../pages/content-management/NewContentPage"; import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage"; import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage"; import { CourseDetailPage } from "../pages/content-management/CourseDetailPage"; +import { LessonPracticesPage } from "../pages/content-management/LessonPracticesPage"; import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage"; import { AddVideoFlow } from "../pages/content-management/AddVideoFlow"; 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" element={} /> + } + /> } diff --git a/src/components/content-management/DynamicSchemaSlotField.tsx b/src/components/content-management/DynamicSchemaSlotField.tsx new file mode 100644 index 0000000..fa9af4d --- /dev/null +++ b/src/components/content-management/DynamicSchemaSlotField.tsx @@ -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(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) => { + const file = e.target.files?.[0] + e.target.value = "" + if (file) void processFile(file) + } + + const zoneDisabled = disabled || uploading + const hasImage = Boolean(value.trim()) + + return ( +
+
+ + {slotMeta} +
+
+
{ + 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 ? ( +
+ + +
+ ) : ( +
+ +

Preview appears here after you upload or paste a URL on the right.

+
+ )} +
+ +
+ + + + 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" + /> +
+
+
+ ) +} + +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 ( +
+ {heights.map((h, idx) => ( +
+ ))} +
+ ) +} + +function DynamicAudioSlot({ + value, + onChange, + disabled, + slotLabel, + slotMeta, +}: { + value: string + onChange: (next: string) => void + disabled: boolean + slotLabel: string + slotMeta: string +}) { + const fileInputRef = useRef(null) + const audioRef = useRef(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) => { + 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 ( +
+
+ + {slotMeta} +
+
+
+ + {hasMedia ? ( +
+ +
+ +

+ {fileLabelFromValue(value)} +

+
+ +
+ ) : ( +
+ +

Playback preview appears here after you upload or paste a URL on the right.

+
+ )} +
+ +
+ + + 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" + /> +
+
+
+ ) +} + +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 ( +
+
+ + {slotMeta} +
+