Content admin: course hierarchy, sub-categories API, and stability fixes

- Sub-categories: load from GET categories/:id/sub-categories; SubCategoryCoursesPage
- Course structure: levels/modules/sub-modules APIs; SubCoursesPage hierarchy browser
- Sub-module detail: HumanLanguageSubModulePage for category routes; resolveSubModuleForCourse
- Types and courses API: module sub-modules endpoint, hierarchy array guard
- Misc: AppLayout/IssuesPage fixes, CoursesPage refactor, Human Language hierarchy page

Made-with: Cursor
This commit is contained in:
Yared Yemane 2026-04-20 10:30:28 -07:00
parent f6344c19f9
commit 73f11ea1a0
14 changed files with 3867 additions and 6035 deletions

View File

@ -49,7 +49,18 @@ import type {
GetLearningPathResponse,
GetSubModuleLessonDetailResponse,
GetHumanLanguageLessonsResponse,
GetHumanLanguageHierarchyResponse,
GetSubModuleLessonsResponse,
GetHumanLanguageSubCategoriesResponse,
GetCategorySubCategoriesResponse,
GetSubCategoryCoursesResponse,
GetCourseLevelsForCourseResponse,
GetCourseLevelsAllResponse,
GetCourseLevelByIdResponse,
GetHumanLanguageHierarchyFlatResponse,
GetCourseHierarchyResponse,
GetSubModulesByModuleResponse,
CourseHierarchyRow,
SubCourse,
CreateHumanLanguageLessonRequest,
GetSubCourseEntryAssessmentResponse,
ReorderItem,
@ -57,6 +68,8 @@ import type {
GetRatingsParams,
GetVimeoSampleResponse,
CreateCourseVideoRequest,
UpdateSubModuleLessonRequest,
UpdateSubModuleLessonResponse,
} from "../types/course.types"
type UnifiedHierarchyRow = {
@ -68,17 +81,6 @@ type UnifiedHierarchyRow = {
course_title?: string | null
}
type CourseHierarchyRow = {
course_id: number
course_title: string
level_id?: number | null
cefr_level?: string | null
module_id?: number | null
module_title?: string | null
sub_module_id?: number | null
sub_module_title?: string | null
}
async function withSingleRetry<T>(request: () => Promise<T>, retryDelayMs = 400): Promise<T> {
try {
return await request()
@ -169,6 +171,26 @@ export const deleteCourseCategory = (categoryId: number) =>
export const deleteCourseSubCategory = (subCategoryId: number) =>
http.delete(`/course-management/sub-categories/${subCategoryId}`)
export const getSubCategoriesByCategoryId = (categoryId: number) =>
http.get<GetCategorySubCategoriesResponse>(`/course-management/categories/${categoryId}/sub-categories`)
export const createSubCategory = (payload: {
category_id: number
name: string
description?: string | null
display_order?: number
}) => http.post("/course-management/sub-categories", payload)
export const updateSubCategory = (
subCategoryId: number,
payload: Partial<{
name: string
description: string | null
is_active: boolean
display_order: number
}>,
) => http.patch(`/course-management/sub-categories/${subCategoryId}`, payload)
export const getCoursesByCategory = (categoryId: number) =>
withSingleRetry(() => http.get("/course-management/hierarchy")).then((res) => {
const rows: UnifiedHierarchyRow[] = res.data?.data ?? []
@ -221,17 +243,39 @@ export const updateCourseStatus = (courseId: number, isActive: boolean) =>
export const updateCourse = (courseId: number, data: UpdateCourseRequest) =>
http.put(`/course-management/courses/${courseId}`, data)
export const getCourseHierarchyByCourseId = (courseId: number) =>
http.get<GetCourseHierarchyResponse>(`/course-management/courses/${courseId}/hierarchy`)
// Sub-Module APIs (Unified Hierarchy)
export const getSubModulesByCourse = (courseId: number) =>
http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => {
const rows: CourseHierarchyRow[] = res.data?.data ?? []
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 }>()
getCourseHierarchyByCourseId(courseId).then((res) => {
const raw = res.data?.data
const rows: CourseHierarchyRow[] = Array.isArray(raw) ? raw : []
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) => {
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, {
id: r.sub_module_id,
course_id: courseId,
level_id: r.level_id ?? undefined,
module_id: r.module_id ?? undefined,
title: r.sub_module_title ?? "",
description: "",
@ -242,7 +286,17 @@ export const getSubModulesByCourse = (courseId: number) =>
sub_level: r.cefr_level ?? undefined,
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())
return {
@ -299,6 +353,11 @@ export const deleteSubModule = (subModuleId: number) =>
export const getVideosBySubModule = (subModuleId: number) =>
http.get<GetSubCourseVideosResponse>(`/course-management/sub-modules/${subModuleId}/videos`)
export const getLessonsBySubModule = (subModuleId: number, options?: { includeInactive?: boolean }) =>
http.get<GetSubModuleLessonsResponse>(`/course-management/sub-modules/${subModuleId}/lessons`, {
params: { include_inactive: options?.includeInactive ?? true },
})
export const getSubModuleLessonById = (
lessonId: number,
options?: {
@ -313,6 +372,14 @@ export const getSubModuleLessonById = (
params: options?.cacheBust ? { _t: Date.now() } : undefined,
})
export const updateSubModuleLesson = (lessonId: number, data: UpdateSubModuleLessonRequest) =>
http.put<UpdateSubModuleLessonResponse>(`/course-management/sub-module-lessons/${lessonId}`, data)
export const softDeleteSubModuleLesson = (lessonId: number) =>
http.put<UpdateSubModuleLessonResponse>(`/course-management/sub-module-lessons/${lessonId}`, {
is_active: false,
})
export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) =>
http.post("/course-management/sub-module-videos", {
sub_module_id: data.sub_module_id ?? data.sub_course_id,
@ -631,270 +698,92 @@ export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: st
params: { cefr_level },
})
export const getHumanLanguageSubCategories = () =>
http.get<GetHumanLanguageSubCategoriesResponse>("/course-management/human-language/sub-categories")
export const getCoursesBySubCategoryId = (subCategoryId: number) =>
http.get<GetSubCategoryCoursesResponse>(`/course-management/sub-categories/${subCategoryId}/courses`)
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.
* Use when the legacy hierarchy flatten (`getSubModulesByCourse`) does not include the row.
*/
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 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<GetHumanLanguageHierarchyResponse>("/course-management/hierarchy", {
http.get<GetHumanLanguageHierarchyFlatResponse>("/course-management/human-language/hierarchy", {
params: options?.cacheBust ? { _t: Date.now() } : undefined,
}),
).then(async (res) => {
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 : []
const categoryMap = new Map<
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) => {
const categoryId = Number(row.category_id)
if (!Number.isFinite(categoryId)) return
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
category_id: categoryId,
category_name: row.category_name ?? "",
sub_categories: new Map(),
})
}
if (!row.sub_category_id) return
const subCategoryId = Number(row.sub_category_id)
if (!Number.isFinite(subCategoryId)) return
const categoryNode = categoryMap.get(categoryId)!
if (!categoryNode.sub_categories.has(subCategoryId)) {
categoryNode.sub_categories.set(subCategoryId, {
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 categories = Array.from(categoryMap.values())
const humanLanguageCandidates = categories.filter((c) => c.category_name.toLowerCase().includes("human"))
const selectedCategory = (humanLanguageCandidates.length ? humanLanguageCandidates : categories).sort((a, b) => {
const aSubCategoryCount = a.sub_categories.size
const bSubCategoryCount = b.sub_categories.size
if (aSubCategoryCount !== bSubCategoryCount) return bSubCategoryCount - aSubCategoryCount
const aCourseCount = Array.from(a.sub_categories.values()).reduce((sum, sub) => sum + sub.courses.size, 0)
const bCourseCount = Array.from(b.sub_categories.values()).reduce((sum, sub) => sum + sub.courses.size, 0)
if (aCourseCount !== bCourseCount) return bCourseCount - aCourseCount
// If tied on richness, pick the latest category id.
return b.category_id - a.category_id
})[0]
if (!selectedCategory) {
return {
...res,
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_id?: number
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_id: Number(row.level_id), 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: [],
lessons: [],
practices: [],
})
}
})
return {
course_id: course.course_id,
course_name: course.course_name,
levels: Array.from(levelMap.values()).map((levelNode) => ({
level_id: levelNode.level_id,
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 subModuleIds = subCategories.flatMap((sub) =>
sub.courses.flatMap((course) =>
course.levels.flatMap((levelNode) => levelNode.modules.flatMap((moduleNode) => moduleNode.sub_modules.map((sm) => sm.id))),
),
)
type QuestionSetListItem = {
id: number
title?: string
set_type?: string
status?: string
intro_video_url?: string | null
question_count?: number
created_at?: string
}
const questionSetsBySubModule = new Map<number, QuestionSetListItem[]>()
await Promise.all(
subModuleIds.map(async (subModuleID) => {
try {
const questionSetRes = await http.get<GetQuestionSetsResponse>("/question-sets/by-owner", {
params: { owner_type: "SUB_MODULE", owner_id: subModuleID },
})
const payload = questionSetRes.data?.data
const sets = Array.isArray(payload)
? payload
: Array.isArray((payload as { question_sets?: QuestionSetListItem[] } | undefined)?.question_sets)
? ((payload as { question_sets: QuestionSetListItem[] }).question_sets ?? [])
: []
questionSetsBySubModule.set(subModuleID, sets as QuestionSetListItem[])
} catch {
questionSetsBySubModule.set(subModuleID, [])
}
}),
)
subCategories.forEach((sub) => {
sub.courses.forEach((course) => {
course.levels.forEach((levelNode) => {
levelNode.modules.forEach((moduleNode) => {
moduleNode.sub_modules.forEach((subModuleNode) => {
const sets = questionSetsBySubModule.get(subModuleNode.id) ?? []
const lessons = sets
.filter((set) => String(set.set_type ?? "").toUpperCase() === "QUIZ")
.sort((a, b) => {
const ad = Date.parse(String(a.created_at ?? "")) || 0
const bd = Date.parse(String(b.created_at ?? "")) || 0
return ad - bd
})
.map((set, idx) => ({
id: Number(set.id),
question_set_id: Number(set.id),
title: set.title?.trim() || `Lesson ${idx + 1}`,
status: set.status ?? "DRAFT",
question_count: Number(set.question_count ?? 0),
display_order: idx + 1,
intro_video_url: set.intro_video_url ?? null,
}))
subModuleNode.lessons = lessons
})
})
})
})
})
return {
...res,
data: {
...res.data,
data: {
category_id: selectedCategory.category_id,
category_name: selectedCategory.category_name,
sub_categories: subCategories,
},
},
} as unknown as { data: GetHumanLanguageHierarchyResponse }
})
)
export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest) =>
http

View File

@ -12,6 +12,7 @@ let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
const TOKEN_REFRESH_BUFFER_SECONDS = 120;
const processQueue = (error: Error | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
@ -32,23 +33,47 @@ const clearAuthAndRedirect = () => {
window.location.href = "/login";
};
const refreshAccessToken = async (): Promise<string> => {
const accessToken = localStorage.getItem("access_token");
const refreshToken = localStorage.getItem("refresh_token");
const role = localStorage.getItem("role");
const memberId = localStorage.getItem("member_id");
const decodeJwtPayload = (token: string): Record<string, unknown> | null => {
try {
const payloadPart = token.split(".")[1];
if (!payloadPart) return null;
const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
const json = atob(padded);
return JSON.parse(json) as Record<string, unknown>;
} catch {
return null;
}
};
if (!refreshToken || !memberId) {
const isAccessTokenExpiringSoon = (token: string) => {
const payload = decodeJwtPayload(token);
const exp = Number(payload?.exp);
if (!Number.isFinite(exp)) return true;
const nowSeconds = Math.floor(Date.now() / 1000);
return exp - nowSeconds <= TOKEN_REFRESH_BUFFER_SECONDS;
};
const isAuthEndpointRequest = (url?: string) => {
if (!url) return false;
return (
url.includes("/team/login") ||
url.includes("/team/google-login") ||
url.includes("/team/refresh")
);
};
const refreshAccessToken = async (): Promise<string> => {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) {
throw new Error("No refresh token available");
}
const response = await axios.post(
`${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
`${import.meta.env.VITE_API_BASE_URL}/team/refresh`,
{
access_token: accessToken,
refresh_token: refreshToken,
role: role || "admin",
member_id: Number(memberId),
}
);
@ -65,9 +90,43 @@ const refreshAccessToken = async (): Promise<string> => {
return newAccessToken;
};
const getValidAccessToken = async (forceRefresh = false): Promise<string> => {
const currentToken = localStorage.getItem("access_token");
if (!forceRefresh && currentToken && !isAccessTokenExpiringSoon(currentToken)) {
return currentToken;
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
});
}
isRefreshing = true;
try {
const newToken = await refreshAccessToken();
processQueue(null, newToken);
return newToken;
} catch (refreshError) {
processQueue(refreshError as Error, null);
clearAuthAndRedirect();
throw refreshError;
} finally {
isRefreshing = false;
}
};
// Attach access token to every request
http.interceptors.request.use((config) => {
const token = localStorage.getItem("access_token");
http.interceptors.request.use(async (config) => {
if (isAuthEndpointRequest(config.url)) {
return config;
}
let token = localStorage.getItem("access_token");
if (token && isAccessTokenExpiringSoon(token)) {
token = await getValidAccessToken();
}
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
@ -80,32 +139,19 @@ http.interceptors.response.use(
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return http(originalRequest);
})
.catch((err) => Promise.reject(err));
}
if (
error.response?.status === 401 &&
!originalRequest._retry &&
!isAuthEndpointRequest(originalRequest.url)
) {
originalRequest._retry = true;
isRefreshing = true;
try {
const newToken = await refreshAccessToken();
processQueue(null, newToken);
const newToken = await getValidAccessToken(true);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return http(originalRequest);
} catch (refreshError) {
processQueue(refreshError as Error, null);
clearAuthAndRedirect();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}

View File

@ -12,7 +12,6 @@ import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuest
import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage"
import { AddNewLessonPage } from "../pages/content-management/AddNewLessonPage"
import { SubModulesPage } from "../pages/content-management/SubCoursesPage"
import { SubModuleContentPage } from "../pages/content-management/SubCourseContentPage"
import { SpeakingPage } from "../pages/content-management/SpeakingPage"
import { AddVideoPage } from "../pages/content-management/AddVideoPage"
import { AddPracticePage } from "../pages/content-management/AddPracticePage"
@ -32,8 +31,9 @@ import { PracticeDetailsPage } from "../pages/content-management/PracticeDetails
import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage"
import { QuestionsPage } from "../pages/content-management/QuestionsPage"
import { AddQuestionPage } from "../pages/content-management/AddQuestionPage"
import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage"
import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLanguageHierarchyPage"
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage"
import { SubCategoryCoursesPage } from "../pages/content-management/SubCategoryCoursesPage"
import { UserLogPage } from "../pages/user-log/UserLogPage"
import { IssuesPage } from "../pages/issues/IssuesPage"
import { ProfilePage } from "../pages/ProfilePage"
@ -79,7 +79,7 @@ export function AppRoutes() {
<Route index element={<CourseCategoryPage />} />
<Route path="courses" element={<AllCoursesPage />} />
<Route path="flows" element={<CourseFlowBuilderPage />} />
<Route path="human-language" element={<HumanLanguagePage />} />
<Route path="human-language" element={<HumanLanguageHierarchyPage />} />
<Route
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/add-practice"
element={<AddNewPracticePage />}
@ -92,21 +92,29 @@ export function AppRoutes() {
path="human-language/:categoryId/:courseId/sub-module/:subModuleId/practices/:practiceId/questions"
element={<PracticeQuestionsPage />}
/>
<Route
path="human-language/:categoryId/:courseId/level/:levelId/practices/:practiceId/questions"
element={<PracticeQuestionsPage />}
/>
<Route
path="human-language/:categoryId/:courseId/sub-module/:subModuleId"
element={<HumanLanguageSubModulePage />}
/>
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
<Route
path="category/:categoryId/sub-categories/:subCategoryId/courses"
element={<SubCategoryCoursesPage />}
/>
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
{/* Course → Sub-module → Lesson/Practice */}
<Route path="category/:categoryId/courses/:courseId/sub-modules" element={<SubModulesPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId" element={<SubModuleContentPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId" element={<HumanLanguageSubModulePage />} />
<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 path="category/:categoryId/courses/:courseId/sub-modules/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />
{/* Legacy aliases */}
<Route path="category/:categoryId/courses/:courseId/sub-courses" element={<SubModulesPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId" element={<SubModuleContentPage />} />
<Route path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId" element={<HumanLanguageSubModulePage />} />
<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 path="category/:categoryId/courses/:courseId/sub-courses/:subModuleId/practices/:practiceId/questions" element={<PracticeQuestionsPage />} />

View File

@ -13,9 +13,6 @@ export function AppLayout() {
const routeKey = useMemo(() => `${location.pathname}${location.search}`, [location.pathname, location.search])
const token = localStorage.getItem("access_token")
if (!token) {
return <Navigate to="/login" replace />
}
const handleSidebarToggle = useCallback(() => {
setSidebarOpen((prev) => !prev)
@ -58,6 +55,10 @@ export function AppLayout() {
}
}, [routeKey])
if (!token) {
return <Navigate to="/login" replace />
}
return (
<div className="flex min-h-screen bg-grayScale-100">
<Sidebar

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -58,7 +58,13 @@ const typeColors: Record<QuestionType, string> = {
}
export function PracticeQuestionsPage() {
const { categoryId, courseId, subModuleId, practiceId } = useParams()
const { categoryId, courseId, subModuleId, levelId, practiceId } = useParams<{
categoryId: string
courseId: string
subModuleId?: string
levelId?: string
practiceId?: string
}>()
const location = useLocation()
const [questions, setQuestions] = useState<PracticeQuestion[]>([])
@ -102,11 +108,14 @@ export function PracticeQuestionsPage() {
const [saveError, setSaveError] = useState<string | null>(null)
const backLink = useMemo(() => {
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/")) {
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/level/") && levelId) {
return "/content/human-language"
}
if (location.pathname.includes("/content/human-language/") && location.pathname.includes("/sub-module/") && subModuleId) {
return `/content/human-language/${categoryId}/${courseId}/sub-module/${subModuleId}`
}
return `/content/category/${categoryId}/courses/${courseId}/sub-modules/${subModuleId}`
}, [location.pathname, categoryId, courseId, subModuleId])
}, [location.pathname, categoryId, courseId, subModuleId, levelId])
const buildDefaultOptions = (type: QuestionType, sampleAnswerText?: string): DraftOption[] => {
if (type === "TRUE_FALSE") {

View File

@ -0,0 +1,144 @@
import { useEffect, useState } from "react"
import { Link, useNavigate, useParams } from "react-router-dom"
import { ArrowLeft, BookOpen, ChevronRight } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import alertSrc from "../../assets/Alert.svg"
import { Badge } from "../../components/ui/badge"
import { getCoursesBySubCategoryId, getSubCategoriesByCategoryId } from "../../api/courses.api"
import type { CategorySubCategoryListItem, SubCategoryCourseListItem } from "../../types/course.types"
import { cn } from "../../lib/utils"
export function SubCategoryCoursesPage() {
const { categoryId, subCategoryId } = useParams<{
categoryId: string
subCategoryId: string
}>()
const navigate = useNavigate()
const [subCategory, setSubCategory] = useState<CategorySubCategoryListItem | null>(null)
const [courses, setCourses] = useState<SubCategoryCourseListItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const run = async () => {
if (!categoryId || !subCategoryId) return
const cid = Number(categoryId)
const sid = Number(subCategoryId)
if (!Number.isFinite(cid) || !Number.isFinite(sid)) {
setError("Invalid route parameters")
setLoading(false)
return
}
setLoading(true)
setError(null)
try {
const [subRes, coursesRes] = await Promise.all([
getSubCategoriesByCategoryId(cid),
getCoursesBySubCategoryId(sid),
])
const list = subRes.data?.data?.sub_categories ?? []
const found = Array.isArray(list) ? list.find((s) => s.id === sid) : undefined
setSubCategory(found ?? null)
const raw = coursesRes.data?.data?.courses
setCourses(Array.isArray(raw) ? raw : [])
} catch (e) {
console.error(e)
setError("Failed to load courses for this sub-category")
setCourses([])
} finally {
setLoading(false)
}
}
void run()
}, [categoryId, subCategoryId])
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-32">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
<p className="mt-4 text-sm text-grayScale-500">Loading courses</p>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-32">
<div className="mx-4 flex w-full max-w-md items-center gap-3 rounded-2xl border border-red-100 bg-red-50 px-6 py-5 shadow-sm">
<img src={alertSrc} alt="" className="h-10 w-10 shrink-0" />
<p className="text-sm font-medium text-red-600">{error}</p>
</div>
</div>
)
}
const label = subCategory?.name ?? "Sub-category"
return (
<div className="space-y-6">
<div className="rounded-2xl border border-grayScale-100 bg-white px-5 py-4 shadow-sm sm:px-6 sm:py-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3.5">
<Link
to={`/content/category/${categoryId}/courses`}
className="mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-grayScale-50 text-grayScale-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<div>
<p className="text-xs font-medium text-grayScale-400">Sub-category</p>
<h1 className="text-xl font-bold text-grayScale-700 sm:text-2xl">{label}</h1>
<p className="mt-0.5 text-sm text-grayScale-400">
{courses.length} course{courses.length !== 1 ? "s" : ""} open a course to manage sub-modules
</p>
</div>
</div>
</div>
</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 sub-category yet</p>
<p className="mt-1 text-sm text-grayScale-400">Add a course from your authoring flow or API.</p>
</div>
) : (
<div className="space-y-3">
{courses.map((c) => (
<button
key={c.id}
type="button"
onClick={() =>
navigate(`/content/category/${categoryId}/courses/${c.id}/sub-modules`)
}
className={cn(
"flex w-full items-center justify-between gap-4 rounded-xl border border-grayScale-200 bg-white px-4 py-4 text-left shadow-sm transition-all",
"hover:border-brand-200 hover:bg-brand-50/40 hover:shadow-md",
)}
>
<div className="flex min-w-0 items-center gap-3">
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400">
<BookOpen className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="font-semibold text-grayScale-800">{c.title}</p>
{c.description?.trim() ? (
<p className="mt-0.5 line-clamp-2 text-sm text-grayScale-500">{c.description}</p>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<Badge variant={c.is_active ? "success" : "secondary"} className="text-[11px]">
{c.is_active ? "Active" : "Inactive"}
</Badge>
<ChevronRight className="h-5 w-5 text-grayScale-300" />
</div>
</button>
))}
</div>
)}
</div>
)
}

View File

@ -103,9 +103,10 @@ export function SubModuleContentPage() {
try {
const subCoursesRes = await getSubModulesByCourse(Number(courseId))
const foundSubCourse = subCoursesRes.data.data.sub_courses?.find(
(sc) => sc.id === Number(subModuleId)
)
const list = subCoursesRes.data?.data?.sub_courses
const foundSubCourse = Array.isArray(list)
? list.find((sc) => sc.id === Number(subModuleId))
: undefined
setSubCourse(foundSubCourse ?? null)
} catch (err) {
console.error("Failed to fetch course data:", err)
@ -123,7 +124,9 @@ export function SubModuleContentPage() {
setPracticesLoading(true)
try {
const res = await getQuestionSetsByOwner("SUB_MODULE", Number(subModuleId))
setPractices(res.data.data ?? [])
const raw = res.data?.data
const list = Array.isArray(raw) ? raw : (raw as { question_sets?: QuestionSet[] })?.question_sets ?? []
setPractices(Array.isArray(list) ? list : [])
} catch (err) {
console.error("Failed to fetch practices:", err)
} finally {
@ -136,7 +139,8 @@ export function SubModuleContentPage() {
setVideosLoading(true)
try {
const res = await getVideosBySubModule(Number(subModuleId))
setVideos(res.data.data.videos ?? [])
const vids = res.data?.data?.videos ?? []
setVideos(Array.isArray(vids) ? vids : [])
} catch (err) {
console.error("Failed to fetch videos:", err)
} finally {
@ -154,7 +158,7 @@ export function SubModuleContentPage() {
limit: ratingsPageSize,
offset,
})
setRatings(res.data.data ?? [])
setRatings(res.data?.data ?? [])
} catch (err) {
console.error("Failed to fetch ratings:", err)
} finally {
@ -405,8 +409,8 @@ export function SubModuleContentPage() {
const idMatch = video.video_url?.match(/(\d{5,})/)
const vimeoId = idMatch?.[1] ?? "76979871" // fallback to Big Buck Bunny
const res = await getVimeoSample(vimeoId)
setPreviewIframe(res.data.data.iframe)
setPreviewVideo(res.data.data.video)
setPreviewIframe(res.data?.data?.iframe ?? "")
setPreviewVideo(res.data?.data?.video ?? null)
} catch {
setPreviewIframe("")
} finally {
@ -414,7 +418,7 @@ export function SubModuleContentPage() {
}
}
const filteredPractices = practices.filter((practice) => {
const filteredPractices = (Array.isArray(practices) ? practices : []).filter((practice) => {
if (statusFilter === "all") return true
if (statusFilter === "published") return practice.status === "PUBLISHED"
if (statusFilter === "draft") return practice.status === "DRAFT"
@ -440,6 +444,19 @@ export function SubModuleContentPage() {
)
}
if (!subCourse) {
return (
<div className="flex flex-col items-center justify-center py-20">
<img src={alertSrc} alt="" className="h-12 w-12" />
<p className="mt-3 text-sm font-medium text-grayScale-600">Sub-module not found</p>
<p className="mt-1 text-sm text-grayScale-400">It may have been removed or the link is invalid.</p>
<Button className="mt-6" variant="outline" asChild>
<Link to={`/content/category/${categoryId}/courses/${courseId}/sub-modules`}>Back to sub-modules</Link>
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Back Button */}
@ -590,7 +607,7 @@ export function SubModuleContentPage() {
<div className="flex items-center gap-3 text-xs text-grayScale-400">
<div className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
<span>{practice.owner_type.replace("_", " ")}</span>
<span>{String(practice.owner_type ?? "SUB_MODULE").replace(/_/g, " ")}</span>
</div>
{practice.shuffle_questions && (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 ring-1 ring-inset ring-amber-200">Shuffle ON</span>
@ -599,11 +616,13 @@ export function SubModuleContentPage() {
<div className="mt-auto flex items-center justify-between border-t border-grayScale-100 pt-3">
<span className="text-xs font-medium text-grayScale-400">
{new Date(practice.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
{practice.created_at
? new Date(practice.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
: "—"}
</span>
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
<button

File diff suppressed because it is too large Load Diff

View File

@ -95,12 +95,13 @@ function getStatusConfig(status: string): {
}
}
function getIssueTypeConfig(type: string): {
function getIssueTypeConfig(type: string | null | undefined): {
label: string;
classes: string;
icon: typeof Bug;
} {
switch (type) {
const t = String(type ?? "").trim();
switch (t) {
case "bug":
return {
label: "Bug",
@ -133,7 +134,7 @@ function getIssueTypeConfig(type: string): {
};
default:
return {
label: type.charAt(0).toUpperCase() + type.slice(1),
label: t ? t.charAt(0).toUpperCase() + t.slice(1) : "Other",
classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
icon: HelpCircle,
};
@ -173,8 +174,10 @@ function getRelativeTime(dateStr: string): string {
return formatDate(dateStr);
}
function formatRoleLabel(role: string): string {
return role
function formatRoleLabel(role: string | null | undefined): string {
const r = String(role ?? "").trim();
if (!r) return "—";
return r
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
@ -221,8 +224,9 @@ export function IssuesPage() {
offset: (page - 1) * pageSize,
};
const res = await getIssues(filters);
setIssues(res.data.data.issues);
setTotalCount(res.data.data.total_count);
const payload = res.data?.data;
setIssues(Array.isArray(payload?.issues) ? payload.issues : []);
setTotalCount(typeof payload?.total_count === "number" ? payload.total_count : 0);
} catch (error) {
console.error("Failed to fetch issues:", error);
setIssues([]);
@ -241,7 +245,7 @@ export function IssuesPage() {
setDetailLoading(true);
try {
const res = await getIssueById(issueId);
setSelectedIssue(res.data.data);
setSelectedIssue(res.data?.data ?? null);
} catch (error) {
console.error("Failed to fetch issue detail:", error);
} finally {
@ -305,16 +309,15 @@ export function IssuesPage() {
};
// Client-side filtering (status, type, search)
const filteredIssues = issues.filter((issue) => {
const filteredIssues = (Array.isArray(issues) ? issues : []).filter((issue) => {
if (statusFilter && issue.status !== statusFilter) return false;
if (typeFilter && issue.issue_type !== typeFilter) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
return (
issue.subject.toLowerCase().includes(q) ||
issue.description.toLowerCase().includes(q) ||
issue.issue_type.toLowerCase().includes(q)
);
const subject = String(issue.subject ?? "").toLowerCase();
const description = String(issue.description ?? "").toLowerCase();
const issueType = String(issue.issue_type ?? "").toLowerCase();
return subject.includes(q) || description.includes(q) || issueType.includes(q);
}
return true;
});
@ -537,10 +540,10 @@ export function IssuesPage() {
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-grayScale-600 truncate">
{issue.subject}
{issue.subject?.trim() ? issue.subject : "—"}
</p>
<p className="text-xs text-grayScale-400 truncate mt-0.5">
{issue.description}
{issue.description?.trim() ? issue.description : "No description"}
</p>
</div>
</div>
@ -572,6 +575,9 @@ export function IssuesPage() {
{getStatusConfig(s).label}
</option>
))}
{!STATUSES.includes(issue.status as (typeof STATUSES)[number]) && issue.status ? (
<option value={issue.status}>{getStatusConfig(issue.status).label}</option>
) : null}
</select>
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 pointer-events-none opacity-50" />
</div>

View File

@ -173,7 +173,13 @@ export interface GetModulesResponse {
export interface CreateModuleRequest {
level_id: number
title: string
content: string
/** Legacy field kept for backward compatibility. */
content?: string
/** Preferred field for module detail text. */
description?: string
icon_url?: string
display_order?: number
is_active?: boolean
}
/** @deprecated Use UpdateSubCourseRequest instead */
@ -193,6 +199,8 @@ export interface UpdateModuleStatusRequest {
export interface SubCourse {
id: number
course_id: number
/** Present when derived from course hierarchy rows (levels → modules → sub-modules). */
level_id?: number
module_id?: number
title: string
description: string
@ -705,15 +713,31 @@ export interface HumanLanguageLesson {
export interface SubModuleLessonDetail {
id: number
sub_module_id: number
question_set_id: number
intro_video_url?: string | null
display_order: number
is_active: boolean
created_at: string
title: string
description?: string | null
status: string
set_type: string
question_count: number
thumbnail?: string | null
teaching_text?: string | null
teaching_image_url?: string | null
teaching_audio_url?: string | null
teaching_video_url?: string | null
}
export interface SubModuleLesson {
id: number
sub_module_id: number
display_order: number
is_active: boolean
created_at: string
title: string
description?: string | null
thumbnail?: string | null
teaching_text?: string | null
teaching_image_url?: string | null
teaching_audio_url?: string | null
teaching_video_url?: string | null
}
export interface GetSubModuleLessonDetailResponse {
@ -724,6 +748,34 @@ export interface GetSubModuleLessonDetailResponse {
metadata: unknown
}
export interface UpdateSubModuleLessonRequest {
title: string
description?: string | null
thumbnail?: string | null
teaching_text?: string | null
teaching_image_url?: string | null
teaching_audio_url?: string | null
teaching_video_url?: string | null
display_order: number
is_active: boolean
}
export interface UpdateSubModuleLessonResponse {
message: string
data: SubModuleLessonDetail
success: boolean
status_code: number
metadata: unknown
}
export interface GetSubModuleLessonsResponse {
message: string
data: SubModuleLesson[]
success: boolean
status_code: number
metadata: unknown
}
export interface GetHumanLanguageLessonsResponse {
message: string
data: {
@ -737,6 +789,196 @@ export interface GetHumanLanguageLessonsResponse {
metadata: unknown
}
/** Row from GET /course-management/human-language/sub-categories */
export interface HumanLanguageSubCategoryListItem {
id: number
category_id: number
category_name: string
name: string
description?: string | null
display_order: number
is_active: boolean
created_at: string
/** Present on some payloads; ignore if unused. */
total_count?: number
}
export interface GetHumanLanguageSubCategoriesResponse {
message: string
data: {
sub_categories: HumanLanguageSubCategoryListItem[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown
}
/** Row from GET /course-management/categories/:categoryId/sub-categories */
export interface CategorySubCategoryListItem {
id: number
category_id: number
category_name: string
name: string
description?: string | null
display_order: number
is_active: boolean
created_at: string
/** Sometimes echoed per row by the API; safe to ignore. */
total_count?: number
}
export interface GetCategorySubCategoriesResponse {
message: string
data: {
sub_categories: CategorySubCategoryListItem[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown
}
/** Row from GET /course-management/sub-categories/:subCategoryId/courses */
export interface SubCategoryCourseListItem {
id: number
category_id: number
sub_category_id: number
title: string
description?: string | null
thumbnail?: string | null
intro_video_url?: string | null
is_active: boolean
total_count?: number
}
export interface GetSubCategoryCoursesResponse {
message: string
data: {
courses: SubCategoryCourseListItem[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown
}
/** Row from GET /course-management/courses/:courseId/levels or GET /course-management/levels */
export interface CourseLevelRow {
id: number
course_id: number
cefr_level: string
display_order: number
is_active: boolean
created_at: string
title: string
description?: string | null
thumbnail?: string | null
total_count?: number
}
export interface GetCourseLevelsForCourseResponse {
message: string
data: {
levels: CourseLevelRow[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown
}
export interface GetCourseLevelsAllResponse {
message: string
data: {
levels: CourseLevelRow[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown
}
export interface GetCourseLevelByIdResponse {
message: string
data: CourseLevelRow
success: boolean
status_code: number
metadata: unknown
}
/** Row from GET /course-management/modules/:moduleId/sub-modules */
export interface CourseSubModuleListItem {
id: number
module_id: number
title: string
description?: string | null
display_order: number
is_active: boolean
created_at: string
legacy_sub_course_id?: number | null
thumbnail?: string | null
tips?: string | null
total_count?: number
}
export interface GetSubModulesByModuleResponse {
message: string
data: {
sub_modules: CourseSubModuleListItem[]
total_count: number
}
success: boolean
status_code: number
metadata: unknown
}
/** Row from GET /course-management/human-language/hierarchy */
export interface HumanLanguageHierarchyFlatRow {
category_id: number
category_name: string
sub_category_id?: number | null
sub_category_name?: string | null
course_id?: number | null
course_title?: string | null
}
export interface GetHumanLanguageHierarchyFlatResponse {
message: string
data: HumanLanguageHierarchyFlatRow[]
success: boolean
status_code: number
metadata: unknown
}
/** Row from GET /course-management/courses/:courseId/hierarchy */
export interface CourseHierarchyRow {
course_id: number
course_title: string
level_id?: number | null
cefr_level?: string | null
level_title?: string | null
level_description?: string | null
level_thumbnail?: string | null
module_id?: number | null
module_title?: string | null
module_icon_url?: string | null
sub_module_id?: number | null
sub_module_title?: string | null
sub_module_description?: string | null
sub_module_thumbnail?: string | null
sub_module_tips?: string | null
sub_module_display_order?: number | null
}
export interface GetCourseHierarchyResponse {
message: string
data: CourseHierarchyRow[]
success: boolean
status_code: number
metadata: unknown
}
export interface HumanLanguageSubModule {
id: number
title: string