From 92a2fab833767f46161732419ce5ba3fb4704ee6 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Thu, 4 Jun 2026 12:34:39 -0700 Subject: [PATCH] feat(admin): dynamic content flows, cleaner UI copy, and table pagination Add Learn English practice and question-type builder integrations with dynamic schema slots and HTML table editor. Remove API path labels from admin pages and standardize table page-size options to 5, 10, 30, 50, and 100. Co-authored-by: Cursor --- src/api/files.api.ts | 4 +- .../DynamicSchemaSlotField.tsx | 147 +++++++++- .../DynamicTableBuilder.tsx | 250 ++++++++++++++++++ .../PracticeQuestionEditorFields.tsx | 5 +- src/lib/dynamicTableValue.ts | 57 ++++ src/lib/learnEnglishDefinitionQuestion.ts | 102 ++++++- src/lib/learnEnglishPracticePublish.ts | 13 +- src/lib/tablePagination.ts | 6 + .../content-management/AddNewPracticePage.tsx | 44 +-- .../content-management/AddPracticeFlow.tsx | 9 +- .../content-management/AddQuestionPage.tsx | 4 +- .../content-management/AllCoursesPage.tsx | 3 +- .../content-management/CourseDetailPage.tsx | 6 +- src/pages/content-management/CoursesPage.tsx | 3 +- .../CreateQuestionTypeFlow.tsx | 45 ++-- .../HumanLanguageHierarchyPage.tsx | 6 +- .../HumanLanguageSubModulePage.tsx | 2 +- .../content-management/LearnEnglishPage.tsx | 17 +- .../LessonPracticesPage.tsx | 6 - .../content-management/ModuleDetailPage.tsx | 10 +- .../PracticeDetailsPage.tsx | 8 +- .../PracticeQuestionsPage.tsx | 74 ++++-- .../content-management/ProgramCoursesPage.tsx | 17 +- .../QuestionTypeLibraryPage.tsx | 4 +- .../content-management/QuestionsPage.tsx | 3 +- src/pages/content-management/SpeakingPage.tsx | 78 ++++-- .../components/AddModuleModal.tsx | 6 +- .../components/CreatePracticeWizard.tsx | 17 +- .../components/practice-steps/ContextStep.tsx | 21 +- .../practice-steps/PublishStatusField.tsx | 3 +- .../practice-steps/QuestionsStep.tsx | 77 ++++-- .../DefinitionRuntimeHint.tsx | 43 +++ .../QuestionTypeBasicInfoStep.tsx | 6 +- .../QuestionTypeConfigStep.tsx | 76 +----- .../QuestionTypeReviewPublishStep.tsx | 132 ++++++--- .../QuestionTypeValidatePreviewStep.tsx | 89 +++++-- .../question-type-steps/componentKindUi.ts | 2 + .../video-steps/VideoDetailStep.tsx | 16 +- .../lib/questionTypeDefinitionValidation.ts | 48 ++++ src/pages/issues/IssuesPage.tsx | 3 +- .../notifications/CreateEmailTemplatePage.tsx | 6 +- .../notifications/EmailTemplatesPage.tsx | 7 +- src/pages/notifications/NotificationsPage.tsx | 35 ++- .../components/EmailTemplateCreateForm.tsx | 5 +- .../components/EmailTemplateEditForm.tsx | 6 +- src/pages/payments/PaymentsPage.tsx | 7 +- src/pages/role-management/RolesListPage.tsx | 81 ++++-- .../components/InviteTeamMemberDialog.tsx | 8 +- src/pages/settings/AppVersionsTab.tsx | 52 ++-- src/pages/settings/SubscriptionPlansTab.tsx | 5 +- .../components/CreateAppVersionDialog.tsx | 5 +- .../CreateSubscriptionPlanDialog.tsx | 3 +- .../components/EditAppVersionDialog.tsx | 5 +- .../components/EditSubscriptionPlanDialog.tsx | 5 +- src/pages/team/TeamManagementPage.tsx | 3 +- src/pages/user-log/UserLogPage.tsx | 3 +- .../user-management/DeletionRequestsPage.tsx | 3 +- src/pages/user-management/UsersListPage.tsx | 3 +- src/types/course.types.ts | 3 +- 59 files changed, 1226 insertions(+), 481 deletions(-) create mode 100644 src/components/content-management/DynamicTableBuilder.tsx create mode 100644 src/lib/dynamicTableValue.ts create mode 100644 src/lib/tablePagination.ts create mode 100644 src/pages/content-management/components/question-type-steps/DefinitionRuntimeHint.tsx diff --git a/src/api/files.api.ts b/src/api/files.api.ts index 101195c..b7e5d54 100644 --- a/src/api/files.api.ts +++ b/src/api/files.api.ts @@ -1,6 +1,6 @@ import http from "./http" -export type UploadMediaType = "image" | "audio" | "video" +export type UploadMediaType = "image" | "audio" | "video" | "pdf" export type UploadProvider = "MINIO" | "VIMEO" export interface UploadMediaResponse { @@ -121,6 +121,8 @@ export const uploadVideoFile = (fileOrUrl: File | string, options?: UploadMediaO }) : uploadMediaFile("video", fileOrUrl, options) +export const uploadPdfFile = (file: File) => uploadMediaFile("pdf", file) + export const resolveFileUrl = (key: string) => http.get("/files/url", { params: { key }, diff --git a/src/components/content-management/DynamicSchemaSlotField.tsx b/src/components/content-management/DynamicSchemaSlotField.tsx index fa9af4d..61cc808 100644 --- a/src/components/content-management/DynamicSchemaSlotField.tsx +++ b/src/components/content-management/DynamicSchemaSlotField.tsx @@ -6,15 +6,17 @@ import { type ChangeEvent, type DragEvent, } from "react" -import { CloudUpload, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react" +import { CloudUpload, FileText, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react" import { toast } from "sonner" -import { uploadAudioFile, uploadImageFile } from "../../api/files.api" +import { uploadAudioFile, uploadImageFile, uploadPdfFile } from "../../api/files.api" import { resolveMediaPreviewUrl } from "../../lib/practiceMedia" +import { Button } from "../ui/button" 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" +import { DynamicTableBuilder } from "./DynamicTableBuilder" const MAX_IMAGE_BYTES = 10 * 1024 * 1024 const MAX_AUDIO_BYTES = 50 * 1024 * 1024 @@ -28,9 +30,11 @@ export interface DynamicSchemaSlotRow { required?: boolean } -function slotMediaMode(kind: string): "image" | "audio" | "text" { +function slotMediaMode(kind: string): "image" | "audio" | "pdf" | "table" | "text" { const u = kind.trim().toUpperCase() if (u === "IMAGE") return "image" + if (u === "TABLE") return "table" + if (u === "PDF_ATTACHMENT" || u === "PDF_UPLOAD") return "pdf" if (u.startsWith("AUDIO")) return "audio" return "text" } @@ -537,6 +541,103 @@ export interface DynamicSchemaSlotFieldProps { disabled?: boolean } +function DynamicPdfSlot({ + value, + onChange, + disabled, + slotLabel, + slotMeta, +}: { + value: string + onChange: (next: string) => void + disabled: boolean + slotLabel: string + slotMeta: string +}) { + const fileInputRef = useRef(null) + const [uploading, setUploading] = useState(false) + + const processFile = useCallback( + async (file: File) => { + if (disabled || uploading) return + const ext = file.name.split(".").pop()?.toLowerCase() ?? "" + if (ext !== "pdf") { + toast.error("Only PDF files are allowed") + return + } + if (file.size > 25 * 1024 * 1024) { + toast.error("PDF is too large", { description: "Maximum size is 25 MB." }) + return + } + setUploading(true) + try { + const res = await uploadPdfFile(file) + const url = res.data?.data?.url?.trim() + if (!url) throw new Error("Upload did not return a URL") + onChange(url) + toast.success("PDF uploaded") + } catch (e) { + console.error(e) + toast.error("Failed to upload PDF") + } finally { + setUploading(false) + } + }, + [disabled, onChange, uploading], + ) + + return ( +
+
+ + {slotMeta} +
+ { + const file = e.target.files?.[0] + e.target.value = "" + if (file) void processFile(file) + }} + /> +
+ + {value.trim() ? ( + + ) : null} +
+ onChange(e.target.value)} + placeholder="https://… or upload above" + className="h-11 rounded-lg border-grayScale-200 font-mono text-sm" + disabled={disabled || uploading} + /> +
+ ) +} + export function DynamicSchemaSlotField({ row, value, @@ -546,10 +647,30 @@ export function DynamicSchemaSlotField({ const mode = slotMediaMode(row.kind) const baseLabel = row.label?.trim() || - (mode === "image" ? "Image" : mode === "audio" ? "Audio" : row.kind) + (mode === "image" + ? "Image" + : mode === "audio" + ? "Audio" + : mode === "pdf" + ? "PDF" + : mode === "table" + ? "Table" + : row.kind) const slotLabel = `${baseLabel}${row.required ? " *" : ""}` const slotMeta = `${row.id} · ${row.kind}` + if (mode === "table") { + return ( + + ) + } + if (mode === "text") { return (
@@ -561,7 +682,11 @@ export function DynamicSchemaSlotField({ rows={3} value={value} onChange={(e) => onChange(e.target.value)} - placeholder="URL, plain text, or JSON object" + placeholder={ + row.kind === "OPTION" + ? '{"options":[{"id":"a","text":"…","is_correct":true}]}' + : "URL, plain text, or JSON object" + } className="min-h-[88px] resize-y rounded-lg border-grayScale-200 font-mono text-sm" disabled={disabled} /> @@ -581,6 +706,18 @@ export function DynamicSchemaSlotField({ ) } + if (mode === "pdf") { + return ( + + ) + } + return ( void + disabled?: boolean + slotLabel: string + slotMeta: string +} + +function normalizeTable(table: DynamicTableValue): DynamicTableValue { + const columns = + table.columns.length > 0 + ? table.columns.map((c, i) => c.trim() || `Column ${i + 1}`) + : ["Column 1"] + const colCount = columns.length + const rows = + table.rows.length > 0 + ? table.rows.map((row) => { + const cells = [...row] + while (cells.length < colCount) cells.push("") + return cells.slice(0, colCount) + }) + : [Array(colCount).fill("")] + return { columns, rows } +} + +export function DynamicTableBuilder({ + value, + onChange, + disabled = false, + slotLabel, + slotMeta, +}: DynamicTableBuilderProps) { + const table = useMemo(() => normalizeTable(parseTableSlotValue(value)), [value]) + + const commit = (next: DynamicTableValue) => { + onChange(serializeTableSlotValue(normalizeTable(next))) + } + + const updateColumn = (colIndex: number, text: string) => { + const columns = [...table.columns] + columns[colIndex] = text + commit({ columns, rows: table.rows }) + } + + const updateCell = (rowIndex: number, colIndex: number, text: string) => { + const rows = table.rows.map((r) => [...r]) + rows[rowIndex][colIndex] = text + commit({ columns: table.columns, rows }) + } + + const addColumn = () => { + const columns = [...table.columns, `Column ${table.columns.length + 1}`] + const rows = table.rows.map((row) => [...row, ""]) + commit({ columns, rows }) + } + + const removeColumn = (colIndex: number) => { + if (table.columns.length <= 1) return + const columns = table.columns.filter((_, i) => i !== colIndex) + const rows = table.rows.map((row) => row.filter((_, i) => i !== colIndex)) + commit({ columns, rows }) + } + + const addRow = () => { + const rows = [...table.rows, Array(table.columns.length).fill("")] + commit({ columns: table.columns, rows }) + } + + const removeRow = (rowIndex: number) => { + if (table.rows.length <= 1) return + const rows = table.rows.filter((_, i) => i !== rowIndex) + commit({ columns: table.columns, rows }) + } + + const resetTable = () => { + commit(createEmptyTable(2, 1)) + } + + const previewColumns = table.columns.map((c, i) => c.trim() || `Column ${i + 1}`) + const previewRows = table.rows.map((row) => + row.map((cell, ci) => cell.trim() || ""), + ) + + return ( +
+
+ + {slotMeta} +
+ +

+ Build the reference table learners will see with the question. +

+ +
+ + + + {table.columns.map((col, colIndex) => ( + + ))} + + + + {table.rows.map((row, rowIndex) => ( + + {row.map((cell, colIndex) => ( + + ))} + + + ))} + +
+
+ updateColumn(colIndex, e.target.value)} + placeholder={`Column ${colIndex + 1}`} + className="h-9 border-grayScale-200 bg-white text-xs font-semibold" + /> + {table.columns.length > 1 ? ( + + ) : null} +
+
+
+ updateCell(rowIndex, colIndex, e.target.value)} + placeholder="Cell value" + className="h-9 border-grayScale-200 bg-[#F8FAFC] text-sm" + /> + + +
+
+ +
+ + + +
+ +
+

+ Learner preview +

+
+ + + + {previewColumns.map((col, i) => ( + + ))} + + + + {previewRows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {col} +
+ {cell || } +
+
+
+
+ ) +} diff --git a/src/components/content-management/PracticeQuestionEditorFields.tsx b/src/components/content-management/PracticeQuestionEditorFields.tsx index 341d488..3188a79 100644 --- a/src/components/content-management/PracticeQuestionEditorFields.tsx +++ b/src/components/content-management/PracticeQuestionEditorFields.tsx @@ -778,9 +778,8 @@ export function PracticeQuestionEditorFields({ {value.questionType === "DYNAMIC" && (

- Image / Audio slots: drop file or paste URL - (imports via POST /files/upload). Other - slots: text or JSON. + Image, audio, and PDF slots support upload or a URL. Table slots use the visual builder. Other + fields accept text or structured values where noted.

- Create a practice: question types from{" "} - GET /questions/type-definitions, then - question set and POST /practices. + Create a practice with story details, a persona, and questions from your question type library.

{lessonId ? (
diff --git a/src/pages/content-management/AddQuestionPage.tsx b/src/pages/content-management/AddQuestionPage.tsx index e79fd09..e336b08 100644 --- a/src/pages/content-management/AddQuestionPage.tsx +++ b/src/pages/content-management/AddQuestionPage.tsx @@ -268,7 +268,7 @@ export function AddQuestionPage() { return } } catch { - toast.error("Invalid JSON", { description: "Fix dynamic_payload JSON before saving." }) + toast.error("Invalid JSON", { description: "Fix the dynamic content JSON before saving." }) return } } @@ -419,7 +419,7 @@ export function AddQuestionPage() {