Xây Dựng Video Platform: Admin Dashboard

API đã sẵn sàng — 20 endpoint để quản lý course, section, và lesson. Nhưng không ai quản lý course platform bằng curl command cả. Trong bài này, chúng ta sẽ build admin dashboard xịn với sidebar layout, form validation, và drag-and-drop reorder.
Đây là bài đầu tiên trong series thuần frontend. Mọi thứ ở đây kết nối với admin API đã build ở Post #4.
Thời gian: 3–4 giờ
Yêu cầu: Phase 3: Course & Lesson Data Model
Những gì chúng ta sẽ build trong bài này:
✅ Admin layout với sidebar navigation và route protection
✅ Setup shadcn/ui component library
✅ Course list page với status badge và action
✅ Course editor form với react-hook-form + zod validation
✅ Section và lesson management panel
✅ Drag-and-drop reorder với @dnd-kit
✅ Toast notification cho success/error feedback
Admin Dashboard Architecture
Setup shadcn/ui
shadcn/ui không phải component library bạn install — mà là tập hợp component bạn copy vào project. Điều này cho bạn toàn quyền kiểm soát styling và behavior.
Khởi tạo shadcn/ui
cd web
npx shadcn@latest initKhi được hỏi:
- Style: Default
- Base color: Slate
- CSS variables: Yes
Lệnh này tạo thư mục components/ui/ và update tailwind.config.ts với cấu hình cần thiết.
Cài Component
npx shadcn@latest add button card input label textarea select badge
npx shadcn@latest add dialog dropdown-menu table toast separator
npx shadcn@latest add form tabs sheet scroll-areaMỗi lệnh copy source của component vào components/ui/. Bạn sở hữu các file này — tuỳ biến thoải mái.
Admin Layout
Cấu Trúc Thư Mục
web/src/
├── app/
│ ├── (public)/ # Trang public (catalog, v.v.)
│ └── admin/
│ ├── layout.tsx # Admin layout với sidebar
│ ├── page.tsx # Dashboard home (redirect tới courses)
│ └── courses/
│ ├── page.tsx # Course list
│ ├── new/
│ │ └── page.tsx # Tạo course
│ └── [id]/
│ └── page.tsx # Edit course
├── components/
│ ├── admin/
│ │ ├── AdminSidebar.tsx
│ │ ├── CourseList.tsx
│ │ ├── CourseForm.tsx
│ │ ├── SectionManager.tsx
│ │ ├── LessonManager.tsx
│ │ └── SortableItem.tsx
│ └── ui/ # shadcn/ui component
└── lib/
└── admin-api.ts # Admin API clientAdmin Layout Component
// web/src/app/admin/layout.tsx
"use client";
import { useAuth } from "@/components/AuthProvider";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && (!user || user.role !== "ADMIN")) {
router.push("/login");
}
}, [user, loading, router]);
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (!user || user.role !== "ADMIN") {
return null;
}
return (
<div className="flex h-screen bg-background">
<AdminSidebar />
<main className="flex-1 overflow-y-auto">
<div className="container max-w-6xl py-8 px-6">
{children}
</div>
</main>
</div>
);
}Sidebar Navigation
// web/src/components/admin/AdminSidebar.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
BookOpen,
Upload,
Users,
BarChart3,
Settings,
LogOut,
} from "lucide-react";
import { useAuth } from "@/components/AuthProvider";
const navItems = [
{ href: "/admin", icon: LayoutDashboard, label: "Dashboard" },
{ href: "/admin/courses", icon: BookOpen, label: "Courses" },
{ href: "/admin/upload", icon: Upload, label: "Upload" },
{ href: "/admin/users", icon: Users, label: "Users" },
{ href: "/admin/analytics", icon: BarChart3, label: "Analytics" },
{ href: "/admin/settings", icon: Settings, label: "Settings" },
];
export function AdminSidebar() {
const pathname = usePathname();
const { logout } = useAuth();
return (
<aside className="w-64 border-r bg-card flex flex-col">
{/* Logo */}
<div className="h-16 flex items-center px-6 border-b">
<Link href="/admin" className="flex items-center gap-2">
<BookOpen className="h-6 w-6 text-primary" />
<span className="font-semibold text-lg">Admin</span>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 py-4 px-3 space-y-1">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/admin" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
{/* Logout */}
<div className="p-3 border-t">
<button
onClick={logout}
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm
font-medium text-muted-foreground hover:bg-muted
hover:text-foreground w-full transition-colors"
>
<LogOut className="h-4 w-4" />
Log out
</button>
</div>
</aside>
);
}Admin API Client
Mở rộng Axios client từ Post #3 với các method dành cho admin:
// web/src/lib/admin-api.ts
import api from "@/lib/api";
// ==================== Type ====================
export interface Course {
id: number;
slug: string;
title: string;
description: string | null;
shortDescription: string | null;
thumbnailUrl: string | null;
difficulty: "BEGINNER" | "INTERMEDIATE" | "ADVANCED";
status: "DRAFT" | "PUBLISHED" | "ARCHIVED";
totalDuration: number;
totalLessons: number;
sortOrder: number;
publishedAt: string | null;
createdAt: string;
}
export interface CourseDetail extends Course {
sections: Section[];
}
export interface Section {
id: number;
title: string;
sortOrder: number;
lessons: Lesson[];
}
export interface Lesson {
id: number;
title: string;
description: string | null;
duration: number;
sortOrder: number;
freePreview: boolean;
status: "PROCESSING" | "READY" | "FAILED";
videoPath: string | null;
}
export interface CreateCourseInput {
title: string;
description?: string;
shortDescription?: string;
difficulty?: string;
}
export interface UpdateCourseInput {
title?: string;
description?: string;
shortDescription?: string;
thumbnailUrl?: string;
difficulty?: string;
status?: string;
}
export interface CreateSectionInput {
title: string;
}
export interface CreateLessonInput {
title: string;
description?: string;
freePreview?: boolean;
}
// ==================== API Function ====================
export const adminApi = {
// Course
getCourses: () =>
api.get<{ data: Course[] }>("/api/admin/courses").then((r) => r.data.data),
getCourse: (id: number) =>
api
.get<{ data: CourseDetail }>(`/api/admin/courses/${id}`)
.then((r) => r.data.data),
createCourse: (data: CreateCourseInput) =>
api
.post<{ data: Course }>("/api/admin/courses", data)
.then((r) => r.data.data),
updateCourse: (id: number, data: UpdateCourseInput) =>
api
.put<{ data: Course }>(`/api/admin/courses/${id}`, data)
.then((r) => r.data.data),
deleteCourse: (id: number) => api.delete(`/api/admin/courses/${id}`),
reorderCourses: (orderedIds: number[]) =>
api.put("/api/admin/courses/reorder", { orderedIds }),
// Section
createSection: (courseId: number, data: CreateSectionInput) =>
api
.post<{ data: Section }>(
`/api/admin/courses/${courseId}/sections`,
data
)
.then((r) => r.data.data),
updateSection: (sectionId: number, data: CreateSectionInput) =>
api
.put<{ data: Section }>(
`/api/admin/courses/sections/${sectionId}`,
data
)
.then((r) => r.data.data),
deleteSection: (sectionId: number) =>
api.delete(`/api/admin/courses/sections/${sectionId}`),
reorderSections: (courseId: number, orderedIds: number[]) =>
api.put(`/api/admin/courses/${courseId}/sections/reorder`, { orderedIds }),
// Lesson
createLesson: (sectionId: number, data: CreateLessonInput) =>
api
.post<{ data: Lesson }>(
`/api/admin/courses/sections/${sectionId}/lessons`,
data
)
.then((r) => r.data.data),
updateLesson: (lessonId: number, data: CreateLessonInput) =>
api
.put<{ data: Lesson }>(
`/api/admin/courses/lessons/${lessonId}`,
data
)
.then((r) => r.data.data),
deleteLesson: (lessonId: number) =>
api.delete(`/api/admin/courses/lessons/${lessonId}`),
reorderLessons: (sectionId: number, orderedIds: number[]) =>
api.put(
`/api/admin/courses/sections/${sectionId}/lessons/reorder`,
{ orderedIds }
),
};Wrapper type-safe gọn gàng. Mỗi method trả về field data đã unwrap từ ApiResponse<T> wrapper.
Course List Page
// web/src/app/admin/courses/page.tsx
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { adminApi, Course } from "@/lib/admin-api";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Plus, MoreHorizontal, Pencil, Trash2, Eye } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
const statusColors = {
DRAFT: "secondary",
PUBLISHED: "default",
ARCHIVED: "outline",
} as const;
const difficultyLabels = {
BEGINNER: "Beginner",
INTERMEDIATE: "Intermediate",
ADVANCED: "Advanced",
};
export default function CoursesPage() {
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
const fetchCourses = async () => {
try {
const data = await adminApi.getCourses();
setCourses(data);
} catch (error) {
toast({
title: "Error",
description: "Failed to load courses",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCourses();
}, []);
const handleDelete = async (id: number, title: string) => {
if (!confirm(`Xoá "${title}"? Tất cả section và lesson sẽ bị xoá theo.`)) {
return;
}
try {
await adminApi.deleteCourse(id);
setCourses((prev) => prev.filter((c) => c.id !== id));
toast({ title: "Course deleted" });
} catch (error) {
toast({
title: "Error",
description: "Failed to delete course",
variant: "destructive",
});
}
};
const formatDuration = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
if (loading) {
return <div className="animate-pulse space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-16 bg-muted rounded" />
))}
</div>;
}
return (
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold">Courses</h1>
<p className="text-muted-foreground mt-1">
Quản lý course catalog
</p>
</div>
<Link href="/admin/courses/new">
<Button>
<Plus className="h-4 w-4 mr-2" />
New Course
</Button>
</Link>
</div>
{courses.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Chưa có course nào. Tạo course đầu tiên để bắt đầu.</p>
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Difficulty</TableHead>
<TableHead className="text-right">Lessons</TableHead>
<TableHead className="text-right">Duration</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{courses.map((course) => (
<TableRow key={course.id}>
<TableCell>
<Link
href={`/admin/courses/${course.id}`}
className="font-medium hover:underline"
>
{course.title}
</Link>
<p className="text-sm text-muted-foreground mt-0.5">
/{course.slug}
</p>
</TableCell>
<TableCell>
<Badge variant={statusColors[course.status]}>
{course.status}
</Badge>
</TableCell>
<TableCell>{difficultyLabels[course.difficulty]}</TableCell>
<TableCell className="text-right">
{course.totalLessons}
</TableCell>
<TableCell className="text-right">
{formatDuration(course.totalDuration)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/courses/${course.id}`}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</Link>
</DropdownMenuItem>
{course.status === "PUBLISHED" && (
<DropdownMenuItem asChild>
<Link href={`/courses/${course.slug}`} target="_blank">
<Eye className="h-4 w-4 mr-2" />
View
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDelete(course.id, course.title)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}Form Validation với Zod
Định nghĩa validation schema khớp với CreateCourseRequest và UpdateCourseRequest của API:
// web/src/lib/validations/course.ts
import { z } from "zod";
export const createCourseSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(255, "Title must be under 255 characters"),
description: z.string().optional(),
shortDescription: z
.string()
.max(500, "Short description must be under 500 characters")
.optional(),
difficulty: z.enum(["BEGINNER", "INTERMEDIATE", "ADVANCED"]).default("BEGINNER"),
});
export const updateCourseSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(255, "Title must be under 255 characters"),
description: z.string().optional(),
shortDescription: z
.string()
.max(500, "Short description must be under 500 characters")
.optional(),
difficulty: z.enum(["BEGINNER", "INTERMEDIATE", "ADVANCED"]),
status: z.enum(["DRAFT", "PUBLISHED", "ARCHIVED"]),
});
export const sectionSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(255, "Title must be under 255 characters"),
});
export const lessonSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(255, "Title must be under 255 characters"),
description: z.string().optional(),
freePreview: z.boolean().default(false),
});
export type CreateCourseInput = z.infer<typeof createCourseSchema>;
export type UpdateCourseInput = z.infer<typeof updateCourseSchema>;
export type SectionInput = z.infer<typeof sectionSchema>;
export type LessonInput = z.infer<typeof lessonSchema>;Zod schema validate ở client trước khi request đến server. Server validate lại bằng Bean Validation — defense in depth.
Course Editor Form
Trang Tạo Course
// web/src/app/admin/courses/new/page.tsx
"use client";
import { useRouter } from "next/navigation";
import { CourseForm } from "@/components/admin/CourseForm";
import { adminApi } from "@/lib/admin-api";
import { CreateCourseInput } from "@/lib/validations/course";
import { useToast } from "@/hooks/use-toast";
export default function NewCoursePage() {
const router = useRouter();
const { toast } = useToast();
const handleCreate = async (data: CreateCourseInput) => {
try {
const course = await adminApi.createCourse(data);
toast({ title: "Course created" });
router.push(`/admin/courses/${course.id}`);
} catch (error) {
toast({
title: "Error",
description: "Failed to create course",
variant: "destructive",
});
}
};
return (
<div>
<h1 className="text-2xl font-bold mb-8">New Course</h1>
<CourseForm onSubmit={handleCreate} />
</div>
);
}Course Form Component
// web/src/components/admin/CourseForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
createCourseSchema,
updateCourseSchema,
CreateCourseInput,
UpdateCourseInput,
} from "@/lib/validations/course";
import { Course } from "@/lib/admin-api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface CourseFormProps {
course?: Course;
onSubmit: (data: CreateCourseInput | UpdateCourseInput) => Promise<void>;
}
export function CourseForm({ course, onSubmit }: CourseFormProps) {
const isEditing = !!course;
const schema = isEditing ? updateCourseSchema : createCourseSchema;
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
title: course?.title ?? "",
description: course?.description ?? "",
shortDescription: course?.shortDescription ?? "",
difficulty: course?.difficulty ?? "BEGINNER",
...(isEditing ? { status: course.status } : {}),
},
});
const difficulty = watch("difficulty");
const status = isEditing ? watch("status") : undefined;
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Course Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
placeholder="e.g., Spring Boot Masterclass"
{...register("title")}
/>
{errors.title && (
<p className="text-sm text-destructive">{errors.title.message}</p>
)}
</div>
{/* Short Description */}
<div className="space-y-2">
<Label htmlFor="shortDescription">Short Description</Label>
<Input
id="shortDescription"
placeholder="Mô tả ngắn hiển thị trên course card"
{...register("shortDescription")}
/>
{errors.shortDescription && (
<p className="text-sm text-destructive">
{errors.shortDescription.message}
</p>
)}
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Mô tả đầy đủ cho course..."
rows={6}
{...register("description")}
/>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Difficulty */}
<div className="space-y-2">
<Label>Difficulty</Label>
<Select
value={difficulty}
onValueChange={(value) =>
setValue("difficulty", value as "BEGINNER" | "INTERMEDIATE" | "ADVANCED")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BEGINNER">Beginner</SelectItem>
<SelectItem value="INTERMEDIATE">Intermediate</SelectItem>
<SelectItem value="ADVANCED">Advanced</SelectItem>
</SelectContent>
</Select>
</div>
{/* Status (chỉ khi edit) */}
{isEditing && (
<div className="space-y-2">
<Label>Status</Label>
<Select
value={status}
onValueChange={(value) =>
setValue("status", value as "DRAFT" | "PUBLISHED" | "ARCHIVED")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="PUBLISHED">Published</SelectItem>
<SelectItem value="ARCHIVED">Archived</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => history.back()}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? "Saving..."
: isEditing
? "Save Changes"
: "Create Course"}
</Button>
</div>
</form>
);
}Form tự adapt tuỳ theo đang tạo hay edit. Khi edit, nó hiện thêm status dropdown. zodResolver validate hết field trước khi onSubmit fire.
Course Editor Page
Editor page kết hợp course form với section và lesson management:
// web/src/app/admin/courses/[id]/page.tsx
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { adminApi, CourseDetail } from "@/lib/admin-api";
import { UpdateCourseInput } from "@/lib/validations/course";
import { CourseForm } from "@/components/admin/CourseForm";
import { SectionManager } from "@/components/admin/SectionManager";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
export default function EditCoursePage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const { toast } = useToast();
const [course, setCourse] = useState<CourseDetail | null>(null);
const [loading, setLoading] = useState(true);
const fetchCourse = async () => {
try {
const data = await adminApi.getCourse(Number(id));
setCourse(data);
} catch (error) {
toast({
title: "Error",
description: "Course not found",
variant: "destructive",
});
router.push("/admin/courses");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCourse();
}, [id]);
const handleUpdate = async (data: UpdateCourseInput) => {
try {
await adminApi.updateCourse(Number(id), data);
toast({ title: "Course updated" });
fetchCourse(); // Refresh data
} catch (error) {
toast({
title: "Error",
description: "Failed to update course",
variant: "destructive",
});
}
};
if (loading || !course) {
return <div className="animate-pulse space-y-4">
<div className="h-8 w-64 bg-muted rounded" />
<div className="h-96 bg-muted rounded" />
</div>;
}
return (
<div>
<div className="mb-8">
<Link
href="/admin/courses"
className="text-sm text-muted-foreground hover:text-foreground
inline-flex items-center gap-1 mb-4"
>
<ArrowLeft className="h-3 w-3" />
Back to courses
</Link>
<h1 className="text-2xl font-bold">{course.title}</h1>
<p className="text-muted-foreground mt-1">/{course.slug}</p>
</div>
<Tabs defaultValue="details">
<TabsList>
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="content">
Content ({course.totalLessons} lessons)
</TabsTrigger>
</TabsList>
<TabsContent value="details" className="mt-6">
<CourseForm course={course} onSubmit={handleUpdate} />
</TabsContent>
<TabsContent value="content" className="mt-6">
<SectionManager
courseId={course.id}
sections={course.sections}
onUpdate={fetchCourse}
/>
</TabsContent>
</Tabs>
</div>
);
}Layout hai tab tách riêng course metadata và content management. Tab "Content" là nơi diễn ra action chính.
Section Manager
// web/src/components/admin/SectionManager.tsx
"use client";
import { useState } from "react";
import { adminApi, Section } from "@/lib/admin-api";
import { LessonManager } from "./LessonManager";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import {
Plus,
GripVertical,
Pencil,
Trash2,
ChevronDown,
ChevronRight,
} from "lucide-react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { SortableItem } from "./SortableItem";
interface SectionManagerProps {
courseId: number;
sections: Section[];
onUpdate: () => void;
}
export function SectionManager({
courseId,
sections: initialSections,
onUpdate,
}: SectionManagerProps) {
const [sections, setSections] = useState(initialSections);
const [expandedSections, setExpandedSections] = useState<Set<number>>(
new Set(initialSections.map((s) => s.id))
);
const [newSectionTitle, setNewSectionTitle] = useState("");
const [editingSection, setEditingSection] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const { toast } = useToast();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const toggleSection = (id: number) => {
setExpandedSections((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const handleAddSection = async () => {
if (!newSectionTitle.trim()) return;
try {
await adminApi.createSection(courseId, { title: newSectionTitle });
setNewSectionTitle("");
setDialogOpen(false);
toast({ title: "Section created" });
onUpdate();
} catch (error) {
toast({
title: "Error",
description: "Failed to create section",
variant: "destructive",
});
}
};
const handleUpdateSection = async (sectionId: number) => {
if (!editTitle.trim()) return;
try {
await adminApi.updateSection(sectionId, { title: editTitle });
setEditingSection(null);
toast({ title: "Section updated" });
onUpdate();
} catch (error) {
toast({
title: "Error",
description: "Failed to update section",
variant: "destructive",
});
}
};
const handleDeleteSection = async (sectionId: number, title: string) => {
if (
!confirm(
`Xoá "${title}"? Tất cả lesson trong section này sẽ bị xoá theo.`
)
) {
return;
}
try {
await adminApi.deleteSection(sectionId);
toast({ title: "Section deleted" });
onUpdate();
} catch (error) {
toast({
title: "Error",
description: "Failed to delete section",
variant: "destructive",
});
}
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sections.findIndex((s) => s.id === active.id);
const newIndex = sections.findIndex((s) => s.id === over.id);
const reordered = arrayMove(sections, oldIndex, newIndex);
setSections(reordered); // Optimistic update
try {
await adminApi.reorderSections(
courseId,
reordered.map((s) => s.id)
);
} catch (error) {
setSections(sections); // Revert nếu lỗi
toast({
title: "Error",
description: "Failed to reorder sections",
variant: "destructive",
});
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
Sections ({sections.length})
</h2>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Section
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New Section</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-4">
<Input
placeholder="Section title"
value={newSectionTitle}
onChange={(e) => setNewSectionTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddSection()}
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
>
Cancel
</Button>
<Button onClick={handleAddSection}>Create</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{sections.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Chưa có section nào. Thêm section để bắt đầu tổ chức content.
</CardContent>
</Card>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sections.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
{sections.map((section) => (
<SortableItem key={section.id} id={section.id}>
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<button
onClick={() => toggleSection(section.id)}
className="p-1 hover:bg-muted rounded"
>
{expandedSections.has(section.id) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{editingSection === section.id ? (
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleUpdateSection(section.id);
if (e.key === "Escape") setEditingSection(null);
}}
onBlur={() => handleUpdateSection(section.id)}
className="h-8"
autoFocus
/>
) : (
<CardTitle className="text-base flex-1">
{section.title}
<span className="text-sm text-muted-foreground font-normal ml-2">
({section.lessons.length} lessons)
</span>
</CardTitle>
)}
<div className="flex items-center gap-1 ml-auto">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
setEditingSection(section.id);
setEditTitle(section.title);
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() =>
handleDeleteSection(section.id, section.title)
}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardHeader>
{expandedSections.has(section.id) && (
<CardContent className="pt-0 pb-4 px-4">
<LessonManager
sectionId={section.id}
lessons={section.lessons}
onUpdate={onUpdate}
/>
</CardContent>
)}
</Card>
</SortableItem>
))}
</SortableContext>
</DndContext>
)}
</div>
);
}Lesson Manager
// web/src/components/admin/LessonManager.tsx
"use client";
import { useState } from "react";
import { adminApi, Lesson } from "@/lib/admin-api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { Plus, GripVertical, Pencil, Trash2, Play, Eye } from "lucide-react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { SortableItem } from "./SortableItem";
interface LessonManagerProps {
sectionId: number;
lessons: Lesson[];
onUpdate: () => void;
}
const statusIcons = {
PROCESSING: "bg-yellow-500",
READY: "bg-green-500",
FAILED: "bg-red-500",
};
export function LessonManager({
sectionId,
lessons: initialLessons,
onUpdate,
}: LessonManagerProps) {
const [lessons, setLessons] = useState(initialLessons);
const [dialogOpen, setDialogOpen] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newDescription, setNewDescription] = useState("");
const [newFreePreview, setNewFreePreview] = useState(false);
const { toast } = useToast();
const sensors = useSensors(useSensor(PointerSensor));
const handleAddLesson = async () => {
if (!newTitle.trim()) return;
try {
await adminApi.createLesson(sectionId, {
title: newTitle,
description: newDescription || undefined,
freePreview: newFreePreview,
});
setNewTitle("");
setNewDescription("");
setNewFreePreview(false);
setDialogOpen(false);
toast({ title: "Lesson created" });
onUpdate();
} catch (error) {
toast({
title: "Error",
description: "Failed to create lesson",
variant: "destructive",
});
}
};
const handleDeleteLesson = async (lessonId: number, title: string) => {
if (!confirm(`Xoá "${title}"?`)) return;
try {
await adminApi.deleteLesson(lessonId);
toast({ title: "Lesson deleted" });
onUpdate();
} catch (error) {
toast({
title: "Error",
description: "Failed to delete lesson",
variant: "destructive",
});
}
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = lessons.findIndex((l) => l.id === active.id);
const newIndex = lessons.findIndex((l) => l.id === over.id);
const reordered = arrayMove(lessons, oldIndex, newIndex);
setLessons(reordered);
try {
await adminApi.reorderLessons(
sectionId,
reordered.map((l) => l.id)
);
} catch (error) {
setLessons(lessons);
toast({
title: "Error",
description: "Failed to reorder lessons",
variant: "destructive",
});
}
};
const formatDuration = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
};
return (
<div className="space-y-2">
{lessons.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
Chưa có lesson nào trong section này.
</p>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={lessons.map((l) => l.id)}
strategy={verticalListSortingStrategy}
>
{lessons.map((lesson, index) => (
<SortableItem key={lesson.id} id={lesson.id}>
<div className="flex items-center gap-3 py-2 px-3 rounded-md border
bg-background hover:bg-muted/50 transition-colors">
<GripVertical className="h-3.5 w-3.5 text-muted-foreground cursor-grab flex-shrink-0" />
{/* Status indicator */}
<div
className={`h-2 w-2 rounded-full flex-shrink-0 ${statusIcons[lesson.status]}`}
title={lesson.status}
/>
{/* Số thứ tự lesson */}
<span className="text-sm text-muted-foreground w-6">
{index + 1}.
</span>
{/* Title */}
<span className="text-sm flex-1 truncate">{lesson.title}</span>
{/* Badge */}
<div className="flex items-center gap-2 flex-shrink-0">
{lesson.freePreview && (
<Badge variant="outline" className="text-xs">
<Eye className="h-3 w-3 mr-1" />
Free
</Badge>
)}
{lesson.duration > 0 && (
<span className="text-xs text-muted-foreground">
{formatDuration(lesson.duration)}
</span>
)}
</div>
{/* Action */}
<div className="flex items-center gap-1 flex-shrink-0">
<Button variant="ghost" size="icon" className="h-7 w-7">
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => handleDeleteLesson(lesson.id, lesson.title)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</SortableItem>
))}
</SortableContext>
</DndContext>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full mt-2">
<Plus className="h-3.5 w-3.5 mr-2" />
Add Lesson
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New Lesson</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Title *</Label>
<Input
placeholder="Lesson title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
placeholder="Mô tả ngắn (tuỳ chọn)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="freePreview"
checked={newFreePreview}
onCheckedChange={(checked) =>
setNewFreePreview(checked as boolean)
}
/>
<Label htmlFor="freePreview" className="text-sm">
Free preview (xem được mà không cần subscription)
</Label>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddLesson}>Create</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}Drag-and-Drop với @dnd-kit
Cài Dependency
cd web
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilitiesSortableItem Wrapper
Component SortableItem wrap mọi draggable item với sortable behavior của @dnd-kit:
// web/src/components/admin/SortableItem.tsx
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
interface SortableItemProps {
id: number;
children: React.ReactNode;
}
export function SortableItem({ id, children }: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
position: "relative" as const,
zIndex: isDragging ? 10 : 0,
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
{children}
</div>
);
}Cách Drag-and-Drop Hoạt Động
Pattern quan trọng là optimistic update: UI reorder ngay lập tức, rồi sync với server ở background. Nếu API call fail, chúng ta revert về thứ tự cũ. User không bao giờ phải đợi network round-trip.
Tổng Hợp Dependency
Đây là tất cả npm package dùng trong bài này:
cd web
npm install react-hook-form @hookform/resolvers zod
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
npm install lucide-react
npx shadcn@latest init| Package | Mục đích |
|---|---|
react-hook-form | Form state management hiệu suất cao |
@hookform/resolvers | Kết nối zod schema với react-hook-form |
zod | TypeScript-first schema validation |
@dnd-kit/core | Drag-and-drop primitive |
@dnd-kit/sortable | Sortable list preset cho @dnd-kit |
@dnd-kit/utilities | CSS transform helper |
lucide-react | Icon library (tree-shakeable) |
shadcn/ui | Copy-paste component collection |
Component Interaction Flow
Đây là cách tất cả component khớp nhau trên course editor page:
Test Dashboard
1. Start Dev Server
# Terminal 1 — Spring Boot API
cd api
./gradlew bootRun
# Terminal 2 — Next.js frontend
cd web
npm run dev2. Tạo Admin User
Nếu chưa có admin user, register rồi update role trực tiếp:
-- Chạy trong PostgreSQL
UPDATE users SET role = 'ADMIN' WHERE email = '[email protected]';3. Chạy Qua Toàn Bộ Flow
- Truy cập
http://localhost:3000/admin - Bạn sẽ bị redirect tới login nếu chưa đăng nhập
- Login bằng tài khoản admin
- Click "New Course" → điền form → submit
- Course xuất hiện trong list với badge "DRAFT"
- Click vào course → chuyển sang tab "Content"
- Click "Add Section" → nhập title → create
- Click "Add Lesson" → điền thông tin → create
- Kéo section để reorder — chú ý UI update ngay lập tức
- Kéo lesson trong section — tương tự
- Quay lại tab "Details" → đổi status thành "Published"
- Course giờ hiển thị trong public catalog
4. Kiểm Tra API Call
Mở DevTools Network tab trên browser khi tương tác với dashboard. Bạn sẽ thấy:
GET /api/admin/courses → Course list
POST /api/admin/courses → Create course
GET /api/admin/courses/1 → Course detail
PUT /api/admin/courses/1 → Update course
POST /api/admin/courses/1/sections → Create section
PUT /api/admin/courses/1/sections/reorder → Reorder sections
POST /api/admin/courses/sections/1/lessons → Create lessonLỗi Thường Gặp
1. Quên Optimistic Update
Không có optimistic update, drag-and-drop sẽ lag vì UI đợi API response:
// Chậm — đợi API
const handleDragEnd = async (event: DragEndEvent) => {
const reordered = arrayMove(items, oldIndex, newIndex);
await api.reorder(reordered.map((i) => i.id)); // User đợi...
setItems(reordered); // Rồi UI mới update
};
// Nhanh — UI update ngay
const handleDragEnd = async (event: DragEndEvent) => {
const reordered = arrayMove(items, oldIndex, newIndex);
setItems(reordered); // UI update ngay
try {
await api.reorder(reordered.map((i) => i.id)); // Sync background
} catch {
setItems(items); // Revert nếu fail
}
};2. Không Xử Lý Concurrent Edit
Nếu hai tab admin mở cùng edit một course, chúng có thể ghi đè thay đổi của nhau. Với platform nhỏ (dưới 500 user, thường chỉ 1 admin), điều này chấp nhận được. Nếu cần conflict resolution sau này, thêm field updatedAt vào update request rồi so sánh phía server.
3. Thiếu Form Validation Feedback
Luôn hiện validation error ngay cạnh field gây lỗi, không chỉ trong toast:
// Tốt — error message inline
{errors.title && (
<p className="text-sm text-destructive">{errors.title.message}</p>
)}4. Không Debounce Reorder Call
Nếu user kéo item liên tục, mỗi sự kiện kéo tạo một API call riêng. Với use case này thì OK vì mỗi lần kéo là một action riêng biệt, nhưng nếu bạn implement real-time reorder (update mỗi pixel di chuyển), bạn cần debounce các API call.
Bước Tiếp Theo
Admin giờ có thể tạo và tổ chức course qua dashboard xịn. Nhưng lesson chỉ là title — chưa có video. Trong Post #6, chúng ta sẽ thêm video upload:
- Multipart upload endpoint với file validation
- Storage layout trên disk cho video file có tổ chức
- Upload progress tracking với progress bar
- Admin UI để upload video cho từng lesson
- File size limit và format validation
Đã đến lúc xử lý video content thật sự.
Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 3: Course & Lesson Data Model
Tiếp theo: Phase 5: Video Upload & Storage
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.