Initial commit

This commit is contained in:
twotalesanimation
2026-06-11 10:46:09 +02:00
commit 81ad7e4ea9
223 changed files with 39530 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
// app/admin/admin-client.tsx
'use client';
import React from 'react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AdminNotifications } from '@/components/admin-notifications';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import {
IconFileUpload,
IconBooks,
IconList,
IconUsers,
IconUsers as IconUsersManage,
IconChartBar,
} from '@tabler/icons-react';
function AdminUI() {
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Admin Panel</h1>
<p className="text-muted-foreground">
Manage your users, courses, playlists, videos, and enrollments.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconChartBar className="w-5 h-5" />
Video Statistics
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
View video performance metrics and analytics.
</p>
<Link href="/admin/stats">
<Button variant="outline" className="w-full">
Go to Stats
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconUsersManage className="w-5 h-5" />
Manage Users
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
View users and their activity.
</p>
<Link href="/admin/users">
<Button variant="outline" className="w-full">
Go to Users
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconBooks className="w-5 h-5" />
Manage Courses
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Create and manage courses.
</p>
<Link href="/admin/courses">
<Button variant="outline" className="w-full">
Go to Courses
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconList className="w-5 h-5" />
Manage Playlists
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Create and manage playlists.
</p>
<Link href="/admin/playlists">
<Button variant="outline" className="w-full">
Go to Playlists
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconFileUpload className="w-5 h-5" />
Manage Videos
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Upload and manage videos.
</p>
<Link href="/admin/videos">
<Button variant="outline" className="w-full">
Go to Videos
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconUsers className="w-5 h-5" />
Manage Enrollments
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Manage user enrollments.
</p>
<Link href="/admin/enrollments">
<Button variant="outline" className="w-full">
Go to Enrollments
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconUsersManage className="w-5 h-5" />
Allowed Students
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Manage whitelisted student access.
</p>
<Link href="/admin/allowed-students">
<Button variant="outline" className="w-full">
Go to Students
</Button>
</Link>
</CardContent>
</Card>
</div>
</div>
<div className="lg:col-span-1">
<Card>
<CardHeader>
<CardTitle>Activity Feed</CardTitle>
</CardHeader>
<CardContent>
<AdminNotifications />
</CardContent>
</Card>
</div>
</div>
</div>
);
}
export default function AdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<AdminUI />
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+393
View File
@@ -0,0 +1,393 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import { Trash2, Edit, Upload, Plus } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
type AllowedStudent = {
id: string;
email: string;
levels: string;
active: boolean;
createdAt: string;
updatedAt: string;
};
export default function AllowedStudentsClient() {
const [students, setStudents] = useState<AllowedStudent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false);
const [isImportLoading, setIsImportLoading] = useState(false);
const [editingStudent, setEditingStudent] = useState<AllowedStudent | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [newEmail, setNewEmail] = useState('');
const [newLevels, setNewLevels] = useState('');
// Fetch students on mount
useEffect(() => {
fetchStudents();
}, []);
const fetchStudents = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/admin/allowed-students');
if (res.ok) {
const data = await res.json();
setStudents(data);
} else {
toast.error('Failed to fetch students');
}
} catch (err) {
console.error('Failed to fetch students:', err);
toast.error('Error fetching students');
} finally {
setIsLoading(false);
}
};
const handleAddStudent = async () => {
if (!newEmail.trim() || !newLevels.trim()) {
toast.error('Email and course codes required');
return;
}
try {
const res = await fetch('/api/admin/allowed-students', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: newEmail.trim(),
levels: newLevels.trim(),
}),
});
if (res.status === 201) {
toast.success('Student added');
setNewEmail('');
setNewLevels('');
setIsAddDialogOpen(false);
fetchStudents();
} else if (res.status === 409) {
toast.error('Email already registered');
} else {
const data = await res.json();
toast.error(data.error || 'Failed to add student');
}
} catch (err) {
console.error('Failed to add student:', err);
toast.error('Error adding student');
}
};
const handleEditStudent = async () => {
if (!editingStudent) return;
try {
const res = await fetch('/api/admin/allowed-students', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingStudent.id,
email: editingStudent.email,
levels: editingStudent.levels,
active: editingStudent.active,
}),
});
if (res.ok) {
toast.success('Student updated');
setIsEditDialogOpen(false);
setEditingStudent(null);
fetchStudents();
} else {
const data = await res.json();
toast.error(data.error || 'Failed to update student');
}
} catch (err) {
console.error('Failed to update student:', err);
toast.error('Error updating student');
}
};
const handleDeleteStudent = async () => {
if (!deleteId) return;
try {
const res = await fetch(`/api/admin/allowed-students?id=${deleteId}`, {
method: 'DELETE',
});
if (res.ok) {
toast.success('Student removed');
setIsDeleteAlertOpen(false);
setDeleteId(null);
fetchStudents();
} else {
toast.error('Failed to delete student');
}
} catch (err) {
console.error('Failed to delete student:', err);
toast.error('Error deleting student');
}
};
const handleImportCSV = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsImportLoading(true);
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/admin/allowed-students/import', {
method: 'POST',
body: formData,
});
if (res.ok) {
const result = await res.json();
toast.success(`Imported: ${result.imported}, Updated: ${result.updated}`);
if (result.errors.length > 0) {
toast.warning(`${result.errors.length} errors during import`);
}
fetchStudents();
} else {
const data = await res.json();
toast.error(data.error || 'Failed to import students');
}
} catch (err) {
console.error('Failed to import CSV:', err);
toast.error('Error importing students');
} finally {
setIsImportLoading(false);
}
};
if (isLoading) {
return <div className="p-4 text-sm text-muted-foreground">Loading allowed students...</div>;
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Allowed Students ({students.length})</CardTitle>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => setIsAddDialogOpen(true)}
className="gap-2"
>
<Plus className="h-4 w-4" />
Add Student
</Button>
<div className="relative">
<input
type="file"
accept=".csv"
onChange={handleImportCSV}
disabled={isImportLoading}
className="hidden"
id="csv-upload"
/>
<Button
size="sm"
variant="outline"
onClick={() => document.getElementById('csv-upload')?.click()}
disabled={isImportLoading}
className="gap-2"
>
<Upload className="h-4 w-4" />
Import CSV
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Course Codes</TableHead>
<TableHead>Status</TableHead>
<TableHead>Added</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No students added yet
</TableCell>
</TableRow>
) : (
students.map((student) => (
<TableRow key={student.id}>
<TableCell className="font-medium">{student.email}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{student.levels.split(',').map((code) => (
<Badge key={code.trim()} variant="outline">
{code.trim()}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<Badge variant={student.active ? 'default' : 'secondary'}>
{student.active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(student.createdAt), { addSuffix: true })}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setEditingStudent(student);
setIsEditDialogOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
setDeleteId(student.id);
setIsDeleteAlertOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Add Student Sheet */}
<Sheet open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Add Student</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-4 mt-4">
<div>
<label className="text-sm font-medium">Email</label>
<Input
placeholder="student@university.edu"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium">Course Codes</label>
<Input
placeholder="3D100,3D200,3D300"
value={newLevels}
onChange={(e) => setNewLevels(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
Comma-separated course codes
</p>
</div>
</div>
<SheetFooter className="mt-6">
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddStudent}>Add Student</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* Edit Student Sheet */}
<Sheet open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Edit Student</SheetTitle>
</SheetHeader>
{editingStudent && (
<div className="flex flex-col gap-4 mt-4">
<div>
<label className="text-sm font-medium">Email</label>
<Input
value={editingStudent.email}
onChange={(e) =>
setEditingStudent({ ...editingStudent, email: e.target.value })
}
/>
</div>
<div>
<label className="text-sm font-medium">Course Codes</label>
<Input
placeholder="3D100,3D200,3D300"
value={editingStudent.levels}
onChange={(e) =>
setEditingStudent({ ...editingStudent, levels: e.target.value })
}
/>
</div>
</div>
)}
<SheetFooter className="mt-6">
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleEditStudent}>Save Changes</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* Delete Confirmation */}
<AlertDialog open={isDeleteAlertOpen} onOpenChange={setIsDeleteAlertOpen}>
<AlertDialogContent>
<AlertDialogTitle>Remove Student</AlertDialogTitle>
<AlertDialogDescription>
Are you sure? The student will be prevented from logging in, but their existing enrollments will remain.
</AlertDialogDescription>
<div className="flex justify-end gap-2">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteStudent} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Remove
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
// app/admin/allowed-students/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import { SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset } from '@/components/ui/sidebar';
import AllowedStudentsClient from './admin-client';
export const dynamic = 'force-dynamic';
export default async function AllowedStudentsPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<AllowedStudentsClient />
</SidebarInset>
</SidebarProvider>
);
}
+247
View File
@@ -0,0 +1,247 @@
'use client';
import React, { useEffect, useState } from 'react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Trash2Icon } from 'lucide-react';
type Course = { id: string; title: string; code?: string };
function CoursesUI() {
const router = useRouter();
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(false);
const [courseTitle, setCourseTitle] = useState('');
const [courseCode, setCourseCode] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
fetchCourses();
}, []);
async function fetchCourses() {
try {
const res = await fetch('/api/admin/meta');
if (res.ok) {
const json = await res.json();
setCourses(json.courses || []);
}
} catch (err) {
console.error('Failed to fetch courses', err);
toast.error('Failed to fetch courses');
}
}
async function createCourse(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
const res = await fetch('/api/admin/create-course', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: courseTitle, code: courseCode }),
});
if (res.ok) {
toast.success('Course created');
setCourseTitle('');
setCourseCode('');
await fetchCourses();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to create course: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setLoading(false);
}
}
async function deleteCourse(courseId: string) {
setDeletingId(courseId);
try {
const res = await fetch(`/api/admin/delete-course`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ courseId }),
});
if (res.ok) {
toast.success('Course deleted');
await fetchCourses();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to delete course: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setDeletingId(null);
}
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader>
<CardTitle>Create Course</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={createCourse}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="courseTitle">Course title</FieldLabel>
<Input
id="courseTitle"
required
value={courseTitle}
onChange={(e) => setCourseTitle(e.target.value)}
placeholder="3D ANIMATION 300"
/>
</Field>
<Field>
<FieldLabel htmlFor="courseCode">Course code</FieldLabel>
<Input
id="courseCode"
value={courseCode}
onChange={(e) => setCourseCode(e.target.value)}
placeholder="3D100"
/>
</Field>
<Field>
<Button type="submit" disabled={loading}>
Create course
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Courses</CardTitle>
</CardHeader>
<CardContent>
{courses.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No courses yet
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Code</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{courses.map((course) => (
<TableRow key={course.id}>
<TableCell className="font-medium">
{course.title}
</TableCell>
<TableCell>{course.code || '—'}</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={deletingId === course.id}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Course</AlertDialogTitle>
<AlertDialogDescription>
Are you sure? This will delete the course and all
associated playlists and videos.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-2 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteCourse(course.id)}
>
Delete
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function CoursesAdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<CoursesUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -0,0 +1,22 @@
// app/admin/courses/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import CoursesAdminClient from './admin-client';
export const dynamic = 'force-dynamic';
export default async function CoursesAdminPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return <CoursesAdminClient />;
}
+349
View File
@@ -0,0 +1,349 @@
'use client';
import React, { useEffect, useState } from 'react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Progress } from '@/components/ui/progress';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardFooter,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Field,
FieldGroup,
FieldLabel,
FieldDescription,
} from '@/components/ui/field';
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
type User = { id: number | string; name?: string | null; email: string };
type Course = { id: number | string; title: string; code: string };
type Enrollment = {
id: number;
role?: string | null;
createdAt?: string;
user: User;
course: Course;
};
function AdminUI() {
const [users, setUsers] = useState<User[]>([]);
const [courses, setCourses] = useState<Course[]>([]);
const [enrollments, setEnrollments] = useState<Enrollment[]>([]);
// placeholder sentinel
const [selectedUser, setSelectedUser] = useState<string>('none');
const [selectedCourse, setSelectedCourse] = useState<string>('none');
const [role, setRole] = useState<string>('student');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
Promise.all([
fetch('/api/users')
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.catch(() => []),
fetch('/api/courses')
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.catch(() => []),
fetch('/api/enrollments')
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.catch(() => []),
])
.then(([u, c, e]) => {
setUsers(u ?? []);
setCourses(c ?? []);
setEnrollments(e ?? []);
})
.catch((err) => {
console.error('Failed to load admin data', err);
setError('Failed to load admin data');
})
.finally(() => setLoading(false));
}, []);
function extractIdString(val: unknown): string | null {
const s = val === null || val === undefined ? '' : String(val).trim();
console.debug('extractIdString raw value:', s);
if (!s) return null;
if (s.length > 0) return s;
return null;
}
async function handleCreate(e?: React.FormEvent) {
e?.preventDefault();
setError(null);
const userIdStr = extractIdString(selectedUser);
const courseIdStr = extractIdString(selectedCourse);
if (!userIdStr || !courseIdStr) {
setError(
`Invalid selection. user="${String(selectedUser)}" course="${String(
selectedCourse
)}". Expected values containing numeric ids or direct ids.`
);
return;
}
// send strings to the server (your Prisma schema expects String ids)
const payload = {
userId: String(userIdStr),
courseId: String(courseIdStr),
};
console.debug('Creating enrollment with payload:', payload);
setSaving(true);
try {
const res = await fetch('/api/enrollments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
setError(data?.error || 'Failed to create enrollment');
} else {
setEnrollments((prev) => [data, ...prev]);
setSelectedUser('none');
setSelectedCourse('none');
setRole('student');
}
} catch (err) {
console.error('Network error while creating enrollment', err);
setError('Network error while creating enrollment');
} finally {
setSaving(false);
}
}
async function handleDelete(id: number) {
if (!confirm('Remove this enrollment?')) return;
setSaving(true);
setError(null);
try {
const res = await fetch('/api/enrollments', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
const data = await res.json();
if (!res.ok && !data?.success) {
setError(data?.error || 'Failed to delete enrollment');
} else {
setEnrollments((prev) => prev.filter((en) => en.id !== id));
}
} catch (err) {
console.error('Network error while deleting enrollment', err);
setError('Network error while deleting enrollment');
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card className="mb-6">
<CardHeader>
<CardTitle>Assign enrollment</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={(e) => handleCreate(e)}
className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end"
>
<div className="col-span-2">
<FieldGroup>
<Field>
<FieldLabel htmlFor="student">Student</FieldLabel>
<Select
value={selectedUser}
onValueChange={(v) => {
console.debug('Select user changed ->', v);
setSelectedUser(v);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a user" />
</SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={String(u.id)} value={String(u.id)}>
{u.name ? `${u.name}${u.email}` : u.email}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
</div>
<div className="col-span-1">
<FieldGroup>
<Field>
<FieldLabel htmlFor="course">Course</FieldLabel>
<Select
value={selectedCourse}
onValueChange={(v) => {
console.debug('Select course changed ->', v);
setSelectedCourse(v);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a course" />
</SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={String(c.id)} value={String(c.id)}>
{c.title} | {c.code}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
</div>
<div className="col-span-1 flex gap-2">
<Button
type="submit"
disabled={saving}
className="self-end w-full"
>
Add
</Button>
</div>
</form>
{error && (
<div className="mt-3 text-sm text-destructive">{error}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Current enrollments</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
{loading && enrollments.length === 0 ? (
<div className="p-4">Loading</div>
) : enrollments.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">
No enrollments yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Student</TableHead>
<TableHead className="w-[300px]">Email</TableHead>
<TableHead>Course</TableHead>
<TableHead>Role</TableHead>
<TableHead>Enrolled</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{enrollments.map((en) => (
<TableRow key={en.id}>
<TableCell className="font-medium">
{en.user.name ?? en.user.email}
</TableCell>
<TableCell className="font-medium">
{en.user.email}
</TableCell>
<TableCell className="italic">
{en.course.title}{' '}
<span className="text-xs text-muted-foreground">
| {en.course.code}
</span>
</TableCell>
<TableCell>{en.role ?? 'student'}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{en.createdAt
? new Date(en.createdAt).toLocaleString()
: '—'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(en.id)}
disabled={saving}
>
Remove
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
export default function AdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<AdminUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -0,0 +1,22 @@
// app/admin/enrollments/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import AdminClient from './admin-client';
export const dynamic = 'force-dynamic';
export default async function EnrollmentsAdminPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return <AdminClient />;
}
+23
View File
@@ -0,0 +1,23 @@
// app/admin/page.tsx (server component)
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import AdminClient from './admin-client'; // the client UI component (see below)
export const dynamic = 'force-dynamic';
export default async function AdminPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
// authorised — render the client admin UI
return <AdminClient />;
}
+539
View File
@@ -0,0 +1,539 @@
'use client';
import React, { useEffect, useState } from 'react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldGroup,
FieldLabel,
FieldDescription,
} from '@/components/ui/field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Trash2Icon, Edit2, Check, X } from 'lucide-react';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
type Course = { id: string; title: string; code?: string };
type Playlist = { id: string; title: string; courseId: string };
function PlaylistsUI() {
const router = useRouter();
const [courses, setCourses] = useState<Course[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [loading, setLoading] = useState(false);
const [playlistTitle, setPlaylistTitle] = useState('');
const [playlistCourseId, setPlaylistCourseId] = useState('');
const [additionalCourseIds, setAdditionalCourseIds] = useState<string[]>([]);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [savingEditId, setSavingEditId] = useState<string | null>(null);
useEffect(() => {
fetchData();
}, []);
async function fetchData() {
try {
const res = await fetch('/api/admin/meta');
if (res.ok) {
const json = await res.json();
setCourses(json.courses || []);
setPlaylists(json.playlists || []);
if (!playlistCourseId && json.courses?.[0]) {
setPlaylistCourseId(json.courses[0].id);
}
}
} catch (err) {
console.error('Failed to fetch data', err);
toast.error('Failed to fetch data');
}
}
async function createPlaylist(e: React.FormEvent) {
e.preventDefault();
if (!playlistCourseId) {
toast.error('Pick a primary course first');
return;
}
setLoading(true);
try {
const res = await fetch('/api/admin/create-playlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: playlistTitle,
courseId: playlistCourseId,
additionalCourseIds,
}),
});
if (res.ok) {
toast.success('Playlist created');
setPlaylistTitle('');
setAdditionalCourseIds([]);
await fetchData();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to create playlist: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setLoading(false);
}
}
async function deletePlaylist(playlistId: string) {
setDeletingId(playlistId);
try {
const res = await fetch(`/api/admin/delete-playlist`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlistId }),
});
if (res.ok) {
toast.success('Playlist deleted');
await fetchData();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to delete playlist: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setDeletingId(null);
}
}
async function updatePlaylistTitle(playlistId: string, newTitle: string) {
if (!newTitle.trim()) {
toast.error('Title cannot be empty');
return;
}
setSavingEditId(playlistId);
try {
const res = await fetch(`/api/admin/update-playlist`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlistId, title: newTitle }),
});
if (res.ok) {
toast.success('Playlist updated');
setEditingId(null);
setEditingTitle('');
await fetchData();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to update playlist: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setSavingEditId(null);
}
}
// Organizer state & helpers
const [selectedPlaylistId, setSelectedPlaylistId] = useState<string>('');
const [videos, setVideos] = useState<Array<{ id: string; title: string; thumbnail?: string; index: number; durationSec?: number }>>([]);
const sensors = useSensors(useSensor(PointerSensor));
function SortableItem({ id, title, thumbnail }: { id: string; title: string; thumbnail?: string }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<div ref={setNodeRef} style={style} className="w-40 p-2">
<div className="cursor-move" {...attributes} {...listeners}>
{thumbnail ? (
<img src={thumbnail} alt={title} className="w-full h-24 object-cover rounded" />
) : (
<div className="w-full h-24 bg-muted rounded flex items-center justify-center">No image</div>
)}
<div className="text-sm mt-2 truncate">{title}</div>
</div>
</div>
);
}
async function fetchPlaylistVideos(playlistId: string) {
if (!playlistId) return;
try {
const res = await fetch(`/api/admin/playlist-videos?playlistId=${playlistId}`);
if (res.ok) {
const data = await res.json();
setVideos(data || []);
} else {
toast.error('Failed to load playlist videos');
}
} catch (err) {
console.error('Failed to fetch playlist videos', err);
toast.error('Failed to load playlist videos');
}
}
async function handleDragEnd(e: any) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const oldIndex = videos.findIndex((v) => v.id === active.id);
const newIndex = videos.findIndex((v) => v.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(videos, oldIndex, newIndex);
setVideos(newOrder.map((v, idx) => ({ ...v, index: idx })));
// Persist order
try {
const orderedIds = newOrder.map((v) => v.id);
const res = await fetch('/api/admin/reorder-videos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlistId: selectedPlaylistId, orderedIds }),
});
if (!res.ok) {
toast.error('Failed to save new order');
// refetch to revert
await fetchPlaylistVideos(selectedPlaylistId);
} else {
toast.success('Order saved');
}
} catch (err) {
console.error('Failed to save order', err);
toast.error('Failed to save new order');
await fetchPlaylistVideos(selectedPlaylistId);
}
}
const getCourseTitle = (courseId: string) => {
return courses.find((c) => c.id === courseId)?.title || 'Unknown Course';
};
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader>
<CardTitle>Create Playlist</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={createPlaylist}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="playlistTitle">Playlist title</FieldLabel>
<FieldDescription>
e.g. Character Rigging Basics
</FieldDescription>
<Input
id="playlistTitle"
required
value={playlistTitle}
onChange={(e) => setPlaylistTitle(e.target.value)}
placeholder="Playlist title"
/>
</Field>
<Field>
<FieldLabel htmlFor="playlistCourse">Assign to course (primary)</FieldLabel>
<FieldDescription>
Select the main course. The playlist will always be accessible from here.
</FieldDescription>
<Select
value={playlistCourseId}
onValueChange={(val) => setPlaylistCourseId(val)}
>
<SelectTrigger aria-label="Choose Primary Course">
<SelectValue placeholder="Choose Primary Course" />
</SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.title} {c.code ? `| ${c.code}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Assign to additional courses (optional)</FieldLabel>
<FieldDescription>
Select other courses where this playlist should appear.
</FieldDescription>
<div className="space-y-2 mt-2">
{courses
.filter((c) => c.id !== playlistCourseId)
.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={additionalCourseIds.includes(c.id)}
onChange={(e) => {
if (e.target.checked) {
setAdditionalCourseIds([...additionalCourseIds, c.id]);
} else {
setAdditionalCourseIds(additionalCourseIds.filter((id) => id !== c.id));
}
}}
className="w-4 h-4"
/>
<span>
{c.title} {c.code ? `| ${c.code}` : ''}
</span>
</label>
))}
</div>
</Field>
<Field>
<Button type="submit" disabled={loading || !playlistCourseId}>
Create playlist
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Organize Playlist</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 items-end">
<div>
<Field>
<FieldLabel htmlFor="organizePlaylist">Select playlist</FieldLabel>
<Select
value={selectedPlaylistId}
onValueChange={(val) => {
setSelectedPlaylistId(val);
fetchPlaylistVideos(val);
}}
>
<SelectTrigger aria-label="Choose playlist">
<SelectValue placeholder="Choose playlist to organize" />
</SelectTrigger>
<SelectContent>
{playlists.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.title}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
</div>
<div className="mt-4">
{videos.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">No videos loaded</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={videos.map((v) => v.id)} strategy={verticalListSortingStrategy}>
<div className="flex gap-4 overflow-auto py-2">
{videos.map((v) => (
<SortableItem key={v.id} id={v.id} title={v.title} thumbnail={v.thumbnail} />
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Playlists</CardTitle>
</CardHeader>
<CardContent>
{playlists.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No playlists yet
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Course</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{playlists.map((playlist) => (
<TableRow key={playlist.id}>
<TableCell className="font-medium">
{editingId === playlist.id ? (
<div className="flex gap-2 items-center">
<Input
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
className="h-8"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
updatePlaylistTitle(playlist.id, editingTitle);
}
if (e.key === 'Escape') {
setEditingId(null);
setEditingTitle('');
}
}}
/>
<Button
variant="ghost"
size="sm"
onClick={() => updatePlaylistTitle(playlist.id, editingTitle)}
disabled={savingEditId === playlist.id}
>
<Check className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(null);
setEditingTitle('');
}}
disabled={savingEditId === playlist.id}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<div className="flex gap-2 items-center">
<span>{playlist.title}</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(playlist.id);
setEditingTitle(playlist.title);
}}
>
<Edit2 className="w-4 h-4" />
</Button>
</div>
)}
</TableCell>
<TableCell>{getCourseTitle(playlist.courseId)}</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={deletingId === playlist.id || editingId === playlist.id}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Playlist</AlertDialogTitle>
<AlertDialogDescription>
Are you sure? This will delete the playlist and all
associated videos.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-2 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deletePlaylist(playlist.id)}
>
Delete
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function PlaylistsAdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<PlaylistsUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -0,0 +1,22 @@
// app/admin/playlists/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import PlaylistsAdminClient from './admin-client';
export const dynamic = 'force-dynamic';
export default async function PlaylistsAdminPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return <PlaylistsAdminClient />;
}
+298
View File
@@ -0,0 +1,298 @@
// app/admin/stats-client.tsx
'use client';
import React, { useEffect, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle } from 'lucide-react';
interface VideoStats {
id: string;
title: string;
playlistTitle: string;
uploaderName: string;
views: number;
totalViewers: number;
completions: number;
completionRate: string;
likes: number;
comments: number;
engagement: number;
avgPercentWatched: number;
totalSecondsWatched: number;
totalSegments: number;
durationSec: number | null;
createdAt: string;
}
export default function StatsClient() {
const [stats, setStats] = useState<VideoStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<keyof VideoStats>('views');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
useEffect(() => {
const fetchStats = async () => {
try {
setLoading(true);
const response = await fetch('/api/admin/stats');
if (!response.ok) throw new Error('Failed to fetch stats');
const data = await response.json();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
const handleSort = (column: keyof VideoStats) => {
if (sortBy === column) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(column);
setSortOrder('desc');
}
};
const sortedStats = [...stats].sort((a, b) => {
const aVal = a[sortBy];
const bVal = b[sortBy];
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortOrder === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
const aNum = typeof aVal === 'number' ? aVal : 0;
const bNum = typeof bVal === 'number' ? bVal : 0;
return sortOrder === 'asc' ? aNum - bNum : bNum - aNum;
});
const formatDuration = (seconds: number | null) => {
if (!seconds) return 'N/A';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours}h ${minutes}m ${secs}s`;
};
const SortHeader = ({ column, label }: { column: keyof VideoStats; label: string }) => (
<TableHead
className="cursor-pointer hover:bg-muted whitespace-nowrap"
onClick={() => handleSort(column)}
>
<div className="flex items-center gap-1">
{label}
{sortBy === column && (
<span className="text-xs">
{sortOrder === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</TableHead>
);
if (error) {
return (
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
<AlertCircle className="w-5 h-5" />
<div>
<p className="font-semibold">Error loading stats</p>
<p className="text-sm">{error}</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Video Statistics</h1>
<p className="text-muted-foreground">
Performance metrics for all videos in the system
</p>
</div>
<Badge variant="outline" className="text-sm">
{stats.length} Videos
</Badge>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Views
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.reduce((sum, s) => sum + s.views, 0).toLocaleString()}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Unique Viewers
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.reduce((sum, s) => sum + s.totalViewers, 0).toLocaleString()}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Engagements
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.reduce((sum, s) => sum + s.engagement, 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground mt-1">
Likes + Comments
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Avg. Completion Rate
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{(
stats.reduce((sum, s) => sum + parseFloat(s.completionRate), 0) /
(stats.length || 1)
).toFixed(1)}
%
</div>
</CardContent>
</Card>
</div>
{/* Data Table */}
<Card>
<CardHeader>
<CardTitle>Video Performance Details</CardTitle>
</CardHeader>
<CardContent>
<div className="w-full overflow-x-auto">
{loading ? (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : stats.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
No videos found
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<SortHeader column="title" label="Title" />
<SortHeader column="uploaderName" label="Uploader" />
<SortHeader column="views" label="Unlocks" />
<SortHeader column="totalViewers" label="Viewers" />
<SortHeader column="completions" label="Completions" />
<SortHeader column="completionRate" label="Completion %" />
<SortHeader column="avgPercentWatched" label="Avg % Watched" />
<SortHeader column="engagement" label="Engagement" />
<SortHeader column="likes" label="Likes" />
<SortHeader column="comments" label="Comments" />
<SortHeader column="totalSecondsWatched" label="Total Seconds Watched" />
<SortHeader column="durationSec" label="Duration" />
</TableRow>
</TableHeader>
<TableBody>
{sortedStats.map((video) => (
<TableRow key={video.id}>
<TableCell className="font-medium whitespace-nowrap">
<div className="flex flex-col">
<span className="max-w-xs truncate">{video.title}</span>
<span className="text-xs text-muted-foreground">
{video.playlistTitle}
</span>
</div>
</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{video.uploaderName}
</TableCell>
<TableCell className="text-sm font-medium">
{video.views}
</TableCell>
<TableCell className="text-sm font-medium">
{video.totalViewers}
</TableCell>
<TableCell className="text-sm">
{video.completions}
</TableCell>
<TableCell className="text-sm font-semibold">
<Badge
variant={
parseFloat(video.completionRate) >= 50
? 'default'
: parseFloat(video.completionRate) >= 25
? 'secondary'
: 'destructive'
}
>
{video.completionRate}%
</Badge>
</TableCell>
<TableCell className="text-sm">
{video.avgPercentWatched.toFixed(1)}%
</TableCell>
<TableCell className="text-sm font-bold">
{video.engagement}
</TableCell>
<TableCell className="text-sm">
<Badge variant="outline">{video.likes}</Badge>
</TableCell>
<TableCell className="text-sm">
<Badge variant="outline">{video.comments}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{video.totalSecondsWatched.toLocaleString()}s
</TableCell>
<TableCell className="text-sm whitespace-nowrap">
{formatDuration(video.durationSec)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
// app/admin/stats/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import StatsClient from '../stats-client';
export const dynamic = 'force-dynamic';
export default async function StatsPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<StatsClient />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
// app/admin/users/[userId]/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import UserDetailClient from './user-detail-client';
export default async function UserDetailPage({
params,
}: {
params: Promise<{ userId: string }>;
}) {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
const { userId } = await params;
return <UserDetailClient userId={userId} />;
}
@@ -0,0 +1,564 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { SegmentedProgressBar, WatchSegment } from '@/components/segmented-progress-bar';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatDistanceToNow } from 'date-fns';
import { ArrowLeft, Trash2, Save } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
type Video = {
id: string;
title: string;
};
type UserProgress = {
id: string;
videoId: string;
video: Video;
watchedSec: number;
lastPos: number | null;
percent: number;
completed: boolean;
durationSec: number | null;
updatedAt: string;
createdAt: string;
};
type UserComment = {
id: string;
content: string;
createdAt: string;
video: Video;
replies: Array<{ id: string }>;
};
type UserDetail = {
id: string;
email: string;
name: string | null;
image: string | null;
role: string;
createdAt: string;
enrollments: Array<{
course: {
id: string;
title: string;
};
}>;
progress: UserProgress[];
comments: UserComment[];
};
interface UserDetailClientProps {
userId: string;
}
function UserDetailClientUI({ userId }: UserDetailClientProps) {
const { data: session } = useSession();
const [user, setUser] = useState<UserDetail | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [progressSegments, setProgressSegments] = useState<Record<string, WatchSegment[]>>({});
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [selectedRole, setSelectedRole] = useState<string>('');
const [isRoleChangeAlertOpen, setIsRoleChangeAlertOpen] = useState(false);
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
const router = useRouter();
const isSuperadmin = (session?.user as any)?.role === 'superadmin';
useEffect(() => {
const fetchUserDetail = async () => {
setIsLoading(true);
try {
const res = await fetch(`/api/admin/users/${userId}`);
if (res.ok) {
const data = await res.json();
setUser(data);
setSelectedRole(data.role);
// Fetch segments for all progress entries
const segments: Record<string, WatchSegment[]> = {};
for (const prog of data.progress) {
try {
const segRes = await fetch(`/api/admin/users/${userId}/progress/${prog.videoId}/segments`);
if (segRes.ok) {
const segData = await segRes.json();
segments[prog.videoId] = segData.segments ?? [];
}
} catch (err) {
console.error(`Failed to fetch segments for video ${prog.videoId}:`, err);
}
}
setProgressSegments(segments);
}
} catch (err) {
console.error('Failed to fetch user detail:', err);
} finally {
setIsLoading(false);
}
};
fetchUserDetail();
}, [userId]);
const handleDeleteUser = async () => {
setIsDeleting(true);
try {
const res = await fetch(`/api/admin/users/${userId}`, {
method: 'DELETE',
});
if (res.ok) {
toast.success('User deleted successfully');
router.push('/admin/users');
} else {
const data = await res.json();
toast.error(data.error || 'Failed to delete user');
}
} catch (err) {
console.error('Failed to delete user:', err);
toast.error('Error deleting user');
} finally {
setIsDeleting(false);
setIsDeleteAlertOpen(false);
}
};
const handleUpdateRole = async () => {
if (!user || selectedRole === user.role) {
setIsRoleChangeAlertOpen(false);
return;
}
setIsUpdatingRole(true);
try {
const res = await fetch('/api/admin/update-user-role', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
newRole: selectedRole,
}),
});
if (res.ok) {
const data = await res.json();
setUser({ ...user, role: selectedRole });
toast.success(`User role updated to ${selectedRole}`);
setIsRoleChangeAlertOpen(false);
} else {
const data = await res.json();
toast.error(data.error || 'Failed to update user role');
setSelectedRole(user.role);
}
} catch (err) {
console.error('Failed to update user role:', err);
toast.error('Error updating user role');
setSelectedRole(user.role);
} finally {
setIsUpdatingRole(false);
}
};
if (isLoading) {
return (
<div className="p-4 text-sm text-muted-foreground">Loading user details...</div>
);
}
if (!user) {
return (
<div className="p-4">
<div className="text-red-500">User not found</div>
<Button onClick={() => router.back()} className="mt-4">
Go back
</Button>
</div>
);
}
const getInitials = (name: string | null, email: string) => {
if (!name) {
return email.substring(0, 2).toUpperCase();
}
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase();
};
const formatSeconds = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
}
return `${minutes}m ${secs}s`;
};
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
>
<ArrowLeft className="w-4 h-4" />
Back
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setIsDeleteAlertOpen(true)}
disabled={isDeleting}
className="gap-2"
>
<Trash2 className="w-4 h-4" />
Delete User
</Button>
</div>
{/* User Header */}
<Card>
<CardHeader>
<div className="flex items-start gap-4">
<Avatar className="w-16 h-16">
<AvatarImage src={user.image || undefined} alt={user.name || user.email} />
<AvatarFallback>
{getInitials(user.name, user.email)}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">
{user.name || user.email}
</h1>
<Badge variant={user.role === 'admin' ? 'secondary' : user.role === 'superadmin' ? 'default' : 'outline'}>
{user.role}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{user.email}</p>
<p className="text-xs text-muted-foreground">
Joined {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
</p>
</div>
</div>
</CardHeader>
</Card>
{/* Role Assignment Card (Superadmin Only) */}
{isSuperadmin && (
<Card>
<CardHeader>
<CardTitle>Assign Role</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-end gap-4">
<div className="flex-1">
<label className="block text-sm font-medium mb-2">User Role</label>
<Select value={selectedRole} onValueChange={setSelectedRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User (Student)</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="superadmin">Superadmin</SelectItem>
</SelectContent>
</Select>
</div>
<Button
onClick={() => setIsRoleChangeAlertOpen(true)}
disabled={selectedRole === user.role || isUpdatingRole}
className="gap-2"
>
<Save className="w-4 h-4" />
{isUpdatingRole ? 'Updating...' : 'Update Role'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
<strong>User:</strong> Regular student with access to enrolled courses
<br />
<strong>Admin:</strong> Can manage courses, playlists, videos, and users
<br />
<strong>Superadmin:</strong> Full access + can assign roles to other admins
</p>
</CardContent>
</Card>
)}
{/* Enrollments */}
{user.enrollments.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Enrollments ({user.enrollments.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{user.enrollments.map((enrollment, idx) => (
<Badge key={idx} variant="outline">
{enrollment.course.title}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Tabs for Watch History and Comments */}
<Tabs defaultValue="history" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="history">
Watch History ({user.progress.length})
</TabsTrigger>
<TabsTrigger value="comments">
Comments ({user.comments.length})
</TabsTrigger>
</TabsList>
{/* Watch History Tab */}
<TabsContent value="history">
<Card>
<CardContent className="p-0">
{user.progress.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No watch history
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Video</TableHead>
<TableHead>Watched</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{user.progress.map((progress) => (
<TableRow key={progress.id}>
<TableCell className="font-medium">
{progress.video.title}
</TableCell>
<TableCell>
{formatSeconds(progress.watchedSec)}
</TableCell>
<TableCell>
{progress.durationSec
? formatSeconds(progress.durationSec)
: '-'}
</TableCell>
<TableCell className="max-w-md">
<SegmentedProgressBar
segments={progressSegments[progress.videoId] ?? []}
duration={progress.durationSec ?? 1}
percent={progress.percent}
height="sm"
showTooltip={true}
/>
</TableCell>
<TableCell>
{progress.completed ? (
<Badge>Completed</Badge>
) : (
<Badge variant="outline">In Progress</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(progress.updatedAt), {
addSuffix: true,
})}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Comments Tab */}
<TabsContent value="comments">
<Card>
<CardContent className="p-4">
{user.comments.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No comments
</div>
) : (
<div className="space-y-4">
{user.comments.map((comment) => (
<div key={comment.id} className="border rounded-lg p-3 space-y-2">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium">
Video: {comment.video.title}
</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(comment.createdAt), {
addSuffix: true,
})}
</p>
</div>
{comment.replies.length > 0 && (
<Badge variant="outline" className="text-xs">
{comment.replies.length} replies
</Badge>
)}
</div>
<p className="text-sm text-foreground">{comment.content}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Delete User Alert Dialog */}
<AlertDialog open={isDeleteAlertOpen} onOpenChange={setIsDeleteAlertOpen}>
<AlertDialogContent>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-3">
<p>
Are you sure you want to delete <span className="font-semibold">{user?.email}</span>?
</p>
<p className="text-sm text-destructive">
This will permanently delete:
</p>
<ul className="text-sm list-disc list-inside space-y-1 ml-2 text-destructive">
<li>User account and profile</li>
<li>All enrollments</li>
<li>All progress and watch history</li>
<li>All comments and replies</li>
<li>All video likes</li>
<li>All video unlocks</li>
</ul>
<p className="text-xs text-muted-foreground mt-3">
This action cannot be undone.
</p>
</div>
</AlertDialogDescription>
<div className="flex justify-end gap-2">
<AlertDialogCancel disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteUser}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? 'Deleting...' : 'Delete User'}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
{/* Role Change Confirmation Dialog */}
<AlertDialog open={isRoleChangeAlertOpen} onOpenChange={setIsRoleChangeAlertOpen}>
<AlertDialogContent>
<AlertDialogTitle>Update User Role</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-3">
<p>
Change <span className="font-semibold">{user?.email}</span>'s role from{' '}
<span className="font-semibold">{user?.role}</span> to{' '}
<span className="font-semibold">{selectedRole}</span>?
</p>
{selectedRole === 'superadmin' && (
<p className="text-sm text-amber-600">
⚠️ This user will have full access including the ability to assign roles to other users.
</p>
)}
{selectedRole === 'user' && user?.role !== 'user' && (
<p className="text-sm text-blue-600">
️ This user will lose admin access but will still have access to enrolled courses.
</p>
)}
</div>
</AlertDialogDescription>
<div className="flex justify-end gap-2">
<AlertDialogCancel disabled={isUpdatingRole}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleUpdateRole}
disabled={isUpdatingRole}
>
{isUpdatingRole ? 'Updating...' : 'Update Role'}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
export default function UserDetailClient({ userId }: UserDetailClientProps) {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<UserDetailClientUI userId={userId} />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+185
View File
@@ -0,0 +1,185 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { formatDistanceToNow } from 'date-fns';
import { Eye } from 'lucide-react';
type User = {
id: string;
email: string;
name: string | null;
image: string | null;
role: string;
createdAt: Date;
enrollments: Array<{
course: {
id: string;
title: string;
};
}>;
lastActivity: Date | null;
};
function UsersAdminClientUI() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
useEffect(() => {
const fetchUsers = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/admin/users');
if (res.ok) {
const data = await res.json();
setUsers(data);
}
} catch (err) {
console.error('Failed to fetch users:', err);
} finally {
setIsLoading(false);
}
};
fetchUsers();
}, []);
const getRoleBadgeVariant = (role: string) => {
switch (role) {
case 'superadmin':
return 'default';
case 'admin':
return 'secondary';
default:
return 'outline';
}
};
if (isLoading) {
return (
<div className="p-4 text-sm text-muted-foreground">Loading users...</div>
);
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader>
<CardTitle>Total: {users.length} users</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Name</TableHead>
<TableHead>Role</TableHead>
<TableHead>Courses</TableHead>
<TableHead>Last Activity</TableHead>
<TableHead>Joined</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No users found
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.email}</TableCell>
<TableCell>{user.name || '-'}</TableCell>
<TableCell>
<Badge variant={getRoleBadgeVariant(user.role)}>
{user.role}
</Badge>
</TableCell>
<TableCell>
{user.enrollments.length > 0 ? (
<div className="flex gap-1 flex-wrap">
{user.enrollments.map((enrollment, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{enrollment.course.title}
</Badge>
))}
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell>
{user.lastActivity ? (
formatDistanceToNow(new Date(user.lastActivity), { addSuffix: true })
) : (
<span className="text-muted-foreground text-sm">No activity</span>
)}
</TableCell>
<TableCell>
{formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/users/${user.id}`)}
>
<Eye className="w-4 h-4" />
View
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default function sersAdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<UsersAdminClientUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -0,0 +1,22 @@
// app/admin/users/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import UsersAdminClient from './admin-client';
export const dynamic = 'force-dynamic';
export default async function UsersAdminPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return <UsersAdminClient />;
}
@@ -0,0 +1,268 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import Image from 'next/image';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldGroup,
FieldLabel,
FieldDescription,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
interface VideoData {
id: string;
title: string;
description: string;
thumbnail?: string;
playlistTitle: string;
courseTitle: string;
restrictedCourseIds?: string[];
}
interface CourseData {
id: string;
title: string;
}
export default function EditVideoClient({ video }: { video: VideoData }) {
const router = useRouter();
const [title, setTitle] = useState(video.title);
const [description, setDescription] = useState(video.description);
const [thumbFile, setThumbFile] = useState<File | null>(null);
const [thumbPreview, setThumbPreview] = useState(video.thumbnail);
const [loading, setLoading] = useState(false);
const [courses, setCourses] = useState<CourseData[]>([]);
const [coursesLoading, setCoursesLoading] = useState(true);
const [restrictedCourseIds, setRestrictedCourseIds] = useState<string[]>(
video.restrictedCourseIds ?? []
);
useEffect(() => {
let cancelled = false;
setCoursesLoading(true);
fetch('/api/admin/meta')
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!cancelled) {
setCourses(json.courses ?? []);
}
})
.catch((err) => {
console.error('Failed to load courses', err);
if (!cancelled) {
setCourses([]);
}
})
.finally(() => {
if (!cancelled) setCoursesLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setThumbFile(file);
const reader = new FileReader();
reader.onload = (event) => {
setThumbPreview(event.target?.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const formData = new FormData();
formData.append('videoId', video.id);
formData.append('title', title);
formData.append('description', description);
if (thumbFile) {
formData.append('thumbnail', thumbFile);
}
formData.append('restrictedCourseIds', JSON.stringify(restrictedCourseIds));
const res = await fetch('/api/admin/update-video', {
method: 'POST',
body: formData,
});
if (res.ok) {
toast.success('Video updated successfully');
router.push('/admin/videos');
} else {
const err = await res.text();
toast.error('Failed to update video: ' + err);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setLoading(false);
}
};
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">
Edit Video
</h1>
<p className="text-muted-foreground">
{video.courseTitle} {video.playlistTitle}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>{video.title}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="title">Video Title</FieldLabel>
<Input
id="title"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter video title"
/>
</Field>
<Field>
<FieldLabel htmlFor="description">
Description
</FieldLabel>
<FieldDescription>
Optional description for the video
</FieldDescription>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter video description"
rows={4}
/>
</Field>
<Field>
<FieldLabel>Restricted courses</FieldLabel>
<FieldDescription>
Only users enrolled in the checked courses will see this video. Leave
empty to allow all enrolled courses to access it.
</FieldDescription>
{coursesLoading ? (
<div className="text-sm text-muted-foreground">Loading courses</div>
) : courses.length === 0 ? (
<div className="text-sm text-muted-foreground">No courses available.</div>
) : (
<div className="grid gap-2 text-sm">
{courses.map((course) => (
<label
key={course.id}
className="flex items-center gap-2 text-sm font-medium"
>
<input
type="checkbox"
checked={restrictedCourseIds.includes(course.id)}
onChange={() => {
setRestrictedCourseIds((prev) =>
prev.includes(course.id)
? prev.filter((id) => id !== course.id)
: [...prev, course.id]
);
}}
className="accent-primary"
/>
<span>{course.title}</span>
</label>
))}
</div>
)}
</Field>
<Field>
<FieldLabel htmlFor="thumbnail">Thumbnail</FieldLabel>
<FieldDescription>
Upload a new thumbnail image (optional)
</FieldDescription>
<div className="grid w-full max-w-sm items-center gap-3">
<Input
id="thumbnail"
type="file"
accept="image/*"
onChange={handleThumbnailChange}
className="block w-full text-sm"
/>
{thumbPreview ? (
<div className="relative w-40 h-24">
<Image
src={thumbPreview}
alt="Thumbnail preview"
fill
unoptimized
className="object-cover rounded"
/>
</div>
) : null}
</div>
</Field>
<div className="flex gap-2 pt-4">
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push('/admin/videos')}
>
Cancel
</Button>
</div>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+57
View File
@@ -0,0 +1,57 @@
// app/admin/videos/[videoId]/edit/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { redirect } from 'next/navigation';
import EditVideoClient from './edit-video-client';
export default async function EditVideoPage({
params,
}: {
params: Promise<{ videoId: string }>;
}) {
const { videoId } = await params;
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
const video = await prisma.video.findUnique({
where: { id: videoId },
include: {
playlist: {
include: {
course: true,
},
},
videoCourses: true,
},
});
if (!video) {
redirect('/admin/videos');
}
return (
<EditVideoClient
video={{
id: video.id,
title: video.title,
description: (video as any).description || '',
thumbnail: video.thumbnail ?? undefined,
playlistTitle: video.playlist.title,
courseTitle: video.playlist.course.title,
restrictedCourseIds: video.videoCourses
.filter((vc) => vc.exclusive)
.map((vc) => vc.courseId),
}}
/>
);
}
+815
View File
@@ -0,0 +1,815 @@
'use client';
import React, { useEffect, useState } from 'react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { Badge } from '@/components/ui/badge';
import { SiteHeader } from '@/components/site-header';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Progress } from '@/components/ui/progress';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldGroup,
FieldLabel,
FieldDescription,
} from '@/components/ui/field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Trash2Icon, Edit, Check, X } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { getVideoDuration } from '@/utils/getVideoDuration';
type Course = { id: string; title: string; code?: string };
type Playlist = { id: string; title: string; courseId: string };
type Video = {
id: string;
title: string;
durationSec?: number;
thumbnail?: string;
url: string;
index: number;
locked: boolean;
instantAccess: boolean;
playlistId: string;
playlist: {
id: string;
title: string;
course: {
id: string;
title: string;
};
};
restrictedCourseIds?: string[];
};
function VideosUI() {
const router = useRouter();
const [courses, setCourses] = useState<Course[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
// form state
const [videoTitle, setVideoTitle] = useState('');
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPlaylistId, setVideoPlaylistId] = useState('');
const [thumbFile, setThumbFile] = useState<File | null>(null);
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [savingEditId, setSavingEditId] = useState<string | null>(null);
// Manual file copy mode
const [useManualFileCopy, setUseManualFileCopy] = useState(false);
const [pendingVideoId, setPendingVideoId] = useState<string | null>(null);
const [finalizingVideoId, setFinalizingVideoId] = useState<string | null>(null);
useEffect(() => {
fetchData();
}, []);
async function toggleInstantAccess(videoId: string, currentValue: boolean) {
setTogglingId(videoId);
try {
const res = await fetch(`/api/admin/videos/${videoId}/instant-access`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instantAccess: !currentValue }),
});
if (res.ok) {
const data = await res.json();
// Update the video in state
setVideos((prev) =>
prev.map((v) =>
v.id === videoId ? { ...v, instantAccess: data.video.instantAccess } : v
)
);
toast.success(`Video ${!currentValue ? 'set to' : 'removed from'} instant access`);
} else {
toast.error('Failed to toggle instant access');
}
} catch (err) {
console.error('Error toggling instant access:', err);
toast.error('Error toggling instant access');
} finally {
setTogglingId(null);
}
}
async function toggleLocked(videoId: string, currentValue: boolean) {
setTogglingId(videoId);
try {
const res = await fetch(`/api/admin/videos/${videoId}/locked`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locked: !currentValue }),
});
if (res.ok) {
const data = await res.json();
// Update the video in state
setVideos((prev) =>
prev.map((v) =>
v.id === videoId ? { ...v, locked: data.video.locked } : v
)
);
toast.success(`Video ${!currentValue ? 'locked' : 'unlocked'}`);
} else {
toast.error('Failed to toggle lock status');
}
} catch (err) {
console.error('Error toggling locked:', err);
toast.error('Error toggling lock status');
} finally {
setTogglingId(null);
}
}
async function fetchData() {
try {
const [metaRes, videosRes] = await Promise.all([
fetch('/api/admin/meta'),
fetch('/api/admin/videos'),
]);
if (metaRes.ok) {
const json = await metaRes.json();
setCourses(json.courses || []);
setPlaylists(json.playlists || []);
if (!videoPlaylistId && json.playlists?.[0]) {
setVideoPlaylistId(json.playlists[0].id);
}
}
if (videosRes.ok) {
const json = await videosRes.json();
// Ensure data is serialized properly
setVideos(json.map((v: any) => ({
id: v.id,
title: v.title,
durationSec: v.durationSec,
thumbnail: v.thumbnail,
url: v.url,
index: v.index,
locked: v.locked,
instantAccess: v.instantAccess,
playlistId: v.playlistId,
playlist: {
id: v.playlist.id,
title: v.playlist.title,
course: {
id: v.playlist.course.id,
title: v.playlist.course.title,
},
},
restrictedCourseIds: (v.videoCourses ?? [])
.filter((assignment: any) => assignment.exclusive)
.map((assignment: any) => assignment.courseId),
})));
}
} catch (err) {
console.error('Failed to fetch data', err);
toast.error('Failed to fetch data');
}
}
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0] ?? null;
setVideoFile(f);
setVideoDurationSec(null);
if (!f) return;
try {
const secs = await getVideoDuration(f);
setVideoDurationSec(secs);
toast.success(`Duration: ${secs}s`);
} catch (err) {
console.warn('duration read failed', err);
toast.error('Could not read duration (server will measure)');
}
}
async function uploadVideo(e: React.FormEvent) {
e.preventDefault();
if (!videoPlaylistId) {
toast.error('Select a playlist');
return;
}
// Manual file copy mode
if (useManualFileCopy) {
if (!videoFile) {
toast.error('Select a file to get the filename');
return;
}
// Create video entry without uploading the actual file
setLoading(true);
try {
const form = new FormData();
form.append('title', videoTitle);
form.append('playlistId', videoPlaylistId);
form.append('manualFileCopy', 'true'); // Flag to skip file upload
if (videoDurationSec) form.append('durationSec', String(videoDurationSec));
if (thumbFile) form.append('thumbnail', thumbFile);
const res = await fetch('/api/admin/upload', {
method: 'POST',
body: form,
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error ?? 'Upload failed');
return;
}
// Show modal with the video ID
setPendingVideoId(data.video.id);
toast.success(`Video created! Copy file as: ${data.video.id}.mp4`);
} catch (err: any) {
console.error('upload error', err);
toast.error(String(err?.message ?? 'Upload failed'));
} finally {
setLoading(false);
}
return;
}
// Normal upload mode
if (!videoFile) {
toast.error('Pick a file first');
return;
}
setLoading(true);
setUploadProgress(0);
try {
const form = new FormData();
form.append('file', videoFile);
form.append('title', videoTitle);
form.append('playlistId', videoPlaylistId);
if (videoDurationSec) form.append('durationSec', String(videoDurationSec));
if (thumbFile) form.append('thumbnail', thumbFile);
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/admin/upload');
xhr.upload.onprogress = (ev) => {
if (ev.lengthComputable) {
const pct = Math.round((ev.loaded / ev.total) * 100);
setUploadProgress(pct);
}
};
xhr.onload = async () => {
let body: any = null;
try {
body = xhr.responseText ? JSON.parse(xhr.responseText) : null;
} catch (err) {
body = { error: xhr.responseText };
}
if (xhr.status >= 200 && xhr.status < 300) {
toast.success('Upload complete');
setUploadProgress(null);
setVideoFile(null);
setVideoTitle('');
setVideoDurationSec(null);
setThumbFile(null);
await fetchData();
router.refresh();
resolve();
} else {
const errMsg = body?.error ?? `Upload failed (${xhr.status})`;
toast.error(errMsg);
setUploadProgress(null);
reject(new Error(errMsg));
}
};
xhr.onerror = () => {
toast.error('Upload failed (network)');
setUploadProgress(null);
reject(new Error('network error'));
};
// Timeout for 2GB uploads over Tailscale: 50 minutes
xhr.timeout = 50 * 60 * 1000;
xhr.ontimeout = () => {
toast.error('Upload timed out');
setUploadProgress(null);
reject(new Error('timeout'));
};
xhr.send(form);
});
} catch (err: any) {
console.error('upload error', err);
if (!uploadProgress) setUploadProgress(null);
toast.error(String(err?.message ?? 'Upload failed'));
} finally {
setLoading(false);
}
}
async function finalizeManualUpload(videoId: string) {
setFinalizingVideoId(videoId);
try {
const res = await fetch('/api/admin/upload/finalize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId }),
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error ?? 'Finalization failed');
return;
}
toast.success(`Video finalized! ${data.message}`);
setPendingVideoId(null);
setVideoFile(null);
setVideoTitle('');
setVideoDurationSec(null);
setThumbFile(null);
setUseManualFileCopy(false);
await fetchData();
router.refresh();
} catch (err: any) {
console.error('finalize error', err);
toast.error(String(err?.message ?? 'Finalization failed'));
} finally {
setFinalizingVideoId(null);
}
}
async function deleteVideo(videoId: string) {
setDeletingId(videoId);
try {
const res = await fetch(`/api/admin/delete-video`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId }),
});
if (res.ok) {
toast.success('Video deleted');
await fetchData();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to delete video: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setDeletingId(null);
}
}
async function updateVideoTitle(videoId: string, newTitle: string) {
if (!newTitle.trim()) {
toast.error('Title cannot be empty');
return;
}
setSavingEditId(videoId);
try {
const res = await fetch(`/api/admin/update-video`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId, title: newTitle }),
});
if (res.ok) {
toast.success('Video updated');
setEditingId(null);
setEditingTitle('');
await fetchData();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to update video: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setSavingEditId(null);
}
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader>
<CardTitle>Upload Video</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={uploadVideo}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="videoTitle">Video title</FieldLabel>
<FieldDescription>Title shown in the playlist.</FieldDescription>
<Input
id="videoTitle"
required
value={videoTitle}
onChange={(e) => setVideoTitle(e.target.value)}
placeholder="e.g. Lesson 1 — Intro to the Rig"
/>
</Field>
<Field>
<div className="flex items-center gap-3">
<Switch
id="manualFileCopy"
checked={useManualFileCopy}
onCheckedChange={setUseManualFileCopy}
/>
<label htmlFor="manualFileCopy" className="text-sm cursor-pointer">
Manual file copy mode
</label>
</div>
<FieldDescription>
{useManualFileCopy
? 'Creates DB entry and thumbnail. You will copy the file manually to the server.'
: 'Upload file directly from browser.'}
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="videoPlaylist">Playlist</FieldLabel>
<FieldDescription>
Select which playlist this video belongs to.
</FieldDescription>
<Select
value={videoPlaylistId}
onValueChange={(val) => setVideoPlaylistId(val)}
>
<SelectTrigger aria-label="Choose playlist">
<SelectValue placeholder="Select playlist" />
</SelectTrigger>
<SelectContent>
{playlists.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.title}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="videoFile">Video file</FieldLabel>
<FieldDescription>
{useManualFileCopy
? 'Select the file to get its name, then copy manually to: /uploads/videos/{videoId}.mp4'
: 'Large files will be uploaded to the server.'}
</FieldDescription>
<div className="grid w-full max-w-sm items-center gap-3">
<Input
id="videoFile"
type="file"
accept="video/*"
onChange={handleFileChange}
className="block w-full text-sm"
required={!useManualFileCopy}
/>
{videoDurationSec ? (
<div className="text-sm text-muted-foreground">
Duration: {videoDurationSec}s
</div>
) : null}
{uploadProgress !== null && !useManualFileCopy ? (
<div className="w-full">
<div className="flex items-center justify-between mb-1">
<div className="text-sm">Uploading</div>
<div className="text-xs text-muted-foreground">
{uploadProgress}%
</div>
</div>
<Progress value={uploadProgress} />
</div>
) : null}
</div>
</Field>
<Field>
<FieldLabel htmlFor="thumbFile">Thumbnail</FieldLabel>
<div className="grid w-full max-w-sm items-center gap-3">
<Input
id="thumbFile"
type="file"
accept="image/*"
onChange={(e) => setThumbFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm"
/>
{thumbFile ? (
<img
src={URL.createObjectURL(thumbFile)}
className="w-32 h-20 rounded object-cover border mt-2"
/>
) : null}
</div>
</Field>
<Field>
<Button
type="submit"
disabled={loading || (!useManualFileCopy && !videoFile) || !videoPlaylistId}
>
{loading
? useManualFileCopy
? 'Creating…'
: 'Uploading…'
: useManualFileCopy
? 'Create & Setup Manual Copy'
: 'Upload video'}
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
{/* Manual file copy modal */}
{pendingVideoId && (
<Card className="border-blue-200 bg-blue-50">
<CardHeader>
<CardTitle className="text-blue-900">File Ready for Manual Copy</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-white p-4 rounded border border-blue-200">
<p className="text-sm text-muted-foreground mb-2">
Copy your video file to the following location on your server:
</p>
<code className="block bg-muted p-3 rounded text-sm font-mono mb-2">
/uploads/videos/{pendingVideoId}.mp4
</code>
<p className="text-sm text-muted-foreground">
The file must be named exactly as shown above (using the video ID).
</p>
</div>
<Button
onClick={() => finalizeManualUpload(pendingVideoId)}
disabled={finalizingVideoId !== null}
className="w-full"
>
{finalizingVideoId ? 'Checking file…' : 'I have placed the file'}
</Button>
<Button
variant="outline"
onClick={() => setPendingVideoId(null)}
disabled={finalizingVideoId !== null}
className="w-full"
>
Cancel
</Button>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Videos</CardTitle>
</CardHeader>
<CardContent>
{videos.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No videos yet
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Thumbnail</TableHead>
<TableHead>Title</TableHead>
<TableHead>Playlist</TableHead>
<TableHead>Course</TableHead>
<TableHead className="text-center">Locked</TableHead>
<TableHead className="text-center">Instant Access</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{videos.map((video) => (
<TableRow key={video.id}>
<TableCell>
{video.thumbnail ? (
<img
src={video.thumbnail}
alt={video.title}
className="w-16 h-9 object-cover rounded text-xs"
/>
) : (
<div className="w-16 h-9 bg-muted rounded flex items-center justify-center">
<span className="text-xs text-muted-foreground">
No image
</span>
</div>
)}
</TableCell>
<TableCell className="font-medium max-w-xs">
{editingId === video.id ? (
<div className="flex gap-2 items-center">
<Input
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
className="h-8"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
updateVideoTitle(video.id, editingTitle);
}
if (e.key === 'Escape') {
setEditingId(null);
setEditingTitle('');
}
}}
/>
<Button
variant="ghost"
size="sm"
onClick={() => updateVideoTitle(video.id, editingTitle)}
disabled={savingEditId === video.id}
>
<Check className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(null);
setEditingTitle('');
}}
disabled={savingEditId === video.id}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<span className="truncate">{video.title}</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(video.id);
setEditingTitle(video.title);
}}
>
<Edit className="w-4 h-4" />
</Button>
</div>
{video.restrictedCourseIds?.length ? (
<div className="flex flex-wrap gap-1">
{video.restrictedCourseIds.map((courseId) => {
const course = courses.find((c) => c.id === courseId);
const label = course?.code ?? course?.title ?? 'Course';
return (
<Badge
key={`${video.id}-${courseId}`}
variant="secondary"
>
{label}
</Badge>
);
})}
</div>
) : null}
</div>
)}
</TableCell>
<TableCell>{video.playlist.title}</TableCell>
<TableCell>{video.playlist.course.title}</TableCell>
<TableCell className="text-center">
<Switch
checked={video.locked}
onCheckedChange={() => toggleLocked(video.id, video.locked)}
disabled={togglingId === video.id}
aria-label="Toggle lock status"
/>
</TableCell>
<TableCell className="text-center">
<Switch
checked={video.instantAccess}
onCheckedChange={() => toggleInstantAccess(video.id, video.instantAccess)}
disabled={togglingId === video.id}
aria-label="Toggle instant access"
/>
</TableCell>
<TableCell className="text-right">
<div className="flex gap-1 justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/videos/${video.id}/edit`)}
>
<Edit className="w-4 h-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={deletingId === video.id}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Video</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{video.title}"?
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-2 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteVideo(video.id)}
>
Delete
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function VideosAdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<VideosUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -0,0 +1,22 @@
// app/admin/videos/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import VideosAdminClient from './admin-client';
export const dynamic = 'force-dynamic';
export default async function VideosAdminPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return <VideosAdminClient />;
}