Back to blog

Build a Video Platform: Admin Dashboard

javaspring-bootreactnextjsvideo-streaming
Build a Video Platform: Admin Dashboard

The API is ready — 20 endpoints for managing courses, sections, and lessons. But no one is going to manage a course platform through curl commands. In this post, we'll build a proper admin dashboard with a sidebar layout, form validation, and drag-and-drop reordering.

This is the first post in the series that's purely frontend. Everything here connects to the admin API we built in Post #4.

Time commitment: 3–4 hours
Prerequisites: Phase 3: Course & Lesson Data Model

What we'll build in this post:
✅ Admin layout with sidebar navigation and route protection
✅ shadcn/ui component library setup
✅ Course list page with status badges and actions
✅ Course editor form with react-hook-form + zod validation
✅ Section and lesson management panels
✅ Drag-and-drop reordering with @dnd-kit
✅ Toast notifications for success/error feedback


Admin Dashboard Architecture


Setting Up shadcn/ui

shadcn/ui is not a component library you install — it's a collection of components you copy into your project. This gives you full control over styling and behavior.

Initialize shadcn/ui

cd web
npx shadcn@latest init

When prompted:

  • Style: Default
  • Base color: Slate
  • CSS variables: Yes

This creates a components/ui/ directory and updates your tailwind.config.ts with the necessary configuration.

Install Components

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

Each command copies the component source into components/ui/. You own these files — customize them freely.


Admin Layout

Folder Structure

web/src/
├── app/
│   ├── (public)/          # Public pages (catalog, etc.)
│   └── admin/
│       ├── layout.tsx      # Admin layout with sidebar
│       ├── page.tsx        # Dashboard home (redirects to courses)
│       └── courses/
│           ├── page.tsx    # Course list
│           ├── new/
│           │   └── page.tsx  # Create course
│           └── [id]/
│               └── page.tsx  # Edit course
├── components/
│   ├── admin/
│   │   ├── AdminSidebar.tsx
│   │   ├── CourseList.tsx
│   │   ├── CourseForm.tsx
│   │   ├── SectionManager.tsx
│   │   ├── LessonManager.tsx
│   │   └── SortableItem.tsx
│   └── ui/                # shadcn/ui components
└── 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

Extend the Axios client from Post #3 with admin-specific methods:

// web/src/lib/admin-api.ts
import api from "@/lib/api";
 
// ==================== Types ====================
 
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 Functions ====================
 
export const adminApi = {
  // Courses
  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 }),
 
  // Sections
  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 }),
 
  // Lessons
  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 }
    ),
};

Clean type-safe wrapper. Every method returns the unwrapped data field from the 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(`Delete "${title}"? This will delete all sections and lessons.`)) {
      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">
            Manage your 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>No courses yet. Create your first course to get started.</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 with Zod

Define validation schemas that match the API's CreateCourseRequest and UpdateCourseRequest:

// 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 schemas validate on the client before the request even reaches the server. The server validates again with Bean Validation — defense in depth.


Course Editor Form

Create Course Page

// 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="Brief summary shown in course cards"
              {...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="Full course description..."
              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 (edit only) */}
            {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>
  );
}

The form adapts based on whether you're creating or editing. When editing, it shows the status dropdown. The zodResolver validates all fields before onSubmit fires.


Course Editor Page

The editor page combines the course form with section and 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>
  );
}

