Compare commits
No commits in common. "a6ccfba733ea525d476c4e68291b9042551cb289" and "af4f713395470bc744a3d05b450378e6aab31e32" have entirely different histories.
a6ccfba733
...
af4f713395
|
|
@ -78,26 +78,6 @@ import type {
|
||||||
CreateTopLevelCourseModuleResponse,
|
CreateTopLevelCourseModuleResponse,
|
||||||
CreateProgramCourseRequest,
|
CreateProgramCourseRequest,
|
||||||
CreateProgramCourseResponse,
|
CreateProgramCourseResponse,
|
||||||
CreateExamPrepCatalogCourseRequest,
|
|
||||||
CreateExamPrepCatalogCourseResponse,
|
|
||||||
GetExamPrepCatalogCoursesResponse,
|
|
||||||
UpdateExamPrepCatalogCourseRequest,
|
|
||||||
UpdateExamPrepCatalogCourseResponse,
|
|
||||||
CreateExamPrepCatalogUnitRequest,
|
|
||||||
CreateExamPrepCatalogUnitResponse,
|
|
||||||
UpdateExamPrepCatalogUnitRequest,
|
|
||||||
UpdateExamPrepCatalogUnitResponse,
|
|
||||||
GetExamPrepCatalogUnitsResponse,
|
|
||||||
CreateExamPrepUnitModuleRequest,
|
|
||||||
CreateExamPrepUnitModuleResponse,
|
|
||||||
UpdateExamPrepUnitModuleRequest,
|
|
||||||
UpdateExamPrepUnitModuleResponse,
|
|
||||||
GetExamPrepUnitModulesResponse,
|
|
||||||
CreateExamPrepModuleLessonRequest,
|
|
||||||
CreateExamPrepModuleLessonResponse,
|
|
||||||
UpdateExamPrepModuleLessonRequest,
|
|
||||||
UpdateExamPrepModuleLessonResponse,
|
|
||||||
GetExamPrepModuleLessonsResponse,
|
|
||||||
GetTopLevelModuleLessonsResponse,
|
GetTopLevelModuleLessonsResponse,
|
||||||
GetPracticesByParentContextResponse,
|
GetPracticesByParentContextResponse,
|
||||||
CreateParentLinkedPracticeRequest,
|
CreateParentLinkedPracticeRequest,
|
||||||
|
|
@ -467,128 +447,6 @@ export const createProgramCourse = (
|
||||||
data: CreateProgramCourseRequest,
|
data: CreateProgramCourseRequest,
|
||||||
) => http.post<CreateProgramCourseResponse>(`/programs/${programId}/courses`, data)
|
) => http.post<CreateProgramCourseResponse>(`/programs/${programId}/courses`, data)
|
||||||
|
|
||||||
/** English proficiency catalog course — POST /exam-prep/catalog-courses */
|
|
||||||
export const createExamPrepCatalogCourse = (
|
|
||||||
data: CreateExamPrepCatalogCourseRequest,
|
|
||||||
) => http.post<CreateExamPrepCatalogCourseResponse>("/exam-prep/catalog-courses", data)
|
|
||||||
|
|
||||||
/** English proficiency catalog courses — GET /exam-prep/catalog-courses */
|
|
||||||
export const getExamPrepCatalogCourses = (params?: { limit?: number; offset?: number }) =>
|
|
||||||
http.get<GetExamPrepCatalogCoursesResponse>("/exam-prep/catalog-courses", { params })
|
|
||||||
|
|
||||||
/** English proficiency catalog course — PUT /exam-prep/catalog-courses/:catalogCourseId */
|
|
||||||
export const updateExamPrepCatalogCourse = (
|
|
||||||
catalogCourseId: number,
|
|
||||||
data: UpdateExamPrepCatalogCourseRequest,
|
|
||||||
) =>
|
|
||||||
http.put<UpdateExamPrepCatalogCourseResponse>(
|
|
||||||
`/exam-prep/catalog-courses/${catalogCourseId}`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency catalog course — DELETE /exam-prep/catalog-courses/:catalogCourseId */
|
|
||||||
export const deleteExamPrepCatalogCourse = (catalogCourseId: number) =>
|
|
||||||
http.delete(`/exam-prep/catalog-courses/${catalogCourseId}`)
|
|
||||||
|
|
||||||
/** English proficiency catalog unit — POST /exam-prep/catalog-courses/:catalogCourseId/units */
|
|
||||||
export const createExamPrepCatalogUnit = (
|
|
||||||
catalogCourseId: number,
|
|
||||||
data: CreateExamPrepCatalogUnitRequest,
|
|
||||||
) =>
|
|
||||||
http.post<CreateExamPrepCatalogUnitResponse>(
|
|
||||||
`/exam-prep/catalog-courses/${catalogCourseId}/units`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency catalog units — GET /exam-prep/catalog-courses/:catalogCourseId/units */
|
|
||||||
export const getExamPrepCatalogUnits = (
|
|
||||||
catalogCourseId: number,
|
|
||||||
params?: { limit?: number; offset?: number },
|
|
||||||
) =>
|
|
||||||
http.get<GetExamPrepCatalogUnitsResponse>(
|
|
||||||
`/exam-prep/catalog-courses/${catalogCourseId}/units`,
|
|
||||||
{ params },
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency unit — PUT /exam-prep/units/:unitId */
|
|
||||||
export const updateExamPrepCatalogUnit = (
|
|
||||||
unitId: number,
|
|
||||||
data: UpdateExamPrepCatalogUnitRequest,
|
|
||||||
) => http.put<UpdateExamPrepCatalogUnitResponse>(`/exam-prep/units/${unitId}`, data)
|
|
||||||
|
|
||||||
/** English proficiency unit — DELETE /exam-prep/units/:unitId */
|
|
||||||
export const deleteExamPrepCatalogUnit = (unitId: number) =>
|
|
||||||
http.delete(`/exam-prep/units/${unitId}`)
|
|
||||||
|
|
||||||
/** English proficiency unit modules — POST /exam-prep/units/:unitId/modules */
|
|
||||||
export const createExamPrepUnitModule = (
|
|
||||||
unitId: number,
|
|
||||||
data: CreateExamPrepUnitModuleRequest,
|
|
||||||
) =>
|
|
||||||
http.post<CreateExamPrepUnitModuleResponse>(
|
|
||||||
`/exam-prep/units/${unitId}/modules`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency unit modules — GET /exam-prep/units/:unitId/modules */
|
|
||||||
export const getExamPrepUnitModules = (
|
|
||||||
unitId: number,
|
|
||||||
params?: { limit?: number; offset?: number },
|
|
||||||
) =>
|
|
||||||
http.get<GetExamPrepUnitModulesResponse>(`/exam-prep/units/${unitId}/modules`, {
|
|
||||||
params,
|
|
||||||
})
|
|
||||||
|
|
||||||
/** English proficiency module — PUT /exam-prep/modules/:moduleId */
|
|
||||||
export const updateExamPrepUnitModule = (
|
|
||||||
moduleId: number,
|
|
||||||
data: UpdateExamPrepUnitModuleRequest,
|
|
||||||
) =>
|
|
||||||
http.put<UpdateExamPrepUnitModuleResponse>(
|
|
||||||
`/exam-prep/modules/${moduleId}`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency module — DELETE /exam-prep/modules/:moduleId */
|
|
||||||
export const deleteExamPrepUnitModule = (moduleId: number) =>
|
|
||||||
http.delete(`/exam-prep/modules/${moduleId}`)
|
|
||||||
|
|
||||||
/** English proficiency module lessons — GET /exam-prep/modules/:moduleId/lessons */
|
|
||||||
export const getExamPrepModuleLessons = (
|
|
||||||
moduleId: number,
|
|
||||||
params?: { limit?: number; offset?: number },
|
|
||||||
) =>
|
|
||||||
http.get<GetExamPrepModuleLessonsResponse>(
|
|
||||||
`/exam-prep/modules/${moduleId}/lessons`,
|
|
||||||
{
|
|
||||||
params,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency module lesson — POST /exam-prep/modules/:moduleId/lessons */
|
|
||||||
export const createExamPrepModuleLesson = (
|
|
||||||
moduleId: number,
|
|
||||||
data: CreateExamPrepModuleLessonRequest,
|
|
||||||
) =>
|
|
||||||
http.post<CreateExamPrepModuleLessonResponse>(
|
|
||||||
`/exam-prep/modules/${moduleId}/lessons`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency lesson — PUT /exam-prep/lessons/:lessonId */
|
|
||||||
export const updateExamPrepModuleLesson = (
|
|
||||||
lessonId: number,
|
|
||||||
data: UpdateExamPrepModuleLessonRequest,
|
|
||||||
) =>
|
|
||||||
http.put<UpdateExamPrepModuleLessonResponse>(
|
|
||||||
`/exam-prep/lessons/${lessonId}`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */
|
|
||||||
export const deleteExamPrepModuleLesson = (lessonId: number) =>
|
|
||||||
http.delete(`/exam-prep/lessons/${lessonId}`)
|
|
||||||
|
|
||||||
/** Top-level course resource (Learn English track) — PUT /courses/:id */
|
/** Top-level course resource (Learn English track) — PUT /courses/:id */
|
||||||
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
|
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
|
||||||
http.put(`/courses/${courseId}`, data)
|
http.put(`/courses/${courseId}`, data)
|
||||||
|
|
|
||||||
|
|
@ -44,36 +44,6 @@ export interface UploadMediaFromUrlPayload extends UploadMediaOptions {
|
||||||
sourceUrl: string
|
sourceUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const GOOGLE_DRIVE_HOSTS = new Set([
|
|
||||||
"drive.google.com",
|
|
||||||
"www.drive.google.com",
|
|
||||||
])
|
|
||||||
|
|
||||||
const getGoogleDriveFileId = (rawUrl: string): string | null => {
|
|
||||||
try {
|
|
||||||
const url = new URL(rawUrl.trim())
|
|
||||||
if (!GOOGLE_DRIVE_HOSTS.has(url.hostname.toLowerCase())) return null
|
|
||||||
const fromQuery = url.searchParams.get("id")?.trim()
|
|
||||||
if (fromQuery) return fromQuery
|
|
||||||
const fileMatch = url.pathname.match(/\/file\/d\/([^/]+)/i)
|
|
||||||
return fileMatch?.[1]?.trim() || null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeSourceUrlForUpload = (
|
|
||||||
mediaType: UploadMediaType,
|
|
||||||
sourceUrl: string,
|
|
||||||
): string => {
|
|
||||||
const trimmed = sourceUrl.trim()
|
|
||||||
if (mediaType !== "image") return trimmed
|
|
||||||
const fileId = getGoogleDriveFileId(trimmed)
|
|
||||||
if (!fileId) return trimmed
|
|
||||||
// Use Drive thumbnail endpoint so backend receives actual image bytes, not HTML viewer.
|
|
||||||
return `https://drive.google.com/thumbnail?id=${encodeURIComponent(fileId)}&sz=w2048`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const uploadMediaFile = (
|
export const uploadMediaFile = (
|
||||||
mediaType: UploadMediaType,
|
mediaType: UploadMediaType,
|
||||||
file: File,
|
file: File,
|
||||||
|
|
@ -97,7 +67,7 @@ export const uploadMediaFromUrl = (
|
||||||
) =>
|
) =>
|
||||||
http.post<UploadMediaResponse>("/files/upload", {
|
http.post<UploadMediaResponse>("/files/upload", {
|
||||||
media_type: mediaType,
|
media_type: mediaType,
|
||||||
source_url: normalizeSourceUrlForUpload(mediaType, payload.sourceUrl),
|
source_url: payload.sourceUrl,
|
||||||
...(mediaType === "video" && payload.title ? { title: payload.title } : {}),
|
...(mediaType === "video" && payload.title ? { title: payload.title } : {}),
|
||||||
...(mediaType === "video" && payload.description ? { description: payload.description } : {}),
|
...(mediaType === "video" && payload.description ? { description: payload.description } : {}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@ import { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTyp
|
||||||
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
|
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
|
||||||
import { CourseManagementPage } from "../pages/content-management/CourseManagementPage";
|
import { CourseManagementPage } from "../pages/content-management/CourseManagementPage";
|
||||||
import { UnitManagementPage } from "../pages/content-management/UnitManagementPage";
|
import { UnitManagementPage } from "../pages/content-management/UnitManagementPage";
|
||||||
import { QuestionTypeLibraryPage } from "../pages/content-management/QuestionTypeLibraryPage";
|
|
||||||
import { CreateQuestionTypeFlow } from "../pages/content-management/CreateQuestionTypeFlow";
|
|
||||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||||
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
|
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
|
||||||
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage";
|
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage";
|
||||||
|
|
@ -166,14 +164,6 @@ export function AppRoutes() {
|
||||||
path="/new-content/courses"
|
path="/new-content/courses"
|
||||||
element={<ProgramTypeSelectionPage />}
|
element={<ProgramTypeSelectionPage />}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/new-content/question-types"
|
|
||||||
element={<QuestionTypeLibraryPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/question-types/create"
|
|
||||||
element={<CreateQuestionTypeFlow />}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/new-content/courses/:programType"
|
path="/new-content/courses/:programType"
|
||||||
element={<ProgramDetailPage />}
|
element={<ProgramDetailPage />}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ import { Select } from "../ui/select"
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
import { SpinnerIcon } from "../ui/spinner-icon"
|
import { SpinnerIcon } from "../ui/spinner-icon"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { ResolvedAudio } from "../media/ResolvedAudio"
|
|
||||||
import { ResolvedImage } from "../media/ResolvedImage"
|
|
||||||
|
|
||||||
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
||||||
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
|
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
|
||||||
|
|
@ -817,7 +815,7 @@ export function PracticeQuestionEditorFields({
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
/>
|
/>
|
||||||
{voicePreviewUrl ? (
|
{voicePreviewUrl ? (
|
||||||
<ResolvedAudio controls src={voicePreviewUrl} className="h-10 w-full max-w-md" />
|
<audio controls src={voicePreviewUrl} className="h-10 w-full max-w-md" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -864,7 +862,7 @@ export function PracticeQuestionEditorFields({
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
/>
|
/>
|
||||||
{samplePreviewUrl ? (
|
{samplePreviewUrl ? (
|
||||||
<ResolvedAudio controls src={samplePreviewUrl} className="h-10 w-full max-w-md" />
|
<audio controls src={samplePreviewUrl} className="h-10 w-full max-w-md" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -900,7 +898,7 @@ export function PracticeQuestionEditorFields({
|
||||||
disabled={controlsDisabled}
|
disabled={controlsDisabled}
|
||||||
/>
|
/>
|
||||||
{imagePreviewUrl ? (
|
{imagePreviewUrl ? (
|
||||||
<ResolvedImage
|
<img
|
||||||
src={imagePreviewUrl}
|
src={imagePreviewUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-28 w-28 rounded-md border border-grayScale-200 object-cover"
|
className="h-28 w-28 rounded-md border border-grayScale-200 object-cover"
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { useEffect, useState, type AudioHTMLAttributes } from "react"
|
|
||||||
import { resolveDisplayMediaUrl } from "../../lib/mediaUrl"
|
|
||||||
|
|
||||||
type ResolvedAudioProps = AudioHTMLAttributes<HTMLAudioElement> & {
|
|
||||||
src?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResolvedAudio({ src, ...audioProps }: ResolvedAudioProps) {
|
|
||||||
const [resolvedSrc, setResolvedSrc] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
;(async () => {
|
|
||||||
const raw = (src ?? "").trim()
|
|
||||||
if (!raw) {
|
|
||||||
setResolvedSrc("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const next = await resolveDisplayMediaUrl(raw)
|
|
||||||
if (!cancelled) setResolvedSrc(next || raw)
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setResolvedSrc(raw)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [src])
|
|
||||||
|
|
||||||
if (!resolvedSrc) return null
|
|
||||||
return <audio {...audioProps} src={resolvedSrc} />
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { useEffect, useState, type ImgHTMLAttributes } from "react"
|
|
||||||
import { resolveDisplayMediaUrl } from "../../lib/mediaUrl"
|
|
||||||
|
|
||||||
type ResolvedImageProps = ImgHTMLAttributes<HTMLImageElement> & {
|
|
||||||
src?: string | null
|
|
||||||
fallbackSrc?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResolvedImage({ src, fallbackSrc, ...imgProps }: ResolvedImageProps) {
|
|
||||||
const [resolvedSrc, setResolvedSrc] = useState("")
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
;(async () => {
|
|
||||||
const raw = (src ?? "").trim()
|
|
||||||
if (!raw) {
|
|
||||||
setResolvedSrc("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const next = await resolveDisplayMediaUrl(raw)
|
|
||||||
if (!cancelled) setResolvedSrc(next || raw)
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setResolvedSrc(raw)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [src])
|
|
||||||
|
|
||||||
const finalSrc = resolvedSrc || fallbackSrc || ""
|
|
||||||
if (!finalSrc) return null
|
|
||||||
return <img {...imgProps} src={finalSrc} />
|
|
||||||
}
|
|
||||||
|
|
@ -22,7 +22,7 @@ export function Stepper({ steps, currentStep, className }: StepperProps) {
|
||||||
{index < steps.length - 1 && (
|
{index < steps.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-4 h-[1.5px] bg-grayScale-200 z-0"
|
className="absolute top-4 h-[1.5px] bg-grayScale-200 z-0"
|
||||||
style={{ left: "calc(50% + 50px)", right: "calc(-50% + 50px)" }}
|
style={{ left: "calc(50% + 24px)", right: "calc(-50% + 24px)" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { refreshFileUrl, resolveFileUrl } from "../api/files.api"
|
|
||||||
|
|
||||||
const HTTP_REGEX = /^https?:\/\//i
|
|
||||||
|
|
||||||
export function isHttpUrl(value: string): boolean {
|
|
||||||
return HTTP_REGEX.test(value.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSignedMinioUrl(value: string): boolean {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!isHttpUrl(trimmed)) return false
|
|
||||||
try {
|
|
||||||
const url = new URL(trimmed)
|
|
||||||
return (
|
|
||||||
url.host === "s3.yimaruacademy.com" &&
|
|
||||||
(url.searchParams.has("X-Amz-Signature") || url.searchParams.has("X-Amz-Expires"))
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveDisplayMediaUrl(value: string): Promise<string> {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!trimmed) return ""
|
|
||||||
|
|
||||||
if (isHttpUrl(trimmed)) {
|
|
||||||
if (!isSignedMinioUrl(trimmed)) return trimmed
|
|
||||||
try {
|
|
||||||
const refreshed = await refreshFileUrl(trimmed)
|
|
||||||
const refreshedUrl = refreshed.data?.data?.url?.trim()
|
|
||||||
return refreshedUrl || trimmed
|
|
||||||
} catch {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = await resolveFileUrl(trimmed)
|
|
||||||
return resolved.data?.data?.url?.trim() || ""
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { resolveDisplayMediaUrl } from "./mediaUrl"
|
import { resolveFileUrl } from "../api/files.api"
|
||||||
|
|
||||||
export function normalizeObjectKey(value: string): string {
|
export function normalizeObjectKey(value: string): string {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
|
|
@ -12,7 +12,10 @@ export function normalizeObjectKey(value: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveMediaPreviewUrl(value: string): Promise<string> {
|
export async function resolveMediaPreviewUrl(value: string): Promise<string> {
|
||||||
const normalized = normalizeObjectKey(value)
|
if (!value.trim()) return ""
|
||||||
if (!normalized) return ""
|
if (value.startsWith("http://") || value.startsWith("https://")) return value
|
||||||
return resolveDisplayMediaUrl(normalized)
|
const key = normalizeObjectKey(value)
|
||||||
|
if (!key) return ""
|
||||||
|
const res = await resolveFileUrl(key)
|
||||||
|
return res.data?.data?.url ?? ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,7 @@ export function toVimeoEmbedUrl(rawUrl: string): string | null {
|
||||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||||
const videoId = segments.find((segment) => /^\d+$/.test(segment));
|
const videoId = segments.find((segment) => /^\d+$/.test(segment));
|
||||||
if (!videoId) return null;
|
if (!videoId) return null;
|
||||||
// Vimeo private/unlisted links often come as /<videoId>/<hash> instead of ?h=<hash>.
|
const hash = parsed.searchParams.get("h");
|
||||||
const hashFromPath = (() => {
|
|
||||||
const videoIdx = segments.findIndex((segment) => segment === videoId);
|
|
||||||
if (videoIdx < 0) return null;
|
|
||||||
const maybeHash = segments[videoIdx + 1];
|
|
||||||
if (!maybeHash) return null;
|
|
||||||
return /^[a-zA-Z0-9]+$/.test(maybeHash) ? maybeHash : null;
|
|
||||||
})();
|
|
||||||
const hash = parsed.searchParams.get("h") || hashFromPath;
|
|
||||||
return hash
|
return hash
|
||||||
? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}`
|
? `https://player.vimeo.com/video/${videoId}?h=${encodeURIComponent(hash)}`
|
||||||
: `https://player.vimeo.com/video/${videoId}`;
|
: `https://player.vimeo.com/video/${videoId}`;
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,7 @@ export function CourseDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Hero Section */}
|
||||||
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-end">
|
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-end">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-2xl font-medium tracking-tight text-grayScale-900">
|
<h1 className="text-2xl font-medium tracking-tight text-grayScale-900">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
|
@ -7,15 +6,12 @@ import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} 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 { Textarea } from "../../components/ui/textarea";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -26,15 +22,6 @@ import {
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
import uploadIcon from "../../assets/icons/upload.png";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ResolvedImage } from "../../components/media/ResolvedImage";
|
|
||||||
import {
|
|
||||||
createExamPrepCatalogUnit,
|
|
||||||
updateExamPrepCatalogUnit,
|
|
||||||
deleteExamPrepCatalogUnit,
|
|
||||||
getExamPrepCatalogUnits,
|
|
||||||
} from "../../api/courses.api";
|
|
||||||
import { uploadImageFile } from "../../api/files.api";
|
|
||||||
|
|
||||||
export function CourseManagementPage() {
|
export function CourseManagementPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -42,38 +29,6 @@ export function CourseManagementPage() {
|
||||||
programType: string;
|
programType: string;
|
||||||
courseId: string;
|
courseId: string;
|
||||||
}>();
|
}>();
|
||||||
const catalogCourseId = Number(courseId);
|
|
||||||
const [addUnitOpen, setAddUnitOpen] = useState(false);
|
|
||||||
const [createName, setCreateName] = useState("");
|
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
|
|
||||||
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [units, setUnits] = useState<
|
|
||||||
Array<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
thumbnail: string;
|
|
||||||
sortOrder: number;
|
|
||||||
modules: number;
|
|
||||||
lessons: number;
|
|
||||||
practices: number;
|
|
||||||
gradient: string;
|
|
||||||
}>
|
|
||||||
>([]);
|
|
||||||
const [unitsLoading, setUnitsLoading] = useState(false);
|
|
||||||
const [editingUnitId, setEditingUnitId] = useState<number | null>(null);
|
|
||||||
const [editName, setEditName] = useState("");
|
|
||||||
const [editDescription, setEditDescription] = useState("");
|
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
|
||||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
|
||||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
|
||||||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [deletingUnitId, setDeletingUnitId] = useState<number | null>(null);
|
|
||||||
const [deletingUnit, setDeletingUnit] = useState(false);
|
|
||||||
|
|
||||||
// Mock data for display titles
|
// Mock data for display titles
|
||||||
const courseTitles: Record<string, string> = {
|
const courseTitles: Record<string, string> = {
|
||||||
|
|
@ -84,289 +39,41 @@ export function CourseManagementPage() {
|
||||||
const courseDisplayName =
|
const courseDisplayName =
|
||||||
courseTitles[courseId || ""] || "Duolingo English Test";
|
courseTitles[courseId || ""] || "Duolingo English Test";
|
||||||
|
|
||||||
const loadUnits = useCallback(async () => {
|
const units = [
|
||||||
if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) {
|
{
|
||||||
setUnits([]);
|
id: "unit1",
|
||||||
return;
|
name: "Greetings & Introductions",
|
||||||
}
|
description:
|
||||||
setUnitsLoading(true);
|
"Learn basic greetings, self-introductions, and polite expressions in everyday situations.",
|
||||||
try {
|
modules: 3,
|
||||||
const response = await getExamPrepCatalogUnits(catalogCourseId, {
|
videos: 9,
|
||||||
limit: 20,
|
practices: 9,
|
||||||
offset: 0,
|
gradient:
|
||||||
});
|
"linear-gradient(135deg, rgba(158, 40, 145, 0.5) 0%, rgba(158, 40, 145, 0.8) 100%)",
|
||||||
const rows = response.data?.data?.units;
|
},
|
||||||
const list = Array.isArray(rows) ? rows : [];
|
{
|
||||||
setUnits(
|
id: "unit2",
|
||||||
list.map((row, index) => ({
|
name: "Speaking",
|
||||||
id: Number(row.id),
|
description:
|
||||||
name: row.name?.trim() || `Unit ${row.id}`,
|
"Core speaking practice and skill building for natural pronunciation and fluency.",
|
||||||
description: row.description?.trim() || "—",
|
modules: 3,
|
||||||
thumbnail: row.thumbnail?.trim() || "",
|
videos: 9,
|
||||||
sortOrder: Number(row.sort_order ?? 0),
|
practices: 9,
|
||||||
modules: Number(row.modules_count ?? 0),
|
gradient:
|
||||||
lessons: Number(row.lessons_count ?? row.videos_count ?? 0),
|
"linear-gradient(135deg, rgba(79, 70, 229, 0.5) 0%, rgba(79, 70, 229, 0.8) 100%)",
|
||||||
practices: Number(row.practices_count ?? 0),
|
},
|
||||||
gradient:
|
{
|
||||||
index % 3 === 1
|
id: "unit3",
|
||||||
? "linear-gradient(135deg, rgba(79, 70, 229, 0.5) 0%, rgba(79, 70, 229, 0.8) 100%)"
|
name: "Reading",
|
||||||
: index % 3 === 2
|
description:
|
||||||
? "linear-gradient(135deg, rgba(124, 58, 237, 0.5) 0%, rgba(124, 58, 237, 0.8) 100%)"
|
"Reading comprehension and vocabulary improvement through various text types.",
|
||||||
: "linear-gradient(135deg, rgba(158, 40, 145, 0.5) 0%, rgba(158, 40, 145, 0.8) 100%)",
|
modules: 3,
|
||||||
})),
|
videos: 9,
|
||||||
);
|
practices: 9,
|
||||||
} catch (error) {
|
gradient:
|
||||||
console.error(error);
|
"linear-gradient(135deg, rgba(124, 58, 237, 0.5) 0%, rgba(124, 58, 237, 0.8) 100%)",
|
||||||
toast.error("Failed to load units");
|
},
|
||||||
setUnits([]);
|
];
|
||||||
} finally {
|
|
||||||
setUnitsLoading(false);
|
|
||||||
}
|
|
||||||
}, [catalogCourseId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadUnits();
|
|
||||||
}, [loadUnits]);
|
|
||||||
|
|
||||||
const isHttpUrl = (value: string) =>
|
|
||||||
value.startsWith("http://") || value.startsWith("https://");
|
|
||||||
|
|
||||||
const isMinioUrl = (value: string) => {
|
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
|
||||||
return url.host === "s3.yimaruacademy.com";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveThumbnailToMinioUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
if (!isHttpUrl(trimmed) || isMinioUrl(trimmed)) return trimmed;
|
|
||||||
const uploaded = await uploadImageFile(trimmed);
|
|
||||||
const uploadedUrl = uploaded.data?.data?.url?.trim();
|
|
||||||
if (!uploadedUrl) throw new Error("Failed to upload thumbnail URL to MinIO");
|
|
||||||
return uploadedUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearCreateUnitForm = () => {
|
|
||||||
setCreateName("");
|
|
||||||
setCreateDescription("");
|
|
||||||
setCreateThumbnail("");
|
|
||||||
if (createThumbnailFileInputRef.current) {
|
|
||||||
createThumbnailFileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateUnitThumbnailFile = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = "";
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.type.startsWith("image/")) {
|
|
||||||
toast.error("Please choose an image file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const maxBytes = 5 * 1024 * 1024;
|
|
||||||
if (file.size > maxBytes) {
|
|
||||||
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploadingThumbnail(true);
|
|
||||||
try {
|
|
||||||
const res = await uploadImageFile(file);
|
|
||||||
const url = res.data?.data?.url?.trim();
|
|
||||||
if (!url) throw new Error("Upload did not return a file URL");
|
|
||||||
setCreateThumbnail(url);
|
|
||||||
toast.success("Thumbnail uploaded");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateUnit = async () => {
|
|
||||||
if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) {
|
|
||||||
toast.error("Invalid catalog course");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const name = createName.trim();
|
|
||||||
if (!name) {
|
|
||||||
toast.error("Unit name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreating(true);
|
|
||||||
try {
|
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
|
|
||||||
const response = await createExamPrepCatalogUnit(catalogCourseId, {
|
|
||||||
name,
|
|
||||||
description: createDescription.trim() || null,
|
|
||||||
thumbnail: minioThumbnail || null,
|
|
||||||
});
|
|
||||||
void response;
|
|
||||||
await loadUnits();
|
|
||||||
toast.success("Unit created");
|
|
||||||
clearCreateUnitForm();
|
|
||||||
setAddUnitOpen(false);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to create unit";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoUploadCreateThumbnailUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
|
|
||||||
setUploadingThumbnail(true);
|
|
||||||
try {
|
|
||||||
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
|
|
||||||
if (minioUrl && minioUrl !== trimmed) {
|
|
||||||
setCreateThumbnail(minioUrl);
|
|
||||||
toast.success("Thumbnail uploaded to MinIO");
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload URL to MinIO";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoUploadEditThumbnailUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
|
|
||||||
setUploadingEditThumbnail(true);
|
|
||||||
try {
|
|
||||||
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
|
|
||||||
if (minioUrl && minioUrl !== trimmed) {
|
|
||||||
setEditThumbnail(minioUrl);
|
|
||||||
toast.success("Thumbnail uploaded to MinIO");
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload URL to MinIO";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditUnit = (unit: (typeof units)[number]) => {
|
|
||||||
setEditingUnitId(unit.id);
|
|
||||||
setEditName(unit.name ?? "");
|
|
||||||
setEditDescription(unit.description ?? "");
|
|
||||||
setEditThumbnail(unit.thumbnail ?? "");
|
|
||||||
setEditSortOrder(String(unit.sortOrder ?? 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeEditUnit = () => {
|
|
||||||
if (savingEdit || uploadingEditThumbnail) return;
|
|
||||||
setEditingUnitId(null);
|
|
||||||
setEditName("");
|
|
||||||
setEditDescription("");
|
|
||||||
setEditThumbnail("");
|
|
||||||
setEditSortOrder("1");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditUnitThumbnailFile = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = "";
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.type.startsWith("image/")) {
|
|
||||||
toast.error("Please choose an image file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploadingEditThumbnail(true);
|
|
||||||
try {
|
|
||||||
const res = await uploadImageFile(file);
|
|
||||||
const url = res.data?.data?.url?.trim();
|
|
||||||
if (!url) throw new Error("Upload did not return a file URL");
|
|
||||||
setEditThumbnail(url);
|
|
||||||
toast.success("Thumbnail uploaded");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEditUnit = async () => {
|
|
||||||
if (!editingUnitId) return;
|
|
||||||
const name = editName.trim();
|
|
||||||
if (!name) {
|
|
||||||
toast.error("Unit name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sortOrderNum = Number(editSortOrder);
|
|
||||||
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
|
|
||||||
toast.error("Sort order must be a valid number");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSavingEdit(true);
|
|
||||||
try {
|
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
|
||||||
await updateExamPrepCatalogUnit(editingUnitId, {
|
|
||||||
name,
|
|
||||||
description: editDescription.trim() || null,
|
|
||||||
thumbnail: minioThumbnail || null,
|
|
||||||
sort_order: sortOrderNum,
|
|
||||||
});
|
|
||||||
await loadUnits();
|
|
||||||
toast.success("Unit updated");
|
|
||||||
closeEditUnit();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to update unit";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setSavingEdit(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteUnit = async () => {
|
|
||||||
if (!deletingUnitId) return;
|
|
||||||
setDeletingUnit(true);
|
|
||||||
try {
|
|
||||||
await deleteExamPrepCatalogUnit(deletingUnitId);
|
|
||||||
await loadUnits();
|
|
||||||
toast.success("Unit deleted");
|
|
||||||
setDeletingUnitId(null);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to delete unit";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setDeletingUnit(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
||||||
|
|
@ -391,51 +98,29 @@ export function CourseManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<Dialog
|
<Dialog>
|
||||||
open={addUnitOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && (creating || uploadingThumbnail)) return;
|
|
||||||
setAddUnitOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2">
|
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2">
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
Add Unit
|
Add Unit
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
|
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
<div className="bg-white">
|
||||||
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
|
<DialogHeader className="px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
|
||||||
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
||||||
Create Unit
|
Create Courses
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
|
<div className="p-8 space-y-8">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">
|
<label className="text-[15px] text-grayScale-800">
|
||||||
Unit Name
|
Unit Name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={createName}
|
|
||||||
onChange={(e) => setCreateName(e.target.value)}
|
|
||||||
placeholder="e.g. Reading"
|
placeholder="e.g. Reading"
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={createDescription}
|
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
|
||||||
placeholder="Short unit description"
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -443,20 +128,7 @@ export function CourseManagementPage() {
|
||||||
<label className="text-[15px] text-grayScale-800">
|
<label className="text-[15px] text-grayScale-800">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="relative group cursor-pointer">
|
||||||
ref={createThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleCreateUnitThumbnailFile(e)}
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group w-full cursor-pointer"
|
|
||||||
onClick={() => createThumbnailFileInputRef.current?.click()}
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
|
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<img
|
<img
|
||||||
|
|
@ -467,62 +139,31 @@ export function CourseManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[15px]">
|
<p className="text-[15px]">
|
||||||
<span className="text-brand-500 font-bold hover:underline">
|
<span className="text-brand-500 font-bold hover:underline">
|
||||||
{uploadingThumbnail ? "Uploading…" : "Click to upload"}
|
Click to upload
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-grayScale-500">
|
<span className="text-grayScale-500">
|
||||||
or paste a URL below
|
or drag and drop
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
||||||
JPG, PNG (MAX 5 MB)
|
JPG, PNG (MAX 1 MB)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
{createThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<ResolvedImage
|
|
||||||
src={createThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={createThumbnail}
|
|
||||||
onChange={(e) => setCreateThumbnail(e.target.value)}
|
|
||||||
onPaste={(event) => {
|
|
||||||
const pasted = event.clipboardData?.getData("text")?.trim();
|
|
||||||
if (!pasted) return;
|
|
||||||
setTimeout(() => {
|
|
||||||
void autoUploadCreateThumbnailUrl(pasted);
|
|
||||||
}, 0);
|
|
||||||
}}
|
|
||||||
placeholder="Optional thumbnail URL (or leave empty for null)"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
onClick={clearCreateUnitForm}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button
|
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
|
||||||
type="button"
|
Create Courses
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
onClick={() => void handleCreateUnit()}
|
|
||||||
>
|
|
||||||
{creating ? "Creating..." : "Create Unit"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -559,61 +200,16 @@ export function CourseManagementPage() {
|
||||||
|
|
||||||
{/* Grid of Units */}
|
{/* Grid of Units */}
|
||||||
<div className="flex flex-wrap gap-4 pt-4">
|
<div className="flex flex-wrap gap-4 pt-4">
|
||||||
{unitsLoading ? (
|
{units.map((unit) => (
|
||||||
<p className="text-sm text-grayScale-500">Loading units...</p>
|
|
||||||
) : units.length === 0 ? (
|
|
||||||
<div className="w-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
|
||||||
<p className="text-sm font-medium text-grayScale-600">
|
|
||||||
No units for this course yet
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
|
||||||
Create your first unit to start organizing modules, lessons, and practices.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
units.map((unit) => (
|
|
||||||
<Card
|
<Card
|
||||||
key={unit.id}
|
key={unit.id}
|
||||||
className="group relative flex w-[400px] flex-col h-full bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
|
className="group flex w-[400px] flex-col h-full bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
|
||||||
>
|
>
|
||||||
<div className="absolute right-2 top-2 z-10 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">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
|
|
||||||
onClick={() => openEditUnit(unit)}
|
|
||||||
aria-label={`Edit ${unit.name}`}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
|
|
||||||
onClick={() => setDeletingUnitId(unit.id)}
|
|
||||||
aria-label={`Delete ${unit.name}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/* Gradient Header */}
|
{/* Gradient Header */}
|
||||||
<div
|
<div
|
||||||
className="relative h-36 w-full overflow-hidden transition-transform duration-500"
|
className="h-36 w-full transition-transform duration-500 "
|
||||||
style={{ background: unit.gradient }}
|
style={{ background: unit.gradient }}
|
||||||
>
|
/>
|
||||||
{unit.thumbnail ? (
|
|
||||||
<ResolvedImage
|
|
||||||
src={unit.thumbnail}
|
|
||||||
alt={`${unit.name} thumbnail`}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
onError={(event) => {
|
|
||||||
event.currentTarget.style.display = "none";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 flex flex-col flex-1 space-y-6">
|
<div className="p-4 flex flex-col flex-1 space-y-6">
|
||||||
<div className="space-y-3 flex-1">
|
<div className="space-y-3 flex-1">
|
||||||
|
|
@ -636,7 +232,7 @@ export function CourseManagementPage() {
|
||||||
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
||||||
<PlayCircle className="h-3.5 w-3.5 text-grayScale-400" />
|
<PlayCircle className="h-3.5 w-3.5 text-grayScale-400" />
|
||||||
<span className="text-[12px] font-bold">
|
<span className="text-[12px] font-bold">
|
||||||
{unit.lessons} Lessons
|
{unit.videos} Videos
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
||||||
|
|
@ -661,169 +257,8 @@ export function CourseManagementPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={editingUnitId !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && (savingEdit || uploadingEditThumbnail)) return;
|
|
||||||
if (!open) closeEditUnit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
|
||||||
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
|
|
||||||
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
|
||||||
Edit Unit
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Unit Name</label>
|
|
||||||
<Input
|
|
||||||
value={editName}
|
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Description</label>
|
|
||||||
<Textarea
|
|
||||||
value={editDescription}
|
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={editSortOrder}
|
|
||||||
onChange={(e) => setEditSortOrder(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
|
||||||
<input
|
|
||||||
ref={editThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleEditUnitThumbnailFile(e)}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group w-full cursor-pointer"
|
|
||||||
onClick={() => editThumbnailFileInputRef.current?.click()}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
|
|
||||||
<div className="mb-4">
|
|
||||||
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[15px]">
|
|
||||||
<span className="text-brand-500 font-bold hover:underline">
|
|
||||||
{uploadingEditThumbnail ? "Uploading…" : "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">or paste a URL below</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
|
||||||
JPG, PNG (MAX 5 MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{editThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<ResolvedImage
|
|
||||||
src={editThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={editThumbnail}
|
|
||||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
|
||||||
onPaste={(event) => {
|
|
||||||
const pasted = event.clipboardData?.getData("text")?.trim();
|
|
||||||
if (!pasted) return;
|
|
||||||
setTimeout(() => {
|
|
||||||
void autoUploadEditThumbnailUrl(pasted);
|
|
||||||
}, 0);
|
|
||||||
}}
|
|
||||||
placeholder="Optional thumbnail URL (or leave empty for null)"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
onClick={closeEditUnit}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
onClick={() => void handleSaveEditUnit()}
|
|
||||||
>
|
|
||||||
{savingEdit ? "Saving..." : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={deletingUnitId !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && !deletingUnit) setDeletingUnitId(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-md rounded-[16px] border-none p-0 overflow-hidden">
|
|
||||||
<div className="bg-white">
|
|
||||||
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
|
|
||||||
<DialogTitle className="text-lg font-bold text-grayScale-900">
|
|
||||||
Delete Unit
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="px-6 py-6 text-sm text-grayScale-600">
|
|
||||||
Are you sure you want to delete this unit? This action cannot be undone.
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 border-t border-grayScale-100 px-6 py-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeletingUnitId(null)}
|
|
||||||
disabled={deletingUnit}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-red-500 hover:bg-red-600"
|
|
||||||
onClick={() => void handleDeleteUnit()}
|
|
||||||
disabled={deletingUnit}
|
|
||||||
>
|
|
||||||
{deletingUnit ? "Deleting..." : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,40 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { ArrowLeft, Plus, FileText, Pencil, Trash2 } from "lucide-react";
|
import { ArrowLeft, Plus, FileText, MoreVertical, Edit2 } from "lucide-react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { Card } from "../../components/ui/card";
|
import { Card } from "../../components/ui/card";
|
||||||
import {
|
|
||||||
Dialog,
|
const MOCK_VIDEOS = [
|
||||||
DialogContent,
|
{
|
||||||
DialogHeader,
|
id: "v1",
|
||||||
DialogTitle,
|
title: "1.1 Introduction to Formal Greetings",
|
||||||
DialogTrigger,
|
duration: "08:45",
|
||||||
DialogClose,
|
status: "Draft",
|
||||||
} from "../../components/ui/dialog";
|
thumbnailColor: "bg-[#CBD5E1]",
|
||||||
import { Input } from "../../components/ui/input";
|
},
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
{
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
id: "v2",
|
||||||
import { toast } from "sonner";
|
title: "1.2 Understanding Email Structure",
|
||||||
import { ResolvedImage } from "../../components/media/ResolvedImage";
|
duration: "08:45",
|
||||||
import { VideoCard } from "./components/VideoCard";
|
status: "Published",
|
||||||
import {
|
thumbnailColor: "bg-[#DBEAFE]",
|
||||||
createExamPrepModuleLesson,
|
},
|
||||||
updateExamPrepModuleLesson,
|
{
|
||||||
deleteExamPrepModuleLesson,
|
id: "v3",
|
||||||
getExamPrepModuleLessons,
|
title: "1.3 Common Business Idioms",
|
||||||
} from "../../api/courses.api";
|
duration: "08:45",
|
||||||
import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
|
status: "Published",
|
||||||
|
thumbnailColor: "bg-[#FEF3C7]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v4",
|
||||||
|
title: "1.4 Video Conference Etiquette",
|
||||||
|
duration: "08:45",
|
||||||
|
status: "Published",
|
||||||
|
thumbnailColor: "bg-[#FCE7F3]",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const MOCK_PRACTICES = [
|
const MOCK_PRACTICES = [
|
||||||
{
|
{
|
||||||
|
|
@ -51,402 +61,19 @@ export function CourseModuleDetailPage() {
|
||||||
unitId: string;
|
unitId: string;
|
||||||
moduleId: string;
|
moduleId: string;
|
||||||
}>();
|
}>();
|
||||||
const parsedModuleId = Number(moduleId);
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
||||||
const [lessonsLoading, setLessonsLoading] = useState(false);
|
const [activeFilter, setActiveFilter] = useState("All");
|
||||||
const [lessons, setLessons] = useState<
|
|
||||||
Array<{
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
videoUrl: string;
|
|
||||||
description: string;
|
|
||||||
thumbnail: string;
|
|
||||||
sortOrder: number;
|
|
||||||
gradient: string;
|
|
||||||
}>
|
|
||||||
>([]);
|
|
||||||
const [createLessonOpen, setCreateLessonOpen] = useState(false);
|
|
||||||
const [createTitle, setCreateTitle] = useState("");
|
|
||||||
const [createVideoUrl, setCreateVideoUrl] = useState("");
|
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
|
||||||
const [creatingLesson, setCreatingLesson] = useState(false);
|
|
||||||
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
|
|
||||||
const [uploadingVideo, setUploadingVideo] = useState(false);
|
|
||||||
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const createVideoFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [editingLessonId, setEditingLessonId] = useState<number | null>(null);
|
|
||||||
const [editTitle, setEditTitle] = useState("");
|
|
||||||
const [editVideoUrl, setEditVideoUrl] = useState("");
|
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
|
||||||
const [editDescription, setEditDescription] = useState("");
|
|
||||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
|
||||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
|
||||||
const [uploadingEditVideo, setUploadingEditVideo] = useState(false);
|
|
||||||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const editVideoFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [deletingLessonId, setDeletingLessonId] = useState<number | null>(null);
|
|
||||||
const [deletingLesson, setDeletingLesson] = useState(false);
|
|
||||||
|
|
||||||
const moduleTitle = "Module 1: Basic Phrases";
|
const moduleTitle = "Module 1: Basic Phrases";
|
||||||
const moduleDescription = "Learn essential phrases for daily conversations.";
|
const moduleDescription = "Learn essential phrases for daily conversations.";
|
||||||
|
|
||||||
const isHttpUrl = (value: string) =>
|
const content = activeTab === "video" ? MOCK_VIDEOS : MOCK_PRACTICES;
|
||||||
value.startsWith("http://") || value.startsWith("https://");
|
const filteredContent = content.filter((item) => {
|
||||||
|
if (activeFilter === "All") return true;
|
||||||
const isMinioUrl = (value: string) => {
|
if (activeFilter === "Drafts") return item.status === "Draft";
|
||||||
try {
|
return item.status === activeFilter;
|
||||||
const url = new URL(value);
|
});
|
||||||
return url.host === "s3.yimaruacademy.com";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveThumbnailToMinioUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
if (!isHttpUrl(trimmed) || isMinioUrl(trimmed)) return trimmed;
|
|
||||||
const uploaded = await uploadImageFile(trimmed);
|
|
||||||
const uploadedUrl = uploaded.data?.data?.url?.trim();
|
|
||||||
if (!uploadedUrl) throw new Error("Failed to upload thumbnail URL to MinIO");
|
|
||||||
return uploadedUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadLessons = useCallback(async () => {
|
|
||||||
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
|
|
||||||
setLessons([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLessonsLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await getExamPrepModuleLessons(parsedModuleId, {
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
const rows = response.data?.data?.lessons;
|
|
||||||
const list = Array.isArray(rows) ? rows : [];
|
|
||||||
setLessons(
|
|
||||||
list.map((row, index) => ({
|
|
||||||
id: Number(row.id),
|
|
||||||
title: row.title?.trim() || `Lesson ${row.id}`,
|
|
||||||
videoUrl: row.video_url?.trim() || "",
|
|
||||||
description: row.description?.trim() || "—",
|
|
||||||
thumbnail: row.thumbnail?.trim() || "",
|
|
||||||
sortOrder: Number(row.sort_order ?? 0),
|
|
||||||
gradient:
|
|
||||||
index % 3 === 1
|
|
||||||
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)"
|
|
||||||
: index % 3 === 2
|
|
||||||
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)"
|
|
||||||
: "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)",
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error("Failed to load lessons");
|
|
||||||
setLessons([]);
|
|
||||||
} finally {
|
|
||||||
setLessonsLoading(false);
|
|
||||||
}
|
|
||||||
}, [parsedModuleId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab !== "video") return;
|
|
||||||
void loadLessons();
|
|
||||||
}, [activeTab, loadLessons]);
|
|
||||||
|
|
||||||
const clearCreateLessonForm = () => {
|
|
||||||
setCreateTitle("");
|
|
||||||
setCreateVideoUrl("");
|
|
||||||
setCreateThumbnail("");
|
|
||||||
setCreateDescription("");
|
|
||||||
if (createThumbnailFileInputRef.current) {
|
|
||||||
createThumbnailFileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
if (createVideoFileInputRef.current) {
|
|
||||||
createVideoFileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateLessonVideoFile = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = "";
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.type.startsWith("video/")) {
|
|
||||||
toast.error("Please choose a video file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploadingVideo(true);
|
|
||||||
try {
|
|
||||||
const res = await uploadVideoFile(file, {
|
|
||||||
title: createTitle.trim() || "Lesson video",
|
|
||||||
description: createDescription.trim() || undefined,
|
|
||||||
});
|
|
||||||
const finalUrl =
|
|
||||||
res.data?.data?.url?.trim() || res.data?.data?.embed_url?.trim() || "";
|
|
||||||
if (!finalUrl) throw new Error("Upload did not return a video URL");
|
|
||||||
setCreateVideoUrl(finalUrl);
|
|
||||||
toast.success("Video uploaded");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload video";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingVideo(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateLessonThumbnailFile = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = "";
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.type.startsWith("image/")) {
|
|
||||||
toast.error("Please choose an image file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploadingThumbnail(true);
|
|
||||||
try {
|
|
||||||
const res = await uploadImageFile(file);
|
|
||||||
const url = res.data?.data?.url?.trim();
|
|
||||||
if (!url) throw new Error("Upload did not return a file URL");
|
|
||||||
setCreateThumbnail(url);
|
|
||||||
toast.success("Thumbnail uploaded");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoUploadCreateThumbnailUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
|
|
||||||
setUploadingThumbnail(true);
|
|
||||||
try {
|
|
||||||
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
|
|
||||||
if (minioUrl && minioUrl !== trimmed) {
|
|
||||||
setCreateThumbnail(minioUrl);
|
|
||||||
toast.success("Thumbnail uploaded to MinIO");
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload URL to MinIO";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateLesson = async () => {
|
|
||||||
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
|
|
||||||
toast.error("Invalid module");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const title = createTitle.trim();
|
|
||||||
const videoUrl = createVideoUrl.trim();
|
|
||||||
if (!title) {
|
|
||||||
toast.error("Lesson title is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!videoUrl) {
|
|
||||||
toast.error("Video URL is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreatingLesson(true);
|
|
||||||
try {
|
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
|
|
||||||
await createExamPrepModuleLesson(parsedModuleId, {
|
|
||||||
title,
|
|
||||||
video_url: videoUrl,
|
|
||||||
thumbnail: minioThumbnail || null,
|
|
||||||
description: createDescription.trim() || null,
|
|
||||||
});
|
|
||||||
await loadLessons();
|
|
||||||
toast.success("Lesson created");
|
|
||||||
clearCreateLessonForm();
|
|
||||||
setCreateLessonOpen(false);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to create lesson";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setCreatingLesson(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditLesson = (lesson: (typeof lessons)[number]) => {
|
|
||||||
setEditingLessonId(lesson.id);
|
|
||||||
setEditTitle(lesson.title ?? "");
|
|
||||||
setEditVideoUrl(lesson.videoUrl ?? "");
|
|
||||||
setEditThumbnail(lesson.thumbnail ?? "");
|
|
||||||
setEditDescription(lesson.description ?? "");
|
|
||||||
setEditSortOrder(String(lesson.sortOrder ?? 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeEditLesson = () => {
|
|
||||||
if (savingEdit || uploadingEditThumbnail || uploadingEditVideo) return;
|
|
||||||
setEditingLessonId(null);
|
|
||||||
setEditTitle("");
|
|
||||||
setEditVideoUrl("");
|
|
||||||
setEditThumbnail("");
|
|
||||||
setEditDescription("");
|
|
||||||
setEditSortOrder("1");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditLessonVideoFile = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = "";
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.type.startsWith("video/")) {
|
|
||||||
toast.error("Please choose a video file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploadingEditVideo(true);
|
|
||||||
try {
|
|
||||||
const res = await uploadVideoFile(file, {
|
|
||||||
title: editTitle.trim() || "Lesson video",
|
|
||||||
description: editDescription.trim() || undefined,
|
|
||||||
});
|
|
||||||
const finalUrl =
|
|
||||||
res.data?.data?.url?.trim() || res.data?.data?.embed_url?.trim() || "";
|
|
||||||
if (!finalUrl) throw new Error("Upload did not return a video URL");
|
|
||||||
setEditVideoUrl(finalUrl);
|
|
||||||
toast.success("Video uploaded");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload video";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditVideo(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditLessonThumbnailFile = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = "";
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.type.startsWith("image/")) {
|
|
||||||
toast.error("Please choose an image file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploadingEditThumbnail(true);
|
|
||||||
try {
|
|
||||||
const res = await uploadImageFile(file);
|
|
||||||
const url = res.data?.data?.url?.trim();
|
|
||||||
if (!url) throw new Error("Upload did not return a file URL");
|
|
||||||
setEditThumbnail(url);
|
|
||||||
toast.success("Thumbnail uploaded");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoUploadEditThumbnailUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed || !isHttpUrl(trimmed) || isMinioUrl(trimmed)) return;
|
|
||||||
setUploadingEditThumbnail(true);
|
|
||||||
try {
|
|
||||||
const minioUrl = await resolveThumbnailToMinioUrl(trimmed);
|
|
||||||
if (minioUrl && minioUrl !== trimmed) {
|
|
||||||
setEditThumbnail(minioUrl);
|
|
||||||
toast.success("Thumbnail uploaded to MinIO");
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload URL to MinIO";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEditLesson = async () => {
|
|
||||||
if (!editingLessonId) return;
|
|
||||||
const title = editTitle.trim();
|
|
||||||
if (!title) {
|
|
||||||
toast.error("Lesson title is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sortOrderNum = Number(editSortOrder);
|
|
||||||
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
|
|
||||||
toast.error("Sort order must be a valid number");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSavingEdit(true);
|
|
||||||
try {
|
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
|
||||||
await updateExamPrepModuleLesson(editingLessonId, {
|
|
||||||
title,
|
|
||||||
video_url: editVideoUrl.trim() || null,
|
|
||||||
thumbnail: minioThumbnail || null,
|
|
||||||
description: editDescription.trim() || null,
|
|
||||||
sort_order: sortOrderNum,
|
|
||||||
});
|
|
||||||
await loadLessons();
|
|
||||||
toast.success("Lesson updated");
|
|
||||||
closeEditLesson();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to update lesson";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setSavingEdit(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteLesson = async () => {
|
|
||||||
if (!deletingLessonId) return;
|
|
||||||
setDeletingLesson(true);
|
|
||||||
try {
|
|
||||||
await deleteExamPrepModuleLesson(deletingLessonId);
|
|
||||||
await loadLessons();
|
|
||||||
toast.success("Lesson deleted");
|
|
||||||
setDeletingLessonId(null);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to delete lesson";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setDeletingLesson(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
||||||
|
|
@ -483,188 +110,10 @@ export function CourseModuleDetailPage() {
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
Attach Practice
|
Attach Practice
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog
|
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-md hover:bg-brand-600 transition-all flex items-center gap-2 text-[15px]">
|
||||||
open={createLessonOpen}
|
<Plus className="h-5 w-5" />
|
||||||
onOpenChange={(open) => {
|
Add Video
|
||||||
if (!open && (creatingLesson || uploadingThumbnail || uploadingVideo))
|
</Button>
|
||||||
return;
|
|
||||||
setCreateLessonOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-md hover:bg-brand-600 transition-all flex items-center gap-2 text-[15px]">
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
Add Lesson
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
|
||||||
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
|
|
||||||
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
|
||||||
Create Lesson
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Lesson Title
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={createTitle}
|
|
||||||
onChange={(e) => setCreateTitle(e.target.value)}
|
|
||||||
placeholder="e.g. Intro lesson"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Video URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={createVideoFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="video/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleCreateLessonVideoFile(e)}
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group w-full cursor-pointer"
|
|
||||||
onClick={() => createVideoFileInputRef.current?.click()}
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
|
|
||||||
<div className="mb-4">
|
|
||||||
<img
|
|
||||||
src={uploadIcon}
|
|
||||||
alt="Upload icon"
|
|
||||||
className="h-10 w-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-[15px]">
|
|
||||||
<span className="text-brand-500 font-bold hover:underline">
|
|
||||||
{uploadingVideo ? "Uploading…" : "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">
|
|
||||||
video from your computer
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
|
||||||
MP4, MOV, WEBM
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<Input
|
|
||||||
value={createVideoUrl}
|
|
||||||
onChange={(e) => setCreateVideoUrl(e.target.value)}
|
|
||||||
placeholder="https://example.com/video"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={createDescription}
|
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
|
||||||
placeholder="Optional lesson description"
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Thumbnail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={createThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleCreateLessonThumbnailFile(e)}
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group w-full cursor-pointer"
|
|
||||||
onClick={() => createThumbnailFileInputRef.current?.click()}
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
|
|
||||||
<div className="mb-4">
|
|
||||||
<img
|
|
||||||
src={uploadIcon}
|
|
||||||
alt="Upload icon"
|
|
||||||
className="h-10 w-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-[15px]">
|
|
||||||
<span className="text-brand-500 font-bold hover:underline">
|
|
||||||
{uploadingThumbnail ? "Uploading…" : "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">
|
|
||||||
or paste a URL below
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
|
||||||
JPG, PNG (MAX 5 MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{createThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<ResolvedImage
|
|
||||||
src={createThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={createThumbnail}
|
|
||||||
onChange={(e) => setCreateThumbnail(e.target.value)}
|
|
||||||
onPaste={(event) => {
|
|
||||||
const pasted = event.clipboardData?.getData("text")?.trim();
|
|
||||||
if (!pasted) return;
|
|
||||||
setTimeout(() => {
|
|
||||||
void autoUploadCreateThumbnailUrl(pasted);
|
|
||||||
}, 0);
|
|
||||||
}}
|
|
||||||
placeholder="Optional thumbnail URL (or leave empty for null)"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
onClick={clearCreateLessonForm}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
|
||||||
onClick={() => void handleCreateLesson()}
|
|
||||||
>
|
|
||||||
{creatingLesson ? "Creating..." : "Create Lesson"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -679,7 +128,7 @@ export function CourseModuleDetailPage() {
|
||||||
: "text-grayScale-400 hover:text-grayScale-600",
|
: "text-grayScale-400 hover:text-grayScale-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Lesson
|
Video
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("practice")}
|
onClick={() => setActiveTab("practice")}
|
||||||
|
|
@ -694,245 +143,40 @@ export function CourseModuleDetailPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid of Content */}
|
{/* Filter Bar */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
|
<div className="bg-white border border-grayScale-100 rounded-[16px] p-4 flex items-center gap-8 shadow-sm">
|
||||||
{activeTab === "video" ? (
|
<div className="text-[12px] font-bold text-grayScale-300 uppercase tracking-widest pl-4">
|
||||||
lessonsLoading ? (
|
STATUS:
|
||||||
<p className="text-sm text-grayScale-500">Loading lessons...</p>
|
</div>
|
||||||
) : lessons.length === 0 ? (
|
<div className="flex items-center gap-2">
|
||||||
<div className="col-span-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
{["All", "Published", "Drafts", "Archived"].map((filter) => (
|
||||||
<p className="text-sm font-medium text-grayScale-600">
|
<button
|
||||||
No lessons for this module yet
|
key={filter}
|
||||||
</p>
|
onClick={() => setActiveFilter(filter)}
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
className={cn(
|
||||||
Create your first lesson to start building this module.
|
"px-5 py-2 rounded-full text-[13px] font-bold transition-all",
|
||||||
</p>
|
activeFilter === filter
|
||||||
</div>
|
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
|
||||||
) : (
|
: "bg-grayScale-100 text-grayScale-500 hover:bg-grayScale-200",
|
||||||
lessons.map((lesson) => (
|
)}
|
||||||
<VideoCard
|
>
|
||||||
key={lesson.id}
|
{filter}
|
||||||
title={lesson.title}
|
</button>
|
||||||
thumbnailUrl={lesson.thumbnail}
|
))}
|
||||||
videoUrl={lesson.videoUrl}
|
</div>
|
||||||
thumbnailGradient={lesson.gradient}
|
|
||||||
hoverModuleActions
|
|
||||||
onEdit={() => openEditLesson(lesson)}
|
|
||||||
onDelete={() => setDeletingLessonId(lesson.id)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
MOCK_PRACTICES.map((item) => <PracticeCard key={item.id} {...item} />)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
{/* Grid of Content */}
|
||||||
open={editingLessonId !== null}
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
|
||||||
onOpenChange={(open) => {
|
{filteredContent.map((item) => (
|
||||||
if (!open && (savingEdit || uploadingEditThumbnail || uploadingEditVideo))
|
<ContentCard key={item.id} {...item} />
|
||||||
return;
|
))}
|
||||||
if (!open) closeEditLesson();
|
</div>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
|
||||||
<DialogHeader className="shrink-0 px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
|
|
||||||
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
|
||||||
Edit Lesson
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-8 space-y-8">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Lesson Title</label>
|
|
||||||
<Input
|
|
||||||
value={editTitle}
|
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Video URL</label>
|
|
||||||
<input
|
|
||||||
ref={editVideoFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="video/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleEditLessonVideoFile(e)}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group w-full cursor-pointer"
|
|
||||||
onClick={() => editVideoFileInputRef.current?.click()}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
|
|
||||||
<div className="mb-4">
|
|
||||||
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[15px]">
|
|
||||||
<span className="text-brand-500 font-bold hover:underline">
|
|
||||||
{uploadingEditVideo ? "Uploading…" : "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">
|
|
||||||
video from your computer
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
|
||||||
MP4, MOV, WEBM
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<Input
|
|
||||||
value={editVideoUrl}
|
|
||||||
onChange={(e) => setEditVideoUrl(e.target.value)}
|
|
||||||
placeholder="https://example.com/video"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Description</label>
|
|
||||||
<Textarea
|
|
||||||
value={editDescription}
|
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={editSortOrder}
|
|
||||||
onChange={(e) => setEditSortOrder(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
|
||||||
<input
|
|
||||||
ref={editThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleEditLessonThumbnailFile(e)}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group w-full cursor-pointer"
|
|
||||||
onClick={() => editThumbnailFileInputRef.current?.click()}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all">
|
|
||||||
<div className="mb-4">
|
|
||||||
<img src={uploadIcon} alt="Upload icon" className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[15px]">
|
|
||||||
<span className="text-brand-500 font-bold hover:underline">
|
|
||||||
{uploadingEditThumbnail ? "Uploading…" : "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">or paste a URL below</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
|
||||||
JPG, PNG (MAX 5 MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{editThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<ResolvedImage
|
|
||||||
src={editThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={editThumbnail}
|
|
||||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
|
||||||
onPaste={(event) => {
|
|
||||||
const pasted = event.clipboardData?.getData("text")?.trim();
|
|
||||||
if (!pasted) return;
|
|
||||||
setTimeout(() => {
|
|
||||||
void autoUploadEditThumbnailUrl(pasted);
|
|
||||||
}, 0);
|
|
||||||
}}
|
|
||||||
placeholder="Optional thumbnail URL (or leave empty for null)"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
onClick={closeEditLesson}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditVideo}
|
|
||||||
onClick={() => void handleSaveEditLesson()}
|
|
||||||
>
|
|
||||||
{savingEdit ? "Saving..." : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={deletingLessonId !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && !deletingLesson) setDeletingLessonId(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-md rounded-[16px] border-none p-0 overflow-hidden">
|
|
||||||
<div className="bg-white">
|
|
||||||
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
|
|
||||||
<DialogTitle className="text-lg font-bold text-grayScale-900">
|
|
||||||
Delete Lesson
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="px-6 py-6 text-sm text-grayScale-600">
|
|
||||||
Are you sure you want to delete this lesson? This action cannot be undone.
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 border-t border-grayScale-100 px-6 py-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeletingLessonId(null)}
|
|
||||||
disabled={deletingLesson}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-red-500 hover:bg-red-600"
|
|
||||||
onClick={() => void handleDeleteLesson()}
|
|
||||||
disabled={deletingLesson}
|
|
||||||
>
|
|
||||||
{deletingLesson ? "Deleting..." : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PracticeCard({
|
function ContentCard({
|
||||||
title,
|
title,
|
||||||
duration,
|
duration,
|
||||||
status,
|
status,
|
||||||
|
|
@ -970,7 +214,9 @@ function PracticeCard({
|
||||||
/>
|
/>
|
||||||
{status}
|
{status}
|
||||||
</div>
|
</div>
|
||||||
<div />
|
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-300 hover:text-grayScale-600 transition-colors">
|
||||||
|
<MoreVertical className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-[14px] font-bold text-[#0F172A] line-clamp-2 leading-snug">
|
<h3 className="text-[14px] font-bold text-[#0F172A] line-clamp-2 leading-snug">
|
||||||
|
|
@ -978,7 +224,11 @@ function PracticeCard({
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="pt-2 grid grid-cols-1 gap-2 mt-auto">
|
<div className="pt-2 grid grid-cols-1 gap-2 mt-auto">
|
||||||
<Button variant="outline" className="w-full h-10 rounded-[10px] border-grayScale-200 text-grayScale-600 font-bold text-xs">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-10 rounded-[10px] border-grayScale-200 text-grayScale-600 font-bold flex items-center justify-center gap-2 text-xs hover:bg-grayScale-25"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -996,4 +246,3 @@ function PracticeCard({
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { Stepper } from "../../components/ui/stepper";
|
|
||||||
import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/QuestionTypeBasicInfoStep";
|
|
||||||
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep";
|
|
||||||
|
|
||||||
export function CreateQuestionTypeFlow() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
"Basic Info",
|
|
||||||
"Input & Answer Configuration",
|
|
||||||
"Versions",
|
|
||||||
"Review & Publish",
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleNext = () =>
|
|
||||||
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
|
|
||||||
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen pb-20 overflow-x-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<div className=" border-b border-grayScale-100 sticky top-0 z-50">
|
|
||||||
<div className="max-w-[1440px] mx-auto py-6">
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<Link
|
|
||||||
to="/new-content/question-types"
|
|
||||||
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-600 transition-colors hover:text-brand-500 group"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
|
||||||
Back to Question Type Library
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h1 className="text-[28px] font-bold text-grayScale-900 tracking-tight">
|
|
||||||
Create Question Type
|
|
||||||
</h1>
|
|
||||||
<p className="text-grayScale-500 text-[14px] font-medium">
|
|
||||||
Create a new immersive practice session for students.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 px-8 rounded-[6px] border-grayScale-200 text-grayScale-900 font-medium hover:bg-grayScale-50"
|
|
||||||
onClick={() => navigate("/new-content/question-types")}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all">
|
|
||||||
Save as Draft
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 mx-auto">
|
|
||||||
<Stepper steps={steps} currentStep={currentStep} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="max-w-[1440px] mx-auto px-10 mt-12 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
||||||
{currentStep === 1 && <QuestionTypeBasicInfoStep onNext={handleNext} />}
|
|
||||||
{currentStep === 2 && (
|
|
||||||
<QuestionTypeConfigStep onNext={handleNext} onBack={handleBack} />
|
|
||||||
)}
|
|
||||||
{currentStep > 2 && (
|
|
||||||
<div className="bg-white rounded-2xl p-12 text-center border border-grayScale-100 shadow-sm">
|
|
||||||
<p className="text-grayScale-400 font-medium">
|
|
||||||
Step {currentStep} implementation in progress...
|
|
||||||
</p>
|
|
||||||
<Button onClick={handleBack} variant="outline" className="mt-4">
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
|
@ -7,13 +6,11 @@ import {
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
X,
|
X,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} 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 { Textarea } from "../../components/ui/textarea";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -23,51 +20,12 @@ import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { toast } from "sonner";
|
import { Select } from "../../components/ui/select";
|
||||||
import { ResolvedImage } from "../../components/media/ResolvedImage";
|
|
||||||
import {
|
|
||||||
createExamPrepCatalogCourse,
|
|
||||||
getExamPrepCatalogCourses,
|
|
||||||
updateExamPrepCatalogCourse,
|
|
||||||
deleteExamPrepCatalogCourse,
|
|
||||||
} from "../../api/courses.api";
|
|
||||||
import { uploadImageFile } from "../../api/files.api";
|
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
import uploadIcon from "../../assets/icons/upload.png";
|
||||||
|
|
||||||
export function ProgramDetailPage() {
|
export function ProgramDetailPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { programType } = useParams<{ programType: string }>();
|
const { programType } = useParams<{ programType: string }>();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [createName, setCreateName] = useState("");
|
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
|
||||||
const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false);
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
|
|
||||||
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [createdCourses, setCreatedCourses] = useState<
|
|
||||||
{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
thumbnail?: string | null;
|
|
||||||
sortOrder: number;
|
|
||||||
unitsCount: number;
|
|
||||||
modulesCount: number;
|
|
||||||
lessonsCount: number;
|
|
||||||
}[]
|
|
||||||
>([]);
|
|
||||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
|
||||||
const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
|
|
||||||
const [editName, setEditName] = useState("");
|
|
||||||
const [editDescription, setEditDescription] = useState("");
|
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
|
||||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
|
||||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
|
||||||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [deletingCourseId, setDeletingCourseId] = useState<number | null>(null);
|
|
||||||
const [deletingCourse, setDeletingCourse] = useState(false);
|
|
||||||
|
|
||||||
// Mock data for "proficiency" program type
|
// Mock data for "proficiency" program type
|
||||||
const programs: Record<string, any> = {
|
const programs: Record<string, any> = {
|
||||||
|
|
@ -75,7 +33,45 @@ export function ProgramDetailPage() {
|
||||||
title: "English Proficiency Exams",
|
title: "English Proficiency Exams",
|
||||||
description:
|
description:
|
||||||
"Manage exam-based learning programs such as Duolingo and IELTS.",
|
"Manage exam-based learning programs such as Duolingo and IELTS.",
|
||||||
courses: [],
|
courses: [
|
||||||
|
{
|
||||||
|
id: "duolingo",
|
||||||
|
name: "Duolingo English Test",
|
||||||
|
description:
|
||||||
|
"Adaptive exam-style practice for speaking, writing, reading, and listening.",
|
||||||
|
coursesCount: 6,
|
||||||
|
questionTypesCount: 13,
|
||||||
|
logo: (
|
||||||
|
<div className="h-14 w-14 rounded-full bg-[#FFB800] flex items-center justify-center relative overflow-hidden">
|
||||||
|
{/* Simple Duolingo-like representation if image not available */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent" />
|
||||||
|
<div className="h-8 w-8 bg-white rounded-full flex items-center justify-center">
|
||||||
|
<div className="h-4 w-4 bg-[#FFB800] rounded-sm transform rotate-45" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
buttonText: "Manage Detail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ielts",
|
||||||
|
name: "IELTS Academic",
|
||||||
|
description:
|
||||||
|
"Full preparation for IELTS speaking, writing, listening, and reading.",
|
||||||
|
coursesCount: 4,
|
||||||
|
questionTypesCount: 18,
|
||||||
|
logo: (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[28px] font-black tracking-tighter text-[#E11D48] ">
|
||||||
|
IELTS
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] font-bold text-[#E11D48] mt-2 tracking-widest uppercase">
|
||||||
|
™
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
buttonText: "View Detail",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"skill-based": {
|
"skill-based": {
|
||||||
title: "Skill-Based Courses",
|
title: "Skill-Based Courses",
|
||||||
|
|
@ -88,327 +84,6 @@ export function ProgramDetailPage() {
|
||||||
const currentProgram =
|
const currentProgram =
|
||||||
programs[programType || "proficiency"] || programs.proficiency;
|
programs[programType || "proficiency"] || programs.proficiency;
|
||||||
|
|
||||||
const loadCatalogCourses = useCallback(async () => {
|
|
||||||
if (programType !== "proficiency") return;
|
|
||||||
setCatalogLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await getExamPrepCatalogCourses({ limit: 20, offset: 0 });
|
|
||||||
const rows = response.data?.data?.catalog_courses;
|
|
||||||
const list = Array.isArray(rows) ? rows : [];
|
|
||||||
setCreatedCourses(
|
|
||||||
list.map((row) => ({
|
|
||||||
id: Number(row.id),
|
|
||||||
name: row.name?.trim() || `Course ${row.id}`,
|
|
||||||
description: row.description?.trim() || "—",
|
|
||||||
thumbnail: row.thumbnail?.trim() || null,
|
|
||||||
sortOrder: Number(row.sort_order ?? 0),
|
|
||||||
unitsCount: Number(row.units_count ?? 0),
|
|
||||||
modulesCount: Number(row.modules_count ?? 0),
|
|
||||||
lessonsCount: Number(row.lessons_count ?? 0),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error("Failed to fetch catalog courses");
|
|
||||||
setCreatedCourses([]);
|
|
||||||
} finally {
|
|
||||||
setCatalogLoading(false);
|
|
||||||
}
|
|
||||||
}, [programType]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadCatalogCourses();
|
|
||||||
}, [loadCatalogCourses]);
|
|
||||||
const proficiencyCourses = [
|
|
||||||
...currentProgram.courses,
|
|
||||||
...createdCourses.map((course) => ({
|
|
||||||
id: course.id,
|
|
||||||
name: course.name,
|
|
||||||
description: course.description,
|
|
||||||
units_count: course.unitsCount,
|
|
||||||
modules_count: course.modulesCount,
|
|
||||||
lessons_count: course.lessonsCount,
|
|
||||||
logo: null,
|
|
||||||
thumbnail: course.thumbnail ?? "",
|
|
||||||
sort_order: course.sortOrder,
|
|
||||||
buttonText: "View Detail",
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
const isHttpUrl = (value: string) =>
|
|
||||||
value.startsWith("http://") || value.startsWith("https://");
|
|
||||||
|
|
||||||
const isMinioUrl = (value: string) => {
|
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
|
||||||
return url.host === "s3.yimaruacademy.com";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const autoUploadThumbnailUrlIfNeeded = async (rawValue: string) => {
|
|
||||||
const candidate = rawValue.trim();
|
|
||||||
if (!candidate) return;
|
|
||||||
if (!isHttpUrl(candidate)) return;
|
|
||||||
if (isMinioUrl(candidate)) return;
|
|
||||||
if (uploadingThumbnail || creating) return;
|
|
||||||
|
|
||||||
setUploadingThumbnail(true);
|
|
||||||
try {
|
|
||||||
const uploaded = await uploadImageFile(candidate);
|
|
||||||
const uploadedUrl = uploaded.data?.data?.url?.trim();
|
|
||||||
if (!uploadedUrl) {
|
|
||||||
throw new Error("Failed to upload thumbnail URL to MinIO");
|
|
||||||
}
|
|
||||||
setCreateThumbnail(uploadedUrl);
|
|
||||||
setCreateThumbnailFromUpload(true);
|
|
||||||
toast.success("Thumbnail URL uploaded to MinIO");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail URL";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveThumbnailToMinioUrl = async (rawValue: string) => {
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
if (!isHttpUrl(trimmed) || isMinioUrl(trimmed)) return trimmed;
|
|
||||||
const uploaded = await uploadImageFile(trimmed);
|
|
||||||
const uploadedUrl = uploaded.data?.data?.url?.trim();
|
|
||||||
if (!uploadedUrl) {
|
|
||||||
throw new Error("Failed to upload thumbnail URL to MinIO");
|
|
||||||
}
|
|
||||||
return uploadedUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateCourse = async () => {
|
|
||||||
if (programType !== "proficiency") {
|
|
||||||
toast.error("Create Course is supported only for proficiency catalog.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const name = createName.trim();
|
|
||||||
if (!name) {
|
|
||||||
toast.error("Course name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreating(true);
|
|
||||||
try {
|
|
||||||
let thumbnailToSend: string | null = createThumbnail.trim() || null;
|
|
||||||
if (
|
|
||||||
thumbnailToSend &&
|
|
||||||
!createThumbnailFromUpload &&
|
|
||||||
isHttpUrl(thumbnailToSend) &&
|
|
||||||
!isMinioUrl(thumbnailToSend)
|
|
||||||
) {
|
|
||||||
const uploaded = await uploadImageFile(thumbnailToSend);
|
|
||||||
const uploadedUrl = uploaded.data?.data?.url?.trim();
|
|
||||||
if (!uploadedUrl) {
|
|
||||||
throw new Error("Failed to upload thumbnail URL to MinIO");
|
|
||||||
}
|
|
||||||
thumbnailToSend = uploadedUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await createExamPrepCatalogCourse({
|
|
||||||
name,
|
|
||||||
description: createDescription.trim() || null,
|
|
||||||
thumbnail: thumbnailToSend,
|
|
||||||
});
|
|
||||||
const row = response.data?.data;
|
|
||||||
if (!row?.id) {
|
|
||||||
throw new Error("Missing created course payload");
|
|
||||||
}
|
|
||||||
setCreatedCourses((prev) => [
|
|
||||||
{
|
|
||||||
id: row.id,
|
|
||||||
name: row.name ?? name,
|
|
||||||
description: row.description?.trim() || createDescription.trim() || "—",
|
|
||||||
thumbnail: row.thumbnail?.trim() || null,
|
|
||||||
sortOrder: Number(row.sort_order ?? 0),
|
|
||||||
unitsCount: Number(row.units_count ?? 0),
|
|
||||||
modulesCount: Number(row.modules_count ?? 0),
|
|
||||||
lessonsCount: Number(row.lessons_count ?? 0),
|
|
||||||
},
|
|
||||||
...prev,
|
|
||||||
]);
|
|
||||||
await loadCatalogCourses();
|
|
||||||
toast.success("Course created");
|
|
||||||
setCreateName("");
|
|
||||||
setCreateDescription("");
|
|
||||||
setCreateThumbnail("");
|
|
||||||
setCreateThumbnailFromUpload(false);
|
|
||||||
setCreateOpen(false);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to create course";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditCourse = (course: (typeof proficiencyCourses)[number]) => {
|
|
||||||
const idNum = Number(course.id);
|
|
||||||
if (!Number.isFinite(idNum)) return;
|
|
||||||
setEditingCourseId(idNum);
|
|
||||||
setEditName(String(course.name ?? ""));
|
|
||||||
setEditDescription(String(course.description ?? ""));
|
|
||||||
setEditThumbnail(String(course.thumbnail ?? ""));
|
|
||||||
setEditSortOrder(String(course.sort_order ?? 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeEditCourse = () => {
|
|
||||||
if (savingEdit || uploadingEditThumbnail) return;
|
|
||||||
setEditingCourseId(null);
|
|
||||||
setEditName("");
|
|
||||||
setEditDescription("");
|
|
||||||
setEditThumbnail("");
|
|
||||||
setEditSortOrder("1");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditThumbnailFile = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = "";
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.type.startsWith("image/")) {
|
|
||||||
toast.error("Please choose an image file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploadingEditThumbnail(true);
|
|
||||||
try {
|
|
||||||
const res = await uploadImageFile(file);
|
|
||||||
const url = res.data?.data?.url?.trim();
|
|
||||||
if (!url) throw new Error("Upload did not return a file URL");
|
|
||||||
setEditThumbnail(url);
|
|
||||||
toast.success("Thumbnail uploaded");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEditCourse = async () => {
|
|
||||||
if (!editingCourseId) return;
|
|
||||||
const name = editName.trim();
|
|
||||||
if (!name) {
|
|
||||||
toast.error("Course name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sortOrderNum = Number(editSortOrder);
|
|
||||||
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
|
|
||||||
toast.error("Sort order must be a valid number");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSavingEdit(true);
|
|
||||||
try {
|
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
|
||||||
const response = await updateExamPrepCatalogCourse(editingCourseId, {
|
|
||||||
name,
|
|
||||||
description: editDescription.trim() || null,
|
|
||||||
thumbnail: minioThumbnail || null,
|
|
||||||
sort_order: sortOrderNum,
|
|
||||||
});
|
|
||||||
const row = response.data?.data;
|
|
||||||
setCreatedCourses((prev) =>
|
|
||||||
prev.map((course) =>
|
|
||||||
course.id === editingCourseId
|
|
||||||
? {
|
|
||||||
...course,
|
|
||||||
name: row?.name ?? name,
|
|
||||||
description: row?.description?.trim() || editDescription.trim() || "—",
|
|
||||||
thumbnail: row?.thumbnail?.trim() || null,
|
|
||||||
sortOrder: Number(row?.sort_order ?? sortOrderNum),
|
|
||||||
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
|
|
||||||
modulesCount: Number(row?.modules_count ?? course.modulesCount ?? 0),
|
|
||||||
lessonsCount: Number(row?.lessons_count ?? course.lessonsCount ?? 0),
|
|
||||||
}
|
|
||||||
: course,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await loadCatalogCourses();
|
|
||||||
toast.success("Course updated");
|
|
||||||
closeEditCourse();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to update course";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setSavingEdit(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteCourse = async () => {
|
|
||||||
if (!deletingCourseId) return;
|
|
||||||
setDeletingCourse(true);
|
|
||||||
try {
|
|
||||||
await deleteExamPrepCatalogCourse(deletingCourseId);
|
|
||||||
await loadCatalogCourses();
|
|
||||||
toast.success("Course deleted");
|
|
||||||
setDeletingCourseId(null);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to delete course";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setDeletingCourse(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateCourseThumbnailFile = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = "";
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.type.startsWith("image/")) {
|
|
||||||
toast.error("Please choose an image file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const maxBytes = 5 * 1024 * 1024;
|
|
||||||
if (file.size > maxBytes) {
|
|
||||||
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploadingThumbnail(true);
|
|
||||||
try {
|
|
||||||
const res = await uploadImageFile(file);
|
|
||||||
const url = res.data?.data?.url?.trim();
|
|
||||||
if (!url) {
|
|
||||||
throw new Error("Upload did not return a file URL");
|
|
||||||
}
|
|
||||||
setCreateThumbnail(url);
|
|
||||||
setCreateThumbnailFromUpload(true);
|
|
||||||
toast.success("Thumbnail uploaded");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(error);
|
|
||||||
const message =
|
|
||||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
|
|
@ -432,136 +107,84 @@ export function ProgramDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<Dialog
|
<Dialog>
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && (creating || uploadingThumbnail)) return;
|
|
||||||
setCreateOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white transition-all flex items-center gap-2">
|
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white transition-all flex items-center gap-2">
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
Create Course
|
Create Course
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
|
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
<div className="bg-white">
|
||||||
<DialogHeader className="shrink-0 border-b border-grayScale-200 px-8 py-6 flex flex-row items-center justify-between">
|
<DialogHeader className="px-8 py-6 border-b border-grayScale-200 flex flex-row items-center justify-between">
|
||||||
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
||||||
Create Course
|
Create Course
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-8 overflow-y-auto p-8">
|
<div className="p-8 space-y-8">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">
|
<label className="text-[15px] text-grayScale-800">
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={createName}
|
|
||||||
onChange={(e) => setCreateName(e.target.value)}
|
|
||||||
placeholder="e.g. TOEFL, IELTS"
|
placeholder="e.g. TOEFL, IELTS"
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
||||||
disabled={creating}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">
|
<label className="text-[15px] text-grayScale-800">
|
||||||
Description
|
Course Order
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Select defaultValue="1">
|
||||||
value={createDescription}
|
<option value="1">1</option>
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
<option value="2">2</option>
|
||||||
placeholder="Optional description"
|
<option value="3">3</option>
|
||||||
rows={4}
|
</Select>
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={creating}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail Field */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">
|
<label className="text-[15px] text-grayScale-800">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="relative group cursor-pointer">
|
||||||
ref={createThumbnailFileInputRef}
|
<div className="flex flex-col items-center justify-center rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white py-8 px-10 transition-all ">
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleCreateCourseThumbnailFile(e)}
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative w-full cursor-pointer rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white px-10 py-8 text-left transition-all hover:border-brand-300 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
onClick={() => createThumbnailFileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<img src={uploadIcon} alt="" className="h-10 w-10" />
|
<img
|
||||||
|
src={uploadIcon}
|
||||||
|
alt="Upload icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[15px]">
|
<p className="text-[15px]">
|
||||||
<span className="font-bold text-brand-500">
|
<span className="text-brand-500 font-bold hover:underline">
|
||||||
{uploadingThumbnail ? "Uploading…" : "Click to upload"}
|
Click to upload
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
<span className="text-grayScale-500">or paste a URL below</span>
|
<span className="text-grayScale-500">
|
||||||
|
or drag and drop
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1.5 text-[12px] uppercase tracking-widest text-grayScale-400">
|
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
||||||
JPG, PNG (MAX 5 MB)
|
JPG, PNG (MAX 1 MB)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
{createThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<ResolvedImage
|
|
||||||
src={createThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={createThumbnail}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCreateThumbnail(e.target.value);
|
|
||||||
setCreateThumbnailFromUpload(false);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
void autoUploadThumbnailUrlIfNeeded(e.target.value);
|
|
||||||
}}
|
|
||||||
onPaste={(e) => {
|
|
||||||
const pasted = e.clipboardData.getData("text");
|
|
||||||
if (!pasted) return;
|
|
||||||
setCreateThumbnail(pasted);
|
|
||||||
setCreateThumbnailFromUpload(false);
|
|
||||||
void autoUploadThumbnailUrlIfNeeded(pasted);
|
|
||||||
}}
|
|
||||||
placeholder="Optional thumbnail URL (or leave empty for null)"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button
|
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
Create Program
|
||||||
disabled={creating || uploadingThumbnail}
|
|
||||||
onClick={() => void handleCreateCourse()}
|
|
||||||
>
|
|
||||||
{creating ? "Creating..." : "Create Course"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -598,70 +221,13 @@ export function ProgramDetailPage() {
|
||||||
|
|
||||||
{/* Cards Grid */}
|
{/* Cards Grid */}
|
||||||
<div className="flex flex-wrap gap-8 mt-10">
|
<div className="flex flex-wrap gap-8 mt-10">
|
||||||
{programType === "proficiency" && catalogLoading ? (
|
{currentProgram.courses.map((course: any) => (
|
||||||
<p className="text-sm text-grayScale-500">Loading catalog courses...</p>
|
<Card
|
||||||
) : null}
|
key={course.id}
|
||||||
{(programType === "proficiency"
|
className="bg-white w-[500px] rounded-[20px] border border-grayScale-100 p-6 flex flex-col items-start shadow-sm hover:shadow-md transition-shadow"
|
||||||
? proficiencyCourses
|
>
|
||||||
: currentProgram.courses
|
|
||||||
).length === 0 && !catalogLoading ? (
|
|
||||||
<div className="w-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
|
||||||
<p className="text-sm font-medium text-grayScale-600">
|
|
||||||
No catalog courses yet
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
|
||||||
Create your first exam-prep catalog course to start organizing units, modules, and lessons.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
(programType === "proficiency"
|
|
||||||
? proficiencyCourses
|
|
||||||
: currentProgram.courses
|
|
||||||
).map((course: any) => (
|
|
||||||
<Card
|
|
||||||
key={course.id}
|
|
||||||
className="group relative bg-white w-[500px] rounded-[20px] border border-grayScale-100 p-6 flex flex-col items-start shadow-sm hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
{programType === "proficiency" ? (
|
|
||||||
<div className="absolute right-3 top-3 z-10 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">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
|
|
||||||
onClick={() => openEditCourse(course)}
|
|
||||||
aria-label={`Edit ${course.name}`}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
|
|
||||||
onClick={() => setDeletingCourseId(Number(course.id))}
|
|
||||||
aria-label={`Delete ${course.name}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="h-16 flex items-center">
|
<div className="h-16 flex items-center">{course.logo}</div>
|
||||||
{course.thumbnail ? (
|
|
||||||
<ResolvedImage
|
|
||||||
src={course.thumbnail}
|
|
||||||
alt={course.name}
|
|
||||||
className="h-14 w-14 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : course.logo ? (
|
|
||||||
course.logo
|
|
||||||
) : (
|
|
||||||
<div className="h-14 w-14 rounded-full bg-brand-50 text-brand-600 grid place-items-center text-xs font-bold">
|
|
||||||
{String(course.name ?? "C").slice(0, 2).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="space-y-4 pt-2 flex-1">
|
<div className="space-y-4 pt-2 flex-1">
|
||||||
|
|
@ -678,19 +244,13 @@ export function ProgramDetailPage() {
|
||||||
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
|
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
|
||||||
<ClipboardList className="h-3 w-3 text-grayScale-400" />
|
<ClipboardList className="h-3 w-3 text-grayScale-400" />
|
||||||
<span className="text-[12px] ">
|
<span className="text-[12px] ">
|
||||||
{Number(course.units_count ?? 0)} Units
|
{course.coursesCount} Courses
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
|
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
|
||||||
<ListChecks className="h-3 w-3 text-grayScale-400" />
|
<ListChecks className="h-3 w-3 text-grayScale-400" />
|
||||||
<span className="text-[12px] ">
|
<span className="text-[12px] ">
|
||||||
{Number(course.modules_count ?? 0)} Modules
|
{course.questionTypesCount} Question Types
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-10 px-4 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-700">
|
|
||||||
<ListChecks className="h-3 w-3 text-grayScale-400" />
|
|
||||||
<span className="text-[12px] ">
|
|
||||||
{Number(course.lessons_count ?? 0)} Lessons
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -705,166 +265,9 @@ export function ProgramDetailPage() {
|
||||||
{course.buttonText}
|
{course.buttonText}
|
||||||
<ChevronRight className="h-5 w-5 transition-transform group-hover/btn:translate-x-1" />
|
<ChevronRight className="h-5 w-5 transition-transform group-hover/btn:translate-x-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={editingCourseId !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && (savingEdit || uploadingEditThumbnail)) return;
|
|
||||||
if (!open) closeEditCourse();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-[600px] flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0">
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-white">
|
|
||||||
<DialogHeader className="shrink-0 border-b border-grayScale-200 px-8 py-6 flex flex-row items-center justify-between">
|
|
||||||
<DialogTitle className="text-[20px] font-bold relative top-2 text-grayScale-900">
|
|
||||||
Edit Course
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="min-h-0 flex-1 space-y-8 overflow-y-auto p-8">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Name</label>
|
|
||||||
<Input
|
|
||||||
value={editName}
|
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Description</label>
|
|
||||||
<Textarea
|
|
||||||
value={editDescription}
|
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={editSortOrder}
|
|
||||||
onChange={(e) => setEditSortOrder(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
|
||||||
<input
|
|
||||||
ref={editThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleEditThumbnailFile(e)}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative w-full cursor-pointer rounded-[12px] border-2 border-dashed border-grayScale-400 bg-white px-10 py-8 text-left transition-all hover:border-brand-300 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
onClick={() => editThumbnailFileInputRef.current?.click()}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<div className="mb-4">
|
|
||||||
<img src={uploadIcon} alt="" className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[15px]">
|
|
||||||
<span className="font-bold text-brand-500">
|
|
||||||
{uploadingEditThumbnail ? "Uploading…" : "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">or paste a URL below</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] uppercase tracking-widest text-grayScale-400">
|
|
||||||
JPG, PNG (MAX 5 MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{editThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<ResolvedImage
|
|
||||||
src={editThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={editThumbnail}
|
|
||||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
|
||||||
placeholder="https://..."
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
|
||||||
onClick={closeEditCourse}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
|
||||||
onClick={() => void handleSaveEditCourse()}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
>
|
|
||||||
{savingEdit ? "Saving..." : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={deletingCourseId !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && !deletingCourse) setDeletingCourseId(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-md rounded-[16px] border-none p-0 overflow-hidden">
|
|
||||||
<div className="bg-white">
|
|
||||||
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
|
|
||||||
<DialogTitle className="text-lg font-bold text-grayScale-900">
|
|
||||||
Delete Course
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="px-6 py-6 text-sm text-grayScale-600">
|
|
||||||
Are you sure you want to delete this course? This action cannot be undone.
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 border-t border-grayScale-100 px-6 py-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeletingCourseId(null)}
|
|
||||||
disabled={deletingCourse}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-red-500 hover:bg-red-600"
|
|
||||||
onClick={() => void handleDeleteCourse()}
|
|
||||||
disabled={deletingCourse}
|
|
||||||
>
|
|
||||||
{deletingCourse ? "Deleting..." : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,9 @@ export function ProgramTypeSelectionPage() {
|
||||||
exams. Select a program type to manage curriculum and modules.
|
exams. Select a program type to manage curriculum and modules.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/new-content/question-types">
|
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2 mt-4">
|
||||||
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2 mt-4">
|
Manage Question Types
|
||||||
Manage Question Types
|
</Button>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gradient Divider */}
|
{/* Gradient Divider */}
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { ArrowLeft, Plus, Search } from "lucide-react";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { Input } from "../../components/ui/input";
|
|
||||||
import { Select } from "../../components/ui/select";
|
|
||||||
import { Card } from "../../components/ui/card";
|
|
||||||
import { cn } from "../../lib/utils";
|
|
||||||
import { QuestionTypeCard } from "./components/QuestionTypeCard";
|
|
||||||
|
|
||||||
export function QuestionTypeLibraryPage() {
|
|
||||||
const [activeTab, setActiveTab] = useState("All");
|
|
||||||
|
|
||||||
const questionTypes = [
|
|
||||||
{
|
|
||||||
title: "Describe a Photo",
|
|
||||||
exam: "DUOLINGO" as const,
|
|
||||||
skill: "Speaking" as const,
|
|
||||||
variations: 12,
|
|
||||||
status: "Published" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Write About the Topic",
|
|
||||||
exam: "DUOLINGO" as const,
|
|
||||||
skill: "Writing" as const,
|
|
||||||
variations: 12,
|
|
||||||
status: "Published" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Fill in the Blanks",
|
|
||||||
exam: "IELTS" as const,
|
|
||||||
skill: "Writing" as const,
|
|
||||||
variations: 12,
|
|
||||||
status: "Published" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Describe a Photo",
|
|
||||||
exam: "DUOLINGO" as const,
|
|
||||||
skill: "Speaking" as const,
|
|
||||||
variations: 12,
|
|
||||||
status: "Published" as const,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
|
|
||||||
{/* Navigation & Header */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Link
|
|
||||||
to="/new-content/courses"
|
|
||||||
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
|
||||||
Back to Courses
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">
|
|
||||||
Question Type Library
|
|
||||||
</h1>
|
|
||||||
<p className="text-grayScale-500 text-[16px] font-medium">
|
|
||||||
Create and manage reusable question structures for practices and
|
|
||||||
assessments.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link to="/new-content/question-types/create">
|
|
||||||
<Button className="h-12 px-8 rounded-[10px] bg-[#9E2891] font-bold text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3">
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
Create Question Type
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Control Bar */}
|
|
||||||
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" />
|
|
||||||
<Input
|
|
||||||
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm"
|
|
||||||
placeholder="Search by practice name, ID, or keywords..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
|
|
||||||
<option>All Exams</option>
|
|
||||||
<option>IELTS</option>
|
|
||||||
<option>Duolingo</option>
|
|
||||||
</Select>
|
|
||||||
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm">
|
|
||||||
<option>All Skills</option>
|
|
||||||
<option>Speaking</option>
|
|
||||||
<option>Writing</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">
|
|
||||||
STATUS:
|
|
||||||
</span>
|
|
||||||
{["All", "Published", "Drafts", "Archived"].map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={cn(
|
|
||||||
"h-10 px-4 rounded-full text-[13px] font-medium transition-all",
|
|
||||||
activeTab === tab
|
|
||||||
? "bg-[#9E2891] text-white shadow-md shadow-brand-500/20"
|
|
||||||
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Grid of Cards */}
|
|
||||||
<div className="grid grid-cols-4 gap-6">
|
|
||||||
{questionTypes.map((qt, index) => (
|
|
||||||
<QuestionTypeCard key={index} {...qt} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,83 +0,0 @@
|
||||||
import { Edit2, Trash2, Mic2, Keyboard, Layers, MicIcon } from "lucide-react";
|
|
||||||
import { Badge } from "../../../components/ui/badge";
|
|
||||||
import { Card } from "../../../components/ui/card";
|
|
||||||
import { cn } from "../../../lib/utils";
|
|
||||||
|
|
||||||
interface QuestionTypeCardProps {
|
|
||||||
title: string;
|
|
||||||
exam: "DUOLINGO" | "IELTS" | "TOEFL";
|
|
||||||
skill: "Speaking" | "Writing" | "Listening" | "Reading";
|
|
||||||
variations: number;
|
|
||||||
status: "Published" | "Draft" | "Archived";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QuestionTypeCard({
|
|
||||||
title,
|
|
||||||
exam,
|
|
||||||
skill,
|
|
||||||
variations,
|
|
||||||
status,
|
|
||||||
}: QuestionTypeCardProps) {
|
|
||||||
const SkillIcon = skill === "Speaking" ? MicIcon : Keyboard;
|
|
||||||
|
|
||||||
const examColors = {
|
|
||||||
DUOLINGO: "bg-[#22C55EE5] text-[#fff] border-transparent",
|
|
||||||
IELTS: "bg-[#EF4444E5] text-[#fff] border-transparent",
|
|
||||||
TOEFL: "bg-[#DBEAFE] text-[#fff] border-transparent",
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusColors = {
|
|
||||||
Published: "bg-[#F0FDF4] text-[#16A34A]",
|
|
||||||
Draft: "bg-grayScale-50 text-grayScale-500",
|
|
||||||
Archived: "bg-red-50 text-red-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="group overflow-hidden border-grayScale-200 rounded-[12px] bg-white transition-all duration-300">
|
|
||||||
<div className="px-4 py-6 space-y-8">
|
|
||||||
<h3 className="text-[20px] font-bold text-grayScale-900 leading-[1.2]">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1 rounded-[4px] text-[11px] font-bold tracking-wider shadow-none border-none",
|
|
||||||
examColors[exam],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{exam}
|
|
||||||
</Badge>
|
|
||||||
<div className="flex items-center gap-1 text-grayScale-900 font-bold text-[13px]">
|
|
||||||
<SkillIcon className="h-4 w-4" />
|
|
||||||
{skill}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 text-[#9E2891] font-medium text-[15px]">
|
|
||||||
<Layers className="h-[16px] w-[16px]" />
|
|
||||||
{variations} Variations
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4 flex items-center justify-between border-t border-grayScale-200">
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1 rounded-[4px] text-[12px] font-bold shadow-none border-none",
|
|
||||||
statusColors[status],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</Badge>
|
|
||||||
<div className="flex items-center gap-5 transition-opacity">
|
|
||||||
<button className="text-grayScale-500/70 transition-all">
|
|
||||||
<Edit2 className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
<button className="text-grayScale-500/70 transition-all">
|
|
||||||
<Trash2 className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Play, Pause, X } from "lucide-react";
|
import { Play, Pause, X } from "lucide-react";
|
||||||
import { cn } from "../../../../lib/utils";
|
import { cn } from "../../../../lib/utils";
|
||||||
import { resolveDisplayMediaUrl } from "../../../../lib/mediaUrl";
|
|
||||||
|
|
||||||
interface VoicePromptProps {
|
interface VoicePromptProps {
|
||||||
/** Either a URL/path to the audio file, or a filename string (for display-only mode) */
|
/** Either a URL/path to the audio file, or a filename string (for display-only mode) */
|
||||||
|
|
@ -22,34 +21,13 @@ export function VoicePrompt({
|
||||||
const [bars, setBars] = useState<number[]>([]);
|
const [bars, setBars] = useState<number[]>([]);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [progress, setProgress] = useState(0); // 0–1
|
const [progress, setProgress] = useState(0); // 0–1
|
||||||
const [playableSrc, setPlayableSrc] = useState("");
|
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
const raw = src?.trim() || "";
|
|
||||||
if (!raw) {
|
|
||||||
setPlayableSrc("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const resolved = await resolveDisplayMediaUrl(raw);
|
|
||||||
if (!cancelled) setPlayableSrc(resolved || raw);
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setPlayableSrc(raw);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [src]);
|
|
||||||
|
|
||||||
// ─── Decode audio and build waveform bars ───────────────────────────────────
|
// ─── Decode audio and build waveform bars ───────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playableSrc) {
|
if (!src) {
|
||||||
// No real audio — generate plausible static bars
|
// No real audio — generate plausible static bars
|
||||||
setBars(generateFakeBars());
|
setBars(generateFakeBars());
|
||||||
return;
|
return;
|
||||||
|
|
@ -58,7 +36,7 @@ export function VoicePrompt({
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const audioCtx = new AudioContext();
|
const audioCtx = new AudioContext();
|
||||||
|
|
||||||
fetch(playableSrc)
|
fetch(src)
|
||||||
.then((r) => r.arrayBuffer())
|
.then((r) => r.arrayBuffer())
|
||||||
.then((buf) => audioCtx.decodeAudioData(buf))
|
.then((buf) => audioCtx.decodeAudioData(buf))
|
||||||
.then((decoded) => {
|
.then((decoded) => {
|
||||||
|
|
@ -84,15 +62,7 @@ export function VoicePrompt({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [playableSrc]);
|
}, [src]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
audioRef.current?.pause();
|
|
||||||
audioRef.current = null;
|
|
||||||
setIsPlaying(false);
|
|
||||||
setProgress(0);
|
|
||||||
stopProgressLoop();
|
|
||||||
}, [playableSrc]);
|
|
||||||
|
|
||||||
// ─── Sync progress while playing ────────────────────────────────────────────
|
// ─── Sync progress while playing ────────────────────────────────────────────
|
||||||
const startProgressLoop = () => {
|
const startProgressLoop = () => {
|
||||||
|
|
@ -114,10 +84,10 @@ export function VoicePrompt({
|
||||||
|
|
||||||
// ─── Play / Pause ────────────────────────────────────────────────────────────
|
// ─── Play / Pause ────────────────────────────────────────────────────────────
|
||||||
const handlePlayPause = () => {
|
const handlePlayPause = () => {
|
||||||
if (!playableSrc) return;
|
if (!src) return;
|
||||||
|
|
||||||
if (!audioRef.current) {
|
if (!audioRef.current) {
|
||||||
audioRef.current = new Audio(playableSrc);
|
audioRef.current = new Audio(src);
|
||||||
audioRef.current.onended = () => {
|
audioRef.current.onended = () => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { X, ArrowRight } from "lucide-react";
|
|
||||||
import { Button } from "../../../../components/ui/button";
|
|
||||||
import { Card } from "../../../../components/ui/card";
|
|
||||||
import { Select } from "../../../../components/ui/select";
|
|
||||||
import { Badge } from "../../../../components/ui/badge";
|
|
||||||
|
|
||||||
interface QuestionTypeBasicInfoStepProps {
|
|
||||||
onNext: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QuestionTypeBasicInfoStep({
|
|
||||||
onNext,
|
|
||||||
}: QuestionTypeBasicInfoStepProps) {
|
|
||||||
const [selectedChips, setSelectedChips] = useState([
|
|
||||||
"Multiple Choice",
|
|
||||||
"Sentence Completion",
|
|
||||||
]);
|
|
||||||
const suggestions = ["Matching Headings", "True/False/NG"];
|
|
||||||
|
|
||||||
const removeChip = (chip: string) => {
|
|
||||||
setSelectedChips(selectedChips.filter((c) => c !== chip));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addChip = (chip: string) => {
|
|
||||||
if (!selectedChips.includes(chip)) {
|
|
||||||
setSelectedChips([...selectedChips, chip]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 pb-32">
|
|
||||||
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
|
|
||||||
<div className="p-10 border-b border-grayScale-200">
|
|
||||||
<h2 className="text-[20px] font-medium text-grayScale-900">
|
|
||||||
STEP 1: Basic Info
|
|
||||||
</h2>
|
|
||||||
<p className="text-grayScale-500 font-medium mt-1">
|
|
||||||
Define what this question type is and where it applies.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-10 space-y-10">
|
|
||||||
{/* Top Row: Course Type & Skill Category */}
|
|
||||||
<div className="grid grid-cols-2 gap-10">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
|
|
||||||
Course Type <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
|
|
||||||
<option>Select an exam type</option>
|
|
||||||
<option>IELTS</option>
|
|
||||||
<option>Duolingo</option>
|
|
||||||
<option>TOEFL</option>
|
|
||||||
</Select>
|
|
||||||
<p className="text-grayScale-400 text-[13px] font-medium leading-relaxed">
|
|
||||||
The core framework for the practice test.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[14px] font-medium text-grayScale-700 flex items-center gap-1">
|
|
||||||
Skill Category <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
|
|
||||||
<option>Select a skill</option>
|
|
||||||
<option>Speaking</option>
|
|
||||||
<option>Writing</option>
|
|
||||||
<option>Listening</option>
|
|
||||||
<option>Reading</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Question Type */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[14px] font-bold text-grayScale-700 flex items-center gap-1">
|
|
||||||
Question Type <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<Select className="h-12 rounded-[12px] border-grayScale-300 bg-[#F8FAFC] font-medium text-grayScale-900 transition-all ">
|
|
||||||
<option>Single Format</option>
|
|
||||||
<option>Mixed Format</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Question Types Chip Input */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[14px] font-bold text-grayScale-700 flex items-center gap-1">
|
|
||||||
Question Types <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="min-h-[56px] p-3 flex flex-wrap gap-2.5 rounded-[12px] border border-grayScale-300 bg-[#F8FAFC]">
|
|
||||||
{selectedChips.map((chip) => (
|
|
||||||
<Badge
|
|
||||||
key={chip}
|
|
||||||
className="bg-[#9E28911A] text-[#9E2891] border-[#9E289133] px-2 py-0 rounded-full text-[13px] font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{chip}
|
|
||||||
<button
|
|
||||||
onClick={() => removeChip(chip)}
|
|
||||||
className="hover:text-red-500 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
<input
|
|
||||||
className="flex-1 min-w-[150px] bg-transparent border-none focus:ring-0 text-[14px] font-medium text-grayScale-900 px-3 placeholder:text-grayScale-400"
|
|
||||||
placeholder="Add question types..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
|
||||||
<span className="text-[13px] font-medium text-grayScale-500">
|
|
||||||
Suggestions:
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{suggestions.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
onClick={() => addChip(s)}
|
|
||||||
className="px-3 py-1.5 rounded-[6px] border border-grayScale-300 text-[13px] font-medium text-grayScale-600 hover:bg-grayScale-50 hover:text-[#9E2891] hover:border-[#9E2891]/20 transition-all"
|
|
||||||
>
|
|
||||||
{s}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="px-4 py-4 border border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 px-6 rounded-[6px] border-none shadow-none text-grayScale-600 font-bold hover:bg-grayScale-100"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={onNext}
|
|
||||||
className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3"
|
|
||||||
>
|
|
||||||
Next: Structure
|
|
||||||
<ArrowRight className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -143,234 +143,6 @@ export interface CreateProgramCourseResponse {
|
||||||
metadata: unknown | null
|
metadata: unknown | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Exam prep catalog course row (e.g. IELTS / DET cards) */
|
|
||||||
export interface ExamPrepCatalogCourseItem {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
sort_order?: number
|
|
||||||
units_count?: number
|
|
||||||
modules_count?: number
|
|
||||||
lessons_count?: number
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepCatalogCourseRequest {
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepCatalogCourseResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepCatalogCourseItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetExamPrepCatalogCoursesResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
offset: number
|
|
||||||
limit: number
|
|
||||||
total_count: number
|
|
||||||
catalog_courses: ExamPrepCatalogCourseItem[]
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepCatalogCourseRequest {
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
sort_order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepCatalogCourseResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepCatalogCourseItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExamPrepCatalogUnitItem {
|
|
||||||
id: number
|
|
||||||
catalog_course_id: number
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
sort_order?: number
|
|
||||||
modules_count?: number
|
|
||||||
lessons_count?: number
|
|
||||||
videos_count?: number
|
|
||||||
practices_count?: number
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepCatalogUnitRequest {
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepCatalogUnitResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepCatalogUnitItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepCatalogUnitRequest {
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
sort_order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepCatalogUnitResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepCatalogUnitItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetExamPrepCatalogUnitsResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
offset: number
|
|
||||||
limit: number
|
|
||||||
total_count: number
|
|
||||||
units: ExamPrepCatalogUnitItem[]
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExamPrepUnitModuleItem {
|
|
||||||
id: number
|
|
||||||
unit_id: number
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
icon?: string | null
|
|
||||||
sort_order?: number
|
|
||||||
lessons_count?: number
|
|
||||||
videos_count?: number
|
|
||||||
practices_count?: number
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepUnitModuleRequest {
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
icon?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepUnitModuleResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepUnitModuleItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepUnitModuleRequest {
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
icon?: string | null
|
|
||||||
sort_order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepUnitModuleResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepUnitModuleItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetExamPrepUnitModulesResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
offset: number
|
|
||||||
limit: number
|
|
||||||
total_count: number
|
|
||||||
modules: ExamPrepUnitModuleItem[]
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExamPrepModuleLessonItem {
|
|
||||||
id: number
|
|
||||||
unit_module_id: number
|
|
||||||
title: string
|
|
||||||
video_url: string
|
|
||||||
thumbnail?: string | null
|
|
||||||
description?: string | null
|
|
||||||
sort_order?: number
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepModuleLessonRequest {
|
|
||||||
title: string
|
|
||||||
video_url: string
|
|
||||||
thumbnail?: string | null
|
|
||||||
description?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateExamPrepModuleLessonResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepModuleLessonItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepModuleLessonRequest {
|
|
||||||
title: string
|
|
||||||
video_url?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
description?: string | null
|
|
||||||
sort_order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateExamPrepModuleLessonResponse {
|
|
||||||
message: string
|
|
||||||
data: ExamPrepModuleLessonItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetExamPrepModuleLessonsResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
lessons: ExamPrepModuleLessonItem[]
|
|
||||||
total_count: number
|
|
||||||
limit: number
|
|
||||||
offset: number
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetProgramCoursesResponse {
|
export interface GetProgramCoursesResponse {
|
||||||
message: string
|
message: string
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user