learning flow fixes
This commit is contained in:
parent
6a201a0108
commit
3614244029
|
|
@ -217,8 +217,41 @@ export const removeSubCoursePrerequisite = (subCourseId: number, prerequisiteId:
|
||||||
export const getLearningPath = (courseId: number) =>
|
export const getLearningPath = (courseId: number) =>
|
||||||
http.get<GetLearningPathResponse>(`/course-management/courses/${courseId}/learning-path`)
|
http.get<GetLearningPathResponse>(`/course-management/courses/${courseId}/learning-path`)
|
||||||
|
|
||||||
export const reorderSubCourses = (courseId: number, items: ReorderItem[]) =>
|
const buildReorderPayload = (items: ReorderItem[]) => {
|
||||||
http.put(`/course-management/courses/${courseId}/reorder-sub-courses`, { items })
|
const normalized = items.map((item, idx) => ({
|
||||||
|
id: Number(item.id),
|
||||||
|
position: Number(item.position ?? idx),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const hasInvalid = normalized.some(
|
||||||
|
(item) =>
|
||||||
|
Number.isNaN(item.id) ||
|
||||||
|
Number.isNaN(item.position) ||
|
||||||
|
!Number.isFinite(item.id) ||
|
||||||
|
!Number.isFinite(item.position),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasInvalid) {
|
||||||
|
throw new Error("Invalid reorder payload: ids/positions must be numeric.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items: normalized }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reorderCategories = (items: ReorderItem[]) =>
|
||||||
|
http.put("/course-management/categories/reorder", buildReorderPayload(items))
|
||||||
|
|
||||||
|
export const reorderCourses = (items: ReorderItem[]) =>
|
||||||
|
http.put("/course-management/courses/reorder", buildReorderPayload(items))
|
||||||
|
|
||||||
|
export const reorderSubCourses = (items: ReorderItem[]) =>
|
||||||
|
http.put("/course-management/sub-courses/reorder", buildReorderPayload(items))
|
||||||
|
|
||||||
|
export const reorderVideos = (items: ReorderItem[]) =>
|
||||||
|
http.put("/course-management/videos/reorder", buildReorderPayload(items))
|
||||||
|
|
||||||
|
export const reorderPractices = (items: ReorderItem[]) =>
|
||||||
|
http.put("/course-management/practices/reorder", buildReorderPayload(items))
|
||||||
|
|
||||||
// Ratings
|
// Ratings
|
||||||
export const getRatings = (params: GetRatingsParams) =>
|
export const getRatings = (params: GetRatingsParams) =>
|
||||||
|
|
|
||||||
|
|
@ -272,20 +272,34 @@ export function ProfilePage() {
|
||||||
const completionPct = profile.profile_completion_percentage ?? 0;
|
const completionPct = profile.profile_completion_percentage ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-6">
|
<div className="mx-auto w-full max-w-7xl space-y-6 pb-8">
|
||||||
{/* ─── Hero Card ─── */}
|
{/* ─── Hero Card ─── */}
|
||||||
<div className="relative overflow-hidden rounded-2xl border border-grayScale-100 bg-white shadow-sm">
|
<div className="relative overflow-hidden rounded-3xl border border-grayScale-100 bg-white shadow-sm ring-1 ring-black/5">
|
||||||
{/* Tall dark gradient banner with content inside */}
|
{/* Tall dark gradient banner with content inside */}
|
||||||
<div className="relative flex min-h-[200px] flex-col justify-between bg-gradient-to-br from-[#1a1f4e] via-[#2d2b6b] to-[#3b3480] px-6 py-8 sm:px-8">
|
<div className="relative flex min-h-[220px] flex-col justify-between bg-gradient-to-br from-[#1a1f4e] via-[#2d2b6b] to-[#3b3480] px-6 py-8 sm:px-8">
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(255,255,255,0.08),transparent_60%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(255,255,255,0.08),transparent_60%)]" />
|
||||||
|
|
||||||
<div className="relative z-10 space-y-2">
|
<div className="relative z-10 space-y-2">
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">
|
<h2 className="text-2xl font-bold tracking-tight text-white sm:text-3xl">
|
||||||
Hello {profile.first_name}
|
Hello {profile.first_name}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="max-w-xl text-sm leading-relaxed text-white/70">
|
<p className="max-w-2xl text-sm leading-relaxed text-white/70">
|
||||||
This is your profile page. You can see the progress you've made with your work and manage your projects or assigned tasks
|
Track your account status, keep profile details up to date, and manage your learning preferences from one place.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
|
||||||
|
<Shield className="h-3.5 w-3.5" />
|
||||||
|
{profile.role}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
Last login {formatDate(profile.last_login)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-2.5 py-1 text-xs font-medium text-white/90">
|
||||||
|
<Target className="h-3.5 w-3.5" />
|
||||||
|
{completionPct}% complete
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 mt-6">
|
<div className="relative z-10 mt-6">
|
||||||
|
|
@ -293,7 +307,7 @@ export function ProfilePage() {
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 border-white/30 bg-white/10 text-xs font-medium text-white shadow-sm backdrop-blur-sm hover:bg-white/20 hover:text-white"
|
className="h-8 gap-1.5 border-white/30 bg-white/10 px-3 text-xs font-medium text-white shadow-sm backdrop-blur-sm hover:bg-white/20 hover:text-white"
|
||||||
onClick={startEditing}
|
onClick={startEditing}
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
|
@ -304,7 +318,7 @@ export function ProfilePage() {
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 gap-1.5 border-white/30 bg-white/10 text-xs text-white backdrop-blur-sm hover:bg-white/20 hover:text-white"
|
className="h-8 gap-1.5 border-white/30 bg-white/10 px-3 text-xs text-white backdrop-blur-sm hover:bg-white/20 hover:text-white"
|
||||||
onClick={cancelEditing}
|
onClick={cancelEditing}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
|
|
@ -330,7 +344,7 @@ export function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Identity info below banner */}
|
{/* Identity info below banner */}
|
||||||
<div className="px-6 py-5 sm:px-8">
|
<div className="bg-gradient-to-b from-white to-grayScale-50/40 px-6 py-5 sm:px-8">
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -405,7 +419,7 @@ export function ProfilePage() {
|
||||||
{/* ─── Detail Cards Grid ─── */}
|
{/* ─── Detail Cards Grid ─── */}
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
{/* ── Contact & Personal ── */}
|
{/* ── Contact & Personal ── */}
|
||||||
<Card className="overflow-hidden border-grayScale-100 shadow-sm lg:col-span-2">
|
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md lg:col-span-2">
|
||||||
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-400" />
|
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-400" />
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0">
|
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0">
|
||||||
|
|
@ -569,9 +583,9 @@ export function ProfilePage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ── Right Sidebar ── */}
|
{/* ── Right Sidebar ── */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 lg:sticky lg:top-24 lg:self-start">
|
||||||
{/* Profile Completion */}
|
{/* Profile Completion */}
|
||||||
<Card className="overflow-hidden border-grayScale-100 shadow-sm">
|
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
|
||||||
<div className="h-1 bg-gradient-to-r from-brand-400 to-mint-400" />
|
<div className="h-1 bg-gradient-to-r from-brand-400 to-mint-400" />
|
||||||
<CardContent className="flex items-center gap-4 p-5">
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
<ProgressRing percent={completionPct} />
|
<ProgressRing percent={completionPct} />
|
||||||
|
|
@ -585,7 +599,7 @@ export function ProfilePage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Activity */}
|
{/* Activity */}
|
||||||
<Card className="overflow-hidden border-grayScale-100 shadow-sm">
|
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
|
||||||
<div className="h-1 bg-gradient-to-r from-grayScale-300 to-grayScale-200" />
|
<div className="h-1 bg-gradient-to-r from-grayScale-300 to-grayScale-200" />
|
||||||
<CardContent className="space-y-4 p-5">
|
<CardContent className="space-y-4 p-5">
|
||||||
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
|
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
|
||||||
|
|
@ -613,7 +627,7 @@ export function ProfilePage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Quick Account Info */}
|
{/* Quick Account Info */}
|
||||||
<Card className="overflow-hidden border-grayScale-100 shadow-sm">
|
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
|
||||||
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-600" />
|
<div className="h-1 bg-gradient-to-r from-brand-500 to-brand-600" />
|
||||||
<CardContent className="space-y-3 p-5">
|
<CardContent className="space-y-3 p-5">
|
||||||
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
|
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
|
||||||
|
|
@ -675,8 +689,13 @@ export function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Learning & Goals Card ─── */}
|
{/* ─── Learning & Goals Card ─── */}
|
||||||
<Card className="overflow-hidden border-grayScale-100 shadow-sm">
|
<Card className="overflow-hidden rounded-2xl border-grayScale-100 shadow-sm transition-shadow hover:shadow-md">
|
||||||
<div className="h-1 bg-gradient-to-r from-brand-600 via-brand-500 to-brand-400" />
|
<div className="h-1 bg-gradient-to-r from-brand-600 via-brand-500 to-brand-400" />
|
||||||
|
<div className="border-b border-grayScale-100 px-5 py-3">
|
||||||
|
<p className="text-[11px] font-bold uppercase tracking-widest text-grayScale-400">
|
||||||
|
Learning & Preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0 lg:grid-cols-4 lg:divide-x lg:divide-y-0">
|
<div className="grid divide-y divide-grayScale-100 sm:grid-cols-2 sm:divide-x sm:divide-y-0 lg:grid-cols-4 lg:divide-x lg:divide-y-0">
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
|
|
|
||||||
|
|
@ -889,7 +889,7 @@ export function AddNewPracticePage() {
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600"
|
className="w-full bg-brand-500 hover:bg-brand-600"
|
||||||
onClick={() => navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)}
|
onClick={() => navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)}
|
||||||
>
|
>
|
||||||
Go back to Sub-course
|
Go back to Course
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export function AllCoursesPage() {
|
||||||
setCourses(allCourses)
|
setCourses(allCourses)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load courses:", err)
|
console.error("Failed to load courses:", err)
|
||||||
setError("Failed to load courses")
|
setError("Failed to load sub-categories")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +116,7 @@ export function AllCoursesPage() {
|
||||||
description: createDescription.trim(),
|
description: createDescription.trim(),
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.success("Course created", {
|
toast.success("Sub-category created", {
|
||||||
description: `"${createTitle.trim()}" has been created.`,
|
description: `"${createTitle.trim()}" has been created.`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -130,7 +130,7 @@ export function AllCoursesPage() {
|
||||||
await fetchAllCourses()
|
await fetchAllCourses()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to create course:", err)
|
console.error("Failed to create course:", err)
|
||||||
toast.error("Failed to create course", {
|
toast.error("Failed to create sub-category", {
|
||||||
description: err?.response?.data?.message || "Please try again.",
|
description: err?.response?.data?.message || "Please try again.",
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -145,7 +145,7 @@ export function AllCoursesPage() {
|
||||||
await fetchAllCourses()
|
await fetchAllCourses()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update course status:", err)
|
console.error("Failed to update course status:", err)
|
||||||
toast.error("Failed to update course status")
|
toast.error("Failed to update sub-category status")
|
||||||
} finally {
|
} finally {
|
||||||
setTogglingId(null)
|
setTogglingId(null)
|
||||||
}
|
}
|
||||||
|
|
@ -173,13 +173,13 @@ export function AllCoursesPage() {
|
||||||
title: editTitle.trim(),
|
title: editTitle.trim(),
|
||||||
description: editDescription.trim(),
|
description: editDescription.trim(),
|
||||||
})
|
})
|
||||||
toast.success("Course updated")
|
toast.success("Sub-category updated")
|
||||||
setEditOpen(false)
|
setEditOpen(false)
|
||||||
setCourseToEdit(null)
|
setCourseToEdit(null)
|
||||||
await fetchAllCourses()
|
await fetchAllCourses()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to update course:", err)
|
console.error("Failed to update course:", err)
|
||||||
toast.error("Failed to update course", {
|
toast.error("Failed to update sub-category", {
|
||||||
description: err?.response?.data?.message || "Please try again.",
|
description: err?.response?.data?.message || "Please try again.",
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -193,7 +193,7 @@ export function AllCoursesPage() {
|
||||||
<div className="rounded-2xl bg-white shadow-sm p-6">
|
<div className="rounded-2xl bg-white shadow-sm p-6">
|
||||||
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
|
<RefreshCw className="h-10 w-10 animate-spin text-brand-600" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all courses…</p>
|
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all sub-categories…</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -216,9 +216,9 @@ export function AllCoursesPage() {
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">All Courses</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">All Sub-categories</h1>
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
View and manage courses across all categories.
|
View and manage sub-categories across all categories.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -226,14 +226,14 @@ export function AllCoursesPage() {
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Create Course
|
Create Sub-category
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="shadow-soft">
|
<Card className="shadow-soft">
|
||||||
<CardHeader className="border-b border-grayScale-200 pb-4">
|
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||||
Course Management
|
Sub-category Management
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5 pt-5">
|
<CardContent className="space-y-5 pt-5">
|
||||||
|
|
@ -375,9 +375,9 @@ export function AllCoursesPage() {
|
||||||
<div className="mb-4 grid h-16 w-16 place-items-center rounded-full bg-gradient-to-br from-grayScale-100 to-grayScale-200">
|
<div className="mb-4 grid h-16 w-16 place-items-center rounded-full bg-gradient-to-br from-grayScale-100 to-grayScale-200">
|
||||||
<BookOpen className="h-8 w-8 text-grayScale-400" />
|
<BookOpen className="h-8 w-8 text-grayScale-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base font-semibold text-grayScale-600">No courses found</p>
|
<p className="text-base font-semibold text-grayScale-600">No sub-categories found</p>
|
||||||
<p className="mt-1.5 max-w-sm text-sm leading-relaxed text-grayScale-400">
|
<p className="mt-1.5 max-w-sm text-sm leading-relaxed text-grayScale-400">
|
||||||
Try adjusting your search or category filter, or create a new course.
|
Try adjusting your search or category filter, or create a new sub-category.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -388,7 +388,7 @@ export function AllCoursesPage() {
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create course</DialogTitle>
|
<DialogTitle>Create sub-category</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Choose a category, add basic details, and optionally attach a thumbnail and intro
|
Choose a category, add basic details, and optionally attach a thumbnail and intro
|
||||||
video.
|
video.
|
||||||
|
|
@ -439,7 +439,7 @@ export function AllCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-1">
|
<div className="sm:col-span-1">
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||||
Course title
|
Sub-category title
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g. Beginner English A1"
|
placeholder="e.g. Beginner English A1"
|
||||||
|
|
@ -455,7 +455,7 @@ export function AllCoursesPage() {
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Short summary of what this course covers."
|
placeholder="Short summary of what this sub-category covers."
|
||||||
value={createDescription}
|
value={createDescription}
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
onChange={(e) => setCreateDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -514,7 +514,7 @@ export function AllCoursesPage() {
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
onClick={handleCreateCourse}
|
onClick={handleCreateCourse}
|
||||||
>
|
>
|
||||||
{creating ? "Creating…" : "Create course"}
|
{creating ? "Creating…" : "Create sub-category"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
@ -524,9 +524,9 @@ export function AllCoursesPage() {
|
||||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit course</DialogTitle>
|
<DialogTitle>Edit sub-category</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update the title and description for this course. Status can be toggled from the
|
Update the title and description for this sub-category. Status can be toggled from the
|
||||||
table.
|
table.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
@ -534,12 +534,12 @@ export function AllCoursesPage() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||||
Course title
|
Sub-category title
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={editTitle}
|
value={editTitle}
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
placeholder="Enter course title"
|
placeholder="Enter sub-category title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -550,7 +550,7 @@ export function AllCoursesPage() {
|
||||||
rows={3}
|
rows={3}
|
||||||
value={editDescription}
|
value={editDescription}
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
placeholder="Short summary of this course."
|
placeholder="Short summary of this sub-category."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const contentSections = [
|
||||||
pathFn: (categoryId: string | undefined) => `/content/category/${categoryId}/courses`,
|
pathFn: (categoryId: string | undefined) => `/content/category/${categoryId}/courses`,
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
title: "Courses",
|
title: "Courses",
|
||||||
description: "Manage course videos and educational content",
|
description: "Manage sub-categories, course videos and educational content",
|
||||||
action: "Manage Courses",
|
action: "Manage Courses",
|
||||||
count: 12,
|
count: 12,
|
||||||
countLabel: "courses",
|
countLabel: "courses",
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ export function CourseCategoryPage() {
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
|
||||||
View Courses
|
View Sub-categories
|
||||||
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
|
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -77,7 +77,7 @@ export function CoursesPage() {
|
||||||
setCategory(foundCategory ?? null)
|
setCategory(foundCategory ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch courses:", err)
|
console.error("Failed to fetch courses:", err)
|
||||||
setError("Failed to load courses")
|
setError("Failed to load sub-categories")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +123,7 @@ export function CoursesPage() {
|
||||||
await fetchCourses()
|
await fetchCourses()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to create course:", err)
|
console.error("Failed to create course:", err)
|
||||||
setSaveError(err.response?.data?.message || "Failed to create course")
|
setSaveError(err.response?.data?.message || "Failed to create sub-category")
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|
@ -206,7 +206,7 @@ export function CoursesPage() {
|
||||||
await fetchCourses()
|
await fetchCourses()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to update course:", err)
|
console.error("Failed to update course:", err)
|
||||||
setUpdateError(err.response?.data?.message || "Failed to update course")
|
setUpdateError(err.response?.data?.message || "Failed to update sub-category")
|
||||||
} finally {
|
} finally {
|
||||||
setUpdating(false)
|
setUpdating(false)
|
||||||
}
|
}
|
||||||
|
|
@ -267,16 +267,16 @@ export function CoursesPage() {
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-grayScale-700 sm:text-2xl">
|
<h1 className="text-xl font-bold text-grayScale-700 sm:text-2xl">
|
||||||
{category?.name} Courses
|
{category?.name} Sub-categories
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-0.5 text-sm text-grayScale-400">
|
<p className="mt-0.5 text-sm text-grayScale-400">
|
||||||
<span className="font-medium text-grayScale-500">{courses.length}</span> courses available
|
<span className="font-medium text-grayScale-500">{courses.length}</span> sub-categories available
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full bg-brand-500 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto" onClick={handleOpenModal}>
|
<Button className="w-full bg-brand-500 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto" onClick={handleOpenModal}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add New Course
|
Add New Sub-category
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -285,16 +285,16 @@ export function CoursesPage() {
|
||||||
<Card className="shadow-soft">
|
<Card className="shadow-soft">
|
||||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||||
Course Management
|
Sub-category Management
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
{courses.length === 0 ? (
|
{courses.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center">
|
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-16 text-center">
|
||||||
<img src={practiceSrc} alt="" className="h-16 w-16" />
|
<img src={practiceSrc} alt="" className="h-16 w-16" />
|
||||||
<h3 className="mt-4 text-base font-semibold text-grayScale-600">No courses yet</h3>
|
<h3 className="mt-4 text-base font-semibold text-grayScale-600">No sub-categories yet</h3>
|
||||||
<p className="mt-1.5 text-sm text-grayScale-400">
|
<p className="mt-1.5 text-sm text-grayScale-400">
|
||||||
No courses found in this category.
|
No sub-categories found in this category.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -302,7 +302,7 @@ export function CoursesPage() {
|
||||||
onClick={handleOpenModal}
|
onClick={handleOpenModal}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add your first course
|
Add your first sub-category
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -311,7 +311,7 @@ export function CoursesPage() {
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
||||||
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
Course
|
Sub-category
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||||
Status
|
Status
|
||||||
|
|
@ -415,7 +415,7 @@ export function CoursesPage() {
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<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-2xl animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-2xl 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">
|
<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">Add New Course</h2>
|
<h2 className="text-lg font-bold text-grayScale-700">Add New Sub-category</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleCloseModal}
|
onClick={handleCloseModal}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
|
@ -441,7 +441,7 @@ export function CoursesPage() {
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="course-title"
|
id="course-title"
|
||||||
placeholder="Enter course title"
|
placeholder="Enter sub-category title"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -456,7 +456,7 @@ export function CoursesPage() {
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="course-description"
|
id="course-description"
|
||||||
placeholder="Enter course description"
|
placeholder="Enter sub-category description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
|
|
@ -478,7 +478,7 @@ export function CoursesPage() {
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
{saving ? "Saving..." : "Save Course"}
|
{saving ? "Saving..." : "Save Sub-category"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -490,7 +490,7 @@ export function CoursesPage() {
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<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-md animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-md 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">
|
<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">Edit Course</h2>
|
<h2 className="text-lg font-bold text-grayScale-700">Edit Sub-category</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleCloseEditModal}
|
onClick={handleCloseEditModal}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
|
@ -516,7 +516,7 @@ export function CoursesPage() {
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-course-title"
|
id="edit-course-title"
|
||||||
placeholder="Enter course title"
|
placeholder="Enter sub-category title"
|
||||||
value={editTitle}
|
value={editTitle}
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -531,7 +531,7 @@ export function CoursesPage() {
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="edit-course-description"
|
id="edit-course-description"
|
||||||
placeholder="Enter course description"
|
placeholder="Enter sub-category description"
|
||||||
value={editDescription}
|
value={editDescription}
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
|
|
@ -564,7 +564,7 @@ export function CoursesPage() {
|
||||||
onClick={handleUpdate}
|
onClick={handleUpdate}
|
||||||
disabled={updating}
|
disabled={updating}
|
||||||
>
|
>
|
||||||
{updating ? "Updating..." : "Update Course"}
|
{updating ? "Updating..." : "Update Sub-category"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -576,7 +576,7 @@ export function CoursesPage() {
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<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-lg animate-in fade-in zoom-in-95 rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-lg 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">
|
<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">Course Ratings</h2>
|
<h2 className="text-lg font-bold text-grayScale-700">Sub-category Ratings</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowRatingsModal(false)
|
setShowRatingsModal(false)
|
||||||
|
|
@ -602,7 +602,7 @@ export function CoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm font-semibold text-grayScale-700">No ratings yet</p>
|
<p className="mt-4 text-sm font-semibold text-grayScale-700">No ratings yet</p>
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
Ratings will appear here once learners start reviewing this course.
|
Ratings will appear here once learners start reviewing this sub-category.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -690,7 +690,7 @@ export function CoursesPage() {
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<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="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">
|
<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>
|
<h2 className="text-lg font-bold text-grayScale-700">Delete Sub-category</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteModal(false)}
|
onClick={() => setShowDeleteModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,8 @@ export function SubCourseContentPage() {
|
||||||
)
|
)
|
||||||
setSubCourse(foundSubCourse ?? null)
|
setSubCourse(foundSubCourse ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch sub-course data:", err)
|
console.error("Failed to fetch course data:", err)
|
||||||
setError("Failed to load sub-course")
|
setError("Failed to load course")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -374,7 +374,7 @@ export function SubCourseContentPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
|
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
|
||||||
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading sub-course…</p>
|
<p className="mt-4 text-sm font-medium text-grayScale-500">Loading course…</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -396,7 +396,7 @@ export function SubCourseContentPage() {
|
||||||
className="group inline-flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium text-grayScale-500 transition-all hover:bg-grayScale-50 hover:text-grayScale-900"
|
className="group inline-flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-medium text-grayScale-500 transition-all hover:bg-grayScale-50 hover:text-grayScale-900"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
||||||
Back to Sub-courses
|
Back to Courses
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* SubCourse Header */}
|
{/* SubCourse Header */}
|
||||||
|
|
@ -721,7 +721,7 @@ export function SubCourseContentPage() {
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm font-semibold text-grayScale-700">No ratings yet</p>
|
<p className="mt-4 text-sm font-semibold text-grayScale-700">No ratings yet</p>
|
||||||
<p className="mt-1 text-sm text-grayScale-400">
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
Ratings will appear here once learners start reviewing this sub-course.
|
Ratings will appear here once learners start reviewing this course.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,350 +1,403 @@
|
||||||
import { useEffect, useState, useRef } from "react"
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { Link, useParams, useNavigate } from "react-router-dom"
|
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft, ToggleLeft, ToggleRight, MoreVertical, X, Trash2, AlertCircle, Edit, Link2, Plus, Loader2, LayoutGrid, GitBranch, ChevronDown, Lock, ArrowRight } from "lucide-react"
|
import {
|
||||||
import practiceSrc from "../../assets/Practice.svg"
|
ArrowLeft,
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
ToggleLeft,
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
ToggleRight,
|
||||||
import alertSrc from "../../assets/Alert.svg"
|
MoreVertical,
|
||||||
import { Badge } from "../../components/ui/badge"
|
X,
|
||||||
import { Button } from "../../components/ui/button"
|
Trash2,
|
||||||
import { getSubCoursesByCourse, getCoursesByCategory, getCourseCategories, createSubCourse, updateSubCourse, updateSubCourseStatus, deleteSubCourse, getSubCoursePrerequisites, addSubCoursePrerequisite, removeSubCoursePrerequisite } from "../../api/courses.api"
|
AlertCircle,
|
||||||
import { Input } from "../../components/ui/input"
|
Edit,
|
||||||
import type { SubCourse, Course, CourseCategory, SubCoursePrerequisite } from "../../types/course.types"
|
Link2,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
LayoutGrid,
|
||||||
|
GitBranch,
|
||||||
|
ChevronDown,
|
||||||
|
Lock,
|
||||||
|
ArrowRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import practiceSrc from "../../assets/Practice.svg";
|
||||||
|
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
||||||
|
import { Card, CardContent } from "../../components/ui/card";
|
||||||
|
import alertSrc from "../../assets/Alert.svg";
|
||||||
|
import { Badge } from "../../components/ui/badge";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
getSubCoursesByCourse,
|
||||||
|
getCoursesByCategory,
|
||||||
|
getCourseCategories,
|
||||||
|
createSubCourse,
|
||||||
|
updateSubCourse,
|
||||||
|
updateSubCourseStatus,
|
||||||
|
deleteSubCourse,
|
||||||
|
getSubCoursePrerequisites,
|
||||||
|
addSubCoursePrerequisite,
|
||||||
|
removeSubCoursePrerequisite,
|
||||||
|
} from "../../api/courses.api";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import type {
|
||||||
|
SubCourse,
|
||||||
|
Course,
|
||||||
|
CourseCategory,
|
||||||
|
SubCoursePrerequisite,
|
||||||
|
} from "../../types/course.types";
|
||||||
|
|
||||||
export function SubCoursesPage() {
|
export function SubCoursesPage() {
|
||||||
const { categoryId, courseId } = useParams<{ categoryId: string; courseId: string }>()
|
const { categoryId, courseId } = useParams<{
|
||||||
const navigate = useNavigate()
|
categoryId: string;
|
||||||
const [subCourses, setSubCourses] = useState<SubCourse[]>([])
|
courseId: string;
|
||||||
const [course, setCourse] = useState<Course | null>(null)
|
}>();
|
||||||
const [category, setCategory] = useState<CourseCategory | null>(null)
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(true)
|
const [subCourses, setSubCourses] = useState<SubCourse[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [course, setCourse] = useState<Course | null>(null);
|
||||||
|
const [category, setCategory] = useState<CourseCategory | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
|
const [openMenuId, setOpenMenuId] = useState<number | null>(null);
|
||||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
const [togglingId, setTogglingId] = useState<number | null>(null);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [subCourseToDelete, setSubCourseToDelete] = useState<SubCourse | null>(null)
|
const [subCourseToDelete, setSubCourseToDelete] = useState<SubCourse | null>(
|
||||||
const [deleting, setDeleting] = useState(false)
|
null,
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false)
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [subCourseToEdit, setSubCourseToEdit] = useState<SubCourse | null>(null)
|
const [subCourseToEdit, setSubCourseToEdit] = useState<SubCourse | null>(
|
||||||
const [title, setTitle] = useState("")
|
null,
|
||||||
const [description, setDescription] = useState("")
|
);
|
||||||
const [level, setLevel] = useState("")
|
const [title, setTitle] = useState("");
|
||||||
const [saving, setSaving] = useState(false)
|
const [description, setDescription] = useState("");
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [level, setLevel] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
// View mode
|
// View mode
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "flow">("grid")
|
const [viewMode, setViewMode] = useState<"grid" | "flow">("grid");
|
||||||
|
|
||||||
// All prerequisites map: subCourseId -> prerequisites[]
|
// All prerequisites map: subCourseId -> prerequisites[]
|
||||||
const [allPrereqMap, setAllPrereqMap] = useState<Record<number, SubCoursePrerequisite[]>>({})
|
const [allPrereqMap, setAllPrereqMap] = useState<
|
||||||
const [allPrereqLoading, setAllPrereqLoading] = useState(false)
|
Record<number, SubCoursePrerequisite[]>
|
||||||
|
>({});
|
||||||
|
const [allPrereqLoading, setAllPrereqLoading] = useState(false);
|
||||||
|
|
||||||
// Prerequisites state
|
// Prerequisites state
|
||||||
const [showPrereqModal, setShowPrereqModal] = useState(false)
|
const [showPrereqModal, setShowPrereqModal] = useState(false);
|
||||||
const [prereqSubCourse, setPrereqSubCourse] = useState<SubCourse | null>(null)
|
const [prereqSubCourse, setPrereqSubCourse] = useState<SubCourse | null>(
|
||||||
const [prerequisites, setPrerequisites] = useState<SubCoursePrerequisite[]>([])
|
null,
|
||||||
const [prereqLoading, setPrereqLoading] = useState(false)
|
);
|
||||||
const [prereqAdding, setPrereqAdding] = useState(false)
|
const [prerequisites, setPrerequisites] = useState<SubCoursePrerequisite[]>(
|
||||||
const [prereqRemoving, setPrereqRemoving] = useState<number | null>(null)
|
[],
|
||||||
const [selectedPrereqId, setSelectedPrereqId] = useState<number | 0>(0)
|
);
|
||||||
|
const [prereqLoading, setPrereqLoading] = useState(false);
|
||||||
|
const [prereqAdding, setPrereqAdding] = useState(false);
|
||||||
|
const [prereqRemoving, setPrereqRemoving] = useState<number | null>(null);
|
||||||
|
const [selectedPrereqId, setSelectedPrereqId] = useState<number | 0>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
setOpenMenuId(null)
|
setOpenMenuId(null);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (openMenuId !== null) {
|
if (openMenuId !== null) {
|
||||||
document.addEventListener("mousedown", handleClickOutside)
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
}
|
}
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [openMenuId])
|
}, [openMenuId]);
|
||||||
|
|
||||||
const fetchSubCourses = async () => {
|
const fetchSubCourses = async () => {
|
||||||
if (!courseId) return
|
if (!courseId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subCoursesRes = await getSubCoursesByCourse(Number(courseId))
|
const subCoursesRes = await getSubCoursesByCourse(Number(courseId));
|
||||||
setSubCourses(subCoursesRes.data.data.sub_courses ?? [])
|
setSubCourses(subCoursesRes.data.data.sub_courses ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch sub-courses:", err)
|
console.error("Failed to fetch sub-courses:", err);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const fetchAllPrerequisites = async (scs: SubCourse[]) => {
|
const fetchAllPrerequisites = async (scs: SubCourse[]) => {
|
||||||
if (scs.length === 0) return
|
if (scs.length === 0) return;
|
||||||
setAllPrereqLoading(true)
|
setAllPrereqLoading(true);
|
||||||
try {
|
try {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
scs.map((sc) => getSubCoursePrerequisites(sc.id).then((res) => ({ id: sc.id, data: res.data.data ?? [] })))
|
scs.map((sc) =>
|
||||||
)
|
getSubCoursePrerequisites(sc.id).then((res) => ({
|
||||||
const map: Record<number, SubCoursePrerequisite[]> = {}
|
id: sc.id,
|
||||||
|
data: res.data.data ?? [],
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const map: Record<number, SubCoursePrerequisite[]> = {};
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
map[r.id] = r.data
|
map[r.id] = r.data;
|
||||||
}
|
}
|
||||||
setAllPrereqMap(map)
|
setAllPrereqMap(map);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch all prerequisites:", err)
|
console.error("Failed to fetch all prerequisites:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setAllPrereqLoading(false)
|
setAllPrereqLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (!courseId || !categoryId) return
|
if (!courseId || !categoryId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [subCoursesRes, coursesRes, categoriesRes] = await Promise.all([
|
const [subCoursesRes, coursesRes, categoriesRes] = await Promise.all([
|
||||||
getSubCoursesByCourse(Number(courseId)),
|
getSubCoursesByCourse(Number(courseId)),
|
||||||
getCoursesByCategory(Number(categoryId)),
|
getCoursesByCategory(Number(categoryId)),
|
||||||
getCourseCategories(),
|
getCourseCategories(),
|
||||||
])
|
]);
|
||||||
|
|
||||||
setSubCourses(subCoursesRes.data.data.sub_courses ?? [])
|
setSubCourses(subCoursesRes.data.data.sub_courses ?? []);
|
||||||
|
|
||||||
const foundCourse = coursesRes.data.data.courses?.find(
|
const foundCourse = coursesRes.data.data.courses?.find(
|
||||||
(c) => c.id === Number(courseId)
|
(c) => c.id === Number(courseId),
|
||||||
)
|
);
|
||||||
setCourse(foundCourse ?? null)
|
setCourse(foundCourse ?? null);
|
||||||
|
|
||||||
const foundCategory = categoriesRes.data.data.categories?.find(
|
const foundCategory = categoriesRes.data.data.categories?.find(
|
||||||
(c) => c.id === Number(categoryId)
|
(c) => c.id === Number(categoryId),
|
||||||
)
|
);
|
||||||
setCategory(foundCategory ?? null)
|
setCategory(foundCategory ?? null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch sub-courses:", err)
|
console.error("Failed to fetch sub-courses:", err);
|
||||||
setError("Failed to load sub-courses")
|
setError("Failed to load courses");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
fetchData()
|
fetchData();
|
||||||
}, [courseId, categoryId])
|
}, [courseId, categoryId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (subCourses.length > 0) {
|
if (subCourses.length > 0) {
|
||||||
fetchAllPrerequisites(subCourses)
|
fetchAllPrerequisites(subCourses);
|
||||||
}
|
}
|
||||||
}, [subCourses])
|
}, [subCourses]);
|
||||||
|
|
||||||
const handleToggleStatus = async (subCourse: SubCourse) => {
|
const handleToggleStatus = async (subCourse: SubCourse) => {
|
||||||
setTogglingId(subCourse.id)
|
setTogglingId(subCourse.id);
|
||||||
try {
|
try {
|
||||||
await updateSubCourseStatus(subCourse.id, {
|
await updateSubCourseStatus(subCourse.id, {
|
||||||
is_active: !subCourse.is_active,
|
is_active: !subCourse.is_active,
|
||||||
level: subCourse.level,
|
level: subCourse.level,
|
||||||
title: subCourse.title,
|
title: subCourse.title,
|
||||||
})
|
});
|
||||||
await fetchSubCourses()
|
await fetchSubCourses();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update sub-course status:", err)
|
console.error("Failed to update sub-course status:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setTogglingId(null)
|
setTogglingId(null);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDeleteClick = (subCourse: SubCourse) => {
|
const handleDeleteClick = (subCourse: SubCourse) => {
|
||||||
setSubCourseToDelete(subCourse)
|
setSubCourseToDelete(subCourse);
|
||||||
setShowDeleteModal(true)
|
setShowDeleteModal(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleConfirmDelete = async () => {
|
const handleConfirmDelete = async () => {
|
||||||
if (!subCourseToDelete) return
|
if (!subCourseToDelete) return;
|
||||||
|
|
||||||
setDeleting(true)
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
await deleteSubCourse(subCourseToDelete.id)
|
await deleteSubCourse(subCourseToDelete.id);
|
||||||
setShowDeleteModal(false)
|
setShowDeleteModal(false);
|
||||||
setSubCourseToDelete(null)
|
setSubCourseToDelete(null);
|
||||||
await fetchSubCourses()
|
await fetchSubCourses();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete sub-course:", err)
|
console.error("Failed to delete sub-course:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleAddSubCourse = () => {
|
const handleAddSubCourse = () => {
|
||||||
setTitle("")
|
setTitle("");
|
||||||
setDescription("")
|
setDescription("");
|
||||||
setLevel("")
|
setLevel("");
|
||||||
setSaveError(null)
|
setSaveError(null);
|
||||||
setShowAddModal(true)
|
setShowAddModal(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSaveNewSubCourse = async () => {
|
const handleSaveNewSubCourse = async () => {
|
||||||
if (!courseId) return
|
if (!courseId) return;
|
||||||
setSaving(true)
|
setSaving(true);
|
||||||
setSaveError(null)
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
await createSubCourse({
|
await createSubCourse({
|
||||||
course_id: Number(courseId),
|
course_id: Number(courseId),
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
level,
|
level,
|
||||||
})
|
});
|
||||||
setShowAddModal(false)
|
setShowAddModal(false);
|
||||||
setTitle("")
|
setTitle("");
|
||||||
setDescription("")
|
setDescription("");
|
||||||
setLevel("")
|
setLevel("");
|
||||||
await fetchSubCourses()
|
await fetchSubCourses();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create sub-course:", err)
|
console.error("Failed to create sub-course:", err);
|
||||||
setSaveError("Failed to create sub-course")
|
setSaveError("Failed to create course");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleEditClick = (subCourse: SubCourse) => {
|
const handleEditClick = (subCourse: SubCourse) => {
|
||||||
setSubCourseToEdit(subCourse)
|
setSubCourseToEdit(subCourse);
|
||||||
setTitle(subCourse.title)
|
setTitle(subCourse.title);
|
||||||
setDescription(subCourse.description)
|
setDescription(subCourse.description);
|
||||||
setLevel(subCourse.level)
|
setLevel(subCourse.level);
|
||||||
setSaveError(null)
|
setSaveError(null);
|
||||||
setShowEditModal(true)
|
setShowEditModal(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSaveEditSubCourse = async () => {
|
const handleSaveEditSubCourse = async () => {
|
||||||
if (!subCourseToEdit) return
|
if (!subCourseToEdit) return;
|
||||||
setSaving(true)
|
setSaving(true);
|
||||||
setSaveError(null)
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
await updateSubCourse(subCourseToEdit.id, {
|
await updateSubCourse(subCourseToEdit.id, {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
level,
|
level,
|
||||||
})
|
});
|
||||||
setShowEditModal(false)
|
setShowEditModal(false);
|
||||||
setSubCourseToEdit(null)
|
setSubCourseToEdit(null);
|
||||||
setTitle("")
|
setTitle("");
|
||||||
setDescription("")
|
setDescription("");
|
||||||
setLevel("")
|
setLevel("");
|
||||||
await fetchSubCourses()
|
await fetchSubCourses();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update sub-course:", err)
|
console.error("Failed to update sub-course:", err);
|
||||||
setSaveError("Failed to update sub-course")
|
setSaveError("Failed to update course");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSubCourseClick = (subCourseId: number) => {
|
const handleSubCourseClick = (subCourseId: number) => {
|
||||||
navigate(`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`)
|
navigate(
|
||||||
}
|
`/content/category/${categoryId}/courses/${courseId}/sub-courses/${subCourseId}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePrereqClick = async (subCourse: SubCourse) => {
|
const handlePrereqClick = async (subCourse: SubCourse) => {
|
||||||
setPrereqSubCourse(subCourse)
|
setPrereqSubCourse(subCourse);
|
||||||
setShowPrereqModal(true)
|
setShowPrereqModal(true);
|
||||||
setPrereqLoading(true)
|
setPrereqLoading(true);
|
||||||
setSelectedPrereqId(0)
|
setSelectedPrereqId(0);
|
||||||
try {
|
try {
|
||||||
const res = await getSubCoursePrerequisites(subCourse.id)
|
const res = await getSubCoursePrerequisites(subCourse.id);
|
||||||
setPrerequisites(res.data.data ?? [])
|
setPrerequisites(res.data.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch prerequisites:", err)
|
console.error("Failed to fetch prerequisites:", err);
|
||||||
setPrerequisites([])
|
setPrerequisites([]);
|
||||||
} finally {
|
} finally {
|
||||||
setPrereqLoading(false)
|
setPrereqLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleAddPrerequisite = async () => {
|
const handleAddPrerequisite = async () => {
|
||||||
if (!prereqSubCourse || !selectedPrereqId) return
|
if (!prereqSubCourse || !selectedPrereqId) return;
|
||||||
setPrereqAdding(true)
|
setPrereqAdding(true);
|
||||||
try {
|
try {
|
||||||
await addSubCoursePrerequisite(prereqSubCourse.id, {
|
await addSubCoursePrerequisite(prereqSubCourse.id, {
|
||||||
prerequisite_sub_course_id: selectedPrereqId,
|
prerequisite_sub_course_id: selectedPrereqId,
|
||||||
})
|
});
|
||||||
const res = await getSubCoursePrerequisites(prereqSubCourse.id)
|
const res = await getSubCoursePrerequisites(prereqSubCourse.id);
|
||||||
setPrerequisites(res.data.data ?? [])
|
setPrerequisites(res.data.data ?? []);
|
||||||
setSelectedPrereqId(0)
|
setSelectedPrereqId(0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to add prerequisite:", err)
|
console.error("Failed to add prerequisite:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setPrereqAdding(false)
|
setPrereqAdding(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleRemovePrerequisite = async (prereqId: number) => {
|
const handleRemovePrerequisite = async (prereqId: number) => {
|
||||||
if (!prereqSubCourse) return
|
if (!prereqSubCourse) return;
|
||||||
setPrereqRemoving(prereqId)
|
setPrereqRemoving(prereqId);
|
||||||
try {
|
try {
|
||||||
await removeSubCoursePrerequisite(prereqSubCourse.id, prereqId)
|
await removeSubCoursePrerequisite(prereqSubCourse.id, prereqId);
|
||||||
const res = await getSubCoursePrerequisites(prereqSubCourse.id)
|
const res = await getSubCoursePrerequisites(prereqSubCourse.id);
|
||||||
setPrerequisites(res.data.data ?? [])
|
setPrerequisites(res.data.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to remove prerequisite:", err)
|
console.error("Failed to remove prerequisite:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setPrereqRemoving(null)
|
setPrereqRemoving(null);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Build flow layers using topological sort
|
// Build flow layers using topological sort
|
||||||
const flowLayers = (() => {
|
const flowLayers = (() => {
|
||||||
if (subCourses.length === 0) return []
|
if (subCourses.length === 0) return [];
|
||||||
|
|
||||||
// Find sub-courses with no prerequisites (roots)
|
// Find sub-courses with no prerequisites (roots)
|
||||||
const hasPrereqs = new Set<number>()
|
const hasPrereqs = new Set<number>();
|
||||||
const isPrereqOf = new Map<number, number[]>() // prereqId -> [subCourseIds that depend on it]
|
const isPrereqOf = new Map<number, number[]>(); // prereqId -> [subCourseIds that depend on it]
|
||||||
|
|
||||||
for (const sc of subCourses) {
|
for (const sc of subCourses) {
|
||||||
const prereqs = allPrereqMap[sc.id] ?? []
|
const prereqs = allPrereqMap[sc.id] ?? [];
|
||||||
if (prereqs.length > 0) {
|
if (prereqs.length > 0) {
|
||||||
hasPrereqs.add(sc.id)
|
hasPrereqs.add(sc.id);
|
||||||
}
|
}
|
||||||
for (const p of prereqs) {
|
for (const p of prereqs) {
|
||||||
const dependents = isPrereqOf.get(p.prerequisite_sub_course_id) ?? []
|
const dependents = isPrereqOf.get(p.prerequisite_sub_course_id) ?? [];
|
||||||
dependents.push(sc.id)
|
dependents.push(sc.id);
|
||||||
isPrereqOf.set(p.prerequisite_sub_course_id, dependents)
|
isPrereqOf.set(p.prerequisite_sub_course_id, dependents);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BFS-based layering
|
// BFS-based layering
|
||||||
const layers: SubCourse[][] = []
|
const layers: SubCourse[][] = [];
|
||||||
const placed = new Set<number>()
|
const placed = new Set<number>();
|
||||||
|
|
||||||
// Layer 0: no prerequisites
|
// Layer 0: no prerequisites
|
||||||
const roots = subCourses.filter((sc) => !hasPrereqs.has(sc.id))
|
const roots = subCourses.filter((sc) => !hasPrereqs.has(sc.id));
|
||||||
if (roots.length > 0) {
|
if (roots.length > 0) {
|
||||||
layers.push(roots)
|
layers.push(roots);
|
||||||
roots.forEach((sc) => placed.add(sc.id))
|
roots.forEach((sc) => placed.add(sc.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subsequent layers: all prereqs already placed
|
// Subsequent layers: all prereqs already placed
|
||||||
let maxIterations = subCourses.length
|
let maxIterations = subCourses.length;
|
||||||
while (placed.size < subCourses.length && maxIterations-- > 0) {
|
while (placed.size < subCourses.length && maxIterations-- > 0) {
|
||||||
const nextLayer = subCourses.filter((sc) => {
|
const nextLayer = subCourses.filter((sc) => {
|
||||||
if (placed.has(sc.id)) return false
|
if (placed.has(sc.id)) return false;
|
||||||
const prereqs = allPrereqMap[sc.id] ?? []
|
const prereqs = allPrereqMap[sc.id] ?? [];
|
||||||
return prereqs.every((p) => placed.has(p.prerequisite_sub_course_id))
|
return prereqs.every((p) => placed.has(p.prerequisite_sub_course_id));
|
||||||
})
|
});
|
||||||
if (nextLayer.length === 0) {
|
if (nextLayer.length === 0) {
|
||||||
// Remaining have circular deps or missing prereqs — just add them
|
// Remaining have circular deps or missing prereqs — just add them
|
||||||
const remaining = subCourses.filter((sc) => !placed.has(sc.id))
|
const remaining = subCourses.filter((sc) => !placed.has(sc.id));
|
||||||
if (remaining.length > 0) layers.push(remaining)
|
if (remaining.length > 0) layers.push(remaining);
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
layers.push(nextLayer)
|
layers.push(nextLayer);
|
||||||
nextLayer.forEach((sc) => placed.add(sc.id))
|
nextLayer.forEach((sc) => placed.add(sc.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return layers
|
return layers;
|
||||||
})()
|
})();
|
||||||
|
|
||||||
const availablePrerequisites = subCourses.filter(
|
const availablePrerequisites = subCourses.filter(
|
||||||
(sc) =>
|
(sc) =>
|
||||||
prereqSubCourse &&
|
prereqSubCourse &&
|
||||||
sc.id !== prereqSubCourse.id &&
|
sc.id !== prereqSubCourse.id &&
|
||||||
!prerequisites.some((p) => p.prerequisite_sub_course_id === sc.id)
|
!prerequisites.some((p) => p.prerequisite_sub_course_id === sc.id),
|
||||||
)
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -355,7 +408,7 @@ export function SubCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading sub-courses...</p> */}
|
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading sub-courses...</p> */}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -366,7 +419,7 @@ export function SubCoursesPage() {
|
||||||
<p className="text-sm font-medium text-red-600">{error}</p>
|
<p className="text-sm font-medium text-red-600">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -382,12 +435,21 @@ export function SubCoursesPage() {
|
||||||
</Link>
|
</Link>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-grayScale-400">
|
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-grayScale-400">
|
||||||
<span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">{category?.name}</span>
|
<span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">
|
||||||
|
{category?.name}
|
||||||
|
</span>
|
||||||
<span className="shrink-0 text-grayScale-300">→</span>
|
<span className="shrink-0 text-grayScale-300">→</span>
|
||||||
<span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">{course?.title}</span>
|
<span className="truncate max-w-[100px] rounded bg-grayScale-50 px-1.5 py-0.5 sm:max-w-none">
|
||||||
|
{course?.title}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Sub-courses</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
||||||
<p className="mt-0.5 text-sm text-grayScale-400">{subCourses.length} sub-course{subCourses.length !== 1 ? "s" : ""} available</p>
|
Courses
|
||||||
|
</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-grayScale-400">
|
||||||
|
{subCourses.length} course{subCourses.length !== 1 ? "s" : ""}{" "}
|
||||||
|
available
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -417,8 +479,11 @@ export function SubCoursesPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button className="w-full rounded-xl bg-brand-500 px-5 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto" onClick={handleAddSubCourse}>
|
<Button
|
||||||
Add New Sub-course
|
className="w-full rounded-xl bg-brand-500 px-5 shadow-sm transition-all hover:bg-brand-600 hover:shadow-md sm:w-auto"
|
||||||
|
onClick={handleAddSubCourse}
|
||||||
|
>
|
||||||
|
Add New Course
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -428,10 +493,17 @@ export function SubCoursesPage() {
|
||||||
<Card className="border border-dashed border-grayScale-200 shadow-none">
|
<Card className="border border-dashed border-grayScale-200 shadow-none">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||||
<img src={practiceSrc} alt="" className="h-20 w-20" />
|
<img src={practiceSrc} alt="" className="h-20 w-20" />
|
||||||
<h3 className="mt-5 text-base font-semibold text-grayScale-600">No sub-courses yet</h3>
|
<h3 className="mt-5 text-base font-semibold text-grayScale-600">
|
||||||
<p className="mt-1.5 max-w-xs text-center text-sm text-grayScale-400">Get started by adding your first sub-course to this course</p>
|
No courses yet
|
||||||
<Button className="mt-5 rounded-xl bg-brand-500 px-5 shadow-sm hover:bg-brand-600 hover:shadow-md" onClick={handleAddSubCourse}>
|
</h3>
|
||||||
Add your first sub-course
|
<p className="mt-1.5 max-w-xs text-center text-sm text-grayScale-400">
|
||||||
|
Get started by adding your first course to this sub-category
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="mt-5 rounded-xl bg-brand-500 px-5 shadow-sm hover:bg-brand-600 hover:shadow-md"
|
||||||
|
onClick={handleAddSubCourse}
|
||||||
|
>
|
||||||
|
Add your first course
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -443,7 +515,7 @@ export function SubCoursesPage() {
|
||||||
"bg-gradient-to-br from-purple-100 to-purple-200",
|
"bg-gradient-to-br from-purple-100 to-purple-200",
|
||||||
"bg-gradient-to-br from-green-100 to-green-200",
|
"bg-gradient-to-br from-green-100 to-green-200",
|
||||||
"bg-gradient-to-br from-yellow-100 to-yellow-200",
|
"bg-gradient-to-br from-yellow-100 to-yellow-200",
|
||||||
]
|
];
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={subCourse.id}
|
key={subCourse.id}
|
||||||
|
|
@ -459,7 +531,9 @@ export function SubCoursesPage() {
|
||||||
className="h-full w-full object-cover rounded-t-lg transition-transform duration-300 group-hover:scale-105"
|
className="h-full w-full object-cover rounded-t-lg transition-transform duration-300 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={`h-full w-full rounded-t-lg transition-transform duration-300 group-hover:scale-105 ${gradients[index % gradients.length]}`} />
|
<div
|
||||||
|
className={`h-full w-full rounded-t-lg transition-transform duration-300 group-hover:scale-105 ${gradients[index % gradients.length]}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{subCourse.level && (
|
{subCourse.level && (
|
||||||
<div className="absolute bottom-2.5 right-2.5 rounded-md bg-brand-600/90 px-2.5 py-1 text-xs font-semibold tracking-wide text-white shadow-sm backdrop-blur-sm">
|
<div className="absolute bottom-2.5 right-2.5 rounded-md bg-brand-600/90 px-2.5 py-1 text-xs font-semibold tracking-wide text-white shadow-sm backdrop-blur-sm">
|
||||||
|
|
@ -479,7 +553,9 @@ export function SubCoursesPage() {
|
||||||
: "border border-grayScale-200 bg-grayScale-50 text-grayScale-500"
|
: "border border-grayScale-200 bg-grayScale-50 text-grayScale-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${subCourse.is_active ? "bg-green-500" : "bg-grayScale-400"}`} />
|
<span
|
||||||
|
className={`mr-1.5 inline-block h-1.5 w-1.5 rounded-full ${subCourse.is_active ? "bg-green-500" : "bg-grayScale-400"}`}
|
||||||
|
/>
|
||||||
{subCourse.is_active ? "Active" : "Inactive"}
|
{subCourse.is_active ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div
|
<div
|
||||||
|
|
@ -488,7 +564,11 @@ export function SubCoursesPage() {
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpenMenuId(openMenuId === subCourse.id ? null : subCourse.id)}
|
onClick={() =>
|
||||||
|
setOpenMenuId(
|
||||||
|
openMenuId === subCourse.id ? null : subCourse.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
className="grid h-7 w-7 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-7 w-7 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
>
|
>
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
|
@ -497,8 +577,8 @@ export function SubCoursesPage() {
|
||||||
<div className="absolute right-0 top-full z-10 mt-1.5 w-44 overflow-hidden rounded-xl border border-grayScale-100 bg-white py-1 shadow-lg">
|
<div className="absolute right-0 top-full z-10 mt-1.5 w-44 overflow-hidden rounded-xl border border-grayScale-100 bg-white py-1 shadow-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handlePrereqClick(subCourse)
|
handlePrereqClick(subCourse);
|
||||||
setOpenMenuId(null)
|
setOpenMenuId(null);
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50"
|
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50"
|
||||||
>
|
>
|
||||||
|
|
@ -508,8 +588,8 @@ export function SubCoursesPage() {
|
||||||
<div className="mx-3 border-t border-grayScale-100" />
|
<div className="mx-3 border-t border-grayScale-100" />
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleToggleStatus(subCourse)
|
handleToggleStatus(subCourse);
|
||||||
setOpenMenuId(null)
|
setOpenMenuId(null);
|
||||||
}}
|
}}
|
||||||
disabled={togglingId === subCourse.id}
|
disabled={togglingId === subCourse.id}
|
||||||
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50 disabled:opacity-50"
|
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-grayScale-600 transition-colors hover:bg-grayScale-50 disabled:opacity-50"
|
||||||
|
|
@ -529,8 +609,8 @@ export function SubCoursesPage() {
|
||||||
<div className="mx-3 border-t border-grayScale-100" />
|
<div className="mx-3 border-t border-grayScale-100" />
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleDeleteClick(subCourse)
|
handleDeleteClick(subCourse);
|
||||||
setOpenMenuId(null)
|
setOpenMenuId(null);
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-red-500 transition-colors hover:bg-red-50"
|
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-red-500 transition-colors hover:bg-red-50"
|
||||||
>
|
>
|
||||||
|
|
@ -544,7 +624,9 @@ export function SubCoursesPage() {
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-grayScale-700 group-hover:text-brand-600 transition-colors">{subCourse.title}</h3>
|
<h3 className="font-semibold text-grayScale-700 group-hover:text-brand-600 transition-colors">
|
||||||
|
{subCourse.title}
|
||||||
|
</h3>
|
||||||
<p className="mt-1 text-sm leading-relaxed text-grayScale-400 line-clamp-2">
|
<p className="mt-1 text-sm leading-relaxed text-grayScale-400 line-clamp-2">
|
||||||
{subCourse.description || "No description available"}
|
{subCourse.description || "No description available"}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -555,8 +637,8 @@ export function SubCoursesPage() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="mt-auto w-full rounded-lg border-grayScale-200 text-grayScale-500 transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600"
|
className="mt-auto w-full rounded-lg border-grayScale-200 text-grayScale-500 transition-all hover:border-brand-200 hover:bg-brand-50 hover:text-brand-600"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
handleEditClick(subCourse)
|
handleEditClick(subCourse);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit className="mr-2 h-3.5 w-3.5" />
|
<Edit className="mr-2 h-3.5 w-3.5" />
|
||||||
|
|
@ -564,7 +646,7 @@ export function SubCoursesPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -574,7 +656,9 @@ export function SubCoursesPage() {
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<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 rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-700">Delete Sub-course</h2>
|
<h2 className="text-lg font-semibold text-grayScale-700">
|
||||||
|
Delete Course
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteModal(false)}
|
onClick={() => setShowDeleteModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
|
@ -589,8 +673,10 @@ export function SubCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
<p className="text-center text-sm leading-relaxed text-grayScale-600">
|
||||||
Are you sure you want to delete{" "}
|
Are you sure you want to delete{" "}
|
||||||
<span className="font-semibold text-grayScale-700">{subCourseToDelete.title}</span>? This action cannot
|
<span className="font-semibold text-grayScale-700">
|
||||||
be undone.
|
{subCourseToDelete.title}
|
||||||
|
</span>
|
||||||
|
? This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -620,7 +706,9 @@ export function SubCoursesPage() {
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<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-md rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-700">Add New Sub-course</h2>
|
<h2 className="text-lg font-semibold text-grayScale-700">
|
||||||
|
Add New Course
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddModal(false)}
|
onClick={() => setShowAddModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
|
@ -631,25 +719,31 @@ export function SubCoursesPage() {
|
||||||
|
|
||||||
<div className="space-y-5 px-6 py-6">
|
<div className="space-y-5 px-6 py-6">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-semibold text-grayScale-600">Title</label>
|
<label className="text-sm font-semibold text-grayScale-600">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Enter sub-course title"
|
placeholder="Enter course title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-semibold text-grayScale-600">Description</label>
|
<label className="text-sm font-semibold text-grayScale-600">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Enter sub-course description"
|
placeholder="Enter course description"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-semibold text-grayScale-600">Level</label>
|
<label className="text-sm font-semibold text-grayScale-600">
|
||||||
|
Level
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={level}
|
value={level}
|
||||||
onChange={(e) => setLevel(e.target.value)}
|
onChange={(e) => setLevel(e.target.value)}
|
||||||
|
|
@ -691,9 +785,14 @@ export function SubCoursesPage() {
|
||||||
<div className="mx-4 w-full max-w-lg rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-lg rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-grayScale-700">Prerequisites</h2>
|
<h2 className="text-lg font-semibold text-grayScale-700">
|
||||||
|
Prerequisites
|
||||||
|
</h2>
|
||||||
<p className="mt-0.5 text-sm text-grayScale-400">
|
<p className="mt-0.5 text-sm text-grayScale-400">
|
||||||
Manage prerequisites for <span className="font-medium text-grayScale-600">{prereqSubCourse.title}</span>
|
Manage prerequisites for{" "}
|
||||||
|
<span className="font-medium text-grayScale-600">
|
||||||
|
{prereqSubCourse.title}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -708,14 +807,18 @@ export function SubCoursesPage() {
|
||||||
{/* Add prerequisite */}
|
{/* Add prerequisite */}
|
||||||
{availablePrerequisites.length > 0 && (
|
{availablePrerequisites.length > 0 && (
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<label className="mb-1.5 block text-sm font-semibold text-grayScale-600">Add Prerequisite</label>
|
<label className="mb-1.5 block text-sm font-semibold text-grayScale-600">
|
||||||
|
Add Prerequisite
|
||||||
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select
|
<select
|
||||||
value={selectedPrereqId}
|
value={selectedPrereqId}
|
||||||
onChange={(e) => setSelectedPrereqId(Number(e.target.value))}
|
onChange={(e) =>
|
||||||
|
setSelectedPrereqId(Number(e.target.value))
|
||||||
|
}
|
||||||
className="flex-1 rounded-lg border border-grayScale-200 bg-white px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
className="flex-1 rounded-lg border border-grayScale-200 bg-white px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||||
>
|
>
|
||||||
<option value={0}>Select a sub-course...</option>
|
<option value={0}>Select a course...</option>
|
||||||
{availablePrerequisites.map((sc) => (
|
{availablePrerequisites.map((sc) => (
|
||||||
<option key={sc.id} value={sc.id}>
|
<option key={sc.id} value={sc.id}>
|
||||||
{sc.title} {sc.level ? `(${sc.level})` : ""}
|
{sc.title} {sc.level ? `(${sc.level})` : ""}
|
||||||
|
|
@ -727,7 +830,11 @@ export function SubCoursesPage() {
|
||||||
onClick={handleAddPrerequisite}
|
onClick={handleAddPrerequisite}
|
||||||
disabled={prereqAdding || !selectedPrereqId}
|
disabled={prereqAdding || !selectedPrereqId}
|
||||||
>
|
>
|
||||||
{prereqAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
{prereqAdding ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -736,13 +843,21 @@ export function SubCoursesPage() {
|
||||||
{/* Current prerequisites list */}
|
{/* Current prerequisites list */}
|
||||||
{prereqLoading ? (
|
{prereqLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
|
<img
|
||||||
|
src={spinnerSrc}
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 animate-spin"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : prerequisites.length === 0 ? (
|
) : prerequisites.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-grayScale-200 px-4 py-8 text-center">
|
<div className="rounded-xl border border-dashed border-grayScale-200 px-4 py-8 text-center">
|
||||||
<Link2 className="mx-auto h-8 w-8 text-grayScale-300" />
|
<Link2 className="mx-auto h-8 w-8 text-grayScale-300" />
|
||||||
<p className="mt-2 text-sm font-medium text-grayScale-500">No prerequisites</p>
|
<p className="mt-2 text-sm font-medium text-grayScale-500">
|
||||||
<p className="mt-0.5 text-xs text-grayScale-400">This sub-course is accessible without completing others first</p>
|
No prerequisites
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-grayScale-400">
|
||||||
|
This course is accessible without completing others first
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -755,7 +870,9 @@ export function SubCoursesPage() {
|
||||||
className="flex items-center justify-between rounded-xl border border-grayScale-100 bg-grayScale-25 px-4 py-3 transition-colors hover:border-grayScale-200"
|
className="flex items-center justify-between rounded-xl border border-grayScale-100 bg-grayScale-25 px-4 py-3 transition-colors hover:border-grayScale-200"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium text-grayScale-700 truncate">{prereq.prerequisite_title}</p>
|
<p className="text-sm font-medium text-grayScale-700 truncate">
|
||||||
|
{prereq.prerequisite_title}
|
||||||
|
</p>
|
||||||
<div className="mt-0.5 flex items-center gap-2">
|
<div className="mt-0.5 flex items-center gap-2">
|
||||||
{prereq.prerequisite_level && (
|
{prereq.prerequisite_level && (
|
||||||
<span className="rounded bg-brand-50 px-1.5 py-0.5 text-[11px] font-medium text-brand-600">
|
<span className="rounded bg-brand-50 px-1.5 py-0.5 text-[11px] font-medium text-brand-600">
|
||||||
|
|
@ -802,7 +919,9 @@ export function SubCoursesPage() {
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<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-md rounded-2xl bg-white shadow-2xl">
|
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
<h2 className="text-lg font-semibold text-grayScale-700">Edit Sub-course</h2>
|
<h2 className="text-lg font-semibold text-grayScale-700">
|
||||||
|
Edit Course
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEditModal(false)}
|
onClick={() => setShowEditModal(false)}
|
||||||
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||||
|
|
@ -813,25 +932,31 @@ export function SubCoursesPage() {
|
||||||
|
|
||||||
<div className="space-y-5 px-6 py-6">
|
<div className="space-y-5 px-6 py-6">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-semibold text-grayScale-600">Title</label>
|
<label className="text-sm font-semibold text-grayScale-600">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Enter sub-course title"
|
placeholder="Enter course title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-semibold text-grayScale-600">Description</label>
|
<label className="text-sm font-semibold text-grayScale-600">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Enter sub-course description"
|
placeholder="Enter course description"
|
||||||
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
className="w-full rounded-lg border border-grayScale-200 px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-semibold text-grayScale-600">Level</label>
|
<label className="text-sm font-semibold text-grayScale-600">
|
||||||
|
Level
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={level}
|
value={level}
|
||||||
onChange={(e) => setLevel(e.target.value)}
|
onChange={(e) => setLevel(e.target.value)}
|
||||||
|
|
@ -867,5 +992,5 @@ export function SubCoursesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -498,8 +498,8 @@ export interface GetLearningPathResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReorderItem {
|
export interface ReorderItem {
|
||||||
sub_course_id: number
|
id: number
|
||||||
display_order: number
|
position: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ratings
|
// Ratings
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user