Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
Reference in New Issue
Block a user