changes
This commit is contained in:
parent
cd2ed66960
commit
46c0c78214
|
|
@ -4,6 +4,8 @@ import { DashboardPage } from "../pages/DashboardPage"
|
||||||
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"
|
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"
|
||||||
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"
|
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"
|
||||||
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage"
|
import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage"
|
||||||
|
import { AllCoursesPage } from "../pages/content-management/AllCoursesPage"
|
||||||
|
import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage"
|
||||||
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"
|
import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"
|
||||||
import { CoursesPage } from "../pages/content-management/CoursesPage"
|
import { CoursesPage } from "../pages/content-management/CoursesPage"
|
||||||
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
|
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
|
||||||
|
|
@ -62,6 +64,8 @@ export function AppRoutes() {
|
||||||
|
|
||||||
<Route path="/content" element={<ContentManagementLayout />}>
|
<Route path="/content" element={<ContentManagementLayout />}>
|
||||||
<Route index element={<CourseCategoryPage />} />
|
<Route index element={<CourseCategoryPage />} />
|
||||||
|
<Route path="courses" element={<AllCoursesPage />} />
|
||||||
|
<Route path="flows" element={<CourseFlowBuilderPage />} />
|
||||||
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
|
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
|
||||||
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
|
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
|
||||||
{/* Course → Sub-course → Video/Practice */}
|
{/* Course → Sub-course → Video/Practice */}
|
||||||
|
|
|
||||||
413
src/pages/content-management/AllCoursesPage.tsx
Normal file
413
src/pages/content-management/AllCoursesPage.tsx
Normal file
|
|
@ -0,0 +1,413 @@
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { Search, Plus, RefreshCw } from "lucide-react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
|
import { Button } from "../../components/ui/button"
|
||||||
|
import { Input } from "../../components/ui/input"
|
||||||
|
import { Select } from "../../components/ui/select"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table"
|
||||||
|
import { Badge } from "../../components/ui/badge"
|
||||||
|
import { FileUpload } from "../../components/ui/file-upload"
|
||||||
|
import { getCourseCategories, getCoursesByCategory, createCourse } from "../../api/courses.api"
|
||||||
|
import type { Course, CourseCategory } from "../../types/course.types"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../../components/ui/dialog"
|
||||||
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
type CourseWithCategory = Course & { category_name: string }
|
||||||
|
|
||||||
|
export function AllCoursesPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [courses, setCourses] = useState<CourseWithCategory[]>([])
|
||||||
|
const [categories, setCategories] = useState<CourseCategory[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<"all" | string>("all")
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [createCategoryId, setCreateCategoryId] = useState<string>("")
|
||||||
|
const [createTitle, setCreateTitle] = useState("")
|
||||||
|
const [createDescription, setCreateDescription] = useState("")
|
||||||
|
const [createThumbnail, setCreateThumbnail] = useState<File | null>(null)
|
||||||
|
const [createVideo, setCreateVideo] = useState<File | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const fetchAllCourses = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const categoriesRes = await getCourseCategories()
|
||||||
|
const cats = categoriesRes.data.data.categories ?? []
|
||||||
|
setCategories(cats)
|
||||||
|
|
||||||
|
const allCourses: CourseWithCategory[] = []
|
||||||
|
for (const cat of cats) {
|
||||||
|
const res = await getCoursesByCategory(cat.id)
|
||||||
|
const catCourses = res.data.data.courses ?? []
|
||||||
|
allCourses.push(
|
||||||
|
...catCourses.map((c) => ({
|
||||||
|
...c,
|
||||||
|
category_name: cat.name,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setCourses(allCourses)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load courses:", err)
|
||||||
|
setError("Failed to load courses")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAllCourses()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filteredCourses = courses.filter((course) => {
|
||||||
|
if (categoryFilter !== "all" && String(course.category_id) !== categoryFilter) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
const haystack = `${course.title} ${course.description} ${course.category_name}`.toLowerCase()
|
||||||
|
if (!haystack.includes(q)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreateCourse = async () => {
|
||||||
|
if (!createCategoryId || !createTitle.trim() || !createDescription.trim()) {
|
||||||
|
toast.error("Missing fields", {
|
||||||
|
description: "Category, title, and description are required.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
await createCourse({
|
||||||
|
category_id: Number(createCategoryId),
|
||||||
|
title: createTitle.trim(),
|
||||||
|
description: createDescription.trim(),
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.success("Course created", {
|
||||||
|
description: `"${createTitle.trim()}" has been created.`,
|
||||||
|
})
|
||||||
|
|
||||||
|
setCreateOpen(false)
|
||||||
|
setCreateCategoryId("")
|
||||||
|
setCreateTitle("")
|
||||||
|
setCreateDescription("")
|
||||||
|
setCreateThumbnail(null)
|
||||||
|
setCreateVideo(null)
|
||||||
|
await fetchAllCourses()
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to create course:", err)
|
||||||
|
toast.error("Failed to create course", {
|
||||||
|
description: err?.response?.data?.message || "Please try again.",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
|
<div className="rounded-2xl bg-brand-50/50 p-6">
|
||||||
|
<RefreshCw className="h-10 w-10 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading all 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">
|
||||||
|
<div className="rounded-full bg-red-100 p-2">
|
||||||
|
<RefreshCw className="h-5 w-5 shrink-0 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">All Courses</h1>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
|
View and manage courses across all categories.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Course
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="shadow-soft">
|
||||||
|
<CardHeader className="border-b border-grayScale-200 pb-4">
|
||||||
|
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||||
|
Course Management
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5 pt-5">
|
||||||
|
{/* Search / Filters */}
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-300" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by title, description, or category…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10 transition-colors focus:border-brand-300 focus:ring-brand-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(e.target.value as typeof categoryFilter)}
|
||||||
|
>
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={String(cat.id)}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs font-medium text-grayScale-400">
|
||||||
|
Showing {filteredCourses.length} of {courses.length} courses
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Courses Table */}
|
||||||
|
{filteredCourses.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-grayScale-200">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-grayScale-100 hover:bg-grayScale-100">
|
||||||
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
|
Course
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
|
Category
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden py-3 text-xs font-semibold uppercase tracking-wider text-grayScale-500 md:table-cell">
|
||||||
|
Status
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="py-3 text-right text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredCourses.map((course, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={course.id}
|
||||||
|
className={`cursor-pointer transition-colors hover:bg-brand-100/30 ${
|
||||||
|
index % 2 === 0 ? "bg-white" : "bg-grayScale-100/40"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableCell className="max-w-md py-3.5">
|
||||||
|
<div className="truncate text-sm font-semibold text-grayScale-700">
|
||||||
|
{course.title}
|
||||||
|
</div>
|
||||||
|
{course.description && (
|
||||||
|
<div className="mt-1 truncate text-xs text-grayScale-400">
|
||||||
|
{course.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3.5 text-sm text-grayScale-500">
|
||||||
|
{course.category_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden py-3.5 md:table-cell">
|
||||||
|
<Badge
|
||||||
|
variant={course.is_active ? "success" : "secondary"}
|
||||||
|
className="text-[11px] font-semibold"
|
||||||
|
>
|
||||||
|
{course.is_active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3.5 text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-xs text-brand-500 hover:bg-brand-50"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
navigate(
|
||||||
|
`/content/category/${course.category_id}/courses/${course.id}/sub-courses`,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-grayScale-200 py-20 text-center">
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
<p className="text-base font-semibold text-grayScale-600">No courses found</p>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create course dialog */}
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create course</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose a category, add basic details, and optionally attach a thumbnail and intro
|
||||||
|
video.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={createCategoryId}
|
||||||
|
onChange={(e) => setCreateCategoryId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select category</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={String(cat.id)}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||||
|
Course title
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Beginner English A1"
|
||||||
|
value={createTitle}
|
||||||
|
onChange={(e) => setCreateTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Short summary of what this course covers."
|
||||||
|
value={createDescription}
|
||||||
|
onChange={(e) => setCreateDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||||
|
Thumbnail image
|
||||||
|
</label>
|
||||||
|
<FileUpload
|
||||||
|
accept="image/*"
|
||||||
|
onFileSelect={setCreateThumbnail}
|
||||||
|
label="Upload thumbnail"
|
||||||
|
description="JPEG or PNG"
|
||||||
|
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||||
|
Intro video
|
||||||
|
</label>
|
||||||
|
<FileUpload
|
||||||
|
accept="video/*"
|
||||||
|
onFileSelect={setCreateVideo}
|
||||||
|
label="Upload video"
|
||||||
|
description="Optional intro or overview"
|
||||||
|
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-grayScale-400">
|
||||||
|
File uploads are currently stored client-side only. Connect your storage/API layer to
|
||||||
|
persist thumbnails and videos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateOpen(false)
|
||||||
|
setCreateCategoryId("")
|
||||||
|
setCreateTitle("")
|
||||||
|
setCreateDescription("")
|
||||||
|
setCreateThumbnail(null)
|
||||||
|
setCreateVideo(null)
|
||||||
|
}}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||||
|
disabled={creating}
|
||||||
|
onClick={handleCreateCourse}
|
||||||
|
>
|
||||||
|
{creating ? "Creating…" : "Create course"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { cn } from "../../lib/utils"
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ label: "Overview", to: "/content" },
|
{ label: "Overview", to: "/content" },
|
||||||
{ label: "Courses", to: "/content/courses" },
|
{ label: "Courses", to: "/content/courses" },
|
||||||
|
{ label: "Flows", to: "/content/flows" },
|
||||||
{ label: "Speaking", to: "/content/speaking" },
|
{ label: "Speaking", to: "/content/speaking" },
|
||||||
{ label: "Practice", to: "/content/practices" },
|
{ label: "Practice", to: "/content/practices" },
|
||||||
{ label: "Questions", to: "/content/questions" },
|
{ label: "Questions", to: "/content/questions" },
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,13 @@ const contentSections = [
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
type ContentSection = (typeof contentSections)[number]
|
||||||
|
|
||||||
export function ContentOverviewPage() {
|
export function ContentOverviewPage() {
|
||||||
const { categoryId } = useParams<{ categoryId: string }>()
|
const { categoryId } = useParams<{ categoryId: string }>()
|
||||||
const [category, setCategory] = useState<CourseCategory | null>(null)
|
const [category, setCategory] = useState<CourseCategory | null>(null)
|
||||||
|
const [sections, setSections] = useState<ContentSection[]>(() => [...contentSections])
|
||||||
|
const [dragKey, setDragKey] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCategory = async () => {
|
const fetchCategory = async () => {
|
||||||
|
|
@ -77,6 +81,51 @@ export function ContentOverviewPage() {
|
||||||
}
|
}
|
||||||
}, [categoryId])
|
}, [categoryId])
|
||||||
|
|
||||||
|
// Load persisted section order from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem("content_sections_order")
|
||||||
|
if (!raw) return
|
||||||
|
const savedKeys: string[] = JSON.parse(raw)
|
||||||
|
const byKey = new Map(contentSections.map((s) => [s.key, s]))
|
||||||
|
const reordered: ContentSection[] = []
|
||||||
|
savedKeys.forEach((k) => {
|
||||||
|
const item = byKey.get(k as ContentSection["key"])
|
||||||
|
if (item) {
|
||||||
|
reordered.push(item)
|
||||||
|
byKey.delete(k as ContentSection["key"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Append any new sections that weren't in saved order
|
||||||
|
byKey.forEach((item) => reordered.push(item))
|
||||||
|
if (reordered.length) {
|
||||||
|
setSections(reordered)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore corrupted localStorage
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Persist order whenever it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const keys = sections.map((s) => s.key)
|
||||||
|
window.localStorage.setItem("content_sections_order", JSON.stringify(keys))
|
||||||
|
}, [sections])
|
||||||
|
|
||||||
|
const handleDropOn = (targetKey: string) => {
|
||||||
|
if (!dragKey || dragKey === targetKey) return
|
||||||
|
setSections((prev) => {
|
||||||
|
const currentIndex = prev.findIndex((s) => s.key === dragKey)
|
||||||
|
const targetIndex = prev.findIndex((s) => s.key === targetKey)
|
||||||
|
if (currentIndex === -1 || targetIndex === -1) return prev
|
||||||
|
const copy = [...prev]
|
||||||
|
const [moved] = copy.splice(currentIndex, 1)
|
||||||
|
copy.splice(targetIndex, 0, moved)
|
||||||
|
return copy
|
||||||
|
})
|
||||||
|
setDragKey(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Header & Breadcrumb */}
|
{/* Header & Breadcrumb */}
|
||||||
|
|
@ -120,18 +169,24 @@ export function ContentOverviewPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cards Grid */}
|
{/* Cards Grid (course builder style – draggable sections) */}
|
||||||
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{contentSections.map((section) => {
|
{sections.map((section) => {
|
||||||
const Icon = section.icon
|
const Icon = section.icon
|
||||||
return (
|
return (
|
||||||
<Link
|
<div
|
||||||
key={section.key}
|
key={section.key}
|
||||||
to={section.pathFn(categoryId)}
|
|
||||||
className="group"
|
className="group"
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragKey(section.key)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => handleDropOn(section.key)}
|
||||||
>
|
>
|
||||||
|
<Link to={section.pathFn(categoryId)} className="block">
|
||||||
<Card
|
<Card
|
||||||
className={`relative h-full overflow-hidden border border-grayScale-100 bg-white transition-all duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] ${section.accentBorder} group-hover:shadow-lg`}
|
className={`relative h-full overflow-hidden border border-grayScale-100 bg-white transition-all duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] ${section.accentBorder} group-hover:shadow-lg ${
|
||||||
|
dragKey === section.key ? "ring-2 ring-brand-300" : ""
|
||||||
|
}`}
|
||||||
style={{
|
style={{
|
||||||
boxShadow: "0 8px 24px rgba(0,0,0,0.06)",
|
boxShadow: "0 8px 24px rgba(0,0,0,0.06)",
|
||||||
}}
|
}}
|
||||||
|
|
@ -183,6 +238,7 @@ export function ContentOverviewPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
447
src/pages/content-management/CourseFlowBuilderPage.tsx
Normal file
447
src/pages/content-management/CourseFlowBuilderPage.tsx
Normal file
|
|
@ -0,0 +1,447 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { ChevronRight, GripVertical, Plus, RefreshCw } from "lucide-react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
|
import { Button } from "../../components/ui/button"
|
||||||
|
import { Input } from "../../components/ui/input"
|
||||||
|
import { Select } from "../../components/ui/select"
|
||||||
|
import { Badge } from "../../components/ui/badge"
|
||||||
|
import { getCourseCategories, getCoursesByCategory } from "../../api/courses.api"
|
||||||
|
import type { Course, CourseCategory } from "../../types/course.types"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
type StepType = "lesson" | "practice" | "exam" | "feedback"
|
||||||
|
|
||||||
|
type FlowStep = {
|
||||||
|
id: string
|
||||||
|
type: StepType
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CourseWithCategory = Course & { category_name: string }
|
||||||
|
|
||||||
|
const STEP_LABELS: Record<StepType, string> = {
|
||||||
|
lesson: "Lesson",
|
||||||
|
practice: "Practice",
|
||||||
|
exam: "Exam",
|
||||||
|
feedback: "Feedback loop",
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_BADGE: Record<StepType, string> = {
|
||||||
|
lesson: "bg-sky-50 text-sky-700 ring-1 ring-inset ring-sky-200",
|
||||||
|
practice: "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200",
|
||||||
|
exam: "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200",
|
||||||
|
feedback: "bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-200",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CourseFlowBuilderPage() {
|
||||||
|
const [categories, setCategories] = useState<CourseCategory[]>([])
|
||||||
|
const [courses, setCourses] = useState<CourseWithCategory[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [selectedCourseId, setSelectedCourseId] = useState<string>("")
|
||||||
|
const [steps, setSteps] = useState<FlowStep[]>([])
|
||||||
|
const [dragStepId, setDragStepId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const selectedCourse = useMemo(
|
||||||
|
() => courses.find((c) => String(c.id) === selectedCourseId),
|
||||||
|
[courses, selectedCourseId],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load courses and categories
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAll = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const catRes = await getCourseCategories()
|
||||||
|
const cats = catRes.data.data.categories ?? []
|
||||||
|
setCategories(cats)
|
||||||
|
|
||||||
|
const all: CourseWithCategory[] = []
|
||||||
|
for (const cat of cats) {
|
||||||
|
const res = await getCoursesByCategory(cat.id)
|
||||||
|
const catCourses = res.data.data.courses ?? []
|
||||||
|
all.push(
|
||||||
|
...catCourses.map((c) => ({
|
||||||
|
...c,
|
||||||
|
category_name: cat.name,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setCourses(all)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load course flows data:", err)
|
||||||
|
setError("Failed to load courses. Please try again.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAll()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load flow for selected course from localStorage or build default
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCourseId) {
|
||||||
|
setSteps([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const key = `course_flow_${selectedCourseId}`
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key)
|
||||||
|
if (raw) {
|
||||||
|
const parsed: FlowStep[] = JSON.parse(raw)
|
||||||
|
setSteps(parsed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and fall through to default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default flow: Lesson -> Practice -> Exam -> Feedback
|
||||||
|
const defaults: FlowStep[] = [
|
||||||
|
{
|
||||||
|
id: `${selectedCourseId}-lesson`,
|
||||||
|
type: "lesson",
|
||||||
|
title: "Core lessons",
|
||||||
|
description: "Main learning content for this course.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${selectedCourseId}-practice`,
|
||||||
|
type: "practice",
|
||||||
|
title: "Practice sessions",
|
||||||
|
description: "Speaking or practice activities to reinforce learning.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${selectedCourseId}-exam`,
|
||||||
|
type: "exam",
|
||||||
|
title: "Exam / Assessment",
|
||||||
|
description: "Formal evaluation of student understanding.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `${selectedCourseId}-feedback`,
|
||||||
|
type: "feedback",
|
||||||
|
title: "Feedback loop",
|
||||||
|
description: "Collect feedback and share results with learners.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
setSteps(defaults)
|
||||||
|
}, [selectedCourseId])
|
||||||
|
|
||||||
|
// Persist flow when steps change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCourseId) return
|
||||||
|
const key = `course_flow_${selectedCourseId}`
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(steps))
|
||||||
|
}, [steps, selectedCourseId])
|
||||||
|
|
||||||
|
const handleReorder = (targetId: string) => {
|
||||||
|
if (!dragStepId || dragStepId === targetId) return
|
||||||
|
setSteps((prev) => {
|
||||||
|
const currentIndex = prev.findIndex((s) => s.id === dragStepId)
|
||||||
|
const targetIndex = prev.findIndex((s) => s.id === targetId)
|
||||||
|
if (currentIndex === -1 || targetIndex === -1) return prev
|
||||||
|
const copy = [...prev]
|
||||||
|
const [moved] = copy.splice(currentIndex, 1)
|
||||||
|
copy.splice(targetIndex, 0, moved)
|
||||||
|
return copy
|
||||||
|
})
|
||||||
|
setDragStepId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddStep = (type: StepType) => {
|
||||||
|
if (!selectedCourseId) return
|
||||||
|
const newStep: FlowStep = {
|
||||||
|
id: `${selectedCourseId}-${type}-${Date.now()}`,
|
||||||
|
type,
|
||||||
|
title: STEP_LABELS[type],
|
||||||
|
description:
|
||||||
|
type === "lesson"
|
||||||
|
? "Add the lessons or modules that introduce key concepts."
|
||||||
|
: type === "practice"
|
||||||
|
? "Connect speaking or practice activities after lessons."
|
||||||
|
: type === "exam"
|
||||||
|
? "Place exams or quizzes where you want to assess learners."
|
||||||
|
: "Ask for feedback, NPS, or reflection after the exam or final lesson.",
|
||||||
|
}
|
||||||
|
setSteps((prev) => [...prev, newStep])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateStep = (id: string, changes: Partial<FlowStep>) => {
|
||||||
|
setSteps((prev) => prev.map((s) => (s.id === id ? { ...s, ...changes } : s)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveStep = (id: string) => {
|
||||||
|
setSteps((prev) => prev.filter((s) => s.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
|
<div className="rounded-2xl bg-brand-50/50 p-6">
|
||||||
|
<RefreshCw className="h-10 w-10 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm font-medium text-grayScale-400">Loading course flows…</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">
|
||||||
|
<div className="rounded-full bg-red-100 p-2">
|
||||||
|
<RefreshCw className="h-5 w-5 shrink-0 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">Course Flows</h1>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
|
Define the sequence of lessons, practice, exams, and feedback for each course.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Course selector */}
|
||||||
|
<Card className="shadow-none border border-grayScale-200">
|
||||||
|
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-[0.14em] text-grayScale-400">
|
||||||
|
Select course
|
||||||
|
</p>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Select
|
||||||
|
value={selectedCourseId}
|
||||||
|
onChange={(e) => setSelectedCourseId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Choose a course…</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<optgroup key={cat.id} label={cat.name}>
|
||||||
|
{courses
|
||||||
|
.filter((c) => c.category_id === cat.id)
|
||||||
|
.map((course) => (
|
||||||
|
<option key={course.id} value={String(course.id)}>
|
||||||
|
{course.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedCourse && (
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-grayScale-400 sm:mt-0">
|
||||||
|
<Badge variant="secondary" className="text-[11px]">
|
||||||
|
{selectedCourse.category_name}
|
||||||
|
</Badge>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-grayScale-300" />
|
||||||
|
<span className="truncate max-w-[180px] text-grayScale-500">
|
||||||
|
{selectedCourse.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Builder */}
|
||||||
|
{selectedCourse ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||||
|
{/* Flow steps */}
|
||||||
|
<Card className="shadow-soft">
|
||||||
|
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||||
|
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||||
|
Flow sequence
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 pt-4">
|
||||||
|
{steps.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/60 px-4 py-6 text-center text-xs text-grayScale-400">
|
||||||
|
No steps yet. Use the buttons on the right to add lessons, practice, exams, and
|
||||||
|
feedback loops.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragStepId(step.id)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => handleReorder(step.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-2 rounded-xl border border-grayScale-100 bg-white p-3.5 shadow-sm transition-colors md:flex-row md:items-start",
|
||||||
|
dragStepId === step.id && "ring-2 ring-brand-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 md:flex-col md:items-start">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hidden h-8 w-8 items-center justify-center rounded-lg text-grayScale-300 hover:bg-grayScale-100 hover:text-grayScale-500 md:flex"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold",
|
||||||
|
STEP_BADGE[step.type],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STEP_LABELS[step.type]}
|
||||||
|
<span className="text-[10px] text-grayScale-400">#{index + 1}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Input
|
||||||
|
value={step.title}
|
||||||
|
onChange={(e) => handleUpdateStep(step.id, { title: e.target.value })}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={step.description ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateStep(step.id, { description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Optional description for this step"
|
||||||
|
className="h-8 text-xs text-grayScale-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 md:flex-col md:items-end">
|
||||||
|
{step.type !== "feedback" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-[11px]"
|
||||||
|
onClick={() => {
|
||||||
|
const feedbackStep: FlowStep = {
|
||||||
|
id: `${selectedCourseId}-feedback-${Date.now()}`,
|
||||||
|
type: "feedback",
|
||||||
|
title: "Feedback loop",
|
||||||
|
description: "Collect feedback after this step.",
|
||||||
|
}
|
||||||
|
setSteps((prev) => {
|
||||||
|
const idx = prev.findIndex((s) => s.id === step.id)
|
||||||
|
if (idx === -1) return prev
|
||||||
|
const copy = [...prev]
|
||||||
|
copy.splice(idx + 1, 0, feedbackStep)
|
||||||
|
return copy
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Feedback
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-[11px] text-destructive hover:bg-red-50"
|
||||||
|
onClick={() => handleRemoveStep(step.id)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Palette / What this controls */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="shadow-soft">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-semibold text-grayScale-600">
|
||||||
|
Add steps
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 pt-3">
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 justify-start gap-2 text-xs"
|
||||||
|
onClick={() => handleAddStep("lesson")}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 rounded-full bg-sky-500" />
|
||||||
|
Lesson
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 justify-start gap-2 text-xs"
|
||||||
|
onClick={() => handleAddStep("practice")}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
Practice
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 justify-start gap-2 text-xs"
|
||||||
|
onClick={() => handleAddStep("exam")}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 rounded-full bg-amber-500" />
|
||||||
|
Exam
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 justify-start gap-2 text-xs"
|
||||||
|
onClick={() => handleAddStep("feedback")}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 rounded-full bg-rose-500" />
|
||||||
|
Feedback
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] leading-relaxed text-grayScale-400">
|
||||||
|
Drag steps in the sequence on the left to change their order. Add feedback loops
|
||||||
|
after exams or any important milestone to keep learners engaged.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/50">
|
||||||
|
<CardContent className="space-y-2 p-4">
|
||||||
|
<p className="text-xs font-semibold text-grayScale-600">How this is used</p>
|
||||||
|
<p className="text-[11px] leading-relaxed text-grayScale-500">
|
||||||
|
This builder helps you map out the ideal learner journey. You can later connect
|
||||||
|
each step to actual lessons, speaking practices, exams, or surveys in your
|
||||||
|
backend. For now, flows are saved locally in your browser.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/60">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||||
|
<p className="text-sm font-semibold text-grayScale-600">
|
||||||
|
Select a course to start structuring its flow.
|
||||||
|
</p>
|
||||||
|
<p className="max-w-sm text-xs leading-relaxed text-grayScale-400">
|
||||||
|
Once a course is selected, you can define the order of lessons, practice, exams, and
|
||||||
|
feedback steps, and reorder them as needed. This works great on both desktop and
|
||||||
|
mobile layouts.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Card, CardContent } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Badge } from "../../components/ui/badge"
|
import { Badge } from "../../components/ui/badge"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
|
import { FileUpload } from "../../components/ui/file-upload"
|
||||||
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
|
import { getCoursesByCategory, getCourseCategories, createCourse, deleteCourse, updateCourseStatus, updateCourse } from "../../api/courses.api"
|
||||||
import type { Course, CourseCategory } from "../../types/course.types"
|
import type { Course, CourseCategory } from "../../types/course.types"
|
||||||
|
|
||||||
|
|
@ -38,6 +39,8 @@ export function CoursesPage() {
|
||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState("")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
const [newThumbnailFile, setNewThumbnailFile] = useState<File | null>(null)
|
||||||
|
const [newVideoFile, setNewVideoFile] = useState<File | null>(null)
|
||||||
|
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
const [courseToDelete, setCourseToDelete] = useState<Course | null>(null)
|
const [courseToDelete, setCourseToDelete] = useState<Course | null>(null)
|
||||||
|
|
@ -109,6 +112,8 @@ export function CoursesPage() {
|
||||||
setTitle("")
|
setTitle("")
|
||||||
setDescription("")
|
setDescription("")
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
|
setNewThumbnailFile(null)
|
||||||
|
setNewVideoFile(null)
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,6 +122,8 @@ export function CoursesPage() {
|
||||||
setTitle("")
|
setTitle("")
|
||||||
setDescription("")
|
setDescription("")
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
|
setNewThumbnailFile(null)
|
||||||
|
setNewVideoFile(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|
@ -465,6 +472,29 @@ export function CoursesPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-grayScale-600">Thumbnail image</p>
|
||||||
|
<FileUpload
|
||||||
|
accept="image/*"
|
||||||
|
onFileSelect={setNewThumbnailFile}
|
||||||
|
label="Upload thumbnail"
|
||||||
|
description="Optional course cover image"
|
||||||
|
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-grayScale-600">Intro video</p>
|
||||||
|
<FileUpload
|
||||||
|
accept="video/*"
|
||||||
|
onFileSelect={setNewVideoFile}
|
||||||
|
label="Upload intro video"
|
||||||
|
description="Optional overview for this course"
|
||||||
|
className="min-h-[90px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
|
<div className="rounded-lg bg-grayScale-50 px-3 py-2 text-xs text-grayScale-400">
|
||||||
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
|
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user