The two-tab layout separates course metadata from content management. The "Content" tab is where the real action happens.


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(
        `Delete "${title}"? This will delete all lessons in this section.`
      )
    ) {
      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 on error
      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">
            No sections yet. Add a section to start organizing your 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(`Delete "${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">
          No lessons in this section yet.
        </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}
                  />
 
                  {/* Lesson number */}
                  <span className="text-sm text-muted-foreground w-6">
                    {index + 1}.
                  </span>
 
                  {/* Title */}
                  <span className="text-sm flex-1 truncate">{lesson.title}</span>
 
                  {/* Badges */}
                  <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>
 
                  {/* Actions */}
                  <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="Brief description (optional)"
                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 (visible without 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 with @dnd-kit

Install Dependencies

cd web
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

SortableItem Wrapper

The SortableItem component wraps any draggable item with @dnd-kit's sortable behavior:

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

How Drag-and-Drop Works

The key pattern is optimistic updates: the UI reorders instantly, then syncs with the server in the background. If the API call fails, we revert to the previous order. Users never wait for a network round-trip.


Dependencies Summary

Here are all the npm packages used in this post:

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
PackagePurpose
react-hook-formPerformant form state management
@hookform/resolversConnects zod schemas to react-hook-form
zodTypeScript-first schema validation
@dnd-kit/coreDrag-and-drop primitives
@dnd-kit/sortableSortable list preset for @dnd-kit
@dnd-kit/utilitiesCSS transform helpers
lucide-reactIcon library (tree-shakeable)
shadcn/uiCopy-paste component collection

Component Interaction Flow

Here's how all the components fit together on the course editor page:


Testing the Dashboard

1. Start the Dev Servers

# Terminal 1 — Spring Boot API
cd api
./gradlew bootRun
 
# Terminal 2 — Next.js frontend
cd web
npm run dev

2. Create an Admin User

If you don't have an admin user yet, register one and update the role directly:

-- Run in PostgreSQL
UPDATE users SET role = 'ADMIN' WHERE email = 'admin@example.com';

3. Walk Through the Flow

  1. Navigate to http://localhost:3000/admin
  2. You should be redirected to login if not authenticated
  3. Log in with the admin account
  4. Click "New Course" → fill in the form → submit
  5. The course appears in the list with "DRAFT" badge
  6. Click the course → go to "Content" tab
  7. Click "Add Section" → enter title → create
  8. Click "Add Lesson" → fill in details → create
  9. Drag sections to reorder — notice the instant update
  10. Drag lessons within a section — same thing
  11. Go back to "Details" tab → change status to "Published"
  12. The course is now visible in the public catalog

4. Verify the API Calls

Open the browser DevTools Network tab while interacting with the dashboard. You should see:

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

Common Mistakes

1. Forgetting Optimistic Updates

Without optimistic updates, drag-and-drop feels laggy because the UI waits for the API response:

// Slow — waits for API
const handleDragEnd = async (event: DragEndEvent) => {
  const reordered = arrayMove(items, oldIndex, newIndex);
  await api.reorder(reordered.map((i) => i.id)); // User waits...
  setItems(reordered); // Then UI updates
};
 
// Fast — updates UI immediately
const handleDragEnd = async (event: DragEndEvent) => {
  const reordered = arrayMove(items, oldIndex, newIndex);
  setItems(reordered); // UI updates now
  try {
    await api.reorder(reordered.map((i) => i.id)); // Background sync
  } catch {
    setItems(items); // Revert on failure
  }
};

2. Not Handling Concurrent Edits

If two admin tabs are open editing the same course, they can overwrite each other's changes. For a small platform (under 500 users, likely 1 admin), this is acceptable. If you need conflict resolution later, add an updatedAt field to the update request and compare it server-side.

3. Missing Form Validation Feedback

Always show validation errors near the field that caused them, not just in a toast:

// Good — inline error message
{errors.title && (
  <p className="text-sm text-destructive">{errors.title.message}</p>
)}

4. Not Debouncing Reorder Calls

If a user drags items rapidly, each drag event fires a separate API call. For this use case it's fine since each drag is a discrete action, but if you ever implement real-time reordering (updating on every pixel moved), you'd need to debounce the API calls.


What's Next?

The admin can now create and organize courses through a proper dashboard. But lessons are just titles — no video yet. In Post #6, we'll add video upload:

  • Multipart upload endpoint with file validation
  • Storage layout on disk for organized video files
  • Upload progress tracking with a progress bar
  • Admin UI for uploading videos to specific lessons
  • File size limits and format validation

Time to start handling actual video content.

Series: Build a Video Streaming Platform
Previous: Phase 3: Course & Lesson Data Model
Next: Phase 5: Video Upload & Storage

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.