Back to blog

Xây Dựng Video Platform: Admin Dashboard

javaspring-bootreactnextjsvideo-streaming
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 init

Khi đượ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-area

Mỗ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 client

Admin 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>
  );
}
// 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/utilities

SortableItem 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
PackageMục đích
react-hook-formForm state management hiệu suất cao
@hookform/resolversKết nối zod schema với react-hook-form
zodTypeScript-first schema validation
@dnd-kit/coreDrag-and-drop primitive
@dnd-kit/sortableSortable list preset cho @dnd-kit
@dnd-kit/utilitiesCSS transform helper
lucide-reactIcon library (tree-shakeable)
shadcn/uiCopy-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 dev

2. 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

  1. Truy cập http://localhost:3000/admin
  2. Bạn sẽ bị redirect tới login nếu chưa đăng nhập
  3. Login bằng tài khoản admin
  4. Click "New Course" → điền form → submit
  5. Course xuất hiện trong list với badge "DRAFT"
  6. Click vào course → chuyển sang tab "Content"
  7. Click "Add Section" → nhập title → create
  8. Click "Add Lesson" → điền thông tin → create
  9. Kéo section để reorder — chú ý UI update ngay lập tức
  10. Kéo lesson trong section — tương tự
  11. Quay lại tab "Details" → đổi status thành "Published"
  12. 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 lesson

Lỗ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.