Yimaru-Admin/src/pages/content-management/CourseCategoryPage.tsx

377 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from "react"
import { Link } from "react-router-dom"
import { FolderOpen, RefreshCw, BookOpen, Plus } from "lucide-react"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
import alertSrc from "../../assets/Alert.svg"
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 {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { getCourseCategories, createCourseCategory } from "../../api/courses.api"
import type { CourseCategory } from "../../types/course.types"
import { toast } from "sonner"
export function CourseCategoryPage() {
const [categories, setCategories] = useState<CourseCategory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [newCategoryName, setNewCategoryName] = useState("")
const [creating, setCreating] = useState(false)
const [parentCategoryId, setParentCategoryId] = useState<number | null>(null)
const [newSubCategoryName, setNewSubCategoryName] = useState("")
const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState("")
const fetchCategories = async () => {
setLoading(true)
setError(null)
try {
const res = await getCourseCategories()
setCategories(res.data.data.categories)
} catch (err) {
console.error("Failed to fetch categories:", err)
setError("Failed to load categories")
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchCategories()
}, [])
const normalizedQuery = searchQuery.trim().toLowerCase()
const filteredCategories = normalizedQuery
? categories.filter((c) => c.name?.toLowerCase().includes(normalizedQuery))
: categories
if (loading) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-24">
<img src={spinnerSrc} alt="" className="h-10 w-10 animate-spin" />
<span className="text-sm font-medium text-grayScale-400">Loading categories</span>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center py-24">
<div className="flex flex-col items-center gap-4 rounded-2xl border border-red-100 bg-red-50/60 px-10 py-8 text-center shadow-sm">
<img src={alertSrc} alt="" className="h-12 w-12" />
<div>
<p className="text-sm font-semibold text-red-700">{error}</p>
<p className="mt-1 text-xs text-red-400">
Please check your connection and try again
</p>
</div>
<button
onClick={fetchCategories}
className="mt-1 inline-flex items-center gap-2 rounded-lg bg-red-500 px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-red-600"
>
<RefreshCw className="h-3.5 w-3.5" />
Retry
</button>
</div>
</div>
)
}
return (
<div className="space-y-8">
{/* Page header */}
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-grayScale-600">Course Categories</h1>
<p className="mt-1 text-sm text-grayScale-400">
Browse and manage your course categories below
</p>
</div>
<div className="w-full max-w-sm">
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search categories..."
aria-label="Search categories"
/>
</div>
<Button
className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
size="sm"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
New Category
</Button>
</div>
{categories.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-5 rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-24">
<div className="relative">
<div className="grid h-20 w-20 place-items-center rounded-2xl bg-gradient-to-br from-brand-100 to-brand-200 shadow-sm">
<FolderOpen className="h-9 w-9 text-brand-500" />
</div>
<div className="absolute -bottom-1 -right-1 grid h-8 w-8 place-items-center rounded-lg bg-white shadow ring-1 ring-grayScale-100">
<BookOpen className="h-4 w-4 text-grayScale-300" />
</div>
</div>
<div className="text-center">
<p className="text-sm font-semibold text-grayScale-500">No categories yet</p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-grayScale-400">
Course categories will appear here once created. Start by adding your first category.
</p>
</div>
</div>
) : filteredCategories.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-5 rounded-2xl border border-dashed border-grayScale-200 bg-grayScale-50/50 py-24">
<div className="grid h-20 w-20 place-items-center rounded-2xl bg-grayScale-50 shadow-sm">
<FolderOpen className="h-9 w-9 text-grayScale-300" />
</div>
<div className="text-center">
<p className="text-sm font-semibold text-grayScale-500">No matching categories</p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-grayScale-400">
Try a different search term.
</p>
</div>
</div>
) : (
<div className="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filteredCategories.map((category) => (
<Link
key={category.id}
to={`/content/category/${category.id}/courses`}
className="group"
>
<Card className="relative h-full overflow-hidden border border-grayScale-100 shadow-sm transition-all duration-300 group-hover:scale-[1.02] group-hover:border-brand-200 group-hover:shadow-lg">
{/* Decorative gradient strip */}
<div className="h-1 w-full bg-gradient-to-r from-brand-500 to-brand-600 opacity-70 transition-opacity duration-300 group-hover:opacity-100" />
<CardHeader className="pt-5">
<div className="mb-4 grid h-12 w-12 place-items-center rounded-xl bg-gradient-to-br from-brand-100 to-brand-200 text-brand-600 shadow-sm transition-all duration-300 group-hover:from-brand-500 group-hover:to-brand-600 group-hover:text-white group-hover:shadow-md">
<FolderOpen className="h-6 w-6" />
</div>
<CardTitle className="text-lg font-semibold text-grayScale-600 transition-colors group-hover:text-grayScale-700">
{category.name}
</CardTitle>
</CardHeader>
<CardContent>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-500 transition-colors group-hover:text-brand-600">
View Sub-categories
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
</span>
</span>
</CardContent>
</Card>
</Link>
))}
</div>
)}
{/* Create category dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-4 w-4 text-brand-500" />
<span>Create course category</span>
</DialogTitle>
<DialogDescription>
Add a new high-level bucket to organize your courses. You can also nest it under an existing parent category.
</DialogDescription>
</DialogHeader>
<div className="mt-4 grid gap-6 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)]">
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Category name
</label>
<Input
placeholder="e.g. Beginner English, Exam Prep"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
Parent category (optional)
</label>
<Select
value={parentCategoryId ?? ""}
onChange={(e) =>
setParentCategoryId(e.target.value ? Number(e.target.value) : null)
}
>
<option value="">No parent (top level)</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</Select>
<p className="mt-1 text-[11px] text-grayScale-400">
When left empty, this becomes a parent category. Any sub categories you add on the
right will be created under it.
</p>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-grayScale-600">
Sub categories for this category (optional)
</p>
{pendingSubCategories.length > 0 && (
<span className="text-[11px] text-grayScale-400">
{pendingSubCategories.length} sub categor
{pendingSubCategories.length === 1 ? "y" : "ies"} to create
</span>
)}
</div>
<div className="flex gap-2">
<Input
placeholder="e.g. Grammar basics, Speaking, Exam practice"
value={newSubCategoryName}
onChange={(e) => setNewSubCategoryName(e.target.value)}
className="h-9 text-sm"
/>
<Button
type="button"
size="sm"
className="h-9 px-3 text-xs"
onClick={() => {
const name = newSubCategoryName.trim()
if (!name) return
if (pendingSubCategories.includes(name)) {
setNewSubCategoryName("")
return
}
setPendingSubCategories((prev) => [...prev, name])
setNewSubCategoryName("")
}}
>
Add
</Button>
</div>
<div className="min-h-[3.5rem] rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/60 p-2">
{pendingSubCategories.length === 0 ? (
<p className="text-[11px] leading-relaxed text-grayScale-400">
Added sub categories will appear here so you can visually confirm the structure
before saving. This is optional.
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{pendingSubCategories.map((name) => (
<button
key={name}
type="button"
className="group inline-flex items-center gap-1 rounded-full bg-white px-2 py-0.5 text-[11px] text-grayScale-600 shadow-sm ring-1 ring-grayScale-200 hover:bg-red-50 hover:text-red-600 hover:ring-red-200"
onClick={() =>
setPendingSubCategories((prev) =>
prev.filter((subName) => subName !== name),
)
}
>
<span className="max-w-[160px] truncate">{name}</span>
<span className="text-[10px] text-grayScale-300 group-hover:text-red-400">
×
</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setCreateOpen(false)
setNewCategoryName("")
setParentCategoryId(null)
setNewSubCategoryName("")
setPendingSubCategories([])
}}
disabled={creating}
>
Cancel
</Button>
<Button
className="bg-brand-500 text-white hover:bg-brand-600"
disabled={creating || !newCategoryName.trim()}
onClick={async () => {
if (!newCategoryName.trim()) return
setCreating(true)
try {
const name = newCategoryName.trim()
const parentRes = await createCourseCategory({ name })
let createdCategoryId: number | null = null
try {
const data: any = parentRes?.data
createdCategoryId =
data?.data?.id ??
data?.data?.category?.id ??
data?.data?.id ??
data?.category?.id ??
data?.id ??
null
} catch {
createdCategoryId = null
}
if (createdCategoryId && pendingSubCategories.length > 0) {
await Promise.all(
pendingSubCategories.map((subName) =>
createCourseCategory({ name: subName }),
),
)
}
toast.success("Category created", {
description:
pendingSubCategories.length > 0
? `"${name}" and ${pendingSubCategories.length} sub categor${
pendingSubCategories.length === 1 ? "y" : "ies"
} have been added.`
: `"${name}" has been added.`,
})
setNewCategoryName("")
setParentCategoryId(null)
setNewSubCategoryName("")
setPendingSubCategories([])
setCreateOpen(false)
fetchCategories()
} catch (err: any) {
const message =
err?.response?.data?.message ||
"Failed to create category. Please try again."
toast.error("Could not create category", {
description: message,
})
} finally {
setCreating(false)
}
}}
>
{creating ? "Creating..." : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}