Compare commits
No commits in common. "3634d2eb790bb40479d3d5011e5f50c75616012f" and "73f11ea1a068582f84eaad54e65bfb948131caed" have entirely different histories.
3634d2eb79
...
73f11ea1a0
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -23,7 +23,6 @@
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-is": "^19.2.5",
|
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|
@ -5316,10 +5315,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.5",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
|
||||||
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
|
"integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-is": "^19.2.5",
|
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|
|
||||||
|
|
@ -44,40 +44,32 @@ import type {
|
||||||
GetQuestionsResponse,
|
GetQuestionsResponse,
|
||||||
CreateVimeoVideoRequest,
|
CreateVimeoVideoRequest,
|
||||||
CreateCourseCategoryRequest,
|
CreateCourseCategoryRequest,
|
||||||
GetCategorySubCategoriesResponse,
|
|
||||||
GetSubCategoryCoursesResponse,
|
|
||||||
GetSubCoursePrerequisitesResponse,
|
GetSubCoursePrerequisitesResponse,
|
||||||
AddSubCoursePrerequisiteRequest,
|
AddSubCoursePrerequisiteRequest,
|
||||||
GetLearningPathResponse,
|
GetLearningPathResponse,
|
||||||
GetHumanLanguageLessonsResponse,
|
|
||||||
GetHumanLanguageHierarchyResponse,
|
|
||||||
GetCourseHierarchyResponse,
|
|
||||||
CreateHumanLanguageLessonRequest,
|
|
||||||
GetSubModuleLessonsResponse,
|
|
||||||
GetSubModuleLessonDetailResponse,
|
GetSubModuleLessonDetailResponse,
|
||||||
UpdateSubModuleLessonRequest,
|
GetHumanLanguageLessonsResponse,
|
||||||
UpdateSubModuleLessonResponse,
|
GetSubModuleLessonsResponse,
|
||||||
|
GetHumanLanguageSubCategoriesResponse,
|
||||||
|
GetCategorySubCategoriesResponse,
|
||||||
|
GetSubCategoryCoursesResponse,
|
||||||
GetCourseLevelsForCourseResponse,
|
GetCourseLevelsForCourseResponse,
|
||||||
|
GetCourseLevelsAllResponse,
|
||||||
|
GetCourseLevelByIdResponse,
|
||||||
|
GetHumanLanguageHierarchyFlatResponse,
|
||||||
|
GetCourseHierarchyResponse,
|
||||||
GetSubModulesByModuleResponse,
|
GetSubModulesByModuleResponse,
|
||||||
|
CourseHierarchyRow,
|
||||||
SubCourse,
|
SubCourse,
|
||||||
|
CreateHumanLanguageLessonRequest,
|
||||||
GetSubCourseEntryAssessmentResponse,
|
GetSubCourseEntryAssessmentResponse,
|
||||||
ReorderItem,
|
ReorderItem,
|
||||||
GetRatingsResponse,
|
GetRatingsResponse,
|
||||||
GetRatingsParams,
|
GetRatingsParams,
|
||||||
GetVimeoSampleResponse,
|
GetVimeoSampleResponse,
|
||||||
CreateCourseVideoRequest,
|
CreateCourseVideoRequest,
|
||||||
GetLearningProgramsResponse,
|
UpdateSubModuleLessonRequest,
|
||||||
UpdateLearningProgramRequest,
|
UpdateSubModuleLessonResponse,
|
||||||
CreateLearningProgramRequest,
|
|
||||||
CreateLearningProgramResponse,
|
|
||||||
GetProgramCoursesResponse,
|
|
||||||
GetTopLevelCourseModulesResponse,
|
|
||||||
UpdateTopLevelCourseRequest,
|
|
||||||
UpdateTopLevelCourseModuleRequest,
|
|
||||||
CreateTopLevelCourseModuleRequest,
|
|
||||||
CreateTopLevelCourseModuleResponse,
|
|
||||||
CreateProgramCourseRequest,
|
|
||||||
CreateProgramCourseResponse,
|
|
||||||
} from "../types/course.types"
|
} from "../types/course.types"
|
||||||
|
|
||||||
type UnifiedHierarchyRow = {
|
type UnifiedHierarchyRow = {
|
||||||
|
|
@ -89,21 +81,22 @@ type UnifiedHierarchyRow = {
|
||||||
course_title?: string | null
|
course_title?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type CourseHierarchyRow = {
|
async function withSingleRetry<T>(request: () => Promise<T>, retryDelayMs = 400): Promise<T> {
|
||||||
course_id: number
|
try {
|
||||||
course_title: string
|
return await request()
|
||||||
level_id?: number | null
|
} catch {
|
||||||
cefr_level?: string | null
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs))
|
||||||
module_id?: number | null
|
return request()
|
||||||
module_title?: string | null
|
}
|
||||||
sub_module_id?: number | null
|
|
||||||
sub_module_title?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCourseCategories = () =>
|
export const getCourseCategories = () =>
|
||||||
http.get("/course-management/hierarchy").then((res) => {
|
withSingleRetry(() => http.get("/course-management/hierarchy")).then((res) => {
|
||||||
const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
|
const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
|
||||||
const categoriesMap = new Map<number, { id: number; name: string; is_active: boolean; created_at: string }>()
|
const categoriesMap = new Map<
|
||||||
|
number,
|
||||||
|
{ id: number; name: string; is_active: boolean; created_at: string; subCategoryCount: number; courseCount: number }
|
||||||
|
>()
|
||||||
rows.forEach((r) => {
|
rows.forEach((r) => {
|
||||||
if (!categoriesMap.has(r.category_id)) {
|
if (!categoriesMap.has(r.category_id)) {
|
||||||
categoriesMap.set(r.category_id, {
|
categoriesMap.set(r.category_id, {
|
||||||
|
|
@ -111,10 +104,50 @@ export const getCourseCategories = () =>
|
||||||
name: r.category_name,
|
name: r.category_name,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
|
subCategoryCount: 0,
|
||||||
|
courseCount: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const category = categoriesMap.get(r.category_id)!
|
||||||
|
if (r.sub_category_id) category.subCategoryCount += 1
|
||||||
|
if (r.course_id) category.courseCount += 1
|
||||||
})
|
})
|
||||||
const categories = Array.from(categoriesMap.values())
|
|
||||||
|
// Merge duplicate top-level category names by selecting the richest representative.
|
||||||
|
type CategoryAggregate = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
subCategoryCount: number
|
||||||
|
courseCount: number
|
||||||
|
}
|
||||||
|
const categoryByName = new Map<string, CategoryAggregate>()
|
||||||
|
Array.from(categoriesMap.values()).forEach((category) => {
|
||||||
|
const key = category.name.trim().toLowerCase()
|
||||||
|
const existing = categoryByName.get(key)
|
||||||
|
if (!existing) {
|
||||||
|
categoryByName.set(key, category)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (category.subCategoryCount > existing.subCategoryCount) {
|
||||||
|
categoryByName.set(key, category)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (category.subCategoryCount === existing.subCategoryCount && category.courseCount > existing.courseCount) {
|
||||||
|
categoryByName.set(key, category)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
category.subCategoryCount === existing.subCategoryCount &&
|
||||||
|
category.courseCount === existing.courseCount &&
|
||||||
|
category.id > existing.id
|
||||||
|
) {
|
||||||
|
categoryByName.set(key, category)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const categories = Array.from(categoryByName.values()).map(({ subCategoryCount, courseCount, ...category }) => category)
|
||||||
return {
|
return {
|
||||||
...res,
|
...res,
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -135,12 +168,12 @@ export const createCourseCategory = (data: CreateCourseCategoryRequest) =>
|
||||||
export const deleteCourseCategory = (categoryId: number) =>
|
export const deleteCourseCategory = (categoryId: number) =>
|
||||||
http.delete(`/course-management/categories/${categoryId}`)
|
http.delete(`/course-management/categories/${categoryId}`)
|
||||||
|
|
||||||
|
export const deleteCourseSubCategory = (subCategoryId: number) =>
|
||||||
|
http.delete(`/course-management/sub-categories/${subCategoryId}`)
|
||||||
|
|
||||||
export const getSubCategoriesByCategoryId = (categoryId: number) =>
|
export const getSubCategoriesByCategoryId = (categoryId: number) =>
|
||||||
http.get<GetCategorySubCategoriesResponse>(`/course-management/categories/${categoryId}/sub-categories`)
|
http.get<GetCategorySubCategoriesResponse>(`/course-management/categories/${categoryId}/sub-categories`)
|
||||||
|
|
||||||
export const getCoursesBySubCategoryId = (subCategoryId: number) =>
|
|
||||||
http.get<GetSubCategoryCoursesResponse>(`/course-management/sub-categories/${subCategoryId}/courses`)
|
|
||||||
|
|
||||||
export const createSubCategory = (payload: {
|
export const createSubCategory = (payload: {
|
||||||
category_id: number
|
category_id: number
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -148,9 +181,6 @@ export const createSubCategory = (payload: {
|
||||||
display_order?: number
|
display_order?: number
|
||||||
}) => http.post("/course-management/sub-categories", payload)
|
}) => http.post("/course-management/sub-categories", payload)
|
||||||
|
|
||||||
export const deleteCourseSubCategory = (subCategoryId: number) =>
|
|
||||||
http.delete(`/course-management/sub-categories/${subCategoryId}`)
|
|
||||||
|
|
||||||
export const updateSubCategory = (
|
export const updateSubCategory = (
|
||||||
subCategoryId: number,
|
subCategoryId: number,
|
||||||
payload: Partial<{
|
payload: Partial<{
|
||||||
|
|
@ -162,19 +192,34 @@ export const updateSubCategory = (
|
||||||
) => http.patch(`/course-management/sub-categories/${subCategoryId}`, payload)
|
) => http.patch(`/course-management/sub-categories/${subCategoryId}`, payload)
|
||||||
|
|
||||||
export const getCoursesByCategory = (categoryId: number) =>
|
export const getCoursesByCategory = (categoryId: number) =>
|
||||||
http.get("/course-management/hierarchy").then((res) => {
|
withSingleRetry(() => http.get("/course-management/hierarchy")).then((res) => {
|
||||||
const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
|
const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
|
||||||
const courses = rows
|
|
||||||
.filter((r) => r.category_id === categoryId && r.course_id)
|
const requestedCategoryRows = rows.filter((r) => r.category_id === categoryId)
|
||||||
.map((r) => ({
|
const requestedCategoryName = requestedCategoryRows.find((r) => !!r.category_name)?.category_name?.trim().toLowerCase()
|
||||||
id: Number(r.course_id),
|
const relevantRows = requestedCategoryName
|
||||||
category_id: r.category_id,
|
? rows.filter((r) => r.category_name?.trim().toLowerCase() === requestedCategoryName)
|
||||||
sub_category_id: r.sub_category_id ?? null,
|
: requestedCategoryRows
|
||||||
title: r.course_title ?? "",
|
|
||||||
description: "",
|
const courseMap = new Map<number, { id: number; category_id: number; sub_category_id: number | null; title: string; description: string; thumbnail: string; is_active: boolean }>()
|
||||||
thumbnail: "",
|
relevantRows
|
||||||
is_active: true,
|
.filter((r) => r.course_id)
|
||||||
}))
|
.forEach((r) => {
|
||||||
|
const id = Number(r.course_id)
|
||||||
|
if (!Number.isFinite(id)) return
|
||||||
|
if (courseMap.has(id)) return
|
||||||
|
courseMap.set(id, {
|
||||||
|
id,
|
||||||
|
category_id: r.category_id,
|
||||||
|
sub_category_id: r.sub_category_id ?? null,
|
||||||
|
title: r.course_title ?? "",
|
||||||
|
description: "",
|
||||||
|
thumbnail: "",
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const courses = Array.from(courseMap.values())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...res,
|
...res,
|
||||||
data: { ...res.data, data: { courses, total_count: courses.length } },
|
data: { ...res.data, data: { courses, total_count: courses.length } },
|
||||||
|
|
@ -198,21 +243,39 @@ export const updateCourseStatus = (courseId: number, isActive: boolean) =>
|
||||||
export const updateCourse = (courseId: number, data: UpdateCourseRequest) =>
|
export const updateCourse = (courseId: number, data: UpdateCourseRequest) =>
|
||||||
http.put(`/course-management/courses/${courseId}`, data)
|
http.put(`/course-management/courses/${courseId}`, data)
|
||||||
|
|
||||||
// Sub-Module APIs (Unified Hierarchy)
|
|
||||||
export const getCourseHierarchyByCourseId = (courseId: number) =>
|
export const getCourseHierarchyByCourseId = (courseId: number) =>
|
||||||
http.get<GetCourseHierarchyResponse>(`/course-management/courses/${courseId}/hierarchy`)
|
http.get<GetCourseHierarchyResponse>(`/course-management/courses/${courseId}/hierarchy`)
|
||||||
|
|
||||||
|
// Sub-Module APIs (Unified Hierarchy)
|
||||||
export const getSubModulesByCourse = (courseId: number) =>
|
export const getSubModulesByCourse = (courseId: number) =>
|
||||||
http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => {
|
getCourseHierarchyByCourseId(courseId).then((res) => {
|
||||||
const raw = res.data?.data
|
const raw = res.data?.data
|
||||||
const rows: CourseHierarchyRow[] = Array.isArray(raw) ? raw : []
|
const rows: CourseHierarchyRow[] = Array.isArray(raw) ? raw : []
|
||||||
const subModuleMap = new Map<number, { id: number; course_id: number; module_id?: number; title: string; description: string; level: string; cefr_level?: string; thumbnail: string; display_order: number; sub_level?: string; is_active: boolean }>()
|
const subModuleMap = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
id: number
|
||||||
|
course_id: number
|
||||||
|
level_id?: number
|
||||||
|
module_id?: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
level: string
|
||||||
|
cefr_level?: string
|
||||||
|
thumbnail: string
|
||||||
|
display_order: number
|
||||||
|
sub_level?: string
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
>()
|
||||||
rows.forEach((r, idx) => {
|
rows.forEach((r, idx) => {
|
||||||
if (!r.sub_module_id) return
|
if (!r.sub_module_id) return
|
||||||
if (!subModuleMap.has(r.sub_module_id)) {
|
const existing = subModuleMap.get(r.sub_module_id)
|
||||||
|
if (!existing) {
|
||||||
subModuleMap.set(r.sub_module_id, {
|
subModuleMap.set(r.sub_module_id, {
|
||||||
id: r.sub_module_id,
|
id: r.sub_module_id,
|
||||||
course_id: courseId,
|
course_id: courseId,
|
||||||
|
level_id: r.level_id ?? undefined,
|
||||||
module_id: r.module_id ?? undefined,
|
module_id: r.module_id ?? undefined,
|
||||||
title: r.sub_module_title ?? "",
|
title: r.sub_module_title ?? "",
|
||||||
description: "",
|
description: "",
|
||||||
|
|
@ -223,7 +286,17 @@ export const getSubModulesByCourse = (courseId: number) =>
|
||||||
sub_level: r.cefr_level ?? undefined,
|
sub_level: r.cefr_level ?? undefined,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
subModuleMap.set(r.sub_module_id, {
|
||||||
|
...existing,
|
||||||
|
module_id: existing.module_id ?? r.module_id ?? undefined,
|
||||||
|
level_id: existing.level_id ?? r.level_id ?? undefined,
|
||||||
|
title: existing.title || r.sub_module_title || "",
|
||||||
|
level: existing.level || r.cefr_level || "",
|
||||||
|
cefr_level: existing.cefr_level ?? r.cefr_level ?? undefined,
|
||||||
|
sub_level: existing.sub_level ?? r.cefr_level ?? undefined,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
const sub_courses = Array.from(subModuleMap.values())
|
const sub_courses = Array.from(subModuleMap.values())
|
||||||
return {
|
return {
|
||||||
|
|
@ -287,7 +360,13 @@ export const getLessonsBySubModule = (subModuleId: number, options?: { includeIn
|
||||||
|
|
||||||
export const getSubModuleLessonById = (
|
export const getSubModuleLessonById = (
|
||||||
lessonId: number,
|
lessonId: number,
|
||||||
options?: { cacheBust?: boolean },
|
options?: {
|
||||||
|
/**
|
||||||
|
* Cache-bust the request to avoid serving stale lesson data after edits.
|
||||||
|
* This is intentionally implemented via query string to work with default axios config.
|
||||||
|
*/
|
||||||
|
cacheBust?: boolean
|
||||||
|
},
|
||||||
) =>
|
) =>
|
||||||
http.get<GetSubModuleLessonDetailResponse>(`/course-management/sub-module-lessons/${lessonId}`, {
|
http.get<GetSubModuleLessonDetailResponse>(`/course-management/sub-module-lessons/${lessonId}`, {
|
||||||
params: options?.cacheBust ? { _t: Date.now() } : undefined,
|
params: options?.cacheBust ? { _t: Date.now() } : undefined,
|
||||||
|
|
@ -361,6 +440,43 @@ export const createPractice = (data: CreatePracticeRequest) =>
|
||||||
.then(() => res)
|
.then(() => res)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const createLesson = (data: {
|
||||||
|
sub_module_id: number
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
intro_video_url?: string
|
||||||
|
persona?: string
|
||||||
|
status?: "DRAFT" | "PUBLISHED"
|
||||||
|
passing_score?: number
|
||||||
|
time_limit_minutes?: number
|
||||||
|
shuffle_questions?: boolean
|
||||||
|
}) =>
|
||||||
|
http
|
||||||
|
.post<CreateQuestionSetResponse>("/question-sets", {
|
||||||
|
title: data.title,
|
||||||
|
set_type: "QUIZ",
|
||||||
|
owner_type: "SUB_MODULE",
|
||||||
|
owner_id: data.sub_module_id,
|
||||||
|
...(data.description?.trim() ? { description: data.description.trim() } : {}),
|
||||||
|
...(data.intro_video_url?.trim() ? { intro_video_url: data.intro_video_url.trim() } : {}),
|
||||||
|
...(data.persona?.trim() ? { persona: data.persona.trim() } : {}),
|
||||||
|
...(data.status ? { status: data.status } : {}),
|
||||||
|
...(Number.isFinite(data.passing_score) ? { passing_score: data.passing_score } : {}),
|
||||||
|
...(Number.isFinite(data.time_limit_minutes) ? { time_limit_minutes: data.time_limit_minutes } : {}),
|
||||||
|
...(typeof data.shuffle_questions === "boolean" ? { shuffle_questions: data.shuffle_questions } : {}),
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
const questionSetID = res.data?.data?.id
|
||||||
|
if (!questionSetID) return res
|
||||||
|
return http
|
||||||
|
.post("/course-management/sub-module-lessons", {
|
||||||
|
sub_module_id: data.sub_module_id,
|
||||||
|
question_set_id: questionSetID,
|
||||||
|
intro_video_url: data.intro_video_url,
|
||||||
|
})
|
||||||
|
.then(() => res)
|
||||||
|
})
|
||||||
|
|
||||||
export const updatePractice = (practiceId: number, data: UpdatePracticeRequest) =>
|
export const updatePractice = (practiceId: number, data: UpdatePracticeRequest) =>
|
||||||
http.put(`/course-management/practices/${practiceId}`, data)
|
http.put(`/course-management/practices/${practiceId}`, data)
|
||||||
|
|
||||||
|
|
@ -421,63 +537,6 @@ export const updatePracticeQuestion = (questionId: number, data: UpdatePracticeQ
|
||||||
export const deletePracticeQuestion = (questionId: number) =>
|
export const deletePracticeQuestion = (questionId: number) =>
|
||||||
http.delete(`/questions/${questionId}`)
|
http.delete(`/questions/${questionId}`)
|
||||||
|
|
||||||
/** Top-level learning programs (Learn English cards, etc.) — GET /programs */
|
|
||||||
export const getLearningPrograms = (params?: { limit?: number; offset?: number }) =>
|
|
||||||
http.get<GetLearningProgramsResponse>("/programs", { params })
|
|
||||||
|
|
||||||
export const createLearningProgram = (data: CreateLearningProgramRequest) =>
|
|
||||||
http.post<CreateLearningProgramResponse>("/programs", data)
|
|
||||||
|
|
||||||
export const getProgramCourses = (
|
|
||||||
programId: number,
|
|
||||||
params?: { limit?: number; offset?: number },
|
|
||||||
) => http.get<GetProgramCoursesResponse>(`/programs/${programId}/courses`, { params })
|
|
||||||
|
|
||||||
export const createProgramCourse = (
|
|
||||||
programId: number,
|
|
||||||
data: CreateProgramCourseRequest,
|
|
||||||
) => http.post<CreateProgramCourseResponse>(`/programs/${programId}/courses`, data)
|
|
||||||
|
|
||||||
/** Top-level course resource (Learn English track) — PUT /courses/:id */
|
|
||||||
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
|
|
||||||
http.put(`/courses/${courseId}`, data)
|
|
||||||
|
|
||||||
export const deleteTopLevelCourse = (courseId: number) =>
|
|
||||||
http.delete(`/courses/${courseId}`)
|
|
||||||
|
|
||||||
export const getTopLevelCourseModules = (
|
|
||||||
courseId: number,
|
|
||||||
params?: { limit?: number; offset?: number },
|
|
||||||
) =>
|
|
||||||
http.get<GetTopLevelCourseModulesResponse>(`/courses/${courseId}/modules`, {
|
|
||||||
params,
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Learn English top-level module — POST /courses/:courseId/modules */
|
|
||||||
export const createTopLevelCourseModule = (
|
|
||||||
courseId: number,
|
|
||||||
data: CreateTopLevelCourseModuleRequest,
|
|
||||||
) =>
|
|
||||||
http.post<CreateTopLevelCourseModuleResponse>(
|
|
||||||
`/courses/${courseId}/modules`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Learn English top-level module — PUT /modules/:id */
|
|
||||||
export const updateTopLevelCourseModule = (
|
|
||||||
moduleId: number,
|
|
||||||
data: UpdateTopLevelCourseModuleRequest,
|
|
||||||
) => http.put(`/modules/${moduleId}`, data)
|
|
||||||
|
|
||||||
/** Learn English top-level module — DELETE /modules/:id */
|
|
||||||
export const deleteTopLevelCourseModule = (moduleId: number) =>
|
|
||||||
http.delete(`/modules/${moduleId}`)
|
|
||||||
|
|
||||||
export const updateLearningProgram = (programId: number, data: UpdateLearningProgramRequest) =>
|
|
||||||
http.put(`/programs/${programId}`, data)
|
|
||||||
|
|
||||||
export const deleteLearningProgram = (programId: number) => http.delete(`/programs/${programId}`)
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Legacy APIs (deprecated - using SubCourse hierarchy now)
|
// Legacy APIs (deprecated - using SubCourse hierarchy now)
|
||||||
// Keeping for backward compatibility
|
// Keeping for backward compatibility
|
||||||
|
|
@ -516,74 +575,6 @@ export const deleteLevel = (levelId: number) =>
|
||||||
export const getModulesByLevel = (levelId: number) =>
|
export const getModulesByLevel = (levelId: number) =>
|
||||||
http.get<GetModulesResponse>(`/course-management/levels/${levelId}/modules`)
|
http.get<GetModulesResponse>(`/course-management/levels/${levelId}/modules`)
|
||||||
|
|
||||||
export const getCourseLevelsForCourse = (courseId: number) =>
|
|
||||||
http.get<GetCourseLevelsForCourseResponse>(`/course-management/courses/${courseId}/levels`)
|
|
||||||
|
|
||||||
export const getSubModulesByModuleId = (moduleId: number) =>
|
|
||||||
http.get<GetSubModulesByModuleResponse>(`/course-management/modules/${moduleId}/sub-modules`)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds a sub-module under a course by walking levels → modules → sub-modules APIs.
|
|
||||||
*/
|
|
||||||
export async function resolveSubModuleForCourse(
|
|
||||||
courseId: number,
|
|
||||||
subModuleId: number,
|
|
||||||
): Promise<SubCourse | null> {
|
|
||||||
try {
|
|
||||||
const levelsRes = await getCourseLevelsForCourse(courseId)
|
|
||||||
const levels = Array.isArray(levelsRes.data?.data?.levels) ? levelsRes.data.data.levels : []
|
|
||||||
const sortedLevels = [...levels].sort((a, b) => {
|
|
||||||
const o = (a.display_order ?? 0) - (b.display_order ?? 0)
|
|
||||||
if (o !== 0) return o
|
|
||||||
return String(a.cefr_level ?? "").localeCompare(String(b.cefr_level ?? ""))
|
|
||||||
})
|
|
||||||
|
|
||||||
const modulesNested = await Promise.all(
|
|
||||||
sortedLevels.map(async (level) => {
|
|
||||||
const modsRes = await getModulesByLevel(level.id)
|
|
||||||
const rawMods = modsRes.data?.data?.modules
|
|
||||||
const modules = Array.isArray(rawMods) ? rawMods : []
|
|
||||||
const sortedMods = [...modules].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
|
|
||||||
return sortedMods.map((module) => ({ level, module }))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const modulePairs = modulesNested.flat()
|
|
||||||
|
|
||||||
const bundles = await Promise.all(
|
|
||||||
modulePairs.map(async ({ level, module }) => {
|
|
||||||
const subsRes = await getSubModulesByModuleId(module.id)
|
|
||||||
const rawSubs = subsRes.data?.data?.sub_modules
|
|
||||||
const subs = Array.isArray(rawSubs) ? rawSubs : []
|
|
||||||
const sortedSubs = [...subs].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
|
|
||||||
return { level, module, subs: sortedSubs }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const { level, module, subs } of bundles) {
|
|
||||||
const found = subs.find((s) => s.id === subModuleId)
|
|
||||||
if (found) {
|
|
||||||
return {
|
|
||||||
id: found.id,
|
|
||||||
course_id: courseId,
|
|
||||||
level_id: level.id,
|
|
||||||
module_id: module.id,
|
|
||||||
title: found.title,
|
|
||||||
description: found.description ?? "",
|
|
||||||
level: level.cefr_level,
|
|
||||||
cefr_level: level.cefr_level,
|
|
||||||
thumbnail: found.thumbnail ?? "",
|
|
||||||
display_order: found.display_order,
|
|
||||||
sub_level: level.cefr_level,
|
|
||||||
is_active: found.is_active,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("resolveSubModuleForCourse failed:", e)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createModule = (data: CreateModuleRequest) =>
|
export const createModule = (data: CreateModuleRequest) =>
|
||||||
http.post("/course-management/modules", data)
|
http.post("/course-management/modules", data)
|
||||||
|
|
||||||
|
|
@ -707,186 +698,92 @@ export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: st
|
||||||
params: { cefr_level },
|
params: { cefr_level },
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getHumanLanguageHierarchy = () =>
|
export const getHumanLanguageSubCategories = () =>
|
||||||
http.get<GetHumanLanguageHierarchyResponse>("/course-management/hierarchy").then(async (res) => {
|
http.get<GetHumanLanguageSubCategoriesResponse>("/course-management/human-language/sub-categories")
|
||||||
const payload = res.data?.data as unknown
|
|
||||||
if (payload && typeof payload === "object" && !Array.isArray(payload) && "sub_categories" in payload) {
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows: UnifiedHierarchyRow[] = Array.isArray(payload) ? payload : []
|
export const getCoursesBySubCategoryId = (subCategoryId: number) =>
|
||||||
const categoryMap = new Map<
|
http.get<GetSubCategoryCoursesResponse>(`/course-management/sub-categories/${subCategoryId}/courses`)
|
||||||
number,
|
|
||||||
{
|
|
||||||
category_id: number
|
|
||||||
category_name: string
|
|
||||||
sub_categories: Map<
|
|
||||||
number,
|
|
||||||
{
|
|
||||||
sub_category_id: number
|
|
||||||
sub_category_name: string
|
|
||||||
courses: Map<
|
|
||||||
number,
|
|
||||||
{
|
|
||||||
course_id: number
|
|
||||||
course_name: string
|
|
||||||
}
|
|
||||||
>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
}
|
|
||||||
>()
|
|
||||||
|
|
||||||
rows.forEach((row) => {
|
export const getSubModulesByModuleId = (moduleId: number) =>
|
||||||
const categoryId = Number(row.category_id)
|
http.get<GetSubModulesByModuleResponse>(`/course-management/modules/${moduleId}/sub-modules`)
|
||||||
if (!Number.isFinite(categoryId)) return
|
|
||||||
|
|
||||||
if (!categoryMap.has(categoryId)) {
|
/**
|
||||||
categoryMap.set(categoryId, {
|
* Finds a sub-module under a course by walking levels → modules → sub-modules APIs.
|
||||||
category_id: categoryId,
|
* Use when the legacy hierarchy flatten (`getSubModulesByCourse`) does not include the row.
|
||||||
category_name: row.category_name ?? "",
|
*/
|
||||||
sub_categories: new Map(),
|
export async function resolveSubModuleForCourse(
|
||||||
})
|
courseId: number,
|
||||||
}
|
subModuleId: number,
|
||||||
|
): Promise<SubCourse | null> {
|
||||||
if (!row.sub_category_id) return
|
try {
|
||||||
const subCategoryId = Number(row.sub_category_id)
|
const levelsRes = await getCourseLevelsForCourse(courseId)
|
||||||
if (!Number.isFinite(subCategoryId)) return
|
const levels = Array.isArray(levelsRes.data?.data?.levels) ? levelsRes.data.data.levels : []
|
||||||
|
const sortedLevels = [...levels].sort((a, b) => {
|
||||||
const categoryNode = categoryMap.get(categoryId)!
|
const o = (a.display_order ?? 0) - (b.display_order ?? 0)
|
||||||
if (!categoryNode.sub_categories.has(subCategoryId)) {
|
if (o !== 0) return o
|
||||||
categoryNode.sub_categories.set(subCategoryId, {
|
return String(a.cefr_level ?? "").localeCompare(String(b.cefr_level ?? ""))
|
||||||
sub_category_id: subCategoryId,
|
|
||||||
sub_category_name: row.sub_category_name ?? "",
|
|
||||||
courses: new Map(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row.course_id) return
|
|
||||||
const courseId = Number(row.course_id)
|
|
||||||
if (!Number.isFinite(courseId)) return
|
|
||||||
|
|
||||||
const subCategoryNode = categoryNode.sub_categories.get(subCategoryId)!
|
|
||||||
if (!subCategoryNode.courses.has(courseId)) {
|
|
||||||
subCategoryNode.courses.set(courseId, {
|
|
||||||
course_id: courseId,
|
|
||||||
course_name: row.course_title ?? "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedCategory =
|
const modulesNested = await Promise.all(
|
||||||
Array.from(categoryMap.values()).find((c) => c.category_name.toLowerCase().includes("human")) ??
|
sortedLevels.map(async (level) => {
|
||||||
Array.from(categoryMap.values())[0]
|
const modsRes = await getModulesByLevel(level.id)
|
||||||
|
const rawMods = modsRes.data?.data?.modules
|
||||||
if (!selectedCategory) {
|
const modules = Array.isArray(rawMods) ? rawMods : []
|
||||||
return {
|
const sortedMods = [...modules].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
|
||||||
...res,
|
return sortedMods.map((module) => ({ level, module }))
|
||||||
data: {
|
|
||||||
...res.data,
|
|
||||||
data: {
|
|
||||||
category_id: 0,
|
|
||||||
category_name: "",
|
|
||||||
sub_categories: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as { data: GetHumanLanguageHierarchyResponse }
|
|
||||||
}
|
|
||||||
|
|
||||||
const courses = Array.from(selectedCategory.sub_categories.values()).flatMap((sub) =>
|
|
||||||
Array.from(sub.courses.values()).map((course) => ({ sub_category_id: sub.sub_category_id, course })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const hierarchyResponses = await Promise.all(
|
|
||||||
courses.map(({ course }) =>
|
|
||||||
http
|
|
||||||
.get(`/course-management/courses/${course.course_id}/hierarchy`)
|
|
||||||
.then((courseRes) => ({ course_id: course.course_id, rows: (courseRes.data?.data ?? []) as CourseHierarchyRow[] }))
|
|
||||||
.catch(() => ({ course_id: course.course_id, rows: [] as CourseHierarchyRow[] })),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const hierarchyByCourse = new Map<number, CourseHierarchyRow[]>(
|
|
||||||
hierarchyResponses.map((h) => [h.course_id, h.rows]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const subCategories = Array.from(selectedCategory.sub_categories.values()).map((sub) => ({
|
|
||||||
sub_category_id: sub.sub_category_id,
|
|
||||||
sub_category_name: sub.sub_category_name,
|
|
||||||
courses: Array.from(sub.courses.values()).map((course) => {
|
|
||||||
const levelMap = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
level: string
|
|
||||||
modules: Map<
|
|
||||||
number,
|
|
||||||
{
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
sub_modules: Map<number, { id: number; title: string; videos: []; practices: [] }>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
}
|
|
||||||
>()
|
|
||||||
|
|
||||||
;(hierarchyByCourse.get(course.course_id) ?? []).forEach((row) => {
|
|
||||||
if (!row.level_id || !row.cefr_level) return
|
|
||||||
const levelKey = String(row.cefr_level).toUpperCase()
|
|
||||||
if (!levelMap.has(levelKey)) {
|
|
||||||
levelMap.set(levelKey, { level: levelKey, modules: new Map() })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row.module_id) return
|
|
||||||
const levelNode = levelMap.get(levelKey)!
|
|
||||||
const moduleId = Number(row.module_id)
|
|
||||||
if (!levelNode.modules.has(moduleId)) {
|
|
||||||
levelNode.modules.set(moduleId, {
|
|
||||||
id: moduleId,
|
|
||||||
title: row.module_title ?? "",
|
|
||||||
sub_modules: new Map(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row.sub_module_id) return
|
|
||||||
const moduleNode = levelNode.modules.get(moduleId)!
|
|
||||||
const subModuleId = Number(row.sub_module_id)
|
|
||||||
if (!moduleNode.sub_modules.has(subModuleId)) {
|
|
||||||
moduleNode.sub_modules.set(subModuleId, {
|
|
||||||
id: subModuleId,
|
|
||||||
title: row.sub_module_title ?? "",
|
|
||||||
videos: [],
|
|
||||||
practices: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
course_id: course.course_id,
|
|
||||||
course_name: course.course_name,
|
|
||||||
levels: Array.from(levelMap.values()).map((levelNode) => ({
|
|
||||||
level: levelNode.level,
|
|
||||||
modules: Array.from(levelNode.modules.values()).map((moduleNode) => ({
|
|
||||||
id: moduleNode.id,
|
|
||||||
title: moduleNode.title,
|
|
||||||
sub_modules: Array.from(moduleNode.sub_modules.values()),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
}))
|
)
|
||||||
|
const modulePairs = modulesNested.flat()
|
||||||
|
|
||||||
return {
|
const bundles = await Promise.all(
|
||||||
...res,
|
modulePairs.map(async ({ level, module }) => {
|
||||||
data: {
|
const subsRes = await getSubModulesByModuleId(module.id)
|
||||||
...res.data,
|
const rawSubs = subsRes.data?.data?.sub_modules
|
||||||
data: {
|
const subs = Array.isArray(rawSubs) ? rawSubs : []
|
||||||
category_id: selectedCategory.category_id,
|
const sortedSubs = [...subs].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0))
|
||||||
category_name: selectedCategory.category_name,
|
return { level, module, subs: sortedSubs }
|
||||||
sub_categories: subCategories,
|
}),
|
||||||
},
|
)
|
||||||
},
|
|
||||||
} as unknown as { data: GetHumanLanguageHierarchyResponse }
|
for (const { level, module, subs } of bundles) {
|
||||||
})
|
const found = subs.find((s) => s.id === subModuleId)
|
||||||
|
if (found) {
|
||||||
|
return {
|
||||||
|
id: found.id,
|
||||||
|
course_id: courseId,
|
||||||
|
level_id: level.id,
|
||||||
|
module_id: module.id,
|
||||||
|
title: found.title,
|
||||||
|
description: found.description ?? "",
|
||||||
|
level: level.cefr_level,
|
||||||
|
cefr_level: level.cefr_level,
|
||||||
|
thumbnail: found.thumbnail ?? "",
|
||||||
|
display_order: found.display_order,
|
||||||
|
sub_level: level.cefr_level,
|
||||||
|
is_active: found.is_active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("resolveSubModuleForCourse failed:", e)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCourseLevelsForCourse = (courseId: number) =>
|
||||||
|
http.get<GetCourseLevelsForCourseResponse>(`/course-management/courses/${courseId}/levels`)
|
||||||
|
|
||||||
|
export const getAllCourseLevels = () => http.get<GetCourseLevelsAllResponse>("/course-management/levels")
|
||||||
|
|
||||||
|
export const getCourseLevelById = (levelId: number) =>
|
||||||
|
http.get<GetCourseLevelByIdResponse>(`/course-management/levels/${levelId}`)
|
||||||
|
|
||||||
|
export const getHumanLanguageHierarchy = (options?: { cacheBust?: boolean }) =>
|
||||||
|
withSingleRetry(() =>
|
||||||
|
http.get<GetHumanLanguageHierarchyFlatResponse>("/course-management/human-language/hierarchy", {
|
||||||
|
params: options?.cacheBust ? { _t: Date.now() } : undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest) =>
|
export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest) =>
|
||||||
http
|
http
|
||||||
|
|
@ -915,6 +812,34 @@ export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const createModuleInLevel = (
|
||||||
|
levelId: number,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
displayOrder = 0,
|
||||||
|
) =>
|
||||||
|
http.post("/course-management/modules", {
|
||||||
|
level_id: levelId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
display_order: displayOrder,
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createSubModuleInModule = (
|
||||||
|
moduleId: number,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
displayOrder = 0,
|
||||||
|
) =>
|
||||||
|
http.post("/course-management/sub-modules", {
|
||||||
|
module_id: moduleId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
display_order: displayOrder,
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
|
||||||
export const getSubModuleEntryAssessment = (subModuleId: number) =>
|
export const getSubModuleEntryAssessment = (subModuleId: number) =>
|
||||||
http.get<GetSubCourseEntryAssessmentResponse>(
|
http.get<GetSubCourseEntryAssessmentResponse>(
|
||||||
`/question-sets/sub-courses/${subModuleId}/entry-assessment`,
|
`/question-sets/sub-courses/${subModuleId}/entry-assessment`,
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,53 @@
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom"
|
||||||
import { AppLayout } from "../layouts/AppLayout";
|
import { AppLayout } from "../layouts/AppLayout"
|
||||||
import { DashboardPage } from "../pages/DashboardPage";
|
import { DashboardPage } from "../pages/DashboardPage"
|
||||||
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage";
|
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"
|
||||||
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout";
|
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"
|
||||||
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage";
|
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage"
|
||||||
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage";
|
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage"
|
||||||
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage";
|
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage"
|
||||||
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage";
|
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"
|
||||||
import { CoursesPage } from "../pages/content-management/CoursesPage";
|
import { CoursesPage } from "../pages/content-management/CoursesPage"
|
||||||
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage";
|
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
|
||||||
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage";
|
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage"
|
||||||
import { SubModulesPage } from "../pages/content-management/SubCoursesPage";
|
import { AddNewLessonPage } from "../pages/content-management/AddNewLessonPage"
|
||||||
import { SubModuleContentPage } from "../pages/content-management/SubCourseContentPage";
|
import { SubModulesPage } from "../pages/content-management/SubCoursesPage"
|
||||||
import { SpeakingPage } from "../pages/content-management/SpeakingPage";
|
import { SpeakingPage } from "../pages/content-management/SpeakingPage"
|
||||||
import { AddVideoPage } from "../pages/content-management/AddVideoPage";
|
import { AddVideoPage } from "../pages/content-management/AddVideoPage"
|
||||||
import { AddPracticePage } from "../pages/content-management/AddPracticePage";
|
import { AddPracticePage } from "../pages/content-management/AddPracticePage"
|
||||||
import { NewContentPage } from "../pages/content-management/NewContentPage";
|
import { NotFoundPage } from "../pages/NotFoundPage"
|
||||||
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
|
import { NotificationsPage } from "../pages/notifications/NotificationsPage"
|
||||||
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
|
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage"
|
||||||
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
|
import { UserDetailPage } from "../pages/user-management/UserDetailPage"
|
||||||
import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
|
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout"
|
||||||
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
|
import { UsersListPage } from "../pages/user-management/UsersListPage"
|
||||||
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
|
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard"
|
||||||
import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage";
|
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage"
|
||||||
import { AttachPracticeFlow } from "../pages/content-management/AttachPracticeFlow";
|
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage"
|
||||||
import { AttachProgramPracticeFlow } from "../pages/content-management/AttachProgramPracticeFlow";
|
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout"
|
||||||
import { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTypeSelectionPage";
|
import { RolesListPage } from "../pages/role-management/RolesListPage"
|
||||||
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
|
import { AddRolePage } from "../pages/role-management/AddRolePage"
|
||||||
import { CourseManagementPage } from "../pages/content-management/CourseManagementPage";
|
import { PracticeDetailsPage } from "../pages/content-management/PracticeDetailsPage"
|
||||||
import { UnitManagementPage } from "../pages/content-management/UnitManagementPage";
|
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage"
|
||||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
|
||||||
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
|
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
|
||||||
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage";
|
import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLanguageHierarchyPage"
|
||||||
import { UserDetailPage } from "../pages/user-management/UserDetailPage";
|
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage"
|
||||||
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout";
|
import { SubCategoryCoursesPage } from "../pages/content-management/SubCategoryCoursesPage"
|
||||||
import { UsersListPage } from "../pages/user-management/UsersListPage";
|
import { UserLogPage } from "../pages/user-log/UserLogPage"
|
||||||
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard";
|
import { IssuesPage } from "../pages/issues/IssuesPage"
|
||||||
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage";
|
import { ProfilePage } from "../pages/ProfilePage"
|
||||||
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage";
|
import { SettingsPage } from "../pages/SettingsPage"
|
||||||
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout";
|
import { TeamManagementPage } from "../pages/team/TeamManagementPage"
|
||||||
import { RolesListPage } from "../pages/role-management/RolesListPage";
|
import { AddTeamMemberPage } from "../pages/team/AddTeamMemberPage"
|
||||||
import { AddRolePage } from "../pages/role-management/AddRolePage";
|
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"
|
||||||
import { PracticeDetailsPage } from "../pages/content-management/PracticeDetailsPage";
|
import { LoginPage } from "../pages/auth/LoginPage"
|
||||||
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage";
|
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"
|
||||||
import { QuestionsPage } from "../pages/content-management/QuestionsPage";
|
import { VerificationPage } from "../pages/auth/VerificationPage"
|
||||||
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage";
|
import { AboutPage } from "../pages/AboutPage"
|
||||||
import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLanguageHierarchyPage";
|
import { TermsPage } from "../pages/TermsPage"
|
||||||
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
|
import { PrivacyPage } from "../pages/PrivacyPage"
|
||||||
import { UserLogPage } from "../pages/user-log/UserLogPage";
|
import { AccountDeletionPage } from "../pages/AccountDeletionPage"
|
||||||
import { IssuesPage } from "../pages/issues/IssuesPage";
|
|
||||||
import { ProfilePage } from "../pages/ProfilePage";
|
|
||||||
import { SettingsPage } from "../pages/SettingsPage";
|
|
||||||
import { TeamManagementPage } from "../pages/team/TeamManagementPage";
|
|
||||||
import { AddTeamMemberPage } from "../pages/team/AddTeamMemberPage";
|
|
||||||
import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage";
|
|
||||||
import { LoginPage } from "../pages/auth/LoginPage";
|
|
||||||
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage";
|
|
||||||
import { VerificationPage } from "../pages/auth/VerificationPage";
|
|
||||||
import { AboutPage } from "../pages/AboutPage";
|
|
||||||
import { TermsPage } from "../pages/TermsPage";
|
|
||||||
import { PrivacyPage } from "../pages/PrivacyPage";
|
|
||||||
import { AccountDeletionPage } from "../pages/AccountDeletionPage";
|
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -97,60 +84,41 @@ export function AppRoutes() {
|
||||||
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
|
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
|
||||||
element={<AddNewPracticePage />}
|
element={<AddNewPracticePage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-lesson"
|
||||||
|
element={<AddNewLessonPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/practices/:practiceId/questions"
|
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/practices/:practiceId/questions"
|
||||||
element={<PracticeQuestionsPage />}
|
element={<PracticeQuestionsPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="human-language/:categoryId/:courseId/level/:levelId/practices/:practiceId/questions"
|
||||||
|
element={<PracticeQuestionsPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="human-language/:categoryId/:courseId/sub-module/:subModuleId"
|
path="human-language/:categoryId/:courseId/sub-module/:subModuleId"
|
||||||
element={<HumanLanguageSubModulePage />}
|
element={<HumanLanguageSubModulePage />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="category/:categoryId"
|
path="category/:categoryId/sub-categories/:subCategoryId/courses"
|
||||||
element={<ContentOverviewPage />}
|
element={<SubCategoryCoursesPage />}
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="category/:categoryId/courses"
|
|
||||||
element={<CoursesPage />}
|
|
||||||
/>
|
/>
|
||||||
|
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
|
||||||
{/* Course → Sub-module → Lesson/Practice */}
|
{/* Course → Sub-module → Lesson/Practice */}
|
||||||
<Route
|
<Route path="category/:categoryId/courses/:courseId/sub-modules" element={<SubModulesPage />} />
|
||||||
path="category/:categoryId/courses/:courseId/sub-modules"
|
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId" element={<HumanLanguageSubModulePage />} />
|
||||||
element={<SubModulesPage />}
|
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-practice" element={<AddNewPracticePage />} />
|
||||||
/>
|
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-lesson" element={<AddNewLessonPage />} />
|
||||||
<Route
|
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
|
||||||
path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId"
|
|
||||||
element={<SubModuleContentPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/add-practice"
|
|
||||||
element={<AddNewPracticePage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions"
|
|
||||||
element={<PracticeQuestionsPage />}
|
|
||||||
/>
|
|
||||||
{/* Legacy aliases */}
|
{/* Legacy aliases */}
|
||||||
<Route
|
<Route path="category/:categoryId/courses/:courseId/sub-courses" element={<SubModulesPage />} />
|
||||||
path="category/:categoryId/courses/:courseId/sub-courses"
|
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId" element={<HumanLanguageSubModulePage />} />
|
||||||
element={<SubModulesPage />}
|
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-practice" element={<AddNewPracticePage />} />
|
||||||
/>
|
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-lesson" element={<AddNewLessonPage />} />
|
||||||
<Route
|
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
|
||||||
path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId"
|
<Route path="category/:categoryId/courses/add-video" element={<AddVideoPage />} />
|
||||||
element={<SubModuleContentPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/add-practice"
|
|
||||||
element={<AddNewPracticePage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/practices/:practiceId/questions"
|
|
||||||
element={<PracticeQuestionsPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="category/:categoryId/courses/add-video"
|
|
||||||
element={<AddVideoPage />}
|
|
||||||
/>
|
|
||||||
<Route path="speaking" element={<SpeakingPage />} />
|
<Route path="speaking" element={<SpeakingPage />} />
|
||||||
<Route path="speaking/add-practice" element={<AddPracticePage />} />
|
<Route path="speaking/add-practice" element={<AddPracticePage />} />
|
||||||
<Route path="practices" element={<PracticeDetailsPage />} />
|
<Route path="practices" element={<PracticeDetailsPage />} />
|
||||||
|
|
@ -160,65 +128,8 @@ export function AppRoutes() {
|
||||||
<Route path="questions/edit/:id" element={<AddQuestionPage />} />
|
<Route path="questions/edit/:id" element={<AddQuestionPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/new-content" element={<NewContentPage />} />
|
|
||||||
<Route
|
|
||||||
path="/new-content/courses"
|
|
||||||
element={<ProgramTypeSelectionPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/courses/:programType"
|
|
||||||
element={<ProgramDetailPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/courses/:programType/attach-practice"
|
|
||||||
element={<AttachProgramPracticeFlow />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/courses/:programType/:courseId/unit/:unitId/module/:moduleId/attach-practice"
|
|
||||||
element={<AttachPracticeFlow />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/courses/:programType/:courseId"
|
|
||||||
element={<CourseManagementPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/courses/:programType/:courseId/:unitId"
|
|
||||||
element={<UnitManagementPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId"
|
|
||||||
element={<CourseModuleDetailPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/learn-english"
|
|
||||||
element={<LearnEnglishPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/learn-english/:level/courses"
|
|
||||||
element={<ProgramCoursesPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/learn-english/:level/courses/:courseId"
|
|
||||||
element={<CourseDetailPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId"
|
|
||||||
element={<ModuleDetailPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/add-video"
|
|
||||||
element={<AddVideoFlow />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/new-content/learn-english/:level/courses/add-practice"
|
|
||||||
element={<AddPracticeFlow />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path="/notifications" element={<NotificationsPage />} />
|
<Route path="/notifications" element={<NotificationsPage />} />
|
||||||
<Route
|
<Route path="/notifications/create" element={<CreateNotificationPage />} />
|
||||||
path="/notifications/create"
|
|
||||||
element={<CreateNotificationPage />}
|
|
||||||
/>
|
|
||||||
<Route path="/user-log" element={<UserLogPage />} />
|
<Route path="/user-log" element={<UserLogPage />} />
|
||||||
<Route path="/issues" element={<IssuesPage />} />
|
<Route path="/issues" element={<IssuesPage />} />
|
||||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
|
|
@ -232,5 +143,7 @@ export function AppRoutes() {
|
||||||
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 KiB |
|
|
@ -13,65 +13,57 @@ import {
|
||||||
Users,
|
Users,
|
||||||
Users2,
|
Users2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react"
|
||||||
import { type ComponentType, useEffect, useState } from "react";
|
import { type ComponentType, useEffect, useState } from "react"
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom"
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils"
|
||||||
import { BrandLogo } from "../brand/BrandLogo";
|
import { BrandLogo } from "../brand/BrandLogo"
|
||||||
import { getUnreadCount } from "../../api/notifications.api";
|
import { getUnreadCount } from "../../api/notifications.api"
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
label: string;
|
label: string
|
||||||
to: string;
|
to: string
|
||||||
icon: ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>
|
||||||
};
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
|
{ label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
|
||||||
{ label: "User Management", to: "/users", icon: Users },
|
{ label: "User Management", to: "/users", icon: Users },
|
||||||
{ label: "Role Management", to: "/roles", icon: Shield },
|
{ label: "Role Management", to: "/roles", icon: Shield },
|
||||||
{ label: "Content Management", to: "/content", icon: BookOpen },
|
{ label: "Content Management", to: "/content", icon: BookOpen },
|
||||||
{ label: "New Content", to: "/new-content", icon: BookOpen },
|
|
||||||
|
|
||||||
{ label: "Notifications", to: "/notifications", icon: Bell },
|
{ label: "Notifications", to: "/notifications", icon: Bell },
|
||||||
{ label: "User Log", to: "/user-log", icon: ClipboardList },
|
{ label: "User Log", to: "/user-log", icon: ClipboardList },
|
||||||
{ label: "Issue Reports", to: "/issues", icon: CircleAlert },
|
{ label: "Issue Reports", to: "/issues", icon: CircleAlert },
|
||||||
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
|
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
|
||||||
{ label: "Team Management", to: "/team", icon: Users2 },
|
{ label: "Team Management", to: "/team", icon: Users2 },
|
||||||
{ label: "Profile", to: "/profile", icon: UserCircle2 },
|
{ label: "Profile", to: "/profile", icon: UserCircle2 },
|
||||||
];
|
]
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void
|
||||||
onClose: () => void;
|
onClose: () => void
|
||||||
};
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({ isOpen, isCollapsed, onToggleCollapse, onClose }: SidebarProps) {
|
||||||
isOpen,
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
isCollapsed,
|
|
||||||
onToggleCollapse,
|
|
||||||
onClose,
|
|
||||||
}: SidebarProps) {
|
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUnread = async () => {
|
const fetchUnread = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getUnreadCount();
|
const res = await getUnreadCount()
|
||||||
setUnreadCount(res.data.unread);
|
setUnreadCount(res.data.unread)
|
||||||
} catch {
|
} catch {
|
||||||
// silently fail
|
// silently fail
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
fetchUnread();
|
fetchUnread()
|
||||||
|
|
||||||
window.addEventListener("notifications-updated", fetchUnread);
|
window.addEventListener("notifications-updated", fetchUnread)
|
||||||
return () =>
|
return () => window.removeEventListener("notifications-updated", fetchUnread)
|
||||||
window.removeEventListener("notifications-updated", fetchUnread);
|
}, [])
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -94,12 +86,7 @@ export function Sidebar({
|
||||||
isOpen ? "translate-x-0" : "-translate-x-full",
|
isOpen ? "translate-x-0" : "-translate-x-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div className={cn("flex items-center justify-between px-2", isCollapsed && "justify-center")}>
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-between px-2",
|
|
||||||
isCollapsed && "justify-center",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<span className="h-10 w-10 overflow-hidden">
|
<span className="h-10 w-10 overflow-hidden">
|
||||||
<BrandLogo className="h-10 w-auto max-w-none" />
|
<BrandLogo className="h-10 w-auto max-w-none" />
|
||||||
|
|
@ -116,11 +103,7 @@ export function Sidebar({
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? <ChevronRight className="h-5 w-5" /> : <ChevronLeft className="h-5 w-5" />}
|
||||||
<ChevronRight className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<ChevronLeft className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -134,7 +117,7 @@ export function Sidebar({
|
||||||
|
|
||||||
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
|
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
|
|
@ -160,36 +143,25 @@ export function Sidebar({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{isCollapsed &&
|
{isCollapsed && item.to === "/notifications" && unreadCount > 0 && (
|
||||||
item.to === "/notifications" &&
|
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
|
||||||
unreadCount > 0 && (
|
|
||||||
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<span className="truncate">{item.label}</span>
|
|
||||||
)}
|
|
||||||
{!isCollapsed &&
|
|
||||||
item.to === "/notifications" &&
|
|
||||||
unreadCount > 0 && (
|
|
||||||
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
|
|
||||||
{unreadCount > 99 ? "99+" : unreadCount}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{!isCollapsed &&
|
</span>
|
||||||
item.to !== "/notifications" &&
|
{!isCollapsed && <span className="truncate">{item.label}</span>}
|
||||||
isActive ? (
|
{!isCollapsed && item.to === "/notifications" && unreadCount > 0 && (
|
||||||
|
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
|
||||||
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isCollapsed && item.to !== "/notifications" && isActive ? (
|
||||||
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
||||||
) : !isCollapsed &&
|
) : !isCollapsed && item.to === "/notifications" && unreadCount === 0 && isActive ? (
|
||||||
item.to === "/notifications" &&
|
|
||||||
unreadCount === 0 &&
|
|
||||||
isActive ? (
|
|
||||||
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -197,8 +169,8 @@ export function Sidebar({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
localStorage.clear();
|
localStorage.clear()
|
||||||
window.location.href = "/login";
|
window.location.href = "/login"
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600",
|
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600",
|
||||||
|
|
@ -212,5 +184,5 @@ export function Sidebar({
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react"
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root;
|
const Dialog = DialogPrimitive.Root
|
||||||
const DialogTrigger = DialogPrimitive.Trigger;
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
const DialogPortal = DialogPrimitive.Portal;
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
const DialogClose = DialogPrimitive.Close;
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
const DialogOverlay = React.forwardRef<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
|
@ -20,8 +20,8 @@ const DialogOverlay = React.forwardRef<
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
))
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
|
@ -38,42 +38,27 @@ const DialogContent = React.forwardRef<
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-6 top-10 rounded-sm opacity-60 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
<X className="h-6 w-6" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
));
|
))
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
className,
|
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||||
...props
|
)
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
DialogHeader.displayName = "DialogHeader";
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
const DialogFooter = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
DialogFooter.displayName = "DialogFooter";
|
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
const DialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
|
@ -81,14 +66,11 @@ const DialogTitle = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
))
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
const DialogDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
|
@ -99,8 +81,8 @@ const DialogDescription = React.forwardRef<
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
))
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -113,4 +95,5 @@ export {
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||||
({ className, type, ...props }, ref) => {
|
return (
|
||||||
return (
|
<input
|
||||||
<input
|
type={type}
|
||||||
type={type}
|
className={cn(
|
||||||
className={cn(
|
"flex h-10 w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
"flex h-10 w-full rounded-[6px] border bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
className,
|
||||||
className,
|
)}
|
||||||
)}
|
ref={ref}
|
||||||
ref={ref}
|
{...props}
|
||||||
{...props}
|
/>
|
||||||
/>
|
)
|
||||||
);
|
})
|
||||||
},
|
Input.displayName = "Input"
|
||||||
);
|
|
||||||
Input.displayName = "Input";
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react"
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
|
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
|
||||||
|
|
||||||
|
|
@ -18,9 +18,10 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-600" />
|
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
Select.displayName = "Select";
|
Select.displayName = "Select"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,61 @@
|
||||||
import { cn } from "../../lib/utils";
|
import * as React from "react"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
export interface StepperProps {
|
export interface StepperProps {
|
||||||
steps: string[];
|
steps: string[]
|
||||||
currentStep: number;
|
currentStep: number
|
||||||
className?: string;
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Stepper({ steps, currentStep, className }: StepperProps) {
|
export function Stepper({ steps, currentStep, className }: StepperProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex w-full items-start justify-between", className)}>
|
<div className={cn("flex w-full items-center", className)}>
|
||||||
{steps.map((step, index) => {
|
{steps.map((step, index) => {
|
||||||
const stepNumber = index + 1;
|
const stepNumber = index + 1
|
||||||
const isCurrent = stepNumber === currentStep;
|
const isCompleted = stepNumber < currentStep
|
||||||
|
const isCurrent = stepNumber === currentStep
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<React.Fragment key={step}>
|
||||||
key={step}
|
<div className="flex items-center">
|
||||||
className="flex-1 relative flex flex-col items-center group"
|
<div className="flex flex-col items-center">
|
||||||
>
|
<div
|
||||||
{/* Connector Line - floats between circles with gap on both sides */}
|
className={cn(
|
||||||
|
"grid h-10 w-10 place-items-center rounded-full border-2 text-sm font-semibold transition-colors",
|
||||||
|
isCompleted && "border-brand-500 bg-brand-500 text-white",
|
||||||
|
// Active step should be visually prominent.
|
||||||
|
isCurrent && "border-brand-500 bg-brand-500 text-white",
|
||||||
|
!isCompleted && !isCurrent && "border-grayScale-300 bg-white text-grayScale-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCompleted ? <Check className="h-5 w-5" /> : stepNumber}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-2 text-xs font-medium",
|
||||||
|
isCurrent && "text-brand-600",
|
||||||
|
!isCurrent && "text-grayScale-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{index < steps.length - 1 && (
|
{index < steps.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-4 h-[1.5px] bg-grayScale-200 z-0"
|
className={cn(
|
||||||
style={{ left: "calc(50% + 24px)", right: "calc(-50% + 24px)" }}
|
// Keep the connector visually continuous with the step circles.
|
||||||
|
"mx-2 h-0.5 flex-1",
|
||||||
|
// Color the track up to the current step.
|
||||||
|
isCompleted || isCurrent ? "bg-brand-500" : "bg-grayScale-200",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</React.Fragment>
|
||||||
{/* Circle */}
|
)
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative z-10 grid h-8 w-8 place-items-center rounded-full border-2 text-sm font-bold transition-all duration-300 mb-3",
|
|
||||||
isCurrent
|
|
||||||
? "border-brand-500 bg-brand-500 text-white shadow-md scale-110"
|
|
||||||
: "border-grayScale-100 bg-white text-grayScale-400 font-medium",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{stepNumber}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Label */}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"relative z-10 text-[12px] font-bold transition-colors duration-300",
|
|
||||||
isCurrent ? "text-brand-500" : "text-grayScale-400 font-medium",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{step}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,9 @@ export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
|
if (token) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
|
@ -159,10 +162,6 @@ export function LoginPage() {
|
||||||
}
|
}
|
||||||
}, [googleReady, handleGoogleCallback]);
|
}, [googleReady, handleGoogleCallback]);
|
||||||
|
|
||||||
if (token) {
|
|
||||||
return <Navigate to="/dashboard" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,268 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Link,
|
|
||||||
useNavigate,
|
|
||||||
useParams,
|
|
||||||
useSearchParams,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { Stepper } from "../../components/ui/stepper";
|
|
||||||
import successIcon from "../../assets/success.svg";
|
|
||||||
|
|
||||||
import { ContextStep } from "./components/practice-steps/ContextStep";
|
|
||||||
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
|
|
||||||
import { PersonaStep } from "./components/practice-steps/PersonaStep";
|
|
||||||
import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
|
|
||||||
import { ReviewStep } from "./components/practice-steps/ReviewStep";
|
|
||||||
|
|
||||||
export function AddPracticeFlow() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { level } = useParams<{ level: string }>();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const backTo = searchParams.get("backTo");
|
|
||||||
const courseId = searchParams.get("courseId");
|
|
||||||
const moduleId = searchParams.get("moduleId");
|
|
||||||
|
|
||||||
const isModuleContext = backTo === "module";
|
|
||||||
const isCourseContext = backTo === "modules";
|
|
||||||
|
|
||||||
const backLabel =
|
|
||||||
backTo === "module"
|
|
||||||
? "Back to Module"
|
|
||||||
: backTo === "modules"
|
|
||||||
? "Back to Modules"
|
|
||||||
: "Back to Courses";
|
|
||||||
const backPath =
|
|
||||||
backTo === "module" && courseId && moduleId
|
|
||||||
? `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`
|
|
||||||
: backTo === "modules" && courseId
|
|
||||||
? `/new-content/learn-english/${level}/courses/${courseId}`
|
|
||||||
: `/new-content/learn-english/${level}/courses`;
|
|
||||||
|
|
||||||
const flowSteps = isModuleContext
|
|
||||||
? ["Context", "Persona", "Questions", "Review"]
|
|
||||||
: ["Context", "Scenario", "Persona", "Questions", "Review"];
|
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
|
||||||
const [selectedPersona, setSelectedPersona] = useState<string | null>(
|
|
||||||
"dawit",
|
|
||||||
);
|
|
||||||
const [isPublished, setIsPublished] = useState(false);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
program: "Intermediate",
|
|
||||||
course: "A2",
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
selectedVideo: "",
|
|
||||||
tips: "Focus on using the present perfect continuous tense to describe an action that started in the past and continues now.",
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
id: "q1",
|
|
||||||
text: "How long have you been studying English?",
|
|
||||||
type: "Speaking",
|
|
||||||
voicePrompt: "prompt_q1_en.mp3",
|
|
||||||
sampleAnswer: "prompt_q1_en.mp3",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextStep = () =>
|
|
||||||
setCurrentStep((prev) => Math.min(prev + 1, flowSteps.length));
|
|
||||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
|
||||||
|
|
||||||
if (isPublished) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-500">
|
|
||||||
<div className="mb-10 relative">
|
|
||||||
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
|
|
||||||
<img
|
|
||||||
src={successIcon}
|
|
||||||
alt="Success"
|
|
||||||
className="h-[128px] w-[128px] relative"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-[28px] font-bold text-grayScale-900 mb-2">
|
|
||||||
Practice Published Successfully!
|
|
||||||
</h1>
|
|
||||||
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
|
|
||||||
Your speaking practice is now active and available inside the module.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(backPath)}
|
|
||||||
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
|
|
||||||
>
|
|
||||||
Go back to Module
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setIsPublished(false);
|
|
||||||
setCurrentStep(1);
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
className="h-14 rounded-[6px] border-[#9E2891] text-[#9E2891] font-semibold text-[16px] bg-white "
|
|
||||||
>
|
|
||||||
Add Another Practice
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to map currentStep to the actual component for the module flow
|
|
||||||
const renderStep = () => {
|
|
||||||
if (!isModuleContext) {
|
|
||||||
switch (currentStep) {
|
|
||||||
case 1:
|
|
||||||
return (
|
|
||||||
<ContextStep
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
nextStep={nextStep}
|
|
||||||
navigate={navigate}
|
|
||||||
level={level!}
|
|
||||||
isModuleContext={isModuleContext}
|
|
||||||
isCourseContext={isCourseContext}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 2:
|
|
||||||
return (
|
|
||||||
<ScenarioStep
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
nextStep={nextStep}
|
|
||||||
prevStep={prevStep}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 3:
|
|
||||||
return (
|
|
||||||
<PersonaStep
|
|
||||||
selectedPersona={selectedPersona}
|
|
||||||
setSelectedPersona={setSelectedPersona}
|
|
||||||
nextStep={nextStep}
|
|
||||||
prevStep={prevStep}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 4:
|
|
||||||
return (
|
|
||||||
<QuestionsStep
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
nextStep={nextStep}
|
|
||||||
prevStep={prevStep}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 5:
|
|
||||||
return (
|
|
||||||
<ReviewStep
|
|
||||||
formData={formData}
|
|
||||||
selectedPersona={selectedPersona}
|
|
||||||
prevStep={prevStep}
|
|
||||||
setIsPublished={setIsPublished}
|
|
||||||
isModuleContext={isModuleContext}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Module Context Flow (Skips Scenario)
|
|
||||||
switch (currentStep) {
|
|
||||||
case 1:
|
|
||||||
return (
|
|
||||||
<ContextStep
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
nextStep={nextStep}
|
|
||||||
navigate={navigate}
|
|
||||||
level={level!}
|
|
||||||
isModuleContext={isModuleContext}
|
|
||||||
isCourseContext={isCourseContext}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 2:
|
|
||||||
return (
|
|
||||||
<PersonaStep
|
|
||||||
selectedPersona={selectedPersona}
|
|
||||||
setSelectedPersona={setSelectedPersona}
|
|
||||||
nextStep={nextStep}
|
|
||||||
prevStep={prevStep}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 3:
|
|
||||||
return (
|
|
||||||
<QuestionsStep
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
nextStep={nextStep}
|
|
||||||
prevStep={prevStep}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 4:
|
|
||||||
return (
|
|
||||||
<ReviewStep
|
|
||||||
formData={formData}
|
|
||||||
selectedPersona={selectedPersona}
|
|
||||||
prevStep={prevStep}
|
|
||||||
setIsPublished={setIsPublished}
|
|
||||||
isModuleContext={isModuleContext}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mx-auto max-w-7xl w-full">
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<Link
|
|
||||||
to={backPath}
|
|
||||||
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-600 transition-colors hover:text-brand-500 decoration-none"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
{backLabel}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className=" mb-10">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-3xl font-bold text-[#0F172A]">
|
|
||||||
Add New Practice
|
|
||||||
</h1>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-[8px] border-grayScale-200 text-grayScale-600 h-10 px-6 font-bold bg-white hover:bg-grayScale-50"
|
|
||||||
onClick={() => navigate(backPath)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-grayScale-400 text-base">
|
|
||||||
Create a new immersive practice session for students.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mx-auto w-[70%] mb-12">
|
|
||||||
<Stepper steps={flowSteps} currentStep={currentStep} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`mx-auto ${(!isModuleContext && currentStep === 3) || (isModuleContext && currentStep === 2) || currentStep === 5 ? "max-w-6xl" : "max-w-4xl"}`}
|
|
||||||
>
|
|
||||||
{renderStep()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { ArrowLeft, Check } from "lucide-react";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { Stepper } from "../../components/ui/stepper";
|
|
||||||
|
|
||||||
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
|
|
||||||
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
|
|
||||||
import successIcon from "../../assets/success.svg";
|
|
||||||
|
|
||||||
const STEPS = [
|
|
||||||
{ id: 1, label: "Video Detail" },
|
|
||||||
{ id: 2, label: "Review & Publish" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AddVideoFlow() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { level, courseId, moduleId } = useParams<{
|
|
||||||
level: string;
|
|
||||||
courseId: string;
|
|
||||||
moduleId: string;
|
|
||||||
}>();
|
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
|
||||||
const [isPublished, setIsPublished] = useState(false);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
title: "",
|
|
||||||
order: "1",
|
|
||||||
description: "",
|
|
||||||
thumbnail: null,
|
|
||||||
videoFile: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextStep = () => setCurrentStep((prev) => Math.min(prev + 1, 2));
|
|
||||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
|
||||||
|
|
||||||
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
|
||||||
|
|
||||||
if (isPublished) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-500 ">
|
|
||||||
{/* Success Icon Wrapper (Jagged Circle Style) */}
|
|
||||||
<div className="mb-12 relative scale-110">
|
|
||||||
<div className="absolute inset-0 bg-brand-500/5 blur-3xl rounded-full" />
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
|
|
||||||
<img
|
|
||||||
src={successIcon}
|
|
||||||
alt="Success"
|
|
||||||
className="h-[128px] w-[128px] relative"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
|
|
||||||
Video Published Successfully!
|
|
||||||
</h1>
|
|
||||||
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
|
|
||||||
Your video is now live and available inside the selected module.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(`/new-content/learn-english/${level}`)}
|
|
||||||
className="h-12 rounded-[6px] bg-brand-500 font-bold text-[17px] text-white transition-all active:scale-95"
|
|
||||||
>
|
|
||||||
Go back to Learn English
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setFormData({
|
|
||||||
title: "",
|
|
||||||
order: "1",
|
|
||||||
description: "",
|
|
||||||
thumbnail: null,
|
|
||||||
videoFile: null,
|
|
||||||
});
|
|
||||||
setIsPublished(false);
|
|
||||||
setCurrentStep(1);
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
className="h-12 rounded-[6px] border-brand-200 text-brand-500 font-bold text-[17px] active:scale-95 bg-white"
|
|
||||||
>
|
|
||||||
Add Another Video
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mx-auto max-w-7xl w-full">
|
|
||||||
<div className="flex items-center justify-between mb-8">
|
|
||||||
<Link
|
|
||||||
to={backPath}
|
|
||||||
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-500 transition-colors hover:text-brand-500 decoration-none"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back to Modules
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-[8px] border-grayScale-200 text-grayScale-600 h-10 px-6 font-bold bg-white hover:bg-grayScale-50"
|
|
||||||
onClick={() => navigate(backPath)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-[#0F172A] mb-10">
|
|
||||||
Add New Video
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mx-auto max-w-4xl mb-12">
|
|
||||||
<Stepper
|
|
||||||
steps={STEPS.map((s) => s.label)}
|
|
||||||
currentStep={currentStep}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step Content */}
|
|
||||||
<div className="mx-auto max-w-7xl">
|
|
||||||
{currentStep === 1 && (
|
|
||||||
<VideoDetailStep
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
nextStep={nextStep}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 2 && (
|
|
||||||
<ReviewPublishStep
|
|
||||||
formData={formData}
|
|
||||||
prevStep={prevStep}
|
|
||||||
setIsPublished={setIsPublished}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
|
|
||||||
type CourseWithCategory = Course & { category_name: string }
|
type CourseWithCategory = Course & { category_name: string }
|
||||||
|
|
||||||
|
|
@ -230,7 +230,10 @@ export function AllCoursesPage() {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-32">
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
<div className="rounded-2xl bg-white shadow-sm p-6">
|
||||||
|
<SpinnerIcon className="h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all sub-categories…</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { ArrowLeft, Clock, FileVideo, Check } from "lucide-react";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { Stepper } from "../../components/ui/stepper";
|
|
||||||
import successIcon from "../../assets/success.svg";
|
|
||||||
|
|
||||||
import { AttachPracticeStep1 } from "./components/practice-steps/AttachPracticeStep1";
|
|
||||||
import { AttachPracticeReviewStep } from "./components/practice-steps/AttachPracticeReviewStep";
|
|
||||||
|
|
||||||
export function AttachPracticeFlow() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { programType, courseId, unitId, moduleId } = useParams<{
|
|
||||||
programType: string;
|
|
||||||
courseId: string;
|
|
||||||
unitId: string;
|
|
||||||
moduleId: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const backPath = `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`;
|
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
|
||||||
const [isPublished, setIsPublished] = useState(false);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
program:
|
|
||||||
programType === "skill"
|
|
||||||
? "Skill-Based Courses"
|
|
||||||
: "English Proficiency Exams",
|
|
||||||
module: "Module 4: Interactive Speaking",
|
|
||||||
video: "Intro to Interactive Speaking",
|
|
||||||
questionType: "speaking",
|
|
||||||
version: "v1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const steps = ["Set Video", "Review & Publish"];
|
|
||||||
|
|
||||||
const nextStep = () =>
|
|
||||||
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
|
|
||||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
|
||||||
|
|
||||||
if (isPublished) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-700 ">
|
|
||||||
{/* Scalloped Success Icon */}
|
|
||||||
<div className="mb-10 relative">
|
|
||||||
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
|
|
||||||
<img
|
|
||||||
src={successIcon}
|
|
||||||
alt="Success"
|
|
||||||
className="h-[128px] w-[128px] relative"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-[28px] font-bold text-grayScale-900 mb-2">
|
|
||||||
Practice Attached Successfully!
|
|
||||||
</h1>
|
|
||||||
<p className="text-grayScale-600 text-md mb-14 max-w-2xl font-medium leading-relaxed">
|
|
||||||
The practice has been successfully linked to a video{" "}
|
|
||||||
<span className="text-[#9E2891]">“{formData.video}”</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Video Info Card */}
|
|
||||||
<div className="w-full max-w-[600px] bg-[#9E289114] border border-[#9E2891] rounded-[12px] p-4 flex items-center justify-between mb-16 shadow-sm">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<div className="h-[60px] w-[120px] rounded-xl overflow-hidden shadow-inner flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src="https://images.unsplash.com/photo-1557425955-df376b5903c8?auto=format&fit=crop&q=80&w=400"
|
|
||||||
alt="Video Thumbnail"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-left space-y-1.5">
|
|
||||||
<h4 className="text-[14px] font-medium text-grayScale-900">
|
|
||||||
Intro to IELTS Speaking Part 1
|
|
||||||
</h4>
|
|
||||||
<div className="flex items-center gap-3 text-grayScale-400 font-medium text-[12px]">
|
|
||||||
<div className="flex items-center gap-1.5 uppercase tracking-wide">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
10:42 mins
|
|
||||||
</div>
|
|
||||||
<span>•</span>
|
|
||||||
<div className="flex items-center gap-1.5 uppercase tracking-wide">
|
|
||||||
MP4
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pr-4">
|
|
||||||
<Check className="h-5 w-5 text-[#9E2891] stroke-[3px]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex flex-col gap-4 w-full max-w-[440px]">
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(backPath)}
|
|
||||||
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white"
|
|
||||||
>
|
|
||||||
Go back to Videos
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIsPublished(false);
|
|
||||||
setCurrentStep(1);
|
|
||||||
}}
|
|
||||||
className="h-14 rounded-[6px] border-[#9E2891] text-[#9E2891] font-semibold text-[16px] bg-white"
|
|
||||||
>
|
|
||||||
Attach More Practice
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderStep = () => {
|
|
||||||
switch (currentStep) {
|
|
||||||
case 1:
|
|
||||||
return (
|
|
||||||
<AttachPracticeStep1
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
nextStep={nextStep}
|
|
||||||
onCancel={() => navigate(backPath)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 2:
|
|
||||||
return (
|
|
||||||
<AttachPracticeReviewStep
|
|
||||||
formData={formData}
|
|
||||||
prevStep={prevStep}
|
|
||||||
onPublish={() => setIsPublished(true)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const title =
|
|
||||||
currentStep === 1 ? "Attach Practice to a Video" : "Review & Publish";
|
|
||||||
const description =
|
|
||||||
currentStep === 1
|
|
||||||
? "Create a new immersive practice session for a video."
|
|
||||||
: "Verify practice details before publishing it.";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 pb-32 px-6 pt-10 min-h-screen animate-in fade-in duration-500">
|
|
||||||
<div className="mx-auto w-full">
|
|
||||||
{/* Navigation Breadcrumb */}
|
|
||||||
<div className="flex items-center justify-between mb-12">
|
|
||||||
<Link
|
|
||||||
to={backPath}
|
|
||||||
className="flex items-center gap-2 text-[15px] font-bold 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 Videos
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stepper Area */}
|
|
||||||
<div className="mb-20 w-full pointer-events-none">
|
|
||||||
<Stepper steps={steps} currentStep={currentStep} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page Title & Header Actions */}
|
|
||||||
<div className="mb-10 flex items-start justify-between">
|
|
||||||
<div className="">
|
|
||||||
<h1 className="text-[30px] font-bold text-[#0D1421] ">{title}</h1>
|
|
||||||
<p className="text-grayScale-400 text-[16px] font-medium ">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 px-8 rounded-[6px] border-grayScale-100 text-grayScale-600 font-bold bg-white hover:bg-grayScale-50 shadow-sm"
|
|
||||||
onClick={() => navigate(backPath)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-bold text-white shadow-md hover:bg-[#8A237E] transition-all">
|
|
||||||
Save as Draft
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form Content */}
|
|
||||||
<div className="w-full">{renderStep()}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { ArrowLeft, Check } from "lucide-react";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { Stepper } from "../../components/ui/stepper";
|
|
||||||
import successIcon from "../../assets/success.svg";
|
|
||||||
|
|
||||||
import { ProgramAttachStep1 } from "./components/practice-steps/ProgramAttachStep1";
|
|
||||||
import { ProgramAttachReviewStep } from "./components/practice-steps/ProgramAttachReviewStep";
|
|
||||||
|
|
||||||
export function AttachProgramPracticeFlow() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { programType } = useParams<{ programType: string }>();
|
|
||||||
|
|
||||||
const backPath = `/new-content/courses/${programType}`;
|
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
|
||||||
const [isPublished, setIsPublished] = useState(false);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
program: "English Proficiency Exams",
|
|
||||||
test: "Mock Exam 1",
|
|
||||||
questionType: "Speaking Practice",
|
|
||||||
version: "V 1.0",
|
|
||||||
});
|
|
||||||
|
|
||||||
const steps = ["Set Program", "Review & Publish"];
|
|
||||||
|
|
||||||
const nextStep = () =>
|
|
||||||
setCurrentStep((prev) => Math.min(prev + 1, steps.length));
|
|
||||||
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
|
|
||||||
|
|
||||||
if (isPublished) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-700 bg-white">
|
|
||||||
{/* Scalloped Success Icon */}
|
|
||||||
<div className="mb-10 relative">
|
|
||||||
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
|
|
||||||
<img
|
|
||||||
src={successIcon}
|
|
||||||
alt="Success"
|
|
||||||
className="h-[128px] w-[128px] relative"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-[28px] font-bold text-grayScale-900 mb-2">
|
|
||||||
Practice Attached Successfully!
|
|
||||||
</h1>
|
|
||||||
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
|
|
||||||
The practice has been successfully linked to the program{" "}
|
|
||||||
<span className="text-[#9E2891]">“{formData.program}”</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full max-w-[440px]">
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(backPath)}
|
|
||||||
className="h-14 rounded-[12px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
|
|
||||||
>
|
|
||||||
Go back to Program
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIsPublished(false);
|
|
||||||
setCurrentStep(1);
|
|
||||||
}}
|
|
||||||
className="h-14 rounded-[12px] border-[#9E2891] text-[#9E2891] font-bold text-[16px] bg-white "
|
|
||||||
>
|
|
||||||
Attach More Practice
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderStep = () => {
|
|
||||||
switch (currentStep) {
|
|
||||||
case 1:
|
|
||||||
return (
|
|
||||||
<ProgramAttachStep1
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
nextStep={nextStep}
|
|
||||||
onCancel={() => navigate(backPath)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 2:
|
|
||||||
return (
|
|
||||||
<ProgramAttachReviewStep
|
|
||||||
formData={formData}
|
|
||||||
prevStep={prevStep}
|
|
||||||
onPublish={() => setIsPublished(true)}
|
|
||||||
onCancel={() => navigate(backPath)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const title =
|
|
||||||
currentStep === 1 ? "Attach Practice to a program" : "Review & Publish";
|
|
||||||
const description =
|
|
||||||
currentStep === 1
|
|
||||||
? "Create a new immersive practice session for a video."
|
|
||||||
: "Verify practice details before publishing it.";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 px-6 pt-10 min-h-screen animate-in fade-in duration-500">
|
|
||||||
<div className=" w-full">
|
|
||||||
{/* Navigation Breadcrumb */}
|
|
||||||
<div className="flex items-center justify-between mb-12">
|
|
||||||
<Link
|
|
||||||
to={backPath}
|
|
||||||
className="flex items-center gap-2 text-[15px] font-bold 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 Program
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stepper Area */}
|
|
||||||
<div className="mb-20 pointer-events-none">
|
|
||||||
<Stepper steps={steps} currentStep={currentStep} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page Title & Header Actions */}
|
|
||||||
<div className="mb-10 flex items-start justify-between">
|
|
||||||
<div className="">
|
|
||||||
<h1 className="text-[30px] font-bold text-[#0D1421] ">{title}</h1>
|
|
||||||
<p className="text-grayScale-500 text-[14px]">{description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 px-8 rounded-[6px] border-grayScale-100 text-grayScale-600 font-bold bg-white hover:bg-grayScale-50 shadow-sm"
|
|
||||||
onClick={() => navigate(backPath)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-bold text-white shadow-md hover:bg-[#8A237E] transition-all">
|
|
||||||
Save as Draft
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form Content */}
|
|
||||||
<div
|
|
||||||
className={`w-full mx-auto ${
|
|
||||||
currentStep === 1 ? "max-w-4xl" : "max-w-none"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{renderStep()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,593 +0,0 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Plus,
|
|
||||||
Calendar,
|
|
||||||
Layers,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { Card } from "../../components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "../../components/ui/dialog";
|
|
||||||
import { Input } from "../../components/ui/input";
|
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
|
||||||
import { cn } from "../../lib/utils";
|
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
|
||||||
import alertSrc from "../../assets/Alert.svg";
|
|
||||||
import {
|
|
||||||
deleteTopLevelCourseModule,
|
|
||||||
getProgramCourses,
|
|
||||||
getTopLevelCourseModules,
|
|
||||||
updateTopLevelCourseModule,
|
|
||||||
} from "../../api/courses.api";
|
|
||||||
import type {
|
|
||||||
ProgramCourseListItem,
|
|
||||||
TopLevelCourseModuleItem,
|
|
||||||
} from "../../types/course.types";
|
|
||||||
import { AddModuleModal } from "./components/AddModuleModal";
|
|
||||||
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
|
|
||||||
|
|
||||||
const MODULE_CARD_GRADIENT =
|
|
||||||
"from-[#8E44AD] to-[#C39BD3]" as const;
|
|
||||||
|
|
||||||
function isLikelyImageUrl(src: string): boolean {
|
|
||||||
const t = src.trim();
|
|
||||||
return (
|
|
||||||
t.startsWith("http://") ||
|
|
||||||
t.startsWith("https://") ||
|
|
||||||
t.startsWith("/") ||
|
|
||||||
t.startsWith("data:")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default purple gradient with optional cover image; gradient stays if URL missing or image errors. */
|
|
||||||
function ModuleCardTopMedia({ iconSrc }: { iconSrc: string }) {
|
|
||||||
const [coverFailed, setCoverFailed] = useState(false);
|
|
||||||
const tryCover =
|
|
||||||
Boolean(iconSrc.trim()) && isLikelyImageUrl(iconSrc) && !coverFailed;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative h-36 w-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 bg-gradient-to-b opacity-90 transition-transform duration-700",
|
|
||||||
MODULE_CARD_GRADIENT,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{tryCover ? (
|
|
||||||
<img
|
|
||||||
src={iconSrc.trim()}
|
|
||||||
alt=""
|
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
|
||||||
onError={() => setCoverFailed(true)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Circular module icon: image when load succeeds, otherwise default Layers icon. */
|
|
||||||
function ModuleIconCircle({
|
|
||||||
iconSrc,
|
|
||||||
index,
|
|
||||||
}: {
|
|
||||||
iconSrc: string;
|
|
||||||
index: number;
|
|
||||||
}) {
|
|
||||||
const [imgFailed, setImgFailed] = useState(false);
|
|
||||||
const showImg =
|
|
||||||
Boolean(iconSrc.trim()) && isLikelyImageUrl(iconSrc) && !imgFailed;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-full border border-purple-100/50 p-2",
|
|
||||||
index % 2 === 1 ? "bg-[#F8FAFC]" : "bg-[#f3e8ff]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{showImg ? (
|
|
||||||
<img
|
|
||||||
src={iconSrc.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-full w-full object-contain"
|
|
||||||
onError={() => setImgFailed(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Layers
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-6",
|
|
||||||
index % 2 === 1 ? "text-[#64748B]" : "text-brand-500",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CourseDetailPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { level: programIdParam, courseId: courseIdParam } = useParams<{
|
|
||||||
level: string;
|
|
||||||
courseId: string;
|
|
||||||
}>();
|
|
||||||
const programId = Number(programIdParam);
|
|
||||||
const courseIdNum = Number(courseIdParam);
|
|
||||||
|
|
||||||
const [course, setCourse] = useState<ProgramCourseListItem | null>(null);
|
|
||||||
const [modules, setModules] = useState<TopLevelCourseModuleItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isAddModuleOpen, setIsAddModuleOpen] = useState(false);
|
|
||||||
|
|
||||||
const [editingModule, setEditingModule] =
|
|
||||||
useState<TopLevelCourseModuleItem | null>(null);
|
|
||||||
const [editModuleName, setEditModuleName] = useState("");
|
|
||||||
const [editModuleDescription, setEditModuleDescription] = useState("");
|
|
||||||
const [editModuleIcon, setEditModuleIcon] = useState("");
|
|
||||||
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
|
|
||||||
useState(false);
|
|
||||||
const [savingModuleEdit, setSavingModuleEdit] = useState(false);
|
|
||||||
|
|
||||||
const [deletingModule, setDeletingModule] =
|
|
||||||
useState<TopLevelCourseModuleItem | null>(null);
|
|
||||||
const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false);
|
|
||||||
|
|
||||||
const openEditModule = (module: TopLevelCourseModuleItem) => {
|
|
||||||
setEditingModule(module);
|
|
||||||
setEditModuleName(module.name ?? "");
|
|
||||||
setEditModuleDescription(module.description ?? "");
|
|
||||||
setEditModuleIcon(module.icon?.trim() ?? "");
|
|
||||||
setEditModuleIconUploadBusy(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeEditModule = () => {
|
|
||||||
if (savingModuleEdit || editModuleIconUploadBusy) return;
|
|
||||||
setEditingModule(null);
|
|
||||||
setEditModuleIconUploadBusy(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadPage = useCallback(async () => {
|
|
||||||
if (!Number.isFinite(programId) || programId < 1) {
|
|
||||||
setError("Invalid program");
|
|
||||||
setCourse(null);
|
|
||||||
setModules([]);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(courseIdNum) || courseIdNum < 1) {
|
|
||||||
setError("Invalid course");
|
|
||||||
setCourse(null);
|
|
||||||
setModules([]);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const [courseOutcome, modulesOutcome] = await Promise.allSettled([
|
|
||||||
getProgramCourses(programId, { limit: 200, offset: 0 }),
|
|
||||||
getTopLevelCourseModules(courseIdNum, { limit: 100, offset: 0 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (courseOutcome.status === "fulfilled") {
|
|
||||||
const raw = courseOutcome.value.data?.data?.courses;
|
|
||||||
const list = Array.isArray(raw) ? raw : [];
|
|
||||||
const found = list.find((c) => c.id === courseIdNum) ?? null;
|
|
||||||
setCourse(found);
|
|
||||||
if (!found) {
|
|
||||||
setError("Course not found in this program");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error(courseOutcome.reason);
|
|
||||||
setCourse(null);
|
|
||||||
setError("Failed to load course");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modulesOutcome.status === "fulfilled") {
|
|
||||||
const raw = modulesOutcome.value.data?.data?.modules;
|
|
||||||
const list = Array.isArray(raw) ? raw : [];
|
|
||||||
const sorted = [...list].sort(
|
|
||||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
|
||||||
);
|
|
||||||
setModules(sorted);
|
|
||||||
} else {
|
|
||||||
console.error(modulesOutcome.reason);
|
|
||||||
setModules([]);
|
|
||||||
toast.error("Could not load modules", {
|
|
||||||
description: "Check your connection or try again.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setError("Failed to load course");
|
|
||||||
setCourse(null);
|
|
||||||
setModules([]);
|
|
||||||
toast.error("Could not load course", {
|
|
||||||
description: "Check your connection or try again.",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [programId, courseIdNum]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadPage();
|
|
||||||
}, [loadPage]);
|
|
||||||
|
|
||||||
const handleSaveModuleEdit = async () => {
|
|
||||||
if (!editingModule) return;
|
|
||||||
const name = editModuleName.trim();
|
|
||||||
if (!name) {
|
|
||||||
toast.error("Module name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSavingModuleEdit(true);
|
|
||||||
try {
|
|
||||||
await updateTopLevelCourseModule(editingModule.id, {
|
|
||||||
name,
|
|
||||||
description: editModuleDescription.trim(),
|
|
||||||
icon: editModuleIcon.trim(),
|
|
||||||
});
|
|
||||||
toast.success("Module updated");
|
|
||||||
setEditModuleIconUploadBusy(false);
|
|
||||||
setEditingModule(null);
|
|
||||||
await loadPage();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to update module";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setSavingModuleEdit(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmDeleteModule = async () => {
|
|
||||||
if (!deletingModule) return;
|
|
||||||
setDeletingModuleInFlight(true);
|
|
||||||
try {
|
|
||||||
await deleteTopLevelCourseModule(deletingModule.id);
|
|
||||||
toast.success("Module deleted");
|
|
||||||
setDeletingModule(null);
|
|
||||||
await loadPage();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to delete module";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setDeletingModuleInFlight(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayTitle =
|
|
||||||
course?.name?.trim() || courseIdParam || "Course";
|
|
||||||
const displayDescription =
|
|
||||||
course?.description?.trim() ||
|
|
||||||
(!loading && !course
|
|
||||||
? "This course could not be loaded."
|
|
||||||
: !course?.description?.trim() && course
|
|
||||||
? "—"
|
|
||||||
: "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-10 pb-20 pt-10">
|
|
||||||
{/* Header Navigation */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
to={`/new-content/learn-english/${programIdParam}/courses`}
|
|
||||||
className="flex items-center gap-2 text-sm font-medium text-grayScale-600 transition-colors hover:text-brand-500"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-5 w-5" />
|
|
||||||
Back to Courses
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
|
||||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : error && !course ? (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border border-red-100 bg-red-50/60 px-6 py-14 text-center">
|
|
||||||
<img src={alertSrc} alt="" className="h-10 w-10" />
|
|
||||||
<p className="mt-3 text-sm font-medium text-red-700">{error}</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="mt-4"
|
|
||||||
onClick={() => void loadPage()}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Hero Section */}
|
|
||||||
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-end">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h1 className="text-2xl font-medium tracking-tight text-grayScale-900">
|
|
||||||
{displayTitle}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 max-w-2xl text-sm font-medium text-grayScale-500">
|
|
||||||
{displayDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-[6px] border-brand-500 text-brand-500 "
|
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
`/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
Add Practice
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
|
|
||||||
onClick={() => setIsAddModuleOpen(true)}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Add Module
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 flex items-center"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div className="w-full border-t border-grayScale-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full rounded-full opacity-20"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AddModuleModal
|
|
||||||
isOpen={isAddModuleOpen}
|
|
||||||
onClose={() => setIsAddModuleOpen(false)}
|
|
||||||
courseId={courseIdNum}
|
|
||||||
onCreated={() => loadPage()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={editingModule !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && savingModuleEdit) return;
|
|
||||||
if (!open && editModuleIconUploadBusy) return;
|
|
||||||
if (!open) closeEditModule();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Edit module</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Update name, description, and icon (upload or URL). Saved with{" "}
|
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
|
||||||
PUT /modules/:id
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4 py-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={editModuleName}
|
|
||||||
onChange={(e) => setEditModuleName(e.target.value)}
|
|
||||||
className="rounded-xl"
|
|
||||||
placeholder="e.g. Grammar basics"
|
|
||||||
disabled={savingModuleEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={editModuleDescription}
|
|
||||||
onChange={(e) => setEditModuleDescription(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[100px] resize-y rounded-xl"
|
|
||||||
placeholder="Optional short description."
|
|
||||||
disabled={savingModuleEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ModuleIconUploadField
|
|
||||||
value={editModuleIcon}
|
|
||||||
onChange={setEditModuleIcon}
|
|
||||||
disabled={savingModuleEdit}
|
|
||||||
onUploadBusyChange={setEditModuleIconUploadBusy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={closeEditModule}
|
|
||||||
disabled={savingModuleEdit || editModuleIconUploadBusy}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
|
||||||
disabled={savingModuleEdit || editModuleIconUploadBusy}
|
|
||||||
onClick={() => void handleSaveModuleEdit()}
|
|
||||||
>
|
|
||||||
{savingModuleEdit ? "Saving…" : "Save changes"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{modules.length === 0 ? (
|
|
||||||
<div className="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 modules in this course yet
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
|
||||||
Add modules when your workflow is connected, or create them via
|
|
||||||
the API.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="grid justify-start gap-10"
|
|
||||||
style={{
|
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{modules.map((module, index) => {
|
|
||||||
const iconSrc = module.icon?.trim() ?? "";
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={module.id}
|
|
||||||
className="group relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-[16px] border border-grayScale-50 bg-white shadow-sm transition-all duration-300 hover:shadow-lg"
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
aria-label={`Edit ${module.name}`}
|
|
||||||
onClick={() => openEditModule(module)}
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
aria-label={`Delete ${module.name}`}
|
|
||||||
onClick={() => setDeletingModule(module)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ModuleCardTopMedia iconSrc={iconSrc} />
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-6 p-2 pb-4 pt-4">
|
|
||||||
<div className="flex min-h-0 flex-1 gap-4">
|
|
||||||
<ModuleIconCircle iconSrc={iconSrc} index={index} />
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
|
||||||
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
|
|
||||||
{module.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[12px] font-medium leading-snug text-grayScale-400">
|
|
||||||
{module.description?.trim() ? module.description : "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto flex shrink-0 items-center gap-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 flex-1 rounded-[6px] border-[#9E2891] text-sm text-[#9E2891] transition-all"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
`/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
View Detail
|
|
||||||
</Button>
|
|
||||||
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10">
|
|
||||||
Publish Practice
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{deletingModule && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
|
||||||
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
|
||||||
<h2 className="text-lg font-bold text-grayScale-700">
|
|
||||||
Delete module
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
!deletingModuleInFlight && setDeletingModule(null)
|
|
||||||
}
|
|
||||||
disabled={deletingModuleInFlight}
|
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-6 py-6">
|
|
||||||
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
|
|
||||||
<Trash2 className="h-5 w-5 text-red-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
|
||||||
Are you sure you want to delete{" "}
|
|
||||||
<span className="font-semibold text-grayScale-700">
|
|
||||||
{deletingModule.name}
|
|
||||||
</span>
|
|
||||||
? This cannot be undone. Related content may be affected
|
|
||||||
depending on your backend.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeletingModule(null)}
|
|
||||||
disabled={deletingModuleInFlight}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
|
|
||||||
disabled={deletingModuleInFlight}
|
|
||||||
onClick={() => void handleConfirmDeleteModule()}
|
|
||||||
>
|
|
||||||
{deletingModuleInFlight ? "Deleting…" : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Plus,
|
|
||||||
FileText,
|
|
||||||
LayoutGrid,
|
|
||||||
PlayCircle,
|
|
||||||
ClipboardCheck,
|
|
||||||
ChevronRight,
|
|
||||||
ArrowRight,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { Card } from "../../components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogClose,
|
|
||||||
} from "../../components/ui/dialog";
|
|
||||||
import { Input } from "../../components/ui/input";
|
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
|
||||||
|
|
||||||
export function CourseManagementPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { programType, courseId } = useParams<{
|
|
||||||
programType: string;
|
|
||||||
courseId: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// Mock data for display titles
|
|
||||||
const courseTitles: Record<string, string> = {
|
|
||||||
duolingo: "Duolingo English Test",
|
|
||||||
ielts: "IELTS Academic",
|
|
||||||
};
|
|
||||||
|
|
||||||
const courseDisplayName =
|
|
||||||
courseTitles[courseId || ""] || "Duolingo English Test";
|
|
||||||
|
|
||||||
const units = [
|
|
||||||
{
|
|
||||||
id: "unit1",
|
|
||||||
name: "Greetings & Introductions",
|
|
||||||
description:
|
|
||||||
"Learn basic greetings, self-introductions, and polite expressions in everyday situations.",
|
|
||||||
modules: 3,
|
|
||||||
videos: 9,
|
|
||||||
practices: 9,
|
|
||||||
gradient:
|
|
||||||
"linear-gradient(135deg, rgba(158, 40, 145, 0.5) 0%, rgba(158, 40, 145, 0.8) 100%)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "unit2",
|
|
||||||
name: "Speaking",
|
|
||||||
description:
|
|
||||||
"Core speaking practice and skill building for natural pronunciation and fluency.",
|
|
||||||
modules: 3,
|
|
||||||
videos: 9,
|
|
||||||
practices: 9,
|
|
||||||
gradient:
|
|
||||||
"linear-gradient(135deg, rgba(79, 70, 229, 0.5) 0%, rgba(79, 70, 229, 0.8) 100%)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "unit3",
|
|
||||||
name: "Reading",
|
|
||||||
description:
|
|
||||||
"Reading comprehension and vocabulary improvement through various text types.",
|
|
||||||
modules: 3,
|
|
||||||
videos: 9,
|
|
||||||
practices: 9,
|
|
||||||
gradient:
|
|
||||||
"linear-gradient(135deg, rgba(124, 58, 237, 0.5) 0%, rgba(124, 58, 237, 0.8) 100%)",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
|
||||||
{/* Navigation */}
|
|
||||||
<Link
|
|
||||||
to={`/new-content/courses/${programType}`}
|
|
||||||
className="flex items-center gap-2.5 text-[15px] font-semibold text-grayScale-600 hover:text-brand-500 transition-colors pt-4 group"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
|
||||||
Back to Courses
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Header section */}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h1 className="text-[28px] font-medium tracking-tight text-grayScale-900">
|
|
||||||
{courseDisplayName}
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-2xl text-[15px] font-medium leading-relaxed text-grayScale-500">
|
|
||||||
Manage units and modules inside the {courseDisplayName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<Dialog>
|
|
||||||
<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">
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
Add Unit
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
|
|
||||||
<div className="bg-white">
|
|
||||||
<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">
|
|
||||||
Create Courses
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="p-8 space-y-8">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Unit Name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Thumbnail
|
|
||||||
</label>
|
|
||||||
<div className="relative group cursor-pointer">
|
|
||||||
<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">
|
|
||||||
Click to upload
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">
|
|
||||||
or drag and drop
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
|
||||||
JPG, PNG (MAX 1 MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
|
|
||||||
Create Courses
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/new-content/courses/${programType}/attach-practice`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FileText className="h-5 w-5" />
|
|
||||||
Attach Practice
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Horizontal Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
|
||||||
<div className="w-full border-t border-grayScale-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid of Units */}
|
|
||||||
<div className="flex flex-wrap gap-4 pt-4">
|
|
||||||
{units.map((unit) => (
|
|
||||||
<Card
|
|
||||||
key={unit.id}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{/* Gradient Header */}
|
|
||||||
<div
|
|
||||||
className="h-36 w-full transition-transform duration-500 "
|
|
||||||
style={{ background: unit.gradient }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="p-4 flex flex-col flex-1 space-y-6">
|
|
||||||
<div className="space-y-3 flex-1">
|
|
||||||
<h3 className="text-[18px] font-medium text-grayScale-900 transition-colors">
|
|
||||||
{unit.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[12px] text-grayScale-500 font-medium line-clamp-3">
|
|
||||||
{unit.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Pills */}
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
|
||||||
<LayoutGrid className="h-3.5 w-3.5 text-grayScale-400" />
|
|
||||||
<span className="text-[12px] font-bold">
|
|
||||||
{unit.modules} Modules
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<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" />
|
|
||||||
<span className="text-[12px] font-bold">
|
|
||||||
{unit.videos} Videos
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-9 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
|
||||||
<ClipboardCheck className="h-3.5 w-3.5 text-grayScale-400" />
|
|
||||||
<span className="text-[12px] font-bold">
|
|
||||||
{unit.practices} Practices
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Button */}
|
|
||||||
<Button
|
|
||||||
className="w-full h-10 bg-brand-500 text-white rounded-[6px] font-bold flex items-center justify-center gap-2 group/btn"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
`/new-content/courses/${programType}/${courseId}/${unit.id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
View Detail
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { ArrowLeft, Plus, FileText, MoreVertical, Edit2 } from "lucide-react";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { cn } from "../../lib/utils";
|
|
||||||
import { Card } from "../../components/ui/card";
|
|
||||||
|
|
||||||
const MOCK_VIDEOS = [
|
|
||||||
{
|
|
||||||
id: "v1",
|
|
||||||
title: "1.1 Introduction to Formal Greetings",
|
|
||||||
duration: "08:45",
|
|
||||||
status: "Draft",
|
|
||||||
thumbnailColor: "bg-[#CBD5E1]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "v2",
|
|
||||||
title: "1.2 Understanding Email Structure",
|
|
||||||
duration: "08:45",
|
|
||||||
status: "Published",
|
|
||||||
thumbnailColor: "bg-[#DBEAFE]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "v3",
|
|
||||||
title: "1.3 Common Business Idioms",
|
|
||||||
duration: "08:45",
|
|
||||||
status: "Published",
|
|
||||||
thumbnailColor: "bg-[#FEF3C7]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "v4",
|
|
||||||
title: "1.4 Video Conference Etiquette",
|
|
||||||
duration: "08:45",
|
|
||||||
status: "Published",
|
|
||||||
thumbnailColor: "bg-[#FCE7F3]",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const MOCK_PRACTICES = [
|
|
||||||
{
|
|
||||||
id: "p1",
|
|
||||||
title: "1.1 Conversation Practice",
|
|
||||||
duration: "08:45",
|
|
||||||
status: "Published",
|
|
||||||
thumbnailColor: "bg-[#E0F2FE]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p2",
|
|
||||||
title: "1.2 Roleplay Scenario",
|
|
||||||
duration: "08:45",
|
|
||||||
status: "Draft",
|
|
||||||
thumbnailColor: "bg-[#F0FDF4]",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function CourseModuleDetailPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { programType, courseId, unitId, moduleId } = useParams<{
|
|
||||||
programType: string;
|
|
||||||
courseId: string;
|
|
||||||
unitId: string;
|
|
||||||
moduleId: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
|
||||||
const [activeFilter, setActiveFilter] = useState("All");
|
|
||||||
|
|
||||||
const moduleTitle = "Module 1: Basic Phrases";
|
|
||||||
const moduleDescription = "Learn essential phrases for daily conversations.";
|
|
||||||
|
|
||||||
const content = activeTab === "video" ? MOCK_VIDEOS : MOCK_PRACTICES;
|
|
||||||
const filteredContent = content.filter((item) => {
|
|
||||||
if (activeFilter === "All") return true;
|
|
||||||
if (activeFilter === "Drafts") return item.status === "Draft";
|
|
||||||
return item.status === activeFilter;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
|
||||||
{/* Navigation */}
|
|
||||||
<Link
|
|
||||||
to={`/new-content/courses/${programType}/${courseId}/${unitId}`}
|
|
||||||
className="flex items-center gap-2.5 text-[15px] font-bold text-grayScale-600 hover:text-brand-500 transition-colors pt-4 group"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
|
||||||
Back to Modules
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Header section */}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h1 className="text-[32px] font-extrabold tracking-tight text-[#0D1421]">
|
|
||||||
{moduleTitle}
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-2xl text-[16px] font-medium leading-relaxed text-grayScale-400">
|
|
||||||
{moduleDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2 shadow-sm"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
`/new-content/courses/${programType}/${courseId}/unit/${unitId}/module/${moduleId}/attach-practice`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FileText className="h-5 w-5" />
|
|
||||||
Attach Practice
|
|
||||||
</Button>
|
|
||||||
<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 Video
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-10 border-b border-grayScale-100">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("video")}
|
|
||||||
className={cn(
|
|
||||||
"pb-4 text-[16px] font-bold transition-all relative px-2",
|
|
||||||
activeTab === "video"
|
|
||||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
|
|
||||||
: "text-grayScale-400 hover:text-grayScale-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Video
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("practice")}
|
|
||||||
className={cn(
|
|
||||||
"pb-4 text-[16px] font-bold transition-all relative px-2",
|
|
||||||
activeTab === "practice"
|
|
||||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500"
|
|
||||||
: "text-grayScale-400 hover:text-grayScale-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Practice
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter Bar */}
|
|
||||||
<div className="bg-white border border-grayScale-100 rounded-[16px] p-4 flex items-center gap-8 shadow-sm">
|
|
||||||
<div className="text-[12px] font-bold text-grayScale-300 uppercase tracking-widest pl-4">
|
|
||||||
STATUS:
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{["All", "Published", "Drafts", "Archived"].map((filter) => (
|
|
||||||
<button
|
|
||||||
key={filter}
|
|
||||||
onClick={() => setActiveFilter(filter)}
|
|
||||||
className={cn(
|
|
||||||
"px-5 py-2 rounded-full text-[13px] font-bold transition-all",
|
|
||||||
activeFilter === filter
|
|
||||||
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
|
|
||||||
: "bg-grayScale-100 text-grayScale-500 hover:bg-grayScale-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{filter}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid of Content */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4">
|
|
||||||
{filteredContent.map((item) => (
|
|
||||||
<ContentCard key={item.id} {...item} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContentCard({
|
|
||||||
title,
|
|
||||||
duration,
|
|
||||||
status,
|
|
||||||
thumbnailColor,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
duration: string;
|
|
||||||
status: string;
|
|
||||||
thumbnailColor: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card className="group flex flex-col bg-white rounded-[20px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-xl hover:shadow-grayScale-400/5 transition-all">
|
|
||||||
{/* Thumbnail Area */}
|
|
||||||
<div className={cn("h-44 w-full relative", thumbnailColor)}>
|
|
||||||
<div className="absolute bottom-3 right-3 bg-black/60 text-white text-[11px] font-bold px-2 py-0.5 rounded backdrop-blur-sm">
|
|
||||||
{duration}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-5 flex flex-col flex-1 space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider flex items-center gap-2 border",
|
|
||||||
status === "Published"
|
|
||||||
? "bg-[#F0FDF4] text-[#16A34A] border-[#DCFCE7]"
|
|
||||||
: "bg-grayScale-50 text-grayScale-400 border-grayScale-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-1.5 w-1.5 rounded-full",
|
|
||||||
status === "Published" ? "bg-[#16A34A]" : "bg-grayScale-300",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{status}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<h3 className="text-[14px] font-bold text-[#0F172A] line-clamp-2 leading-snug">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<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 flex items-center justify-center gap-2 text-xs hover:bg-grayScale-25"
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className={cn(
|
|
||||||
"w-full h-10 rounded-[10px] font-bold text-xs shadow-sm",
|
|
||||||
status === "Published"
|
|
||||||
? "bg-[#ECD5E9] text-[#9E2891] hover:bg-[#EBD0E7]"
|
|
||||||
: "bg-brand-500 text-white hover:bg-brand-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{status === "Published" ? "Published" : "Publish"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,706 +0,0 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Plus, ArrowRight, Pencil, Trash2, X } from "lucide-react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogFooter,
|
|
||||||
} from "../../components/ui/dialog";
|
|
||||||
import { Input } from "../../components/ui/input";
|
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
|
||||||
import alertSrc from "../../assets/Alert.svg";
|
|
||||||
import {
|
|
||||||
getLearningPrograms,
|
|
||||||
createLearningProgram,
|
|
||||||
updateLearningProgram,
|
|
||||||
deleteLearningProgram,
|
|
||||||
} from "../../api/courses.api";
|
|
||||||
import { uploadImageFile } from "../../api/files.api";
|
|
||||||
import type { LearningProgramListItem } from "../../types/course.types";
|
|
||||||
|
|
||||||
export function LearnEnglishPage() {
|
|
||||||
const [programs, setPrograms] = useState<LearningProgramListItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [editingProgram, setEditingProgram] =
|
|
||||||
useState<LearningProgramListItem | null>(null);
|
|
||||||
const [editName, setEditName] = useState("");
|
|
||||||
const [editDescription, setEditDescription] = useState("");
|
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
|
||||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
|
||||||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [createName, setCreateName] = useState("");
|
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
|
||||||
const [createSaving, setCreateSaving] = useState(false);
|
|
||||||
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
|
||||||
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [deletingProgram, setDeletingProgram] =
|
|
||||||
useState<LearningProgramListItem | null>(null);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
|
|
||||||
const openEdit = (program: LearningProgramListItem) => {
|
|
||||||
setEditingProgram(program);
|
|
||||||
setEditName(program.name ?? "");
|
|
||||||
setEditDescription(program.description?.trim() ?? "");
|
|
||||||
setEditThumbnail(program.thumbnail?.trim() ?? "");
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeEdit = () => {
|
|
||||||
setEditingProgram(null);
|
|
||||||
setEditName("");
|
|
||||||
setEditDescription("");
|
|
||||||
setEditThumbnail("");
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
const maxBytes = 5 * 1024 * 1024;
|
|
||||||
if (file.size > maxBytes) {
|
|
||||||
toast.error("Image is too large", { description: "Maximum size is 5 MB." });
|
|
||||||
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 (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearCreateFormFields = () => {
|
|
||||||
setCreateName("");
|
|
||||||
setCreateDescription("");
|
|
||||||
setCreateThumbnail("");
|
|
||||||
if (createThumbnailFileInputRef.current) {
|
|
||||||
createThumbnailFileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateDialogOpenChange = (open: boolean) => {
|
|
||||||
if (!open && (createSaving || createUploadingThumbnail)) return;
|
|
||||||
clearCreateFormFields();
|
|
||||||
setCreateOpen(open);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateThumbnailFile = 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;
|
|
||||||
}
|
|
||||||
setCreateUploadingThumbnail(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 (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setCreateUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateProgram = async () => {
|
|
||||||
const name = createName.trim();
|
|
||||||
if (!name) {
|
|
||||||
toast.error("Program name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreateSaving(true);
|
|
||||||
try {
|
|
||||||
await createLearningProgram({
|
|
||||||
name,
|
|
||||||
description: createDescription.trim(),
|
|
||||||
thumbnail: createThumbnail.trim(),
|
|
||||||
});
|
|
||||||
toast.success("Program created");
|
|
||||||
clearCreateFormFields();
|
|
||||||
setCreateOpen(false);
|
|
||||||
await fetchPrograms();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to create program";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setCreateSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEdit = async () => {
|
|
||||||
if (!editingProgram) return;
|
|
||||||
const name = editName.trim();
|
|
||||||
if (!name) {
|
|
||||||
toast.error("Program name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSavingEdit(true);
|
|
||||||
try {
|
|
||||||
await updateLearningProgram(editingProgram.id, {
|
|
||||||
name,
|
|
||||||
description: editDescription.trim(),
|
|
||||||
thumbnail: editThumbnail.trim(),
|
|
||||||
});
|
|
||||||
toast.success("Program updated");
|
|
||||||
closeEdit();
|
|
||||||
await fetchPrograms();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to update program";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setSavingEdit(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmDelete = async () => {
|
|
||||||
if (!deletingProgram) return;
|
|
||||||
setDeleting(true);
|
|
||||||
try {
|
|
||||||
await deleteLearningProgram(deletingProgram.id);
|
|
||||||
toast.success("Program deleted");
|
|
||||||
setDeletingProgram(null);
|
|
||||||
await fetchPrograms();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to delete program";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchPrograms = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await getLearningPrograms({ limit: 100, offset: 0 });
|
|
||||||
const raw = res.data?.data?.programs;
|
|
||||||
const list = Array.isArray(raw) ? raw : [];
|
|
||||||
const sorted = [...list].sort(
|
|
||||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
|
||||||
);
|
|
||||||
setPrograms(sorted);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setError("Failed to load programs");
|
|
||||||
setPrograms([]);
|
|
||||||
toast.error("Could not load programs", {
|
|
||||||
description: "Check your connection or try again.",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void fetchPrograms();
|
|
||||||
}, [fetchPrograms]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Header section */}
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
|
||||||
Learn English
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-500">
|
|
||||||
Manage learning content by program — cards load from the server
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={createOpen} onOpenChange={handleCreateDialogOpenChange}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="h-11 rounded-[6px] bg-brand-500 px-6 font-semibold ">
|
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
|
||||||
Add Program
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-2xl flex-col gap-0 overflow-hidden border-none p-0">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<DialogHeader className="p-8 pb-4">
|
|
||||||
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
|
||||||
Add New Program
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm text-grayScale-400">
|
|
||||||
Create a learning program via{" "}
|
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
|
||||||
POST /programs
|
|
||||||
</code>
|
|
||||||
. Thumbnail can be a URL or a file uploaded through{" "}
|
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
|
||||||
POST /files/upload
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{/* Gradient Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 flex items-center"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div className="w-full border-t border-grayScale-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className="flex min-h-0 flex-1 flex-col"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleCreateProgram();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-8 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[15px] text-grayScale-700">
|
|
||||||
Program Name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={createName}
|
|
||||||
onChange={(e) => setCreateName(e.target.value)}
|
|
||||||
placeholder="e.g. Intermediate Track"
|
|
||||||
className="h-12 rounded-xl ring-0"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[15px] text-grayScale-700">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={createDescription}
|
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
|
||||||
placeholder="Short summary of the program"
|
|
||||||
rows={3}
|
|
||||||
className="min-h-[88px] resize-y rounded-xl"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[15px] text-grayScale-700">
|
|
||||||
Thumbnail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={createThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleCreateThumbnailFile(e)}
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative w-full cursor-pointer rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-left transition-all hover:border-[#9E289180] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
onClick={() => createThumbnailFileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<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-sm">
|
|
||||||
<span className="font-bold text-[#9E2891]">
|
|
||||||
{createUploadingThumbnail ? "Uploading…" : "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">
|
|
||||||
or paste a URL below
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs font-medium text-grayScale-400 uppercase tracking-wider">
|
|
||||||
JPG, PNG (max 5 MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{createThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<img
|
|
||||||
src={createThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={createThumbnail}
|
|
||||||
onChange={(e) => setCreateThumbnail(e.target.value)}
|
|
||||||
className="h-12 rounded-xl"
|
|
||||||
placeholder="https://…"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 justify-end gap-3 border-t border-grayScale-100 bg-white px-8 py-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-12 min-w-[120px] rounded-[6px] border-grayScale-200 font-semibold"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
onClick={() => handleCreateDialogOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="h-12 min-w-[160px] rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
>
|
|
||||||
{createSaving ? "Creating…" : "Create Program"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gradient Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
|
||||||
<div className="w-full border-t border-grayScale-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
|
||||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
|
||||||
<p className="mt-3 text-sm text-grayScale-500">Loading programs…</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border border-red-100 bg-red-50/60 px-6 py-14 text-center">
|
|
||||||
<img src={alertSrc} alt="" className="h-10 w-10" />
|
|
||||||
<p className="mt-3 text-sm font-medium text-red-700">{error}</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="mt-4"
|
|
||||||
onClick={() => void fetchPrograms()}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : programs.length === 0 ? (
|
|
||||||
<div className="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 programs yet
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
|
||||||
Add programs in the backend or use Add Program when it is connected.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-10">
|
|
||||||
{programs.map((program) => (
|
|
||||||
<Card
|
|
||||||
key={program.id}
|
|
||||||
className="group relative w-[290px] overflow-hidden border-none shadow-soft transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
aria-label={`Edit ${program.name}`}
|
|
||||||
onClick={() => openEdit(program)}
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
aria-label={`Delete ${program.name}`}
|
|
||||||
onClick={() => setDeletingProgram(program)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="h-32 w-full bg-cover bg-center"
|
|
||||||
style={
|
|
||||||
program.thumbnail?.trim()
|
|
||||||
? {
|
|
||||||
backgroundImage: `url(${program.thumbnail.trim()})`,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
background:
|
|
||||||
"linear-gradient(135deg, #9E289180 0%, #9E2891 100%)",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardContent className="bg-white p-6 flex flex-col h-[280px]">
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<h3 className="text-xl font-bold text-grayScale-700 line-clamp-2">
|
|
||||||
{program.name}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 text-sm leading-relaxed text-grayScale-500 line-clamp-4">
|
|
||||||
{program.description?.trim()
|
|
||||||
? program.description
|
|
||||||
: "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to={`/new-content/learn-english/${program.id}/courses`}
|
|
||||||
className="mt-4 block"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Button className="h-11 w-full rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600">
|
|
||||||
View Courses
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={editingProgram !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && (savingEdit || uploadingEditThumbnail)) return;
|
|
||||||
if (!open) closeEdit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Edit program</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Update name, description, and thumbnail. Upload an image from your
|
|
||||||
computer (via file storage) or paste a URL. Changes are saved to the
|
|
||||||
server.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4 py-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={editName}
|
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
|
||||||
className="rounded-xl"
|
|
||||||
placeholder="Program name"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={editDescription}
|
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="rounded-xl resize-y min-h-[100px]"
|
|
||||||
placeholder="Short summary of the program"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
|
||||||
Thumbnail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={editThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleEditThumbnailFile(e)}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 shrink-0 rounded-xl border-grayScale-200 font-semibold"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
onClick={() => editThumbnailFileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
{uploadingEditThumbnail ? "Uploading…" : "Upload from computer"}
|
|
||||||
</Button>
|
|
||||||
{editThumbnail.trim() ? (
|
|
||||||
<div className="flex-1 overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<img
|
|
||||||
src={editThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-24 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={editThumbnail}
|
|
||||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
|
||||||
className="rounded-xl"
|
|
||||||
placeholder="Or paste image URL (https://…)"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-grayScale-500">
|
|
||||||
Local images are sent to{" "}
|
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
|
||||||
POST /files/upload
|
|
||||||
</code>
|
|
||||||
; the returned URL is stored as the program thumbnail.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={closeEdit}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
onClick={() => void handleSaveEdit()}
|
|
||||||
>
|
|
||||||
{savingEdit ? "Saving…" : "Save changes"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{deletingProgram && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
|
||||||
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
|
||||||
<h2 className="text-lg font-bold text-grayScale-700">Delete program</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => !deleting && setDeletingProgram(null)}
|
|
||||||
disabled={deleting}
|
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-6 py-6">
|
|
||||||
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
|
|
||||||
<Trash2 className="h-5 w-5 text-red-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
|
||||||
Are you sure you want to delete{" "}
|
|
||||||
<span className="font-semibold text-grayScale-700">{deletingProgram.name}</span>? This action cannot be
|
|
||||||
undone. Courses under this program may be affected depending on your backend.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeletingProgram(null)}
|
|
||||||
disabled={deleting}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
|
|
||||||
disabled={deleting}
|
|
||||||
onClick={() => void handleConfirmDelete()}
|
|
||||||
>
|
|
||||||
{deleting ? "Deleting…" : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,321 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Video,
|
|
||||||
Calendar,
|
|
||||||
Mic,
|
|
||||||
Layers,
|
|
||||||
Edit2,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { cn } from "../../lib/utils";
|
|
||||||
import { VideoCard } from "./components/VideoCard";
|
|
||||||
|
|
||||||
const MOCK_VIDEOS = [
|
|
||||||
{
|
|
||||||
id: "v1",
|
|
||||||
title: "1.1 Introduction to Formal Greetings",
|
|
||||||
duration: "08:45",
|
|
||||||
status: "Draft",
|
|
||||||
thumbnailGradient: "from-[#CBD5E1] to-[#94A3B8]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "v2",
|
|
||||||
title: "1.2 Understanding Email Structure",
|
|
||||||
duration: "08:45",
|
|
||||||
status: "Published",
|
|
||||||
thumbnailGradient: "from-[#DBEAFE] to-[#93C5FD]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "v3",
|
|
||||||
title: "1.3 Common Business Idioms",
|
|
||||||
duration: "08:45",
|
|
||||||
status: "Published",
|
|
||||||
thumbnailGradient: "from-[#FEF3C7] to-[#FCD34D]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "v4",
|
|
||||||
title: "1.4 Video Conference Etiquette",
|
|
||||||
duration: "08:45",
|
|
||||||
status: "Published",
|
|
||||||
thumbnailGradient: "from-[#FCE7F3] to-[#F9A8D4]",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const MOCK_PRACTICES = [
|
|
||||||
{
|
|
||||||
id: "p1",
|
|
||||||
title: "Describe a Photo",
|
|
||||||
level: "IELTS",
|
|
||||||
variations: 12,
|
|
||||||
status: "Draft",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p2",
|
|
||||||
title: "Describe a Photo",
|
|
||||||
level: "IELTS",
|
|
||||||
variations: 12,
|
|
||||||
status: "Draft",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p3",
|
|
||||||
title: "Describe a Photo",
|
|
||||||
level: "IELTS",
|
|
||||||
variations: 12,
|
|
||||||
status: "Draft",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p4",
|
|
||||||
title: "Describe a Photo",
|
|
||||||
level: "IELTS",
|
|
||||||
variations: 12,
|
|
||||||
status: "Draft",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ModuleDetailPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { level, courseId, moduleId } = useParams<{
|
|
||||||
level: string;
|
|
||||||
courseId: string;
|
|
||||||
moduleId: string;
|
|
||||||
}>();
|
|
||||||
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
|
||||||
const [activeFilter, setActiveFilter] = useState("Draft");
|
|
||||||
const [videos] = useState(MOCK_VIDEOS);
|
|
||||||
const [practices] = useState(MOCK_PRACTICES);
|
|
||||||
|
|
||||||
const moduleTitle =
|
|
||||||
moduleId
|
|
||||||
?.split("-")
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(" ") || "Business English Fundamentals";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-10 pt-10 pb-20 animate-in fade-in duration-500">
|
|
||||||
{/* Header Navigation */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
to={`/new-content/learn-english/${level}/courses/${courseId}`}
|
|
||||||
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-600 transition-colors hover:text-brand-500"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-5 w-5" />
|
|
||||||
Back to Modules
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6">
|
|
||||||
<div className="">
|
|
||||||
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
|
|
||||||
Module 3: {moduleTitle}
|
|
||||||
</h1>
|
|
||||||
<p className="text-grayScale-500 text-[14px] max-w-2xl">
|
|
||||||
This module covers essential vocabulary and phrases used in modern
|
|
||||||
business environments, including email etiquette and meeting
|
|
||||||
protocols.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-[6px] border-brand-500 text-brand-500 "
|
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
Add Practice
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/add-video`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="h-4 w-4 flex items-center justify-center">
|
|
||||||
<span className="text-xl leading-none font-light">+</span>
|
|
||||||
</div>
|
|
||||||
Add Video
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="border-b border-grayScale-200">
|
|
||||||
<div className="flex gap-10">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("video")}
|
|
||||||
className={cn(
|
|
||||||
"pb-4 text-[16px] font-medium transition-all relative",
|
|
||||||
activeTab === "video"
|
|
||||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
|
|
||||||
: "text-grayScale-400 hover:text-grayScale-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Video
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("practice")}
|
|
||||||
className={cn(
|
|
||||||
"pb-4 text-[16px] font-medium transition-all relative",
|
|
||||||
activeTab === "practice"
|
|
||||||
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
|
|
||||||
: "text-grayScale-400 hover:text-grayScale-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Practice
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="mt-8">
|
|
||||||
{activeTab === "video" ? (
|
|
||||||
videos.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{videos.map((video) => (
|
|
||||||
<VideoCard
|
|
||||||
key={video.id}
|
|
||||||
{...(video as any)}
|
|
||||||
onEdit={() => console.log("Edit", video.id)}
|
|
||||||
onPublish={() => console.log("Publish", video.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
|
|
||||||
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
|
|
||||||
<div className="h-14 w-14 rounded-full bg-[#F5EBFF] flex items-center justify-center">
|
|
||||||
<Video className="h-7 w-7 text-brand-500 fill-brand-500/10" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
|
|
||||||
No videos added to this module yet
|
|
||||||
</h2>
|
|
||||||
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
|
|
||||||
Videos are a great way to engage students. Start building your
|
|
||||||
module by adding your first video lesson now.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-12 px-8 rounded-xl border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/add-video`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Video className="h-5 w-5" />
|
|
||||||
Add Video
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Practice Tab Filter Bar */}
|
|
||||||
<div className="bg-white border border-grayScale-100 rounded-2xl p-4 flex items-center gap-10 shadow-sm overflow-x-auto whitespace-nowrap px-8">
|
|
||||||
<div className="flex items-center gap-2 text-[12px] font-bold text-grayScale-300 uppercase tracking-widest mr-2">
|
|
||||||
STATUS:
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{["All", "Published", "Draft", "Archived"].map((label) => (
|
|
||||||
<button
|
|
||||||
key={label}
|
|
||||||
onClick={() => setActiveFilter(label)}
|
|
||||||
className={cn(
|
|
||||||
"h-9 px-5 rounded-full text-[13px] font-bold transition-all",
|
|
||||||
activeFilter === label
|
|
||||||
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
|
|
||||||
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Practice Cards Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{practices.map((practice) => (
|
|
||||||
<PracticeCard key={practice.id} {...practice} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PracticeCard({
|
|
||||||
title,
|
|
||||||
level,
|
|
||||||
variations,
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
level: string;
|
|
||||||
variations: number;
|
|
||||||
status: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-[24px] border border-grayScale-50 shadow-sm overflow-hidden hover:shadow-xl hover:shadow-grayScale-400/5 transition-all group p-6 flex flex-col h-full min-h-[340px]">
|
|
||||||
<div className="flex-1 space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-[18px] font-bold text-grayScale-900 line-clamp-1">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="bg-[#22C55E] text-white text-[11px] font-bold px-2 py-1 rounded-[4px]">
|
|
||||||
{level}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5 text-grayScale-500">
|
|
||||||
<Mic className="h-4 w-4" />
|
|
||||||
<span className="text-[13px] font-bold">Speaking</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 text-brand-400 w-fit py-2 rounded-xl">
|
|
||||||
<Layers className="h-4 w-4" />
|
|
||||||
<span className="text-[14px] font-bold">{variations} Variations</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex border-t border-grayScale-200 items-center justify-between pt-2">
|
|
||||||
<div className="bg-grayScale-100 text-grayScale-400 text-[11px] font-bold px-3 py-1.5 rounded-[6px] tracking-wide uppercase">
|
|
||||||
{status}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-brand-500 hover:border-brand-100 transition-all">
|
|
||||||
<Edit2 className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-red-500 hover:border-red-100 transition-all">
|
|
||||||
<Trash2 className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-2 gap-3">
|
|
||||||
<Button className="bg-brand-500 text-white rounded-xl h-11 text-[13px] font-bold shadow-md shadow-brand-500/10 hover:bg-brand-600 transition-all px-0">
|
|
||||||
Publish Practice
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="border-brand-500 text-brand-500 rounded-xl h-11 text-[13px] font-bold bg-white hover:bg-brand-50 transition-all px-0"
|
|
||||||
>
|
|
||||||
Publish Video
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { Mic } from "lucide-react";
|
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
|
|
||||||
export function NewContentPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Header section */}
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
|
||||||
Content Management
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-500">
|
|
||||||
Upload, organize, and manage learning content across programs and
|
|
||||||
courses
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gradient Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
|
||||||
<div className="w-full border-t border-grayScale-100" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cards Grid */}
|
|
||||||
<div className="grid max-w-5xl gap-8 grid-cols-1 md:grid-cols-2">
|
|
||||||
{/* Learn English Card */}
|
|
||||||
<Card className="overflow-hidden border-none shadow-soft">
|
|
||||||
<div className="flex h-56 items-center justify-center bg-white/50">
|
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-brand-100/30">
|
|
||||||
<Mic className="h-10 w-10 text-brand-500" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CardContent className="border-t border-grayScale-200 bg-white p-8 text-center">
|
|
||||||
<h3 className="text-xl font-bold text-grayScale-700">
|
|
||||||
Learn English
|
|
||||||
</h3>
|
|
||||||
<p className="mt-3 text-sm leading-relaxed text-grayScale-500">
|
|
||||||
Manage structured English learning content based on levels and
|
|
||||||
modules.
|
|
||||||
</p>
|
|
||||||
<Link to="/new-content/learn-english">
|
|
||||||
<Button className="mt-8 h-12 w-full rounded-[6px] bg-brand-500 text-base font-semibold ">
|
|
||||||
Manage Learn English
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Courses Card */}
|
|
||||||
<Card className="overflow-hidden border-none shadow-soft">
|
|
||||||
<div className="flex h-56 items-center justify-center bg-white/50">
|
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-brand-100/30">
|
|
||||||
<Mic className="h-10 w-10 text-brand-500" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CardContent className="border-t border-grayScale-200 bg-white p-8 text-center">
|
|
||||||
<h3 className="text-xl font-bold text-grayScale-700">Courses</h3>
|
|
||||||
<p className="mt-3 text-sm leading-relaxed text-grayScale-500">
|
|
||||||
Manage skill-based and exam preparation courses such as Duolingo
|
|
||||||
and IELTS.
|
|
||||||
</p>
|
|
||||||
<Link to="/new-content/courses" className="block w-full">
|
|
||||||
<Button className="mt-8 h-12 w-full rounded-[6px] bg-brand-500 text-base font-semibold ">
|
|
||||||
Manage Courses
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,835 +0,0 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { ArrowLeft, Plus, FileText, Pencil, Trash2, X } from "lucide-react";
|
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "../../components/ui/dialog";
|
|
||||||
import { Input } from "../../components/ui/input";
|
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
|
||||||
import alertSrc from "../../assets/Alert.svg";
|
|
||||||
import {
|
|
||||||
createProgramCourse,
|
|
||||||
deleteTopLevelCourse,
|
|
||||||
getLearningPrograms,
|
|
||||||
getProgramCourses,
|
|
||||||
updateTopLevelCourse,
|
|
||||||
} from "../../api/courses.api";
|
|
||||||
import { uploadImageFile } from "../../api/files.api";
|
|
||||||
import type {
|
|
||||||
LearningProgramListItem,
|
|
||||||
ProgramCourseListItem,
|
|
||||||
} from "../../types/course.types";
|
|
||||||
|
|
||||||
export function ProgramCoursesPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
/** Route segment is the numeric program id (see Learn English program cards). */
|
|
||||||
const { level: programIdParam } = useParams<{ level: string }>();
|
|
||||||
const programId = Number(programIdParam);
|
|
||||||
|
|
||||||
const [program, setProgram] = useState<LearningProgramListItem | null>(null);
|
|
||||||
const [courses, setCourses] = useState<ProgramCourseListItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [deletingCourse, setDeletingCourse] = useState<ProgramCourseListItem | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
|
|
||||||
const [editingCourse, setEditingCourse] = useState<ProgramCourseListItem | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [editName, setEditName] = useState("");
|
|
||||||
const [editDescription, setEditDescription] = useState("");
|
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
|
||||||
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
|
|
||||||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [createCourseOpen, setCreateCourseOpen] = useState(false);
|
|
||||||
const [createName, setCreateName] = useState("");
|
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
|
||||||
const [createSaving, setCreateSaving] = useState(false);
|
|
||||||
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
|
||||||
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const programIdValid = Number.isFinite(programId) && programId >= 1;
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
|
||||||
if (!Number.isFinite(programId) || programId < 1) {
|
|
||||||
setError("Invalid program");
|
|
||||||
setLoading(false);
|
|
||||||
setCourses([]);
|
|
||||||
setProgram(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const [coursesRes, programsRes] = await Promise.all([
|
|
||||||
getProgramCourses(programId, { limit: 100, offset: 0 }),
|
|
||||||
getLearningPrograms({ limit: 100, offset: 0 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const programRows = programsRes.data?.data?.programs;
|
|
||||||
const list = Array.isArray(programRows) ? programRows : [];
|
|
||||||
const found = list.find((p) => p.id === programId) ?? null;
|
|
||||||
setProgram(found);
|
|
||||||
|
|
||||||
const raw = coursesRes.data?.data?.courses;
|
|
||||||
const courseList = Array.isArray(raw) ? raw : [];
|
|
||||||
const sorted = [...courseList].sort(
|
|
||||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
|
|
||||||
);
|
|
||||||
setCourses(sorted);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setError("Failed to load courses");
|
|
||||||
setCourses([]);
|
|
||||||
setProgram(null);
|
|
||||||
toast.error("Could not load courses", {
|
|
||||||
description: "Check your connection or try again.",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [programId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadData();
|
|
||||||
}, [loadData]);
|
|
||||||
|
|
||||||
const handleConfirmDeleteCourse = async () => {
|
|
||||||
if (!deletingCourse) return;
|
|
||||||
setDeleting(true);
|
|
||||||
try {
|
|
||||||
await deleteTopLevelCourse(deletingCourse.id);
|
|
||||||
toast.success("Course deleted");
|
|
||||||
setDeletingCourse(null);
|
|
||||||
await loadData();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to delete course";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditCourse = (course: ProgramCourseListItem) => {
|
|
||||||
setEditingCourse(course);
|
|
||||||
setEditName(course.name ?? "");
|
|
||||||
setEditDescription(course.description?.trim() ?? "");
|
|
||||||
setEditThumbnail(
|
|
||||||
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeEditCourse = () => {
|
|
||||||
setEditingCourse(null);
|
|
||||||
setEditName("");
|
|
||||||
setEditDescription("");
|
|
||||||
setEditThumbnail("");
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
if (editThumbnailFileInputRef.current) {
|
|
||||||
editThumbnailFileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditCourseThumbnailFile = 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;
|
|
||||||
}
|
|
||||||
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 (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setUploadingEditThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveEditCourse = async () => {
|
|
||||||
if (!editingCourse) return;
|
|
||||||
const name = editName.trim();
|
|
||||||
if (!name) {
|
|
||||||
toast.error("Course name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSavingEdit(true);
|
|
||||||
try {
|
|
||||||
await updateTopLevelCourse(editingCourse.id, {
|
|
||||||
name,
|
|
||||||
description: editDescription.trim(),
|
|
||||||
thumbnail: editThumbnail.trim(),
|
|
||||||
});
|
|
||||||
toast.success("Course updated");
|
|
||||||
closeEditCourse();
|
|
||||||
await loadData();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to update course";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setSavingEdit(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearCreateCourseForm = () => {
|
|
||||||
setCreateName("");
|
|
||||||
setCreateDescription("");
|
|
||||||
setCreateThumbnail("");
|
|
||||||
setCreateUploadingThumbnail(false);
|
|
||||||
if (createThumbnailFileInputRef.current) {
|
|
||||||
createThumbnailFileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateCourseDialogOpenChange = (open: boolean) => {
|
|
||||||
if (!open && (createSaving || createUploadingThumbnail)) return;
|
|
||||||
clearCreateCourseForm();
|
|
||||||
setCreateCourseOpen(open);
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
setCreateUploadingThumbnail(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 (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload thumbnail";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setCreateUploadingThumbnail(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateCourse = async () => {
|
|
||||||
if (!programIdValid) return;
|
|
||||||
const name = createName.trim();
|
|
||||||
if (!name) {
|
|
||||||
toast.error("Course name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreateSaving(true);
|
|
||||||
try {
|
|
||||||
await createProgramCourse(programId, {
|
|
||||||
name,
|
|
||||||
description: createDescription.trim(),
|
|
||||||
thumbnail: createThumbnail.trim(),
|
|
||||||
});
|
|
||||||
toast.success("Course created");
|
|
||||||
clearCreateCourseForm();
|
|
||||||
setCreateCourseOpen(false);
|
|
||||||
await loadData();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to create course";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setCreateSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const programTitle = !programIdValid
|
|
||||||
? "Program not found"
|
|
||||||
: program?.name?.trim() || `Program ${programId}`;
|
|
||||||
const programDescription =
|
|
||||||
program?.description?.trim() ||
|
|
||||||
(!loading && programIdValid && !program
|
|
||||||
? "Program details are unavailable. You can still browse courses below if they loaded."
|
|
||||||
: "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 pt-10">
|
|
||||||
{/* Navigation */}
|
|
||||||
<Link
|
|
||||||
to="/new-content/learn-english"
|
|
||||||
className="flex items-center gap-2 text-sm font-medium text-grayScale-500 transition-colors hover:text-brand-500"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back to Programs
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Header section */}
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-grayScale-700">
|
|
||||||
{programTitle}
|
|
||||||
</h1>
|
|
||||||
{programDescription ? (
|
|
||||||
<p className="max-w-2xl text-[15px] leading-relaxed text-grayScale-400">
|
|
||||||
{programDescription}
|
|
||||||
</p>
|
|
||||||
) : loading ? (
|
|
||||||
<div className="flex items-center gap-2 pt-1">
|
|
||||||
<img
|
|
||||||
src={spinnerSrc}
|
|
||||||
alt=""
|
|
||||||
className="h-6 w-6 animate-spin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{programIdValid ? (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
to={`/new-content/learn-english/${programIdParam}/courses/add-practice`}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-[6px] border-brand-500 text-brand-500 "
|
|
||||||
>
|
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
|
||||||
Add Practice
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={createCourseOpen}
|
|
||||||
onOpenChange={handleCreateCourseDialogOpenChange}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="rounded-[6px] bg-brand-500 font-semibold hover:bg-brand-600"
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-5 w-5" />
|
|
||||||
Add Courses
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-2xl flex-col gap-0 overflow-hidden border-none p-0">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<DialogHeader className="p-8 pb-4">
|
|
||||||
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
|
||||||
Add New Course
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm text-grayScale-400">
|
|
||||||
Create a course via{" "}
|
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
|
||||||
POST /programs/:program_id/courses
|
|
||||||
</code>
|
|
||||||
. Thumbnail can be a URL or a file from{" "}
|
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
|
||||||
POST /files/upload
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* Gradient Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 flex items-center"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div className="w-full border-t border-grayScale-100" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className="flex min-h-0 flex-1 flex-col"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleCreateCourse();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-8 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[15px] font-medium text-grayScale-700">
|
|
||||||
Course Name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={createName}
|
|
||||||
onChange={(e) => setCreateName(e.target.value)}
|
|
||||||
placeholder="e.g. Introduction to German A1"
|
|
||||||
className="h-12 rounded-xl"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[15px] font-medium text-grayScale-700">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={createDescription}
|
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
|
||||||
placeholder="Short summary of the course"
|
|
||||||
rows={3}
|
|
||||||
className="min-h-[88px] resize-y rounded-xl"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[15px] font-medium text-grayScale-700">
|
|
||||||
Thumbnail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={createThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleCreateCourseThumbnailFile(e)}
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative w-full cursor-pointer rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-left transition-all hover:border-[#9E289180] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
onClick={() =>
|
|
||||||
createThumbnailFileInputRef.current?.click()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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-sm">
|
|
||||||
<span className="font-bold text-[#9E2891]">
|
|
||||||
{createUploadingThumbnail
|
|
||||||
? "Uploading…"
|
|
||||||
: "Click to upload"}
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">
|
|
||||||
or paste a URL below
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
|
||||||
JPG, PNG (max 5 MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{createThumbnail.trim() ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<img
|
|
||||||
src={createThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-28 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Input
|
|
||||||
value={createThumbnail}
|
|
||||||
onChange={(e) => setCreateThumbnail(e.target.value)}
|
|
||||||
className="h-12 rounded-xl"
|
|
||||||
placeholder="https://…"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 justify-end gap-3 border-t border-grayScale-100 bg-white px-8 py-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-12 min-w-[120px] rounded-xl border-grayScale-200 font-semibold"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
onClick={() => handleCreateCourseDialogOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold hover:bg-brand-600"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
>
|
|
||||||
{createSaving ? "Creating…" : "Create Course"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gradient Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
|
||||||
<div className="w-full border-t border-grayScale-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
|
||||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : error && courses.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border border-red-100 bg-red-50/60 px-6 py-14 text-center">
|
|
||||||
<img src={alertSrc} alt="" className="h-10 w-10" />
|
|
||||||
<p className="mt-3 text-sm font-medium text-red-700">{error}</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="mt-4"
|
|
||||||
onClick={() => void loadData()}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : courses.length === 0 ? (
|
|
||||||
<div className="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 courses in this program yet
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
|
||||||
Add courses using the button above when the flow is connected to the
|
|
||||||
API.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-10">
|
|
||||||
{courses.map((course) => {
|
|
||||||
const modules = course.modules_count ?? 0;
|
|
||||||
const videos = course.videos_count ?? 0;
|
|
||||||
const practices = course.practices_count ?? 0;
|
|
||||||
const thumbnailSrc =
|
|
||||||
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "";
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={course.id}
|
|
||||||
className="group relative w-[290px] overflow-hidden border border-grayScale-100 shadow-soft transition-all duration-300 hover:shadow-lg"
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
aria-label={`Edit ${course.name}`}
|
|
||||||
onClick={() => openEditCourse(course)}
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
aria-label={`Delete ${course.name}`}
|
|
||||||
onClick={() => setDeletingCourse(course)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="h-32 w-full bg-cover bg-center"
|
|
||||||
style={
|
|
||||||
thumbnailSrc
|
|
||||||
? {
|
|
||||||
backgroundImage: `url(${thumbnailSrc})`,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
background:
|
|
||||||
"linear-gradient(135deg, #9E289180 0%, #9E2891 100%)",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<h3 className="text-xl font-bold text-grayScale-700">
|
|
||||||
{course.name}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 text-[13px] leading-relaxed text-grayScale-500 line-clamp-2">
|
|
||||||
{course.description?.trim() ? course.description : "—"}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="my-6 grid grid-cols-3 gap-4 border-y border-grayScale-50 py-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-base font-bold text-grayScale-700">
|
|
||||||
{modules}
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
|
|
||||||
Modules
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-base font-bold text-grayScale-700">
|
|
||||||
{videos}
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
|
|
||||||
Videos
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-base font-bold text-grayScale-700">
|
|
||||||
{practices}
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] font-medium uppercase tracking-wider text-grayScale-400">
|
|
||||||
Practices
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 flex-1 rounded-[6px] border-brand-500 text-[13px] font-semibold text-brand-500 "
|
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
`/new-content/learn-english/${programIdParam}/courses/${course.id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
View Detail
|
|
||||||
</Button>
|
|
||||||
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-[13px] font-semibold ">
|
|
||||||
Publish Practice
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={editingCourse !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && (savingEdit || uploadingEditThumbnail)) return;
|
|
||||||
if (!open) closeEditCourse();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Edit course</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Update name, description, and thumbnail. Saved with{" "}
|
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
|
||||||
PUT /courses/:id
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="grid gap-4 py-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={editName}
|
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
|
||||||
className="rounded-xl"
|
|
||||||
placeholder="Course name"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={editDescription}
|
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[100px] resize-y rounded-xl"
|
|
||||||
placeholder="Short summary"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
|
||||||
Thumbnail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={editThumbnailFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={(e) => void handleEditCourseThumbnailFile(e)}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 shrink-0 rounded-xl border-grayScale-200 font-semibold"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
onClick={() => editThumbnailFileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
{uploadingEditThumbnail ? "Uploading…" : "Upload from computer"}
|
|
||||||
</Button>
|
|
||||||
{editThumbnail.trim() ? (
|
|
||||||
<div className="flex-1 overflow-hidden rounded-xl border border-grayScale-200 bg-grayScale-50">
|
|
||||||
<img
|
|
||||||
src={editThumbnail.trim()}
|
|
||||||
alt=""
|
|
||||||
className="h-24 w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={editThumbnail}
|
|
||||||
onChange={(e) => setEditThumbnail(e.target.value)}
|
|
||||||
className="rounded-xl"
|
|
||||||
placeholder="Or paste image URL (https://…)"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={closeEditCourse}
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="bg-brand-500 hover:bg-brand-600"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
onClick={() => void handleSaveEditCourse()}
|
|
||||||
>
|
|
||||||
{savingEdit ? "Saving…" : "Save changes"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{deletingCourse && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
|
||||||
<div className="mx-4 w-full max-w-sm animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-5">
|
|
||||||
<h2 className="text-lg font-bold text-grayScale-700">Delete course</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => !deleting && setDeletingCourse(null)}
|
|
||||||
disabled={deleting}
|
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 disabled:pointer-events-none disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-6 py-6">
|
|
||||||
<div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-red-50">
|
|
||||||
<Trash2 className="h-5 w-5 text-red-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
|
||||||
Are you sure you want to delete{" "}
|
|
||||||
<span className="font-semibold text-grayScale-700">
|
|
||||||
{deletingCourse.name}
|
|
||||||
</span>
|
|
||||||
? This cannot be undone. Related modules and content may be
|
|
||||||
affected depending on your backend.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeletingCourse(null)}
|
|
||||||
disabled={deleting}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="w-full bg-red-500 shadow-sm transition-all hover:bg-red-600 hover:shadow-md sm:w-auto"
|
|
||||||
disabled={deleting}
|
|
||||||
onClick={() => void handleConfirmDeleteCourse()}
|
|
||||||
>
|
|
||||||
{deleting ? "Deleting…" : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Plus,
|
|
||||||
FileText,
|
|
||||||
ClipboardList,
|
|
||||||
ListChecks,
|
|
||||||
ChevronRight,
|
|
||||||
X,
|
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { Card } from "../../components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogClose,
|
|
||||||
} from "../../components/ui/dialog";
|
|
||||||
import { Input } from "../../components/ui/input";
|
|
||||||
import { Select } from "../../components/ui/select";
|
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
|
||||||
|
|
||||||
export function ProgramDetailPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { programType } = useParams<{ programType: string }>();
|
|
||||||
|
|
||||||
// Mock data for "proficiency" program type
|
|
||||||
const programs: Record<string, any> = {
|
|
||||||
proficiency: {
|
|
||||||
title: "English Proficiency Exams",
|
|
||||||
description:
|
|
||||||
"Manage exam-based learning programs such as Duolingo and IELTS.",
|
|
||||||
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": {
|
|
||||||
title: "Skill-Based Courses",
|
|
||||||
description:
|
|
||||||
"Practice-focused communication and skills training for real-world scenarios.",
|
|
||||||
courses: [], // To be implemented or shown if needed
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentProgram =
|
|
||||||
programs[programType || "proficiency"] || programs.proficiency;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
|
||||||
{/* Navigation */}
|
|
||||||
<Link
|
|
||||||
to="/new-content/courses"
|
|
||||||
className="flex items-center gap-2.5 text-[15px] font-semibold text-grayScale-600 hover:text-brand-500 transition-colors pt-4 group"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Header section */}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h1 className="text-[26px] font-medium tracking-tight text-grayScale-900">
|
|
||||||
{currentProgram.title}
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
|
|
||||||
{currentProgram.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<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" />
|
|
||||||
Create Course
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
|
|
||||||
<div className="bg-white">
|
|
||||||
<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">
|
|
||||||
Create Course
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="p-8 space-y-8">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Course Order
|
|
||||||
</label>
|
|
||||||
<Select defaultValue="1">
|
|
||||||
<option value="1">1</option>
|
|
||||||
<option value="2">2</option>
|
|
||||||
<option value="3">3</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbnail Field */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Thumbnail
|
|
||||||
</label>
|
|
||||||
<div className="relative group cursor-pointer">
|
|
||||||
<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">
|
|
||||||
Click to upload
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">
|
|
||||||
or drag and drop
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
|
||||||
JPG, PNG (MAX 1 MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
|
|
||||||
Create Program
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold flex items-center gap-2"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/new-content/courses/${programType}/attach-practice`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FileText className="h-5 w-5" />
|
|
||||||
Attach Practice
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gradient Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
|
||||||
<div className="w-full border-t border-grayScale-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cards Grid */}
|
|
||||||
<div className="flex flex-wrap gap-8 mt-10">
|
|
||||||
{currentProgram.courses.map((course: any) => (
|
|
||||||
<Card
|
|
||||||
key={course.id}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="h-16 flex items-center">{course.logo}</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="space-y-4 pt-2 flex-1">
|
|
||||||
<h3 className="text-[18px] font-medium text-grayScale-900">
|
|
||||||
{course.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[14px] text-grayScale-500 font-medium">
|
|
||||||
{course.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Badges/Stats */}
|
|
||||||
<div className="flex items-center pt-4 gap-4">
|
|
||||||
<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" />
|
|
||||||
<span className="text-[12px] ">
|
|
||||||
{course.coursesCount} Courses
|
|
||||||
</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] ">
|
|
||||||
{course.questionTypesCount} Question Types
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Button */}
|
|
||||||
<Button
|
|
||||||
className="w-full mt-4 h-10 bg-brand-500 text-white rounded-[8px] font-bold flex items-center justify-center gap-2 group/btn"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/new-content/courses/${programType}/${course.id}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{course.buttonText}
|
|
||||||
<ChevronRight className="h-5 w-5 transition-transform group-hover/btn:translate-x-1" />
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { GraduationCap, Brain } from "lucide-react";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
|
|
||||||
export function ProgramTypeSelectionPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
||||||
{/* Header section */}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="space-y-1.5 pt-2">
|
|
||||||
<h1 className="text-[28px] font-bold tracking-tight text-grayScale-900">
|
|
||||||
Courses
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
|
|
||||||
Organize courses under skill-based learning or English proficiency
|
|
||||||
exams. Select a program type to manage curriculum and modules.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<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
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gradient Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
|
||||||
<div className="w-full border-t border-grayScale-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selection Cards Grid */}
|
|
||||||
<div className="flex flex-warp gap-10 pt-4">
|
|
||||||
{/* Skill-Based Courses Card */}
|
|
||||||
<Link to="/new-content/courses/skill-based" className="group h-full">
|
|
||||||
<div className="bg-white rounded-[6px] w-[500px] border border-grayScale-100 px-10 py-12 h-full transition-all flex flex-col items-start gap-10">
|
|
||||||
<div className="h-16 w-16 rounded-full bg-brand-50/10 flex items-center justify-center">
|
|
||||||
<Brain className="h-8 w-8 text-brand-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 flex-1">
|
|
||||||
<h3 className="text-[20px] font-bold text-grayScale-900">
|
|
||||||
Skill-Based Courses
|
|
||||||
</h3>
|
|
||||||
<p className="text-[15px] leading-relaxed text-grayScale-500 font-medium">
|
|
||||||
Practice-focused communication and skills training. Create
|
|
||||||
modules for vocabulary, grammar, and real-world conversation
|
|
||||||
scenarios.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* English Proficiency Exams Card */}
|
|
||||||
<Link to="/new-content/courses/proficiency" className="group h-full">
|
|
||||||
<div className="bg-white w-[500px] rounded-[6px] border border-grayScale-100 px-10 py-12 h-full transition-all flex flex-col items-start gap-10">
|
|
||||||
<div className="h-16 w-16 rounded-full bg-brand-50/10 flex items-center justify-center">
|
|
||||||
<GraduationCap className="h-8 w-8 text-brand-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 flex-1">
|
|
||||||
<h3 className="text-[20px] font-bold text-grayScale-900 transition-colors">
|
|
||||||
English Proficiency Exams
|
|
||||||
</h3>
|
|
||||||
<p className="text-[15px] leading-relaxed text-grayScale-500 font-medium">
|
|
||||||
Exam preparation courses such as IELTS, and Duolingo. Structure
|
|
||||||
content by band scores, sections, and mock tests.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -59,6 +59,7 @@ export function SubCategoryCoursesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-32">
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
|
||||||
|
<p className="mt-4 text-sm text-grayScale-500">Loading courses…</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Plus,
|
|
||||||
MessageCircle,
|
|
||||||
PlayCircle,
|
|
||||||
ClipboardCheck,
|
|
||||||
ArrowRight,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
import { Card } from "../../components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
DialogClose,
|
|
||||||
} from "../../components/ui/dialog";
|
|
||||||
import { Input } from "../../components/ui/input";
|
|
||||||
import { Select } from "../../components/ui/select";
|
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
|
||||||
|
|
||||||
export function UnitManagementPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { programType, courseId, unitId } = useParams<{
|
|
||||||
programType: string;
|
|
||||||
courseId: string;
|
|
||||||
unitId: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// Mock titles
|
|
||||||
const unitTitles: Record<string, string> = {
|
|
||||||
unit1: "Greetings & Introductions",
|
|
||||||
unit2: "Speaking",
|
|
||||||
unit3: "Reading",
|
|
||||||
};
|
|
||||||
|
|
||||||
const unitDisplayName =
|
|
||||||
unitTitles[unitId || ""] || "Greetings & Introductions";
|
|
||||||
|
|
||||||
const modules = [
|
|
||||||
{
|
|
||||||
id: "mod1",
|
|
||||||
name: "Module 1: Basic Phrases",
|
|
||||||
description: "Learn essential phrases for daily conversations.",
|
|
||||||
videos: 3,
|
|
||||||
practices: 3,
|
|
||||||
gradient:
|
|
||||||
"linear-gradient(135deg, rgba(158, 40, 145, 0.4) 0%, rgba(158, 40, 145, 0.7) 100%)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mod2",
|
|
||||||
name: "Module 1: Basic Phrases", // Matching Image 2092-1 labels
|
|
||||||
description: "Learn essential phrases for daily conversations.",
|
|
||||||
videos: 3,
|
|
||||||
practices: 3,
|
|
||||||
gradient:
|
|
||||||
"linear-gradient(135deg, rgba(79, 70, 229, 0.4) 0%, rgba(79, 70, 229, 0.7) 100%)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mod3",
|
|
||||||
name: "Module 1: Basic Phrases",
|
|
||||||
description: "Learn essential phrases for daily conversations.",
|
|
||||||
videos: 3,
|
|
||||||
practices: 3,
|
|
||||||
gradient:
|
|
||||||
"linear-gradient(135deg, rgba(124, 58, 237, 0.4) 0%, rgba(124, 58, 237, 0.7) 100%)",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-10">
|
|
||||||
{/* Navigation */}
|
|
||||||
<Link
|
|
||||||
to={`/new-content/courses/${programType}/${courseId}`}
|
|
||||||
className="flex items-center gap-2.5 text-[15px] font-semibold text-grayScale-600 hover:text-brand-500 transition-colors pt-4 group"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
|
||||||
Back to Courses
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Header section */}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<h1 className="text-[28px] font-medium tracking-tight text-grayScale-900">
|
|
||||||
{unitDisplayName}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<Dialog>
|
|
||||||
<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">
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
Add Modules
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-w-[600px] p-0 border-none rounded-[16px] overflow-hidden">
|
|
||||||
<div className="bg-white">
|
|
||||||
<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">
|
|
||||||
Create Modules
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogClose className="rounded-full p-1.5 hover:bg-grayScale-50 transition-colors">
|
|
||||||
<X className="h-5 w-5 text-grayScale-400" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="p-8 space-y-8">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Module Title
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g. 1.1 Exam types"
|
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Module Order
|
|
||||||
</label>
|
|
||||||
<Select defaultValue="1">
|
|
||||||
<option value="1">1</option>
|
|
||||||
<option value="2">2</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Icon</label>
|
|
||||||
<div className="relative group cursor-pointer">
|
|
||||||
<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">
|
|
||||||
Click to upload
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-grayScale-500">
|
|
||||||
or drag and drop
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[12px] text-grayScale-400 uppercase tracking-widest">
|
|
||||||
JPG, PNG (MAX 1 MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-200 flex justify-end gap-3">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600">
|
|
||||||
Create Module
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gradient Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
|
||||||
<div className="w-full border-t border-grayScale-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid of Modules */}
|
|
||||||
<div className="flex flex-wrap gap-4 pt-4">
|
|
||||||
{modules.map((module, index) => (
|
|
||||||
<Card
|
|
||||||
key={`${module.id}-${index}`}
|
|
||||||
className="group flex w-[400px] flex-col bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
|
|
||||||
>
|
|
||||||
{/* Gradient Header */}
|
|
||||||
<div
|
|
||||||
className="h-36 w-full"
|
|
||||||
style={{ background: module.gradient }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="p-5 flex flex-col space-y-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{/* Chat Icon */}
|
|
||||||
<div className="mt-1 h-10 w-10 shrink-0 rounded-full bg-[#9E28911A] border border-[#9E289133] flex items-center justify-center">
|
|
||||||
<MessageCircle className="h-5 w-5 text-brand-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-[16px] font-medium text-grayScale-900 leading-tight">
|
|
||||||
{module.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[12px] text-grayScale-500 font-medium">
|
|
||||||
{module.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Pills */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-8 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" />
|
|
||||||
<span className="text-[12px] font-bold">
|
|
||||||
{module.videos} Videos
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-8 px-3 rounded-[6px] bg-grayScale-100 border border-grayScale-100 flex items-center gap-2 text-grayScale-600">
|
|
||||||
<ClipboardCheck className="h-3.5 w-3.5 text-grayScale-400" />
|
|
||||||
<span className="text-[12px] font-bold">
|
|
||||||
{module.practices} Practices
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Button */}
|
|
||||||
<Button
|
|
||||||
className="w-full h-10 bg-brand-500 text-white rounded-[6px] font-bold flex items-center justify-center gap-2 group/btn"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
`/new-content/courses/${programType}/${courseId}/${unitId}/${module.id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
View Detail
|
|
||||||
<ArrowRight className="ml-1 h-4 w-4 transition-transform group-hover/btn:translate-x-1" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
import { useEffect, useState, type FormEvent } from "react";
|
|
||||||
import { Button } from "../../../components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogClose,
|
|
||||||
} from "../../../components/ui/dialog";
|
|
||||||
import { Input } from "../../../components/ui/input";
|
|
||||||
import { Textarea } from "../../../components/ui/textarea";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { createTopLevelCourseModule } from "../../../api/courses.api";
|
|
||||||
import { ModuleIconUploadField } from "./ModuleIconUploadField";
|
|
||||||
|
|
||||||
interface AddModuleModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
courseId: number;
|
|
||||||
onCreated?: () => void | Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddModuleModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
courseId,
|
|
||||||
onCreated,
|
|
||||||
}: AddModuleModalProps) {
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [icon, setIcon] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [iconUploadBusy, setIconUploadBusy] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setName("");
|
|
||||||
setDescription("");
|
|
||||||
setIcon("");
|
|
||||||
setSubmitting(false);
|
|
||||||
setIconUploadBusy(false);
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const resetAndClose = () => {
|
|
||||||
setName("");
|
|
||||||
setDescription("");
|
|
||||||
setIcon("");
|
|
||||||
setIconUploadBusy(false);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
|
||||||
if (!open && (submitting || iconUploadBusy)) return;
|
|
||||||
if (!open) {
|
|
||||||
resetAndClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const trimmedName = name.trim();
|
|
||||||
if (!trimmedName) {
|
|
||||||
toast.error("Module name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(courseId) || courseId < 1) {
|
|
||||||
toast.error("Invalid course");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await createTopLevelCourseModule(courseId, {
|
|
||||||
name: trimmedName,
|
|
||||||
description: description.trim(),
|
|
||||||
icon: icon.trim(),
|
|
||||||
});
|
|
||||||
toast.success("Module created");
|
|
||||||
if (onCreated) {
|
|
||||||
await onCreated();
|
|
||||||
}
|
|
||||||
resetAndClose();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error(err);
|
|
||||||
const msg =
|
|
||||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to create module";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
|
||||||
<DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-2xl flex-col gap-0 overflow-hidden rounded-[16px] border-none p-0 shadow-2xl">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<DialogHeader className="relative p-8 pb-4">
|
|
||||||
<DialogTitle className="text-2xl font-bold text-grayScale-700">
|
|
||||||
Add New Module
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-sm text-grayScale-400">
|
|
||||||
Create a module with{" "}
|
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
|
||||||
POST /courses/:courseId/modules
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 flex items-center"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div className="w-full border-t border-grayScale-100" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20"
|
|
||||||
style={{ background: "gray" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain p-8 pt-4"
|
|
||||||
onSubmit={(e) => void handleSubmit(e)}
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[15px] font-medium text-grayScale-700">
|
|
||||||
Module title
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="e.g. Greetings & Introductions"
|
|
||||||
className="h-12 rounded-xl"
|
|
||||||
disabled={submitting}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[15px] font-medium text-grayScale-700">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Learn to introduce yourself and talk about your life."
|
|
||||||
className="min-h-[88px] resize-y rounded-xl"
|
|
||||||
disabled={submitting}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ModuleIconUploadField
|
|
||||||
value={icon}
|
|
||||||
onChange={setIcon}
|
|
||||||
disabled={submitting}
|
|
||||||
onUploadBusyChange={setIconUploadBusy}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-12 min-w-[120px] rounded-xl border-grayScale-200 font-semibold"
|
|
||||||
disabled={submitting || iconUploadBusy}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="h-12 min-w-[160px] rounded-xl bg-brand-500 font-semibold text-white shadow-lg shadow-brand-500/20 hover:bg-brand-600"
|
|
||||||
disabled={submitting || iconUploadBusy}
|
|
||||||
>
|
|
||||||
{submitting ? "Creating…" : "Create module"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
import { useCallback, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
|
||||||
import { CloudUpload } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Input } from "../../../components/ui/input";
|
|
||||||
import { cn } from "../../../lib/utils";
|
|
||||||
import { uploadImageFile } from "../../../api/files.api";
|
|
||||||
|
|
||||||
const MAX_ICON_BYTES = 5 * 1024 * 1024;
|
|
||||||
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png"]);
|
|
||||||
|
|
||||||
function isAllowedImageFile(file: File): boolean {
|
|
||||||
if (ALLOWED_IMAGE_TYPES.has(file.type)) return true;
|
|
||||||
const name = file.name.toLowerCase();
|
|
||||||
return /\.(jpe?g|png)$/.test(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModuleIconUploadFieldProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (url: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
/** Notifies parent so dialogs can block closing while an upload is in flight. */
|
|
||||||
onUploadBusyChange?: (busy: boolean) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModuleIconUploadField({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled = false,
|
|
||||||
onUploadBusyChange,
|
|
||||||
className,
|
|
||||||
}: ModuleIconUploadFieldProps) {
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [dragActive, setDragActive] = useState(false);
|
|
||||||
|
|
||||||
const setBusy = useCallback(
|
|
||||||
(next: boolean) => {
|
|
||||||
setUploading(next);
|
|
||||||
onUploadBusyChange?.(next);
|
|
||||||
},
|
|
||||||
[onUploadBusyChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const processFile = useCallback(
|
|
||||||
async (file: File) => {
|
|
||||||
if (disabled || uploading) return;
|
|
||||||
if (!isAllowedImageFile(file)) {
|
|
||||||
toast.error("Please use a JPG or PNG image.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (file.size > MAX_ICON_BYTES) {
|
|
||||||
toast.error("Image is too large", {
|
|
||||||
description: "Maximum size is 5 MB.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy(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");
|
|
||||||
}
|
|
||||||
onChange(url);
|
|
||||||
toast.success("Icon uploaded");
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error(e);
|
|
||||||
const msg =
|
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ?? "Failed to upload icon";
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[disabled, uploading, onChange, setBusy],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
e.target.value = "";
|
|
||||||
if (file) void processFile(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!disabled && !uploading) setDragActive(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDragActive(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDragActive(false);
|
|
||||||
if (disabled || uploading) return;
|
|
||||||
const file = e.dataTransfer.files?.[0];
|
|
||||||
if (file) void processFile(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoneDisabled = disabled || uploading;
|
|
||||||
const showSpinner = uploading;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("space-y-3", className)}>
|
|
||||||
<label className="text-[15px] font-medium text-grayScale-700 md:text-sm">
|
|
||||||
Icon
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,.jpg,.jpeg,.png"
|
|
||||||
className="sr-only"
|
|
||||||
onChange={handleFileInputChange}
|
|
||||||
disabled={zoneDisabled}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={zoneDisabled}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[#9E289133] bg-white p-10 text-center transition-colors",
|
|
||||||
"hover:border-[#9E289180] hover:bg-grayScale-50/30",
|
|
||||||
dragActive && "border-[#9E2891] bg-[#9E289108]",
|
|
||||||
zoneDisabled && "cursor-not-allowed opacity-60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{showSpinner ? (
|
|
||||||
<p className="text-sm font-medium text-grayScale-600">Uploading…</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CloudUpload
|
|
||||||
className="mb-4 h-10 w-10 text-[#9E2891]"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<p className="text-sm">
|
|
||||||
<span className="font-bold text-[#9E2891]">Click to upload</span>{" "}
|
|
||||||
<span className="text-grayScale-500">or paste a URL below</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs font-medium uppercase tracking-wider text-grayScale-400">
|
|
||||||
JPG, PNG (MAX 5 MB)
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<Input
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder="https://…"
|
|
||||||
className="h-12 rounded-xl"
|
|
||||||
disabled={disabled || uploading}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import { MoreVertical, Edit2, Play } from "lucide-react";
|
|
||||||
import { Button } from "../../../components/ui/button";
|
|
||||||
import { cn } from "../../../lib/utils";
|
|
||||||
|
|
||||||
interface VideoCardProps {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
duration: string;
|
|
||||||
status: "Draft" | "Published";
|
|
||||||
thumbnailGradient: string;
|
|
||||||
onEdit?: () => void;
|
|
||||||
onPublish?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VideoCard({
|
|
||||||
title,
|
|
||||||
duration,
|
|
||||||
status,
|
|
||||||
thumbnailGradient,
|
|
||||||
onEdit,
|
|
||||||
onPublish,
|
|
||||||
}: VideoCardProps) {
|
|
||||||
return (
|
|
||||||
<div className="group bg-white rounded-[24px] border border-grayScale-50 overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 flex flex-col">
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative h-44 w-full bg-gradient-to-br",
|
|
||||||
thumbnailGradient,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Duration Badge */}
|
|
||||||
<div className="absolute bottom-3 right-3 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm">
|
|
||||||
{duration}
|
|
||||||
</div>
|
|
||||||
{/* Play Overlay */}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/10">
|
|
||||||
<div className="h-12 w-12 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center border border-white/30">
|
|
||||||
<Play className="h-6 w-6 text-white fill-current" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-5 space-y-4 flex-1 flex flex-col">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{/* Status Badge */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border",
|
|
||||||
status === "Published"
|
|
||||||
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
|
|
||||||
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-1.5 w-1.5 rounded-full",
|
|
||||||
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{status}
|
|
||||||
</div>
|
|
||||||
{/* Menu */}
|
|
||||||
<button className="h-8 w-8 flex items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400">
|
|
||||||
<MoreVertical className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-[16px] font-medium text-grayScale-900 line-clamp-2 leading-snug">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="pt-2 space-y-3 mt-auto">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onEdit}
|
|
||||||
className="w-full h-10 rounded-xl border-grayScale-200 text-grayScale-600 font-bold hover:bg-grayScale-50 transition-all flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={status === "Published"}
|
|
||||||
onClick={onPublish}
|
|
||||||
className={cn(
|
|
||||||
"w-full h-10 rounded-xl font-bold transition-all shadow-sm",
|
|
||||||
status === "Published"
|
|
||||||
? "bg-[#E9D5E5] text-white opacity-100 cursor-default"
|
|
||||||
: "bg-brand-500 text-white hover:bg-brand-600 shadow-brand-500/10",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{status === "Published" ? "Published" : "Publish"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
import {
|
|
||||||
Rocket,
|
|
||||||
GraduationCap,
|
|
||||||
Folder,
|
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
Eye,
|
|
||||||
Video as VideoIcon,
|
|
||||||
ClipboardList,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "../../../../components/ui/button";
|
|
||||||
import { Card } from "../../../../components/ui/card";
|
|
||||||
import { cn } from "../../../../lib/utils";
|
|
||||||
|
|
||||||
interface AttachPracticeReviewStepProps {
|
|
||||||
formData: any;
|
|
||||||
prevStep: () => void;
|
|
||||||
onPublish: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AttachPracticeReviewStep({
|
|
||||||
formData,
|
|
||||||
prevStep,
|
|
||||||
onPublish,
|
|
||||||
}: AttachPracticeReviewStepProps) {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
|
||||||
const [isConfirmed, setIsConfirmed] = useState(false);
|
|
||||||
|
|
||||||
const questions = [
|
|
||||||
{ order: "01", text: "What is the main idea of the passage?" },
|
|
||||||
{ order: "02", text: "What does the speaker mainly talk about in the..." },
|
|
||||||
{ order: "03", text: "Which option best completes the sentence?" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-10 animate-in fade-in duration-500 mx-auto">
|
|
||||||
{/* 1. Video Summary Card */}
|
|
||||||
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white overflow-hidden">
|
|
||||||
<div className="flex gap-8 items-start">
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<div className="relative h-[150px] w-[260px] rounded-xl overflow-hidden shadow-inner flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src="https://images.unsplash.com/photo-1557425955-df376b5903c8?auto=format&fit=crop&q=80&w=600"
|
|
||||||
alt="Video Thumbnail"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-2 right-2 bg-black/70 text-white text-[11px] font-bold px-2 py-0.5 rounded flex items-center gap-1.5">
|
|
||||||
<VideoIcon className="h-3 w-3" />
|
|
||||||
12:30
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Details */}
|
|
||||||
<div className="pt-12">
|
|
||||||
<h3 className="text-[20px] font-bold text-grayScale-900 leading-tight">
|
|
||||||
Intro to Interactive Speaking
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<div className="h-8 pr-2 rounded-full flex items-center gap-2 text-brand-500 text-[13px]">
|
|
||||||
<GraduationCap className="h-4 w-4" />
|
|
||||||
IELTS
|
|
||||||
</div>
|
|
||||||
<div className="h-8 pr-2 rounded-full flex items-center gap-2 text-brand-500 text-[13px]">
|
|
||||||
<Folder className="h-4 w-4" />
|
|
||||||
Unit 2: Speaking
|
|
||||||
</div>
|
|
||||||
<div className="h-8 rounded-full flex items-center gap-2 text-brand-500 text-[13px]">
|
|
||||||
<Folder className="h-4 w-4" />
|
|
||||||
Module 4: Interactive Speaking
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 2. Attached Practices Section */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between ">
|
|
||||||
<h2 className="text-[20px] font-bold text-[#0F172A] flex items-center gap-3">
|
|
||||||
Attached Practices
|
|
||||||
</h2>
|
|
||||||
<span className="h-6 px-3 rounded-full bg-grayScale-200/40 text-grayScale-500 text-[12px] font-bold flex items-center justify-center">
|
|
||||||
Total Items: 3
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="overflow-hidden border border-grayScale-200 shadow-sm rounded-2xl bg-white">
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
className="p-6 flex items-center justify-between transition-colors hover:bg-grayScale-25 cursor-pointer"
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="h-12 w-12 rounded-full bg-[#FDF2F8] flex items-center justify-center text-[#9E2891]">
|
|
||||||
<ClipboardList className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<h4 className="text-[17px] font-medium text-grayScale-900">
|
|
||||||
Multiple Choice
|
|
||||||
</h4>
|
|
||||||
<p className="text-[14px] text-grayScale-400 font-medium">
|
|
||||||
3 Questions • ~4 min to complete
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<button className="flex items-center gap-2 text-[#9E2891] font-medium text-[14px] hover:opacity-80 transition-opacity">
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
Preview
|
|
||||||
</button>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronUp className="h-6 w-6 text-grayScale-300" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-6 w-6 text-grayScale-300" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded Content (Table) */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="animate-in slide-in-from-top-2 duration-300">
|
|
||||||
<div className="border-t border-grayScale-100">
|
|
||||||
<table className="w-full text-left border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-white">
|
|
||||||
<th className="py-4 pl-10 pr-0 w-[80px]"></th>
|
|
||||||
<th className="py-4 px-4 text-[13px] font-medium text-[#A5B4C1] uppercase tracking-wide w-24">
|
|
||||||
Order
|
|
||||||
</th>
|
|
||||||
<th className="py-4 px-4 text-[13px] font-medium text-[#A5B4C1] uppercase tracking-wide">
|
|
||||||
Versions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="border-t border-grayScale-100">
|
|
||||||
{questions.map((q, i) => (
|
|
||||||
<tr
|
|
||||||
key={i}
|
|
||||||
className="border-b border-grayScale-200 last:border-0 group hover:bg-grayScale-25 transition-colors"
|
|
||||||
>
|
|
||||||
<td className="py-6 pl-10 pr-0 text-center">
|
|
||||||
<GripVertical className="h-4 w-4 text-[#A5B4C1]" />
|
|
||||||
</td>
|
|
||||||
<td className="py-6 px-4">
|
|
||||||
<span className="text-[15px] text-[#A5B4C1]">
|
|
||||||
{q.order}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-6 px-4">
|
|
||||||
<p className="text-[15px] font-medium text-[#0D1421]">
|
|
||||||
{q.text}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 3. Confirmation Checkbox */}
|
|
||||||
<div className="bg-[#F1F5F9] border border-[#E2E8F0] px-6 py-4 rounded-[12px] flex items-start gap-4">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="confirm"
|
|
||||||
checked={isConfirmed}
|
|
||||||
onChange={(e) => setIsConfirmed(e.target.checked)}
|
|
||||||
className="mt-1 h-5 w-5 rounded border-grayScale-300 text-brand-500 focus:ring-brand-500 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<div className="">
|
|
||||||
<label
|
|
||||||
htmlFor="confirm"
|
|
||||||
className="text-[16px] font-bold text-grayScale-900 cursor-pointer"
|
|
||||||
>
|
|
||||||
I confirm these details are correct
|
|
||||||
</label>
|
|
||||||
<p className="text-[13px] text-grayScale-400">
|
|
||||||
This action cannot be undone immediately. Rollback requires manual
|
|
||||||
intervention.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 4. Action Footer */}
|
|
||||||
<div className="flex items-center justify-between pt-10 px-2">
|
|
||||||
<Button
|
|
||||||
onClick={prevStep}
|
|
||||||
variant="outline"
|
|
||||||
className="h-12 px-10 rounded-[6px] bg-transparent border-grayScale-400 font-bold text-grayScale-600 transition-all"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-12 px-10 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 bg-white shadow-none transition-all"
|
|
||||||
>
|
|
||||||
Save as Draft
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={onPublish}
|
|
||||||
disabled={!isConfirmed}
|
|
||||||
className={cn(
|
|
||||||
"h-12 px-10 rounded-[6px] font-bold text-white shadow-xl flex items-center gap-3 transition-all active:scale-95",
|
|
||||||
isConfirmed
|
|
||||||
? "bg-[#9E2891] hover:bg-[#8A237E] shadow-[#9E2891]/20"
|
|
||||||
: "bg-grayScale-200 cursor-not-allowed opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Rocket className="h-5 w-5" />
|
|
||||||
Publish Now
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add GripVertical helper since it might not be imported from lucide-react if I missed it
|
|
||||||
function GripVertical({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<div className={cn("grid grid-cols-2 gap-0.5", className)}>
|
|
||||||
{[...Array(6)].map((_, i) => (
|
|
||||||
<div key={i} className="h-0.5 w-0.5 rounded-full bg-current" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
import { LayoutGrid, Video, ArrowRight } from "lucide-react";
|
|
||||||
import { Button } from "../../../../components/ui/button";
|
|
||||||
import { Card } from "../../../../components/ui/card";
|
|
||||||
import { Select } from "../../../../components/ui/select";
|
|
||||||
import { cn } from "../../../../lib/utils";
|
|
||||||
|
|
||||||
interface AttachPracticeStep1Props {
|
|
||||||
formData: any;
|
|
||||||
setFormData: (data: any) => void;
|
|
||||||
nextStep: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AttachPracticeStep1({
|
|
||||||
formData,
|
|
||||||
setFormData,
|
|
||||||
nextStep,
|
|
||||||
onCancel,
|
|
||||||
}: AttachPracticeStep1Props) {
|
|
||||||
return (
|
|
||||||
<Card className="overflow-hidden max-w-4xl mx-auto border-grayScale-100 rounded-3xl bg-white shadow-sm animate-in fade-in duration-500">
|
|
||||||
<div className="space-y-6 p-12">
|
|
||||||
{/* Select Program */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
|
||||||
Select Program
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
|
||||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
|
|
||||||
value={formData.program}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, program: e.target.value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">Choose Program</option>
|
|
||||||
<option value="skill">Skill-Based Courses</option>
|
|
||||||
<option value="exams">English Proficiency Exams</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Select Module */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
|
||||||
Select Module
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
|
||||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
|
|
||||||
value={formData.module}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, module: e.target.value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">Choose Module</option>
|
|
||||||
<option value="m1">Module 1: Basic Phrases</option>
|
|
||||||
<option value="m2">Module 2: Intermediate Grammar</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] text-grayScale-400 font-medium px-1">
|
|
||||||
Select the specific learning module this practice will reinforce.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Select Video */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
|
||||||
Select Video
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
|
||||||
<Video className="h-5 w-5 text-grayScale-400" />
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
|
|
||||||
value={formData.video}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, video: e.target.value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">Choose a video</option>
|
|
||||||
<option value="v1">Intro to Interactive Speaking</option>
|
|
||||||
<option value="v2">Business Meeting Etiquette</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] text-grayScale-400 font-medium px-1">
|
|
||||||
Select the specific video this practice will reinforce.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Select Question Type */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
|
||||||
Select Question Type
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
|
||||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
|
|
||||||
value={formData.questionType}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, questionType: e.target.value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">Choose question type</option>
|
|
||||||
<option value="speaking">Speaking Practice</option>
|
|
||||||
<option value="listening">Listening Quiz</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] text-grayScale-400 font-medium px-1">
|
|
||||||
Select one question type that associates with th selected video
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Set Version */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
|
||||||
Set Version
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
|
||||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
className="h-[56px] w-full rounded-[10px] border-grayScale-200 bg-[#fff] pl-14 text-grayScale-800 font-bold focus:border-brand-500 transition-all text-sm"
|
|
||||||
value={formData.version}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, version: e.target.value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">Choose versions</option>
|
|
||||||
<option value="v1">Version 1.0</option>
|
|
||||||
<option value="v2">Version 2.0</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] text-grayScale-400 font-medium px-1">
|
|
||||||
Select one or more versions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-grayScale-200 bg-[#F8FAFC] py-4 px-12">
|
|
||||||
<button
|
|
||||||
className="text-[14px] text-grayScale-500 transition-colors hover:text-grayScale-700"
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<Button
|
|
||||||
onClick={nextStep}
|
|
||||||
className="h-10 px-12 rounded-[6px] bg-[#9E2891] text-[14px] font-bold text-white shadow-lg shadow-brand-500/10 transition-all active:scale-95 flex items-center gap-3"
|
|
||||||
>
|
|
||||||
Next: Review
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
import { GraduationCap, ArrowRight, LayoutGrid, Monitor } from "lucide-react";
|
|
||||||
import { Button } from "../../../../components/ui/button";
|
|
||||||
import { Card } from "../../../../components/ui/card";
|
|
||||||
import { Select } from "../../../../components/ui/select";
|
|
||||||
|
|
||||||
interface ContextStepProps {
|
|
||||||
formData: any;
|
|
||||||
setFormData: (data: any) => void;
|
|
||||||
nextStep: () => void;
|
|
||||||
navigate: (path: string) => void;
|
|
||||||
level: string;
|
|
||||||
isModuleContext?: boolean;
|
|
||||||
isCourseContext?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContextStep({
|
|
||||||
formData,
|
|
||||||
setFormData,
|
|
||||||
nextStep,
|
|
||||||
navigate,
|
|
||||||
level,
|
|
||||||
isModuleContext,
|
|
||||||
isCourseContext,
|
|
||||||
}: ContextStepProps) {
|
|
||||||
return (
|
|
||||||
<Card className="overflow-hidden border-grayScale-300 rounded-2xl bg-white animate-in fade-in duration-500">
|
|
||||||
<div className="border-b border-grayScale-50 px-8 pt-8 pb-4">
|
|
||||||
<h2 className="text-xl font-bold text-grayScale-900 leading-none">
|
|
||||||
Step 1: Context Definition
|
|
||||||
</h2>
|
|
||||||
<p className="text-grayScale-600 text-base mt-3">
|
|
||||||
Define the educational level and curriculum module for this practice.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
|
||||||
<div className="w-full border-t border-grayScale-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-10 p-10">
|
|
||||||
{/* Program Field */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[16px] text-grayScale-700 ml-1">
|
|
||||||
Program{" "}
|
|
||||||
<span className="text-grayScale-300 font-medium">
|
|
||||||
(Auto-selected)
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-6 top-1/2 -translate-y-1/2">
|
|
||||||
<GraduationCap className="h-6 w-6 text-grayScale-600" />
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
className="h-12 w-full rounded-[6px] border-grayScale-400 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all cursor-default"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<option>{formData.program || "Intermediate"}</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Course Field */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
|
|
||||||
Course{" "}
|
|
||||||
<span className="text-grayScale-300 font-medium">
|
|
||||||
(Auto-selected)
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-6 top-1/2 -translate-y-1/2">
|
|
||||||
<GraduationCap className="h-6 w-6 text-grayScale-600" />
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
className="h-12 w-full rounded-[6px] border-grayScale-400 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all cursor-default"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<option>{formData.course || "B2"}</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Select Module Field */}
|
|
||||||
{(isModuleContext || isCourseContext) && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
|
|
||||||
Select Module
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-6 top-1/2 -translate-y-1/2">
|
|
||||||
<LayoutGrid className="h-6 w-6 text-grayScale-400" />
|
|
||||||
</div>
|
|
||||||
<Select className="h-12 w-full rounded-[6px] border-grayScale-300 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all">
|
|
||||||
<option value="">Choose a module...</option>
|
|
||||||
<option value="m1">Introduction Basics</option>
|
|
||||||
<option value="m2">Daily Routines</option>
|
|
||||||
<option value="m3">Travel Essentials</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] text-grayScale-400 font-medium px-2">
|
|
||||||
Select the specific learning module this practice will reinforce.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Select Video Field (Conditional) */}
|
|
||||||
{isModuleContext && (
|
|
||||||
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
|
|
||||||
<label className="text-[16px] font-bold text-grayScale-700 ml-1">
|
|
||||||
Select Video
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-6 top-1/2 -translate-y-1/2">
|
|
||||||
<Monitor className="h-6 w-6 text-grayScale-400" />
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
className="h-12 w-full rounded-[6px] border-grayScale-300 bg-[#fff] pl-16 text-grayScale-800 font-bold focus:border-brand-500 focus:ring-0 transition-all"
|
|
||||||
value={formData.selectedVideo}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, selectedVideo: e.target.value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">Choose a video</option>
|
|
||||||
<option value="v1">Intro to Greetings</option>
|
|
||||||
<option value="v2">Advanced Grammar</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] text-grayScale-400 font-medium px-2">
|
|
||||||
Select the specific learning module this practice will reinforce.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12">
|
|
||||||
<button
|
|
||||||
className="text-[14px] font-bold text-grayScale-500 transition-colors hover:text-grayScale-700"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/new-content/learn-english/${level}/courses`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<Button
|
|
||||||
onClick={nextStep}
|
|
||||||
className="h-10 px-10 rounded-[6px] bg-brand-500 text-[14px] font-bold text-white transition-all active:scale-95 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Next: {isModuleContext ? "Persona" : "Scenario"}{" "}
|
|
||||||
<ArrowRight className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
import { Check, ArrowRight } from "lucide-react";
|
|
||||||
import { Button } from "../../../../components/ui/button";
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from "../../../../components/ui/avatar";
|
|
||||||
import { cn } from "../../../../lib/utils";
|
|
||||||
import { PERSONAS } from "./constants";
|
|
||||||
|
|
||||||
interface PersonaStepProps {
|
|
||||||
selectedPersona: string | null;
|
|
||||||
setSelectedPersona: (id: string) => void;
|
|
||||||
nextStep: () => void;
|
|
||||||
prevStep: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PersonaStep({
|
|
||||||
selectedPersona,
|
|
||||||
setSelectedPersona,
|
|
||||||
nextStep,
|
|
||||||
prevStep,
|
|
||||||
}: PersonaStepProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="space-y-1 px-2">
|
|
||||||
<h2 className="text-2xl font-extrabold text-grayScale-700">
|
|
||||||
Select Personas
|
|
||||||
</h2>
|
|
||||||
<p className="text-grayScale-400 text-lg">
|
|
||||||
Choose the characters that will participate in this practice scenario.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
|
|
||||||
{PERSONAS.map((persona) => {
|
|
||||||
const isSelected = selectedPersona === persona.id;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={persona.id}
|
|
||||||
onClick={() => setSelectedPersona(persona.id)}
|
|
||||||
className={cn(
|
|
||||||
"group relative w-[260px] cursor-pointer rounded-2xl border-2 bg-white p-6 transition-all duration-300",
|
|
||||||
isSelected
|
|
||||||
? "border-brand-500"
|
|
||||||
: "border-grayScale-100 hover:border-brand-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Top-right checkmark badge */}
|
|
||||||
{isSelected && (
|
|
||||||
<div className="absolute right-2.5 top-2.5 grid h-6 w-6 place-items-center rounded-full bg-brand-500 text-white z-10">
|
|
||||||
<Check className="h-4 w-4 stroke-[3]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
{/* Avatar with conditional purple ring */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-full p-[3px] transition-all duration-300",
|
|
||||||
isSelected ? "bg-brand-500" : "bg-transparent",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Avatar className="h-24 w-24 border-2 border-white">
|
|
||||||
<AvatarImage src={persona.avatar} />
|
|
||||||
<AvatarFallback>
|
|
||||||
{persona.name.substring(0, 2)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-bold text-grayScale-700">
|
|
||||||
{persona.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between pt-8">
|
|
||||||
<Button
|
|
||||||
onClick={prevStep}
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={nextStep}
|
|
||||||
className="h-10 rounded-[6px] bg-brand-500 px-8 hover:bg-brand-600 shadow-md shadow-brand-500/20"
|
|
||||||
>
|
|
||||||
Next: Questions <ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
import { Edit2, Trash2, ArrowRight } from "lucide-react";
|
|
||||||
import { Button } from "../../../../components/ui/button";
|
|
||||||
import { Card } from "../../../../components/ui/card";
|
|
||||||
import { cn } from "../../../../lib/utils";
|
|
||||||
|
|
||||||
interface ProgramAttachReviewStepProps {
|
|
||||||
formData: any;
|
|
||||||
prevStep: () => void;
|
|
||||||
onPublish: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOCK_QUESTIONS = [
|
|
||||||
{
|
|
||||||
id: "q1",
|
|
||||||
title: "1. Speak About The Photo",
|
|
||||||
description:
|
|
||||||
'Passage: "Good morning, everyone. I\'d like to start by reviewing the quarterly figures. As you can see from the chart..."',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "q2",
|
|
||||||
title: "2. Fill In the Blank",
|
|
||||||
description:
|
|
||||||
'Passage: "Attention passengers on Flight 492 to London. We are now inviting passengers with small children..."',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "q3",
|
|
||||||
title: "3. Writing Part 1",
|
|
||||||
description:
|
|
||||||
'Passage: "In today\'s lecture on astrophysics, we will discuss the concept of event horizons and their implications..."',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ProgramAttachReviewStep({
|
|
||||||
formData,
|
|
||||||
prevStep,
|
|
||||||
onPublish,
|
|
||||||
onCancel,
|
|
||||||
}: ProgramAttachReviewStepProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 animate-in fade-in duration-500">
|
|
||||||
{/* Questions List */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{MOCK_QUESTIONS.map((q) => (
|
|
||||||
<Card
|
|
||||||
key={q.id}
|
|
||||||
className="group relative flex border-grayScale-200 rounded-2xl bg-white overflow-hidden transition-all"
|
|
||||||
>
|
|
||||||
{/* Grip Area */}
|
|
||||||
<div className="w-[50px] flex items-center justify-center bg-white border-r border-grayScale-200">
|
|
||||||
<GripVertical className="h-3 w-3 text-grayScale-600" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
<div className="flex-1 p-8 py-10">
|
|
||||||
<h4 className="text-[18px] font-medium text-[#0F172A] mb-3 leading-tight">
|
|
||||||
{q.title}
|
|
||||||
</h4>
|
|
||||||
<p className="text-[14px] text-grayScale-400 leading-relaxed line-clamp-2">
|
|
||||||
{q.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions Area (Vertical Stack) */}
|
|
||||||
<div className="w-[50px] border-l border-grayScale-200 flex flex-col items-center justify-center divide-y divide-grayScale-50">
|
|
||||||
<button className="flex-1 w-full flex items-center justify-center text-grayScale-600 hover:text-brand-500 transition-colors border-b border-grayScale-200">
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button className="flex-1 w-full flex items-center justify-center text-grayScale-600 hover:text-red-500 transition-colors">
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer Actions */}
|
|
||||||
<div className="flex bg-[#F8FAFC] border border-grayScale-200 items-center rounded-[12px] justify-between py-4 px-6">
|
|
||||||
<Button
|
|
||||||
onClick={onCancel}
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 px-6 rounded-[6px] border-grayScale-100 font-bold text-grayScale-500 bg-white hover:bg-grayScale-50 transition-all text-lg"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={onPublish}
|
|
||||||
className="h-10 px-10 rounded-[6px] bg-[#9E2891] text-[16px] font-medium text-white transition-all active:scale-95 flex items-center gap-3"
|
|
||||||
>
|
|
||||||
Next: Review & Publish
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GripVertical helper
|
|
||||||
function GripVertical({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<div className={cn("grid grid-cols-2 gap-0.5", className)}>
|
|
||||||
{[...Array(6)].map((_, i) => (
|
|
||||||
<div key={i} className="h-1 w-1 rounded-full bg-current" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import { LayoutGrid, Plus, ArrowRight } from "lucide-react";
|
|
||||||
import { Button } from "../../../../components/ui/button";
|
|
||||||
import { Card } from "../../../../components/ui/card";
|
|
||||||
import { Select } from "../../../../components/ui/select";
|
|
||||||
import { cn } from "../../../../lib/utils";
|
|
||||||
|
|
||||||
interface ProgramAttachStep1Props {
|
|
||||||
formData: any;
|
|
||||||
setFormData: (data: any) => void;
|
|
||||||
nextStep: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProgramAttachStep1({
|
|
||||||
formData,
|
|
||||||
setFormData,
|
|
||||||
nextStep,
|
|
||||||
onCancel,
|
|
||||||
}: ProgramAttachStep1Props) {
|
|
||||||
return (
|
|
||||||
<Card className="overflow-hidden border-grayScale-100 rounded-[16px] bg-white shadow-sm animate-in fade-in duration-500">
|
|
||||||
<div className="space-y-6 p-8 pb-16">
|
|
||||||
{/* Select Program */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
|
||||||
Select Program
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
|
||||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
className="h-[56px] w-full rounded-[12px] border-grayScale-200 bg-white pl-16 text-grayScale-700 font-medium focus:border-brand-500 transition-all text-sm appearance-none"
|
|
||||||
value={formData.program}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, program: e.target.value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">Choose Program</option>
|
|
||||||
<option value="exams">English Proficiency Exams</option>
|
|
||||||
<option value="skill">Skill-Based Courses</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tests (Auto Select) */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
|
||||||
Tests{" "}
|
|
||||||
<span className="text-grayScale-400 font-medium">
|
|
||||||
(Auto Select)
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
|
||||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
|
||||||
</div>
|
|
||||||
<div className="h-[56px] w-full rounded-[12px] border border-grayScale-200 bg-white flex items-center pl-16 text-grayScale-700 font-medium text-sm">
|
|
||||||
Mock Exam 1
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full border-t border-grayScale-300" />
|
|
||||||
|
|
||||||
{/* Select Question Type */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
|
||||||
Select Question Type
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
|
||||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
className="h-[56px] w-full rounded-[12px] border-grayScale-200 bg-white pl-16 text-grayScale-700 font-medium focus:border-brand-500 transition-all text-sm appearance-none"
|
|
||||||
value={formData.questionType}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, questionType: e.target.value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">Choose question type</option>
|
|
||||||
<option value="Speaking Practice">Speaking Practice</option>
|
|
||||||
<option value="Writing Part 1">Writing Part 1</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] text-grayScale-400 font-medium px-1">
|
|
||||||
Select one question type that associates with th selected video
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Set Version */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="text-[14px] font-medium text-[#0F172A] ml-1">
|
|
||||||
Set Version
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
|
||||||
<LayoutGrid className="h-5 w-5 text-grayScale-400" />
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
className="h-[56px] w-full rounded-[12px] border-grayScale-200 bg-white pl-16 text-grayScale-700 font-medium focus:border-brand-500 transition-all text-sm appearance-none"
|
|
||||||
value={formData.version}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, version: e.target.value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">Choose a version</option>
|
|
||||||
<option value="V 1.0">V 1.0</option>
|
|
||||||
<option value="V 2.0">V 2.0</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add More Button */}
|
|
||||||
<button className="flex items-center gap-2 text-[#9E2891] font-medium text-[14px] group transition-all hover:translate-x-1">
|
|
||||||
<div className="h-5 w-5 rounded-full border-2 border-[#9E2891] flex items-center justify-center">
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
Add More
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-grayScale-200 bg-[#F8FAFC] py-4 px-12">
|
|
||||||
<button
|
|
||||||
className="text-[14px] text-grayScale-500 transition-colors hover:text-grayScale-700"
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<Button
|
|
||||||
onClick={nextStep}
|
|
||||||
className="h-10 px-12 rounded-[6px] bg-[#9E2891] text-[14px] font-bold text-white shadow-lg shadow-brand-500/10 transition-all active:scale-95 flex items-center gap-3"
|
|
||||||
>
|
|
||||||
Next: Review
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
import { GripVertical, Trash2, Plus, ArrowRight } from "lucide-react";
|
|
||||||
import { Button } from "../../../../components/ui/button";
|
|
||||||
import { Card } from "../../../../components/ui/card";
|
|
||||||
import { Input } from "../../../../components/ui/input";
|
|
||||||
import { VoicePrompt } from "./VoicePrompt";
|
|
||||||
|
|
||||||
interface QuestionsStepProps {
|
|
||||||
formData: any;
|
|
||||||
setFormData: (data: any) => void;
|
|
||||||
nextStep: () => void;
|
|
||||||
prevStep: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QuestionsStep({
|
|
||||||
formData,
|
|
||||||
setFormData,
|
|
||||||
nextStep,
|
|
||||||
prevStep,
|
|
||||||
}: QuestionsStepProps) {
|
|
||||||
const addQuestion = () => {
|
|
||||||
const newQuestion = {
|
|
||||||
id: `q${formData.questions.length + 1}`,
|
|
||||||
text: "",
|
|
||||||
type: "Speaking",
|
|
||||||
voicePrompt: "upload_audio.mp3",
|
|
||||||
sampleAnswer: "upload_audio.mp3",
|
|
||||||
};
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
questions: [...formData.questions, newQuestion],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-1 px-2">
|
|
||||||
<h2 className="text-2xl font-bold text-grayScale-700">
|
|
||||||
Create Practice Questions
|
|
||||||
</h2>
|
|
||||||
<p className="text-grayScale-400 text-lg">
|
|
||||||
Define the dialogue flow and interactions for this scenario.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{formData.questions.map((q: any, i: number) => (
|
|
||||||
<Card
|
|
||||||
key={q.id}
|
|
||||||
className="overflow-hidden border-grayScale-50 shadow-soft rounded-2xl bg-white relative"
|
|
||||||
>
|
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-[5px] bg-brand-500" />
|
|
||||||
<div className="px-5 pb-7 pt-2 space-y-6">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-50 pb-4 mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<GripVertical className="h-5 w-5 text-brand-500 cursor-grab" />
|
|
||||||
<span className="font-bold text-grayScale-500 text-base">
|
|
||||||
Question {i + 1}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-brand-500 hover:bg-brand-50 rounded-lg"
|
|
||||||
onClick={() => {
|
|
||||||
const newQuestions = formData.questions.filter(
|
|
||||||
(item: any) => item.id !== q.id,
|
|
||||||
);
|
|
||||||
setFormData({ ...formData, questions: newQuestions });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
|
|
||||||
<div className="md:col-span-8 space-y-3">
|
|
||||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
|
||||||
QUESTION PROMPT
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={q.text}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newQuestions = [...formData.questions];
|
|
||||||
newQuestions[i].text = e.target.value;
|
|
||||||
setFormData({ ...formData, questions: newQuestions });
|
|
||||||
}}
|
|
||||||
className="h-16 rounded-xl border-grayScale-200 font-medium px-6 text-base placeholder:text-grayScale-400 bg-white text-grayScale-700"
|
|
||||||
placeholder="e.g. How long have you been studying English?"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-4 space-y-3">
|
|
||||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
|
||||||
VOICE PROMPT
|
|
||||||
</label>
|
|
||||||
<VoicePrompt
|
|
||||||
src={q.voicePrompt}
|
|
||||||
filename={q.voicePrompt}
|
|
||||||
onRemove={() => {
|
|
||||||
const newQuestions = [...formData.questions];
|
|
||||||
newQuestions[i].voicePrompt = "";
|
|
||||||
setFormData({ ...formData, questions: newQuestions });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="md:w-1/3 space-y-3">
|
|
||||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
|
||||||
SAMPLE ANSWER PROMPT
|
|
||||||
</label>
|
|
||||||
<VoicePrompt
|
|
||||||
src={q.sampleAnswer}
|
|
||||||
filename={q.sampleAnswer}
|
|
||||||
onRemove={() => {
|
|
||||||
const newQuestions = [...formData.questions];
|
|
||||||
newQuestions[i].sampleAnswer = "";
|
|
||||||
setFormData({ ...formData, questions: newQuestions });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
<div className="flex items-center gap-8 pt-4">
|
|
||||||
<button
|
|
||||||
onClick={addQuestion}
|
|
||||||
className="flex items-center gap-3 text-brand-500 font-bold text-base hover:opacity-80 transition-all"
|
|
||||||
>
|
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
|
|
||||||
<Plus className="h-3 w-3 stroke-[4]" />
|
|
||||||
</div>{" "}
|
|
||||||
Add New Question
|
|
||||||
</button>
|
|
||||||
<button className="flex items-center gap-3 text-brand-500 font-bold text-base hover:opacity-80 transition-all">
|
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
|
|
||||||
<Plus className="h-3 w-3 stroke-[4]" />
|
|
||||||
</div>{" "}
|
|
||||||
Add Tips
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between pt-8">
|
|
||||||
<Button
|
|
||||||
onClick={prevStep}
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 w-20 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 shadow-sm"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={nextStep}
|
|
||||||
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold "
|
|
||||||
>
|
|
||||||
Next: Review <ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
import { Edit2, GripVertical, Trash2, Rocket, Info } from "lucide-react";
|
|
||||||
import { Button } from "../../../../components/ui/button";
|
|
||||||
import { Card } from "../../../../components/ui/card";
|
|
||||||
import { Input } from "../../../../components/ui/input";
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from "../../../../components/ui/avatar";
|
|
||||||
import { PERSONAS } from "./constants";
|
|
||||||
import { VoicePrompt } from "./VoicePrompt";
|
|
||||||
|
|
||||||
interface ReviewStepProps {
|
|
||||||
formData: any;
|
|
||||||
selectedPersona: string | null;
|
|
||||||
prevStep: () => void;
|
|
||||||
setIsPublished: (val: boolean) => void;
|
|
||||||
isModuleContext?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReviewStep({
|
|
||||||
formData,
|
|
||||||
selectedPersona,
|
|
||||||
prevStep,
|
|
||||||
setIsPublished,
|
|
||||||
isModuleContext,
|
|
||||||
}: ReviewStepProps) {
|
|
||||||
const persona = PERSONAS.find((p) => p.id === selectedPersona);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-10 animate-in fade-in duration-700">
|
|
||||||
<div className="flex items-center justify-between px-2">
|
|
||||||
<h2 className="text-2xl font-bold text-grayScale-900 tracking-tight">
|
|
||||||
Review Practice Questions
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 1. Basic Info Card (Image 1436.1) */}
|
|
||||||
<Card className="overflow-hidden border border-grayScale-200 rounded-2xl bg-white ">
|
|
||||||
<div className="border-b border-grayScale-50 p-4 px-5 flex justify-between items-center bg-white">
|
|
||||||
<h3 className="text-[17px] font-extrabold text-grayScale-900">
|
|
||||||
Basic Information
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-brand-500 font-bold hover:bg-brand-50 gap-2 h-9"
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/* Gradient Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 flex items-center"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div className="w-full border-t border-grayScale-100" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-8 px-5 flex items-center justify-between ">
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<div className="h-[70px] w-[85px] rounded-xl bg-grayScale-100 overflow-hidden shadow-inner flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src="https://images.unsplash.com/photo-1558403194-611308249627?auto=format&fit=crop&q=80&w=200"
|
|
||||||
alt="Banner"
|
|
||||||
className="w-full h-full object-cover opacity-80"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-[22px] font-bold text-grayScale-900 leading-tight">
|
|
||||||
{formData.title || "Business English 101: Communication"}
|
|
||||||
</h4>
|
|
||||||
<div className="flex items-center gap-6 text-[14px]">
|
|
||||||
<span className="text-grayScale-900 ">
|
|
||||||
Program:{" "}
|
|
||||||
<span className="text-brand-500 ">{formData.program}</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-grayScale-900 ">
|
|
||||||
Course:{" "}
|
|
||||||
<span className="text-brand-500 ">{formData.course}</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-grayScale-900 font-bold">
|
|
||||||
Module:{" "}
|
|
||||||
<span className="text-brand-500 font-extrabold">
|
|
||||||
Module 101
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<span className="text-[11px] text-left font-medium text-grayScale-900 ">
|
|
||||||
Persona
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2 bg-[#FAF5FF] py-1 pl-2.5 pr-4 rounded-full border border-brand-100/30">
|
|
||||||
<Avatar className="h-8 w-8 border-2 border-white shadow-sm font-bold">
|
|
||||||
<AvatarImage src={persona?.avatar} />
|
|
||||||
<AvatarFallback>P</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<span className="text-[14px] text-brand-500 capitalize">
|
|
||||||
{persona?.name || "Alex Johnson"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 2. Tips Section (Image 1436.1) */}
|
|
||||||
<div className="space-y-4 px-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-[12px] font-bold text-grayScale-900 uppercase tracking-widest leading-none">
|
|
||||||
TIPS / GUIDANCE
|
|
||||||
</label>
|
|
||||||
<Info className="h-4 w-4 text-brand-500" />
|
|
||||||
</div>
|
|
||||||
<div className="px-5 pt-2 pb-8 bg-white border border-[#E2E8F0] shadow-sm rounded-xl">
|
|
||||||
<p className="text-[14px] text-grayScale-500 font-medium leading-relaxed">
|
|
||||||
{formData.tips ||
|
|
||||||
"Focus on using the present perfect continuous tense to describe an action that started in the past and continues now."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isModuleContext ? (
|
|
||||||
/* 3. Split Questions & Answers Layout (Image 1413.1) */
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 bg-white rounded-[12px] border border-grayScale-50 shadow-sm overflow-hidden min-h-[600px]">
|
|
||||||
{/* Left Column: Questions */}
|
|
||||||
<div className="border-r border-grayScale-200 flex flex-col">
|
|
||||||
<div className="p-4 border-b border-grayScale-50 flex items-center gap-3 bg-white">
|
|
||||||
<h3 className="text-[16px] font-extrabold text-[#0F172A]">
|
|
||||||
Questions
|
|
||||||
</h3>
|
|
||||||
<span className="h-6 w-6 rounded-full bg-grayScale-100 flex items-center justify-center text-[12px] font-extrabold text-grayScale-500">
|
|
||||||
{formData.questions.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 space-y-14">
|
|
||||||
{formData.questions.map((q: any, i: number) => (
|
|
||||||
<div key={q.id} className="relative pl-12">
|
|
||||||
<span className="absolute left-0 top-0 text-[18px] font-bold text-grayScale-400 tracking-tighter opacity-70">
|
|
||||||
{(i + 1).toString().padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<span className="text-[11px] font-extrabold text-grayScale-600 uppercase tracking-[0.1em] block">
|
|
||||||
TEXT PROMPT
|
|
||||||
</span>
|
|
||||||
<p className="text-[16px] font-medium text-grayScale-600 leading-relaxed max-w-[90%]">
|
|
||||||
{q.text}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<span className="text-[11px] font-extrabold text-grayScale-300 uppercase tracking-[0.1em] block">
|
|
||||||
VOICE PROMPT
|
|
||||||
</span>
|
|
||||||
<VoicePrompt
|
|
||||||
filename={q.voicePrompt}
|
|
||||||
className="bg-[#FAF5FF]/60 border-[#F3E8FF] h-[72px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column: Answers */}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="p-4 border-b border-grayScale-50 flex items-center justify-between bg-white">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h3 className="text-[16px] font-extrabold ">Answers</h3>
|
|
||||||
<span className="h-6 w-6 rounded-full bg-grayScale-100 flex items-center justify-center text-[12px] font-extrabold text-grayScale-500">
|
|
||||||
{formData.questions.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button className="flex items-center gap-2 text-brand-500 font-bold text-[15px] hover:opacity-80 transition-opacity">
|
|
||||||
<Edit2 className="h-3 w-3" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 space-y-14">
|
|
||||||
{formData.questions.map((q: any, i: number) => (
|
|
||||||
<div key={q.id + "_ans"} className="relative pl-12">
|
|
||||||
<span className="absolute left-0 top-0 text-[18px] font-bold text-grayScale-400 tracking-tighter opacity-70">
|
|
||||||
{(i + 1).toString().padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<span className="text-[11px] font-extrabold text-grayScale-600 uppercase tracking-[0.1em] block">
|
|
||||||
VOICE PROMPT
|
|
||||||
</span>
|
|
||||||
<VoicePrompt
|
|
||||||
filename={q.sampleAnswer}
|
|
||||||
className="bg-[#FAF5FF]/60 border-[#F3E8FF] h-[60px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Original Non-Module View */
|
|
||||||
<div className="space-y-6">
|
|
||||||
{formData.questions.map((q: any, i: number) => (
|
|
||||||
<ReviewItem key={q.id} q={q} index={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Footer */}
|
|
||||||
<div className="flex items-center justify-between pt-12">
|
|
||||||
<Button
|
|
||||||
onClick={prevStep}
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 px-10 rounded-[6px] border-grayScale-200 font-bold text-grayScale-600 bg-white shadow-sm hover:bg-grayScale-50 transition-all text-sm"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 bg-white shadow-sm hover:bg-grayScale-50 transition-all text-sm"
|
|
||||||
>
|
|
||||||
Save as Draft
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsPublished(true)}
|
|
||||||
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold hover:bg-brand-600 shadow-xl shadow-brand-500/20 gap-3 active:scale-95 transition-all text-white text-sm"
|
|
||||||
>
|
|
||||||
<Rocket className="h-4 w-4" />
|
|
||||||
Publish Now
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewItem({ q, index }: { q: any; index: number }) {
|
|
||||||
return (
|
|
||||||
<Card className="overflow-hidden border-grayScale-50 shadow-soft rounded-2xl bg-white relative">
|
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-[5px] bg-brand-500" />
|
|
||||||
<div className="px-5 pb-7 pt-2 space-y-6">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-50 pb-4 mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<GripVertical className="h-5 w-5 text-brand-500 cursor-grab" />
|
|
||||||
<span className="font-bold text-grayScale-500 text-base">
|
|
||||||
Question {index + 1}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-brand-500 hover:bg-brand-50 rounded-lg"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
|
|
||||||
<div className="md:col-span-8 space-y-3">
|
|
||||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
|
||||||
QUESTION PROMPT
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={q.text}
|
|
||||||
readOnly
|
|
||||||
className="h-16 rounded-xl border-grayScale-200 font-medium px-6 text-base placeholder:text-grayScale-400 bg-white text-grayScale-700"
|
|
||||||
placeholder="e.g. How long have you been studying English?"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-4 space-y-3">
|
|
||||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
|
||||||
VOICE PROMPT
|
|
||||||
</label>
|
|
||||||
<VoicePrompt
|
|
||||||
src={q.voicePrompt}
|
|
||||||
filename={q.voicePrompt}
|
|
||||||
onRemove={() => {}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="md:w-1/3 space-y-3">
|
|
||||||
<label className="text-[10px] font-bold text-grayScale-700 uppercase tracking-widest">
|
|
||||||
SAMPLE ANSWER PROMPT
|
|
||||||
</label>
|
|
||||||
<VoicePrompt
|
|
||||||
src={q.sampleAnswer}
|
|
||||||
filename={q.sampleAnswer}
|
|
||||||
onRemove={() => {}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
import { Upload, ArrowRight } from "lucide-react";
|
|
||||||
import { Button } from "../../../../components/ui/button";
|
|
||||||
import { Card } from "../../../../components/ui/card";
|
|
||||||
import { Input } from "../../../../components/ui/input";
|
|
||||||
import { Textarea } from "../../../../components/ui/textarea";
|
|
||||||
|
|
||||||
interface ScenarioStepProps {
|
|
||||||
formData: any;
|
|
||||||
setFormData: (data: any) => void;
|
|
||||||
nextStep: () => void;
|
|
||||||
prevStep: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScenarioStep({
|
|
||||||
formData,
|
|
||||||
setFormData,
|
|
||||||
nextStep,
|
|
||||||
prevStep,
|
|
||||||
}: ScenarioStepProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-1 px-2">
|
|
||||||
<h2 className="text-2xl font-extrabold text-grayScale-700">
|
|
||||||
Define Scenario Details
|
|
||||||
</h2>
|
|
||||||
<p className="text-grayScale-400 text-lg">
|
|
||||||
Set the scene and context for this English practice session.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-sm text-grayScale-700">
|
|
||||||
Practice Banner Image
|
|
||||||
</label>
|
|
||||||
<p className="text-xs pb-2 text-grayScale-400">
|
|
||||||
This image will appear as the background for the scenario.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-grayScale-200 bg-[#F8F9FA] p-12 hover:bg-grayScale-50 transition-all">
|
|
||||||
<div className="mb-4 rounded-xl border border-grayScale-100 bg-white p-3 text-brand-500 shadow-sm">
|
|
||||||
<Upload className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm">
|
|
||||||
<span className="text-grayScale-700">
|
|
||||||
Click to upload or drag and drop
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-grayScale-400 uppercase tracking-wide ">
|
|
||||||
SVG, PNG, JPG (MAX 5MB)
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="mt-6 h-10 rounded-[6px] border-grayScale-200 bg-white px-8 font-bold text-brand-500 shadow-sm hover:bg-grayScale-50"
|
|
||||||
>
|
|
||||||
Browse Files
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card className="p-8 space-y-6 border-grayScale-200 rounded-2xl bg-white">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
|
||||||
Practice Title <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g., Ordering Coffee at a Cafe"
|
|
||||||
className="h-12 rounded-xl border-grayScale-200 focus:border-brand-500 placeholder:text-grayScale-500 bg-white"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, title: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
|
||||||
Scenario Description <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Textarea
|
|
||||||
placeholder="Describe the setting..."
|
|
||||||
className="min-h-[160px] rounded-xl resize-none p-4 border-grayScale-200 focus:border-brand-500 leading-relaxed placeholder:text-grayScale-500 bg-white"
|
|
||||||
maxLength={1000}
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
description: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-4 right-4 text-xs font-bold text-grayScale-500">
|
|
||||||
{formData.description.length} / 1000
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-grayScale-500">
|
|
||||||
Provide context for the AI and the student. Be specific about the
|
|
||||||
location and the goal.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<div className="flex items-center justify-between pt-4">
|
|
||||||
<Button
|
|
||||||
onClick={prevStep}
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600 shadow-sm"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={nextStep}
|
|
||||||
disabled={!formData.title || !formData.description}
|
|
||||||
className="h-10 rounded-[6px] bg-brand-500 px-8 "
|
|
||||||
>
|
|
||||||
Next: Persona <ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { Play, Pause, X } from "lucide-react";
|
|
||||||
import { cn } from "../../../../lib/utils";
|
|
||||||
|
|
||||||
interface VoicePromptProps {
|
|
||||||
/** Either a URL/path to the audio file, or a filename string (for display-only mode) */
|
|
||||||
src?: string;
|
|
||||||
filename: string;
|
|
||||||
onRemove?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BAR_COUNT = 24;
|
|
||||||
|
|
||||||
export function VoicePrompt({
|
|
||||||
src,
|
|
||||||
filename,
|
|
||||||
onRemove,
|
|
||||||
className,
|
|
||||||
}: VoicePromptProps) {
|
|
||||||
const [bars, setBars] = useState<number[]>([]);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [progress, setProgress] = useState(0); // 0–1
|
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const rafRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
// ─── Decode audio and build waveform bars ───────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
if (!src) {
|
|
||||||
// No real audio — generate plausible static bars
|
|
||||||
setBars(generateFakeBars());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const audioCtx = new AudioContext();
|
|
||||||
|
|
||||||
fetch(src)
|
|
||||||
.then((r) => r.arrayBuffer())
|
|
||||||
.then((buf) => audioCtx.decodeAudioData(buf))
|
|
||||||
.then((decoded) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const raw = decoded.getChannelData(0);
|
|
||||||
const blockSize = Math.floor(raw.length / BAR_COUNT);
|
|
||||||
const barsData = Array.from({ length: BAR_COUNT }, (_, i) => {
|
|
||||||
let sum = 0;
|
|
||||||
for (let j = 0; j < blockSize; j++) {
|
|
||||||
sum += Math.abs(raw[i * blockSize + j]);
|
|
||||||
}
|
|
||||||
return sum / blockSize;
|
|
||||||
});
|
|
||||||
// Normalize to 0–1
|
|
||||||
const max = Math.max(...barsData, 0.001);
|
|
||||||
setBars(barsData.map((v) => v / max));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setBars(generateFakeBars());
|
|
||||||
})
|
|
||||||
.finally(() => audioCtx.close());
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [src]);
|
|
||||||
|
|
||||||
// ─── Sync progress while playing ────────────────────────────────────────────
|
|
||||||
const startProgressLoop = () => {
|
|
||||||
const tick = () => {
|
|
||||||
const el = audioRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
setProgress(el.currentTime / (el.duration || 1));
|
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopProgressLoop = () => {
|
|
||||||
if (rafRef.current !== null) {
|
|
||||||
cancelAnimationFrame(rafRef.current);
|
|
||||||
rafRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Play / Pause ────────────────────────────────────────────────────────────
|
|
||||||
const handlePlayPause = () => {
|
|
||||||
if (!src) return;
|
|
||||||
|
|
||||||
if (!audioRef.current) {
|
|
||||||
audioRef.current = new Audio(src);
|
|
||||||
audioRef.current.onended = () => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
setProgress(0);
|
|
||||||
stopProgressLoop();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
stopProgressLoop();
|
|
||||||
setIsPlaying(false);
|
|
||||||
} else {
|
|
||||||
audioRef.current.play().then(() => {
|
|
||||||
setIsPlaying(true);
|
|
||||||
startProgressLoop();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Cleanup on unmount ──────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
stopProgressLoop();
|
|
||||||
audioRef.current?.pause();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const playedBars = Math.round(progress * BAR_COUNT);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-4 px-4 py-3 bg-[#F9F0FB] rounded-6px border border-brand-100 min-h-[60px]",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Play / Pause button */}
|
|
||||||
<button
|
|
||||||
onClick={handlePlayPause}
|
|
||||||
className="h-8 w-8 flex-shrink-0 rounded-full bg-brand-500 flex items-center justify-center text-white shadow-md hover:bg-brand-600 transition-colors"
|
|
||||||
>
|
|
||||||
{isPlaying ? (
|
|
||||||
<Pause className="h-3 w-3 fill-current" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-3 w-3 fill-current ml-0.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Waveform + filename */}
|
|
||||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
|
||||||
{/* Bars — centered vertically, grow up and down */}
|
|
||||||
<div className="flex items-center gap-[3.5px] h-6 overflow-hidden">
|
|
||||||
{(bars.length ? bars : generateFakeBars()).map((v, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="w-[4px] rounded-full flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
height: `${Math.max(v * 100, 14)}%`,
|
|
||||||
backgroundColor: i < playedBars ? "#9E2891" : "#D5C5DC",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* Filename */}
|
|
||||||
<p className="text-[11px] font-semibold text-brand-500 truncate">
|
|
||||||
{filename}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Remove button */}
|
|
||||||
{onRemove && (
|
|
||||||
<button
|
|
||||||
onClick={onRemove}
|
|
||||||
className="flex-shrink-0 text-brand-500 hover:text-brand-700 transition-colors p-1"
|
|
||||||
aria-label="Remove"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
function generateFakeBars(): number[] {
|
|
||||||
// Realistic-looking static waveform (peaks in middle, quieter at edges)
|
|
||||||
return Array.from({ length: BAR_COUNT }, (_, i) => {
|
|
||||||
const center = BAR_COUNT / 2;
|
|
||||||
const envelope = 1 - Math.abs(i - center) / center;
|
|
||||||
return Math.max(0.1, envelope * (0.4 + Math.random() * 0.6));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
export const PERSONAS = [
|
|
||||||
{
|
|
||||||
id: "dawit",
|
|
||||||
name: "Dawit",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mahlet",
|
|
||||||
name: "Mahlet",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "amanuel",
|
|
||||||
name: "Amanuel",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "bethel",
|
|
||||||
name: "Bethel",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "liya",
|
|
||||||
name: "Liya",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "aseffa",
|
|
||||||
name: "Aseffa",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "hana",
|
|
||||||
name: "Hana",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "nahom",
|
|
||||||
name: "Nahom",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const STEPS = ["Context", "Scenario", "Persona", "Questions", "Review"];
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
import {
|
|
||||||
Rocket,
|
|
||||||
Edit2,
|
|
||||||
Layout,
|
|
||||||
Volume2,
|
|
||||||
Settings,
|
|
||||||
Maximize2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "../../../../components/ui/button";
|
|
||||||
|
|
||||||
interface ReviewPublishStepProps {
|
|
||||||
formData: any;
|
|
||||||
prevStep: () => void;
|
|
||||||
setIsPublished: (val: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReviewPublishStep({
|
|
||||||
formData,
|
|
||||||
prevStep,
|
|
||||||
setIsPublished,
|
|
||||||
}: ReviewPublishStepProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-20">
|
|
||||||
{/* 1. Video Preview Card */}
|
|
||||||
<div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden">
|
|
||||||
<div className="px-8 py-5 border-b border-grayScale-50 flex items-center justify-between bg-white">
|
|
||||||
<h3 className="text-[17px] font-bold text-grayScale-900">
|
|
||||||
Video Preview
|
|
||||||
</h3>
|
|
||||||
<span className="bg-[#FAF5FF] text-brand-500 text-[10px] font-bold px-3 py-1.5 rounded-[6px] tracking-wider uppercase border border-brand-100/50">
|
|
||||||
PROCESSED
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-10 flex items-center justify-center bg-[#F8FAFC]/30">
|
|
||||||
<div className="relative w-full max-w-4xl aspect-video rounded-[12px] overflow-hidden bg-black shadow-2xl group border-4 border-white">
|
|
||||||
{/* Mock Player Control Overlays */}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="h-16 w-16 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center border border-white/30 cursor-pointer hover:scale-110 transition-transform">
|
|
||||||
<div className="w-0 h-0 border-t-[10px] border-t-transparent border-l-[18px] border-l-white border-b-[10px] border-b-transparent ml-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Controls — Matching Image 1884 */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/95 via-black/40 to-transparent space-y-4">
|
|
||||||
{/* Row 1: Seeker and Timestamps */}
|
|
||||||
<div className="flex items-center gap-4 text-white">
|
|
||||||
<span className="text-[13px] font-medium opacity-90">0:00</span>
|
|
||||||
<div className="flex-1 h-1 bg-white/20 rounded-full relative cursor-pointer overflow-hidden group/seeker">
|
|
||||||
<div
|
|
||||||
className="absolute left-0 top-0 bottom-0 bg-brand-500 rounded-full"
|
|
||||||
style={{ width: "40%" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-[13px] font-medium opacity-90">
|
|
||||||
12:30
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: Icons */}
|
|
||||||
<div className="flex items-center justify-between text-white">
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<Volume2 className="h-[22px] w-[22px] opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
|
|
||||||
<div className="h-5 w-6 border-2 border-white rounded-[3px] flex items-center justify-center text-[9px] font-bold opacity-90 cursor-pointer hover:opacity-100 transition-opacity">
|
|
||||||
CC
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
<Settings className="h-5 w-5 opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
|
|
||||||
<Maximize2 className="h-5 w-5 opacity-90 cursor-pointer hover:opacity-100 transition-opacity" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 2. Content Details Card */}
|
|
||||||
<div className="bg-white rounded-[16px] border border-grayScale-50 shadow-sm overflow-hidden">
|
|
||||||
<div className="px-8 py-5 border-b border-grayScale-50 flex items-center justify-between bg-white">
|
|
||||||
<h3 className="text-[16px] font-bold text-grayScale-900">
|
|
||||||
Content Details
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={prevStep}
|
|
||||||
className="flex items-center gap-2 text-brand-500 font-bold text-sm hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
<Edit2 className="h-3 w-3" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-8 space-y-10">
|
|
||||||
{/* Metadata Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
|
||||||
TITLE
|
|
||||||
</span>
|
|
||||||
<p className="text-[15px] font-medium text-grayScale-900">
|
|
||||||
{formData.title || "Introduction to Past Tense"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
|
||||||
ASSIGNED MODULE
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Layout className="h-4 w-4 text-grayScale-400" />
|
|
||||||
<p className="text-[14px] font-medium text-grayScale-700">
|
|
||||||
Grammar Basics - Level 1
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
|
||||||
TEACHER NAME
|
|
||||||
</span>
|
|
||||||
<p className="text-[15px] font-medium text-grayScale-600">
|
|
||||||
Abebe Kebede
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
|
||||||
FILE SIZE
|
|
||||||
</span>
|
|
||||||
<div className="flex items-baseline gap-1.5">
|
|
||||||
<span className="text-[15px] font-bold text-grayScale-900">
|
|
||||||
245 MB
|
|
||||||
</span>
|
|
||||||
<span className="text-[13px] text-grayScale-400 font-medium">
|
|
||||||
(1080p MP4)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description Section */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
|
||||||
DESCRIPTION
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="text-[14px] text-grayScale-600 leading-relaxed max-w-4xl"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html:
|
|
||||||
formData.description ||
|
|
||||||
"This video covers the fundamental rules of forming the past tense in English, focusing on regular verbs ending in -ed. Suitable for beginners. Includes examples and common pitfalls.",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gradient Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 flex items-center"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div className="w-full border-t border-grayScale-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 3. Normal Footer (Inside Card) */}
|
|
||||||
<div className="px-8 py-6 border-t border-grayScale-50 flex items-center justify-between bg-white">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={prevStep}
|
|
||||||
className="h-10 px-8 rounded-xl border-grayScale-200 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-12 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
|
|
||||||
>
|
|
||||||
Save as Draft
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsPublished(true)}
|
|
||||||
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white shadow-brand-500/20 transition-all flex items-center gap-2.5"
|
|
||||||
>
|
|
||||||
<Rocket className="h-4 w-4" />
|
|
||||||
Publish Now
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
import { useRef, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
Video,
|
|
||||||
List,
|
|
||||||
Link as LinkIcon,
|
|
||||||
Lightbulb,
|
|
||||||
ChevronRight,
|
|
||||||
ImageIcon,
|
|
||||||
ArrowRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "../../../../components/ui/button";
|
|
||||||
import { Input } from "../../../../components/ui/input";
|
|
||||||
import { Select } from "../../../../components/ui/select";
|
|
||||||
|
|
||||||
interface VideoDetailStepProps {
|
|
||||||
formData: any;
|
|
||||||
setFormData: (data: any) => void;
|
|
||||||
nextStep: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VideoDetailStep({
|
|
||||||
formData,
|
|
||||||
setFormData,
|
|
||||||
nextStep,
|
|
||||||
}: VideoDetailStepProps) {
|
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
|
||||||
const isInternalChange = useRef(false);
|
|
||||||
|
|
||||||
// Initialize editor content only once or when needed from outside
|
|
||||||
useEffect(() => {
|
|
||||||
if (editorRef.current && !isInternalChange.current) {
|
|
||||||
editorRef.current.innerHTML = formData.description || "";
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCommand = (command: string, value?: string) => {
|
|
||||||
document.execCommand(command, false, value);
|
|
||||||
syncState();
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncState = () => {
|
|
||||||
if (editorRef.current) {
|
|
||||||
isInternalChange.current = true;
|
|
||||||
setFormData({ ...formData, description: editorRef.current.innerHTML });
|
|
||||||
// Reset after a short delay to allow exterior updates if any (e.g., from step change)
|
|
||||||
setTimeout(() => {
|
|
||||||
isInternalChange.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInput = () => {
|
|
||||||
syncState();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700 max-w-[1200px] mx-auto pb-20">
|
|
||||||
{/* Single Unified Card for Everything */}
|
|
||||||
<div className="bg-white rounded-[24px] border border-grayScale-50 p-10 shadow-sm space-y-8">
|
|
||||||
{/* 1. Upload Video Section */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h3 className="text-[20px] font-bold text-grayScale-900 ml-1">
|
|
||||||
Upload Video
|
|
||||||
</h3>
|
|
||||||
<div className="relative group cursor-pointer">
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-[20px] border-2 border-dashed border-[#E2E8F0] bg-[#F8FAFC]/30 p-14 transition-all hover:border-brand-200 hover:bg-brand-50/5">
|
|
||||||
<div className="h-16 w-16 rounded-full bg-white shadow-sm flex items-center justify-center mb-6">
|
|
||||||
<div className="h-10 w-10 rounded-full bg-[#FAF5FF] flex items-center justify-center">
|
|
||||||
<div className="h-6 w-6 relative flex items-center justify-center">
|
|
||||||
<div className="absolute inset-0 bg-brand-500/10 rounded-full blur-sm" />
|
|
||||||
<Video className="h-5 w-5 text-brand-500 relative" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h4 className="text-[17px] text-grayScale-900 mb-2">
|
|
||||||
Drag and drop video files here
|
|
||||||
</h4>
|
|
||||||
<p className="text-grayScale-400 font-medium text-[13px] mb-8">
|
|
||||||
MP4, MOV, WebM. Max size 2GB.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 w-full max-w-[200px] mb-8">
|
|
||||||
<div className="flex-1 h-[1px] bg-grayScale-200" />
|
|
||||||
<span className="text-[12px] font-bold text-grayScale-300 uppercase tracking-widest">
|
|
||||||
OR
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 h-[1px] bg-grayScale-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 px-8 rounded-xl border-grayScale-200 bg-white font-bold text-brand-500 hover:border-brand-500 hover:bg-brand-50 transition-all shadow-sm text-sm"
|
|
||||||
>
|
|
||||||
Browse Files
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Gradient Divider */}
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 flex items-center"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div className="w-full border-t border-grayScale-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<div
|
|
||||||
className="h-[0.5px] w-full opacity-20 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: "gray",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 2. Form & Side Panel Grid */}
|
|
||||||
<div className="flex flex-col lg:flex-row gap-12 items-start">
|
|
||||||
{/* Left Column: Title, Order, Description */}
|
|
||||||
<div className="flex-1 w-full space-y-10">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
|
||||||
Video Title
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g., Introduction to Past Tense Verbs"
|
|
||||||
className="h-12 rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 placeholder:text-grayScale-500 focus:border-brand-500 font-medium transition-all shadow-sm"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, title: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
|
||||||
Video Order
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
className="h-12 rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 font-medium cursor-pointer focus:border-brand-500 shadow-sm"
|
|
||||||
value={formData.order}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, order: (e.target as any).value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="1">1</option>
|
|
||||||
<option value="2">2</option>
|
|
||||||
<option value="3">3</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<div className="rounded-xl border border-grayScale-200 bg-white overflow-hidden flex flex-col min-h-[200px] shadow-sm focus-within:border-brand-200 transition-all">
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex items-center gap-1 bg-[#F8FAFC]">
|
|
||||||
<div className="flex items-center gap-1 w-fit bg-transparent px-2 py-1 rounded-lg">
|
|
||||||
<button
|
|
||||||
onClick={() => handleCommand("bold")}
|
|
||||||
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif font-bold text-[17px] pb-0.5 active:bg-grayScale-50"
|
|
||||||
>
|
|
||||||
B
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCommand("italic")}
|
|
||||||
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all font-serif italic text-[17px] pr-0.5 active:bg-grayScale-50"
|
|
||||||
>
|
|
||||||
I
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCommand("insertUnorderedList")}
|
|
||||||
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all active:bg-grayScale-50"
|
|
||||||
>
|
|
||||||
<List className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const url = prompt("Enter URL:");
|
|
||||||
if (url) handleCommand("createLink", url);
|
|
||||||
}}
|
|
||||||
className="h-9 w-9 flex items-center justify-center rounded-lg hover:bg-white hover:shadow-sm text-grayScale-900 transition-all active:bg-grayScale-50"
|
|
||||||
>
|
|
||||||
<LinkIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative p-6 flex-1">
|
|
||||||
{(!formData.description ||
|
|
||||||
formData.description === "<br>" ||
|
|
||||||
formData.description === "" ||
|
|
||||||
formData.description === "<div><br></div>") && (
|
|
||||||
<div className="absolute top-6 left-6 text-grayScale-300 font-medium text-[15px] pointer-events-none">
|
|
||||||
Provide a brief summary of what the student will learn...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
ref={editorRef}
|
|
||||||
contentEditable
|
|
||||||
onInput={handleInput}
|
|
||||||
className="w-full min-h-[140px] focus:outline-none text-[15px] text-grayScale-700 font-medium leading-relaxed prose prose-sm max-w-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column: Thumbnail, Pro Tip */}
|
|
||||||
<div className="w-full lg:w-[320px] space-y-5">
|
|
||||||
{/* Thumbnail Section */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-1 ml-1">
|
|
||||||
<h3 className="text-[14px] font-medium text-grayScale-900">
|
|
||||||
Thumbnail
|
|
||||||
</h3>
|
|
||||||
<p className="text-[12px] text-grayScale-400 font-medium leading-relaxed">
|
|
||||||
Upload your video thumbnail. 1280×720px recommended.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="relative group cursor-pointer aspect-video">
|
|
||||||
<div className="h-full w-full flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-grayScale-200 bg-[#F8FAFC]/50 p-6 transition-all group-hover:border-brand-200">
|
|
||||||
<div className="h-10 w-10 flex items-center justify-center mb-3">
|
|
||||||
<ImageIcon className="h-7 w-7 text-grayScale-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] font-bold text-brand-400">
|
|
||||||
Click to upload
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pro Tip Section */}
|
|
||||||
<div className="bg-brand-500/5 flex items-start gap-3 rounded-xl border border-[#F3E8FF] p-6 space-y-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-8 w-8 flex-shrink-0 flex items-center justify-center">
|
|
||||||
<Lightbulb className="h-4 w-4 text-brand-50" fill="#A855F7" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative top-[-10px]">
|
|
||||||
<h3 className="text-[14px] font-bold text-grayScale-900">
|
|
||||||
Pro Tip
|
|
||||||
</h3>
|
|
||||||
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
|
|
||||||
Short, descriptive titles work best. Include keywords like
|
|
||||||
"Grammar" or "Vocabulary" to help students find your content.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer (Inside Card Container) */}
|
|
||||||
<div className="pt-5 border-t border-grayScale-200 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-[14px] font-medium text-grayScale-600">
|
|
||||||
Last saved: Just now
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={nextStep}
|
|
||||||
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white transition-all flex items-center gap-2 text-sm group active:scale-95"
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -57,148 +57,6 @@ export interface UpdateCourseRequest {
|
||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Row from GET /programs (e.g. Beginner / Intermediate program buckets) */
|
|
||||||
export interface LearningProgramListItem {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
description?: string | null
|
|
||||||
thumbnail?: string | null
|
|
||||||
sort_order: number
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateLearningProgramRequest {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
thumbnail: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateLearningProgramRequest {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
thumbnail: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateLearningProgramResponse {
|
|
||||||
message: string
|
|
||||||
data: LearningProgramListItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetLearningProgramsResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
programs: LearningProgramListItem[]
|
|
||||||
total_count: number
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Row from GET /programs/:program_id/courses */
|
|
||||||
export interface ProgramCourseListItem {
|
|
||||||
id: number
|
|
||||||
program_id: number
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
sort_order: number
|
|
||||||
created_at: string
|
|
||||||
thumbnail?: string | null
|
|
||||||
/** Some list endpoints may expose the image as `thumbnail_url` instead. */
|
|
||||||
thumbnail_url?: string | null
|
|
||||||
/** When the API adds aggregates, map these for the course cards. */
|
|
||||||
modules_count?: number
|
|
||||||
videos_count?: number
|
|
||||||
practices_count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Body for PUT /courses/:id (program-linked Learn English courses). */
|
|
||||||
export interface UpdateTopLevelCourseRequest {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
thumbnail: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Body for POST /programs/:program_id/courses */
|
|
||||||
export interface CreateProgramCourseRequest {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
thumbnail: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateProgramCourseResponse {
|
|
||||||
message: string
|
|
||||||
data: ProgramCourseListItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetProgramCoursesResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
total_count: number
|
|
||||||
limit: number
|
|
||||||
offset: number
|
|
||||||
courses: ProgramCourseListItem[]
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Row from GET /courses/:courseId/modules (Learn English track). */
|
|
||||||
export interface TopLevelCourseModuleItem {
|
|
||||||
id: number
|
|
||||||
program_id: number
|
|
||||||
course_id: number
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
icon?: string | null
|
|
||||||
sort_order: number
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetTopLevelCourseModulesResponse {
|
|
||||||
message: string
|
|
||||||
data: {
|
|
||||||
limit: number
|
|
||||||
offset: number
|
|
||||||
modules: TopLevelCourseModuleItem[]
|
|
||||||
total_count: number
|
|
||||||
}
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Body for PUT /modules/:id (Learn English top-level modules). */
|
|
||||||
export interface UpdateTopLevelCourseModuleRequest {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
icon: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Body for POST /courses/:courseId/modules */
|
|
||||||
export interface CreateTopLevelCourseModuleRequest {
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
icon: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateTopLevelCourseModuleResponse {
|
|
||||||
message: string
|
|
||||||
data: TopLevelCourseModuleItem
|
|
||||||
success: boolean
|
|
||||||
status_code: number
|
|
||||||
metadata: unknown | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Legacy Types (deprecated - using SubCourse hierarchy now)
|
// Legacy Types (deprecated - using SubCourse hierarchy now)
|
||||||
// Keeping for backward compatibility with existing API endpoints
|
// Keeping for backward compatibility with existing API endpoints
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user