This commit is contained in:
“kirukib” 2026-02-27 19:39:58 +03:00
parent cd2ed66960
commit 46c0c78214
6 changed files with 1003 additions and 52 deletions

View File

@ -4,6 +4,8 @@ import { DashboardPage } from "../pages/DashboardPage"
import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"
import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"
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 { CoursesPage } from "../pages/content-management/CoursesPage"
import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage"
@ -62,6 +64,8 @@ export function AppRoutes() {
<Route path="/content" element={<ContentManagementLayout />}>
<Route index element={<CourseCategoryPage />} />
<Route path="courses" element={<AllCoursesPage />} />
<Route path="flows" element={<CourseFlowBuilderPage />} />
<Route path="category/:categoryId" element={<ContentOverviewPage />} />
<Route path="category/:categoryId/courses" element={<CoursesPage />} />
{/* Course → Sub-course → Video/Practice */}

View 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>
)
}

View File

@ -4,6 +4,7 @@ import { cn } from "../../lib/utils"
const tabs = [
{ label: "Overview", to: "/content" },
{ label: "Courses", to: "/content/courses" },
{ label: "Flows", to: "/content/flows" },
{ label: "Speaking", to: "/content/speaking" },
{ label: "Practice", to: "/content/practices" },
{ label: "Questions", to: "/content/questions" },

View File

@ -57,9 +57,13 @@ const contentSections = [
},
] as const
type ContentSection = (typeof contentSections)[number]
export function ContentOverviewPage() {
const { categoryId } = useParams<{ categoryId: string }>()
const [category, setCategory] = useState<CourseCategory | null>(null)
const [sections, setSections] = useState<ContentSection[]>(() => [...contentSections])
const [dragKey, setDragKey] = useState<string | null>(null)
useEffect(() => {
const fetchCategory = async () => {
@ -77,6 +81,51 @@ export function ContentOverviewPage() {
}
}, [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 (
<div className="space-y-8">
{/* Header & Breadcrumb */}
@ -120,18 +169,24 @@ export function ContentOverviewPage() {
</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">
{contentSections.map((section) => {
{sections.map((section) => {
const Icon = section.icon
return (
<Link
<div
key={section.key}
to={section.pathFn(categoryId)}
className="group"
draggable
onDragStart={() => setDragKey(section.key)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleDropOn(section.key)}
>
<Link to={section.pathFn(categoryId)} className="block">
<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={{
boxShadow: "0 8px 24px rgba(0,0,0,0.06)",
}}
@ -183,6 +238,7 @@ export function ContentOverviewPage() {
</CardContent>
</Card>
</Link>
</div>
)
})}
</div>

View 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>
)
}

View File

@ -5,6 +5,7 @@ import { Card, CardContent } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Badge } from "../../components/ui/badge"
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 type { Course, CourseCategory } from "../../types/course.types"
@ -38,6 +39,8 @@ export function CoursesPage() {
const [description, setDescription] = useState("")
const [saving, setSaving] = useState(false)
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 [courseToDelete, setCourseToDelete] = useState<Course | null>(null)
@ -109,6 +112,8 @@ export function CoursesPage() {
setTitle("")
setDescription("")
setSaveError(null)
setNewThumbnailFile(null)
setNewVideoFile(null)
setShowModal(true)
}
@ -117,6 +122,8 @@ export function CoursesPage() {
setTitle("")
setDescription("")
setSaveError(null)
setNewThumbnailFile(null)
setNewVideoFile(null)
}
const handleSave = async () => {
@ -465,6 +472,29 @@ export function CoursesPage() {
/>
</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">
Category: <span className="font-semibold text-grayScale-600">{category?.name}</span>
</div>