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 initWhen 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-areaEach 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 clientAdmin Layout Component
// web/src/app/admin/layout.tsx
"use client";
import { useAuth } from "@/components/AuthProvider";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && (!user || user.role !== "ADMIN")) {
router.push("/login");
}
}, [user, loading, router]);
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (!user || user.role !== "ADMIN") {
return null;
}
return (
<div className="flex h-screen bg-background">
<AdminSidebar />
<main className="flex-1 overflow-y-auto">
<div className="container max-w-6xl py-8 px-6">
{children}
</div>
</main>
</div>
);
}Sidebar Navigation
// web/src/components/admin/AdminSidebar.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
BookOpen,
Upload,
Users,
BarChart3,
Settings,
LogOut,
} from "lucide-react";
import { useAuth } from "@/components/AuthProvider";
const navItems = [
{ href: "/admin", icon: LayoutDashboard, label: "Dashboard" },
{ href: "/admin/courses", icon: BookOpen, label: "Courses" },
{ href: "/admin/upload", icon: Upload, label: "Upload" },
{ href: "/admin/users", icon: Users, label: "Users" },
{ href: "/admin/analytics", icon: BarChart3, label: "Analytics" },
{ href: "/admin/settings", icon: Settings, label: "Settings" },
];
export function AdminSidebar() {
const pathname = usePathname();
const { logout } = useAuth();
return (
<aside className="w-64 border-r bg-card flex flex-col">
{/* Logo */}
<div className="h-16 flex items-center px-6 border-b">
<Link href="/admin" className="flex items-center gap-2">
<BookOpen className="h-6 w-6 text-primary" />
<span className="font-semibold text-lg">Admin</span>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 py-4 px-3 space-y-1">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/admin" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
{/* Logout */}
<div className="p-3 border-t">
<button
onClick={logout}
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm
font-medium text-muted-foreground hover:bg-muted
hover:text-foreground w-full transition-colors"
>
<LogOut className="h-4 w-4" />
Log out
</button>
</div>
</aside>
);
}Admin API Client
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/utilitiesSortableItem 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| Package | Purpose |
|---|---|
react-hook-form | Performant form state management |
@hookform/resolvers | Connects zod schemas to react-hook-form |
zod | TypeScript-first schema validation |
@dnd-kit/core | Drag-and-drop primitives |
@dnd-kit/sortable | Sortable list preset for @dnd-kit |
@dnd-kit/utilities | CSS transform helpers |
lucide-react | Icon library (tree-shakeable) |
shadcn/ui | Copy-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 dev2. 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
- Navigate to
http://localhost:3000/admin - You should be redirected to login if not authenticated
- Log in with the admin account
- Click "New Course" → fill in the form → submit
- The course appears in the list with "DRAFT" badge
- Click the course → go to "Content" tab
- Click "Add Section" → enter title → create
- Click "Add Lesson" → fill in details → create
- Drag sections to reorder — notice the instant update
- Drag lessons within a section — same thing
- Go back to "Details" tab → change status to "Published"
- 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 lessonCommon 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.