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 { 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 */}
|
||||
|
|
|
|||
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 = [
|
||||
{ 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" },
|
||||
|
|
|
|||
|
|
@ -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,69 +169,76 @@ 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)}
|
||||
>
|
||||
<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`}
|
||||
style={{
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
{/* Subtle gradient background on icon area */}
|
||||
<div
|
||||
className={`absolute inset-x-0 top-0 h-28 bg-gradient-to-b ${section.gradient} pointer-events-none`}
|
||||
/>
|
||||
<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 ${
|
||||
dragKey === section.key ? "ring-2 ring-brand-300" : ""
|
||||
}`}
|
||||
style={{
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
{/* Subtle gradient background on icon area */}
|
||||
<div
|
||||
className={`absolute inset-x-0 top-0 h-28 bg-gradient-to-b ${section.gradient} pointer-events-none`}
|
||||
/>
|
||||
|
||||
<CardHeader className="relative pb-2">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
{/* Icon with gradient ring */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="grid h-12 w-12 place-items-center rounded-xl bg-white text-brand-600 shadow-sm ring-1 ring-grayScale-100 transition-all duration-300 group-hover:ring-brand-300 group-hover:shadow-md"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(158,40,145,0.08) 0%, rgba(106,27,154,0.04) 100%)",
|
||||
}}
|
||||
>
|
||||
<Icon className="h-5.5 w-5.5 transition-transform duration-300 group-hover:scale-110" />
|
||||
<CardHeader className="relative pb-2">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
{/* Icon with gradient ring */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="grid h-12 w-12 place-items-center rounded-xl bg-white text-brand-600 shadow-sm ring-1 ring-grayScale-100 transition-all duration-300 group-hover:ring-brand-300 group-hover:shadow-md"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(158,40,145,0.08) 0%, rgba(106,27,154,0.04) 100%)",
|
||||
}}
|
||||
>
|
||||
<Icon className="h-5.5 w-5.5 transition-transform duration-300 group-hover:scale-110" />
|
||||
</div>
|
||||
{/* Decorative dot */}
|
||||
<div className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-brand-400 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
||||
</div>
|
||||
{/* Decorative dot */}
|
||||
<div className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-brand-400 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
||||
|
||||
{/* Count Badge */}
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-grayScale-50 px-2.5 py-1 text-xs font-medium text-grayScale-500 ring-1 ring-inset ring-grayScale-100 transition-all duration-300 group-hover:bg-brand-50 group-hover:text-brand-600 group-hover:ring-brand-200">
|
||||
{section.count} {section.countLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Count Badge */}
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-grayScale-50 px-2.5 py-1 text-xs font-medium text-grayScale-500 ring-1 ring-inset ring-grayScale-100 transition-all duration-300 group-hover:bg-brand-50 group-hover:text-brand-600 group-hover:ring-brand-200">
|
||||
{section.count} {section.countLabel}
|
||||
<CardTitle className="text-[15px] font-semibold text-grayScale-700 transition-colors duration-200 group-hover:text-brand-600">
|
||||
{section.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 text-[13px] leading-relaxed text-grayScale-400">
|
||||
{section.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative pt-0">
|
||||
{/* Thin separator */}
|
||||
<div className="mb-3 h-px w-full bg-gradient-to-r from-transparent via-grayScale-100 to-transparent" />
|
||||
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors duration-200 group-hover:text-brand-600">
|
||||
{section.action}
|
||||
<ArrowRight className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1.5" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CardTitle className="text-[15px] font-semibold text-grayScale-700 transition-colors duration-200 group-hover:text-brand-600">
|
||||
{section.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 text-[13px] leading-relaxed text-grayScale-400">
|
||||
{section.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative pt-0">
|
||||
{/* Thin separator */}
|
||||
<div className="mb-3 h-px w-full bg-gradient-to-r from-transparent via-grayScale-100 to-transparent" />
|
||||
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors duration-200 group-hover:text-brand-600">
|
||||
{section.action}
|
||||
<ArrowRight className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1.5" />
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</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 { 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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user