Initial commit

This commit is contained in:
twotalesanimation
2026-06-11 10:46:09 +02:00
commit 81ad7e4ea9
223 changed files with 39530 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
// app/admin/admin-client.tsx
'use client';
import React from 'react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AdminNotifications } from '@/components/admin-notifications';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import {
IconFileUpload,
IconBooks,
IconList,
IconUsers,
IconUsers as IconUsersManage,
IconChartBar,
} from '@tabler/icons-react';
function AdminUI() {
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Admin Panel</h1>
<p className="text-muted-foreground">
Manage your users, courses, playlists, videos, and enrollments.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconChartBar className="w-5 h-5" />
Video Statistics
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
View video performance metrics and analytics.
</p>
<Link href="/admin/stats">
<Button variant="outline" className="w-full">
Go to Stats
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconUsersManage className="w-5 h-5" />
Manage Users
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
View users and their activity.
</p>
<Link href="/admin/users">
<Button variant="outline" className="w-full">
Go to Users
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconBooks className="w-5 h-5" />
Manage Courses
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Create and manage courses.
</p>
<Link href="/admin/courses">
<Button variant="outline" className="w-full">
Go to Courses
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconList className="w-5 h-5" />
Manage Playlists
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Create and manage playlists.
</p>
<Link href="/admin/playlists">
<Button variant="outline" className="w-full">
Go to Playlists
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconFileUpload className="w-5 h-5" />
Manage Videos
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Upload and manage videos.
</p>
<Link href="/admin/videos">
<Button variant="outline" className="w-full">
Go to Videos
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconUsers className="w-5 h-5" />
Manage Enrollments
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Manage user enrollments.
</p>
<Link href="/admin/enrollments">
<Button variant="outline" className="w-full">
Go to Enrollments
</Button>
</Link>
</CardContent>
</Card>
<Card className="hover:bg-accent cursor-pointer transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconUsersManage className="w-5 h-5" />
Allowed Students
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Manage whitelisted student access.
</p>
<Link href="/admin/allowed-students">
<Button variant="outline" className="w-full">
Go to Students
</Button>
</Link>
</CardContent>
</Card>
</div>
</div>
<div className="lg:col-span-1">
<Card>
<CardHeader>
<CardTitle>Activity Feed</CardTitle>
</CardHeader>
<CardContent>
<AdminNotifications />
</CardContent>
</Card>
</div>
</div>
</div>
);
}
export default function AdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<AdminUI />
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+393
View File
@@ -0,0 +1,393 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import { Trash2, Edit, Upload, Plus } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
type AllowedStudent = {
id: string;
email: string;
levels: string;
active: boolean;
createdAt: string;
updatedAt: string;
};
export default function AllowedStudentsClient() {
const [students, setStudents] = useState<AllowedStudent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false);
const [isImportLoading, setIsImportLoading] = useState(false);
const [editingStudent, setEditingStudent] = useState<AllowedStudent | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [newEmail, setNewEmail] = useState('');
const [newLevels, setNewLevels] = useState('');
// Fetch students on mount
useEffect(() => {
fetchStudents();
}, []);
const fetchStudents = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/admin/allowed-students');
if (res.ok) {
const data = await res.json();
setStudents(data);
} else {
toast.error('Failed to fetch students');
}
} catch (err) {
console.error('Failed to fetch students:', err);
toast.error('Error fetching students');
} finally {
setIsLoading(false);
}
};
const handleAddStudent = async () => {
if (!newEmail.trim() || !newLevels.trim()) {
toast.error('Email and course codes required');
return;
}
try {
const res = await fetch('/api/admin/allowed-students', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: newEmail.trim(),
levels: newLevels.trim(),
}),
});
if (res.status === 201) {
toast.success('Student added');
setNewEmail('');
setNewLevels('');
setIsAddDialogOpen(false);
fetchStudents();
} else if (res.status === 409) {
toast.error('Email already registered');
} else {
const data = await res.json();
toast.error(data.error || 'Failed to add student');
}
} catch (err) {
console.error('Failed to add student:', err);
toast.error('Error adding student');
}
};
const handleEditStudent = async () => {
if (!editingStudent) return;
try {
const res = await fetch('/api/admin/allowed-students', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingStudent.id,
email: editingStudent.email,
levels: editingStudent.levels,
active: editingStudent.active,
}),
});
if (res.ok) {
toast.success('Student updated');
setIsEditDialogOpen(false);
setEditingStudent(null);
fetchStudents();
} else {
const data = await res.json();
toast.error(data.error || 'Failed to update student');
}
} catch (err) {
console.error('Failed to update student:', err);
toast.error('Error updating student');
}
};
const handleDeleteStudent = async () => {
if (!deleteId) return;
try {
const res = await fetch(`/api/admin/allowed-students?id=${deleteId}`, {
method: 'DELETE',
});
if (res.ok) {
toast.success('Student removed');
setIsDeleteAlertOpen(false);
setDeleteId(null);
fetchStudents();
} else {
toast.error('Failed to delete student');
}
} catch (err) {
console.error('Failed to delete student:', err);
toast.error('Error deleting student');
}
};
const handleImportCSV = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsImportLoading(true);
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/admin/allowed-students/import', {
method: 'POST',
body: formData,
});
if (res.ok) {
const result = await res.json();
toast.success(`Imported: ${result.imported}, Updated: ${result.updated}`);
if (result.errors.length > 0) {
toast.warning(`${result.errors.length} errors during import`);
}
fetchStudents();
} else {
const data = await res.json();
toast.error(data.error || 'Failed to import students');
}
} catch (err) {
console.error('Failed to import CSV:', err);
toast.error('Error importing students');
} finally {
setIsImportLoading(false);
}
};
if (isLoading) {
return <div className="p-4 text-sm text-muted-foreground">Loading allowed students...</div>;
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Allowed Students ({students.length})</CardTitle>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => setIsAddDialogOpen(true)}
className="gap-2"
>
<Plus className="h-4 w-4" />
Add Student
</Button>
<div className="relative">
<input
type="file"
accept=".csv"
onChange={handleImportCSV}
disabled={isImportLoading}
className="hidden"
id="csv-upload"
/>
<Button
size="sm"
variant="outline"
onClick={() => document.getElementById('csv-upload')?.click()}
disabled={isImportLoading}
className="gap-2"
>
<Upload className="h-4 w-4" />
Import CSV
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Course Codes</TableHead>
<TableHead>Status</TableHead>
<TableHead>Added</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No students added yet
</TableCell>
</TableRow>
) : (
students.map((student) => (
<TableRow key={student.id}>
<TableCell className="font-medium">{student.email}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{student.levels.split(',').map((code) => (
<Badge key={code.trim()} variant="outline">
{code.trim()}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<Badge variant={student.active ? 'default' : 'secondary'}>
{student.active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(student.createdAt), { addSuffix: true })}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setEditingStudent(student);
setIsEditDialogOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
setDeleteId(student.id);
setIsDeleteAlertOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Add Student Sheet */}
<Sheet open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Add Student</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-4 mt-4">
<div>
<label className="text-sm font-medium">Email</label>
<Input
placeholder="student@university.edu"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium">Course Codes</label>
<Input
placeholder="3D100,3D200,3D300"
value={newLevels}
onChange={(e) => setNewLevels(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
Comma-separated course codes
</p>
</div>
</div>
<SheetFooter className="mt-6">
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddStudent}>Add Student</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* Edit Student Sheet */}
<Sheet open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Edit Student</SheetTitle>
</SheetHeader>
{editingStudent && (
<div className="flex flex-col gap-4 mt-4">
<div>
<label className="text-sm font-medium">Email</label>
<Input
value={editingStudent.email}
onChange={(e) =>
setEditingStudent({ ...editingStudent, email: e.target.value })
}
/>
</div>
<div>
<label className="text-sm font-medium">Course Codes</label>
<Input
placeholder="3D100,3D200,3D300"
value={editingStudent.levels}
onChange={(e) =>
setEditingStudent({ ...editingStudent, levels: e.target.value })
}
/>
</div>
</div>
)}
<SheetFooter className="mt-6">
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleEditStudent}>Save Changes</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* Delete Confirmation */}
<AlertDialog open={isDeleteAlertOpen} onOpenChange={setIsDeleteAlertOpen}>
<AlertDialogContent>
<AlertDialogTitle>Remove Student</AlertDialogTitle>
<AlertDialogDescription>
Are you sure? The student will be prevented from logging in, but their existing enrollments will remain.
</AlertDialogDescription>
<div className="flex justify-end gap-2">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteStudent} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Remove
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
// app/admin/allowed-students/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import { SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset } from '@/components/ui/sidebar';
import AllowedStudentsClient from './admin-client';
export const dynamic = 'force-dynamic';
export default async function AllowedStudentsPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<AllowedStudentsClient />
</SidebarInset>
</SidebarProvider>
);
}
+247
View File
@@ -0,0 +1,247 @@
'use client';
import React, { useEffect, useState } from 'react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Trash2Icon } from 'lucide-react';
type Course = { id: string; title: string; code?: string };
function CoursesUI() {
const router = useRouter();
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(false);
const [courseTitle, setCourseTitle] = useState('');
const [courseCode, setCourseCode] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
fetchCourses();
}, []);
async function fetchCourses() {
try {
const res = await fetch('/api/admin/meta');
if (res.ok) {
const json = await res.json();
setCourses(json.courses || []);
}
} catch (err) {
console.error('Failed to fetch courses', err);
toast.error('Failed to fetch courses');
}
}
async function createCourse(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
const res = await fetch('/api/admin/create-course', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: courseTitle, code: courseCode }),
});
if (res.ok) {
toast.success('Course created');
setCourseTitle('');
setCourseCode('');
await fetchCourses();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to create course: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setLoading(false);
}
}
async function deleteCourse(courseId: string) {
setDeletingId(courseId);
try {
const res = await fetch(`/api/admin/delete-course`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ courseId }),
});
if (res.ok) {
toast.success('Course deleted');
await fetchCourses();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to delete course: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setDeletingId(null);
}
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader>
<CardTitle>Create Course</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={createCourse}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="courseTitle">Course title</FieldLabel>
<Input
id="courseTitle"
required
value={courseTitle}
onChange={(e) => setCourseTitle(e.target.value)}
placeholder="3D ANIMATION 300"
/>
</Field>
<Field>
<FieldLabel htmlFor="courseCode">Course code</FieldLabel>
<Input
id="courseCode"
value={courseCode}
onChange={(e) => setCourseCode(e.target.value)}
placeholder="3D100"
/>
</Field>
<Field>
<Button type="submit" disabled={loading}>
Create course
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Courses</CardTitle>
</CardHeader>
<CardContent>
{courses.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No courses yet
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Code</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{courses.map((course) => (
<TableRow key={course.id}>
<TableCell className="font-medium">
{course.title}
</TableCell>
<TableCell>{course.code || '—'}</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={deletingId === course.id}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Course</AlertDialogTitle>
<AlertDialogDescription>
Are you sure? This will delete the course and all
associated playlists and videos.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-2 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteCourse(course.id)}
>
Delete
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function CoursesAdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<CoursesUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -0,0 +1,22 @@
// app/admin/courses/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import CoursesAdminClient from './admin-client';
export const dynamic = 'force-dynamic';
export default async function CoursesAdminPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return <CoursesAdminClient />;
}
+349
View File
@@ -0,0 +1,349 @@
'use client';
import React, { useEffect, useState } from 'react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Progress } from '@/components/ui/progress';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardFooter,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Field,
FieldGroup,
FieldLabel,
FieldDescription,
} from '@/components/ui/field';
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
type User = { id: number | string; name?: string | null; email: string };
type Course = { id: number | string; title: string; code: string };
type Enrollment = {
id: number;
role?: string | null;
createdAt?: string;
user: User;
course: Course;
};
function AdminUI() {
const [users, setUsers] = useState<User[]>([]);
const [courses, setCourses] = useState<Course[]>([]);
const [enrollments, setEnrollments] = useState<Enrollment[]>([]);
// placeholder sentinel
const [selectedUser, setSelectedUser] = useState<string>('none');
const [selectedCourse, setSelectedCourse] = useState<string>('none');
const [role, setRole] = useState<string>('student');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
Promise.all([
fetch('/api/users')
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.catch(() => []),
fetch('/api/courses')
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.catch(() => []),
fetch('/api/enrollments')
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.catch(() => []),
])
.then(([u, c, e]) => {
setUsers(u ?? []);
setCourses(c ?? []);
setEnrollments(e ?? []);
})
.catch((err) => {
console.error('Failed to load admin data', err);
setError('Failed to load admin data');
})
.finally(() => setLoading(false));
}, []);
function extractIdString(val: unknown): string | null {
const s = val === null || val === undefined ? '' : String(val).trim();
console.debug('extractIdString raw value:', s);
if (!s) return null;
if (s.length > 0) return s;
return null;
}
async function handleCreate(e?: React.FormEvent) {
e?.preventDefault();
setError(null);
const userIdStr = extractIdString(selectedUser);
const courseIdStr = extractIdString(selectedCourse);
if (!userIdStr || !courseIdStr) {
setError(
`Invalid selection. user="${String(selectedUser)}" course="${String(
selectedCourse
)}". Expected values containing numeric ids or direct ids.`
);
return;
}
// send strings to the server (your Prisma schema expects String ids)
const payload = {
userId: String(userIdStr),
courseId: String(courseIdStr),
};
console.debug('Creating enrollment with payload:', payload);
setSaving(true);
try {
const res = await fetch('/api/enrollments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
setError(data?.error || 'Failed to create enrollment');
} else {
setEnrollments((prev) => [data, ...prev]);
setSelectedUser('none');
setSelectedCourse('none');
setRole('student');
}
} catch (err) {
console.error('Network error while creating enrollment', err);
setError('Network error while creating enrollment');
} finally {
setSaving(false);
}
}
async function handleDelete(id: number) {
if (!confirm('Remove this enrollment?')) return;
setSaving(true);
setError(null);
try {
const res = await fetch('/api/enrollments', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
const data = await res.json();
if (!res.ok && !data?.success) {
setError(data?.error || 'Failed to delete enrollment');
} else {
setEnrollments((prev) => prev.filter((en) => en.id !== id));
}
} catch (err) {
console.error('Network error while deleting enrollment', err);
setError('Network error while deleting enrollment');
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card className="mb-6">
<CardHeader>
<CardTitle>Assign enrollment</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={(e) => handleCreate(e)}
className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end"
>
<div className="col-span-2">
<FieldGroup>
<Field>
<FieldLabel htmlFor="student">Student</FieldLabel>
<Select
value={selectedUser}
onValueChange={(v) => {
console.debug('Select user changed ->', v);
setSelectedUser(v);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a user" />
</SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={String(u.id)} value={String(u.id)}>
{u.name ? `${u.name}${u.email}` : u.email}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
</div>
<div className="col-span-1">
<FieldGroup>
<Field>
<FieldLabel htmlFor="course">Course</FieldLabel>
<Select
value={selectedCourse}
onValueChange={(v) => {
console.debug('Select course changed ->', v);
setSelectedCourse(v);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a course" />
</SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={String(c.id)} value={String(c.id)}>
{c.title} | {c.code}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
</div>
<div className="col-span-1 flex gap-2">
<Button
type="submit"
disabled={saving}
className="self-end w-full"
>
Add
</Button>
</div>
</form>
{error && (
<div className="mt-3 text-sm text-destructive">{error}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Current enrollments</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
{loading && enrollments.length === 0 ? (
<div className="p-4">Loading</div>
) : enrollments.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">
No enrollments yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Student</TableHead>
<TableHead className="w-[300px]">Email</TableHead>
<TableHead>Course</TableHead>
<TableHead>Role</TableHead>
<TableHead>Enrolled</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{enrollments.map((en) => (
<TableRow key={en.id}>
<TableCell className="font-medium">
{en.user.name ?? en.user.email}
</TableCell>
<TableCell className="font-medium">
{en.user.email}
</TableCell>
<TableCell className="italic">
{en.course.title}{' '}
<span className="text-xs text-muted-foreground">
| {en.course.code}
</span>
</TableCell>
<TableCell>{en.role ?? 'student'}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{en.createdAt
? new Date(en.createdAt).toLocaleString()
: '—'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(en.id)}
disabled={saving}
>
Remove
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
export default function AdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<AdminUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -0,0 +1,22 @@
// app/admin/enrollments/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import AdminClient from './admin-client';
export const dynamic = 'force-dynamic';
export default async function EnrollmentsAdminPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return <AdminClient />;
}
+23
View File
@@ -0,0 +1,23 @@
// app/admin/page.tsx (server component)
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import AdminClient from './admin-client'; // the client UI component (see below)
export const dynamic = 'force-dynamic';
export default async function AdminPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
// authorised — render the client admin UI
return <AdminClient />;
}
+539
View File
@@ -0,0 +1,539 @@
'use client';
import React, { useEffect, useState } from 'react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldGroup,
FieldLabel,
FieldDescription,
} from '@/components/ui/field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Trash2Icon, Edit2, Check, X } from 'lucide-react';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
type Course = { id: string; title: string; code?: string };
type Playlist = { id: string; title: string; courseId: string };
function PlaylistsUI() {
const router = useRouter();
const [courses, setCourses] = useState<Course[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [loading, setLoading] = useState(false);
const [playlistTitle, setPlaylistTitle] = useState('');
const [playlistCourseId, setPlaylistCourseId] = useState('');
const [additionalCourseIds, setAdditionalCourseIds] = useState<string[]>([]);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [savingEditId, setSavingEditId] = useState<string | null>(null);
useEffect(() => {
fetchData();
}, []);
async function fetchData() {
try {
const res = await fetch('/api/admin/meta');
if (res.ok) {
const json = await res.json();
setCourses(json.courses || []);
setPlaylists(json.playlists || []);
if (!playlistCourseId && json.courses?.[0]) {
setPlaylistCourseId(json.courses[0].id);
}
}
} catch (err) {
console.error('Failed to fetch data', err);
toast.error('Failed to fetch data');
}
}
async function createPlaylist(e: React.FormEvent) {
e.preventDefault();
if (!playlistCourseId) {
toast.error('Pick a primary course first');
return;
}
setLoading(true);
try {
const res = await fetch('/api/admin/create-playlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: playlistTitle,
courseId: playlistCourseId,
additionalCourseIds,
}),
});
if (res.ok) {
toast.success('Playlist created');
setPlaylistTitle('');
setAdditionalCourseIds([]);
await fetchData();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to create playlist: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setLoading(false);
}
}
async function deletePlaylist(playlistId: string) {
setDeletingId(playlistId);
try {
const res = await fetch(`/api/admin/delete-playlist`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlistId }),
});
if (res.ok) {
toast.success('Playlist deleted');
await fetchData();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to delete playlist: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setDeletingId(null);
}
}
async function updatePlaylistTitle(playlistId: string, newTitle: string) {
if (!newTitle.trim()) {
toast.error('Title cannot be empty');
return;
}
setSavingEditId(playlistId);
try {
const res = await fetch(`/api/admin/update-playlist`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlistId, title: newTitle }),
});
if (res.ok) {
toast.success('Playlist updated');
setEditingId(null);
setEditingTitle('');
await fetchData();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to update playlist: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setSavingEditId(null);
}
}
// Organizer state & helpers
const [selectedPlaylistId, setSelectedPlaylistId] = useState<string>('');
const [videos, setVideos] = useState<Array<{ id: string; title: string; thumbnail?: string; index: number; durationSec?: number }>>([]);
const sensors = useSensors(useSensor(PointerSensor));
function SortableItem({ id, title, thumbnail }: { id: string; title: string; thumbnail?: string }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<div ref={setNodeRef} style={style} className="w-40 p-2">
<div className="cursor-move" {...attributes} {...listeners}>
{thumbnail ? (
<img src={thumbnail} alt={title} className="w-full h-24 object-cover rounded" />
) : (
<div className="w-full h-24 bg-muted rounded flex items-center justify-center">No image</div>
)}
<div className="text-sm mt-2 truncate">{title}</div>
</div>
</div>
);
}
async function fetchPlaylistVideos(playlistId: string) {
if (!playlistId) return;
try {
const res = await fetch(`/api/admin/playlist-videos?playlistId=${playlistId}`);
if (res.ok) {
const data = await res.json();
setVideos(data || []);
} else {
toast.error('Failed to load playlist videos');
}
} catch (err) {
console.error('Failed to fetch playlist videos', err);
toast.error('Failed to load playlist videos');
}
}
async function handleDragEnd(e: any) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const oldIndex = videos.findIndex((v) => v.id === active.id);
const newIndex = videos.findIndex((v) => v.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(videos, oldIndex, newIndex);
setVideos(newOrder.map((v, idx) => ({ ...v, index: idx })));
// Persist order
try {
const orderedIds = newOrder.map((v) => v.id);
const res = await fetch('/api/admin/reorder-videos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlistId: selectedPlaylistId, orderedIds }),
});
if (!res.ok) {
toast.error('Failed to save new order');
// refetch to revert
await fetchPlaylistVideos(selectedPlaylistId);
} else {
toast.success('Order saved');
}
} catch (err) {
console.error('Failed to save order', err);
toast.error('Failed to save new order');
await fetchPlaylistVideos(selectedPlaylistId);
}
}
const getCourseTitle = (courseId: string) => {
return courses.find((c) => c.id === courseId)?.title || 'Unknown Course';
};
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader>
<CardTitle>Create Playlist</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={createPlaylist}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="playlistTitle">Playlist title</FieldLabel>
<FieldDescription>
e.g. Character Rigging Basics
</FieldDescription>
<Input
id="playlistTitle"
required
value={playlistTitle}
onChange={(e) => setPlaylistTitle(e.target.value)}
placeholder="Playlist title"
/>
</Field>
<Field>
<FieldLabel htmlFor="playlistCourse">Assign to course (primary)</FieldLabel>
<FieldDescription>
Select the main course. The playlist will always be accessible from here.
</FieldDescription>
<Select
value={playlistCourseId}
onValueChange={(val) => setPlaylistCourseId(val)}
>
<SelectTrigger aria-label="Choose Primary Course">
<SelectValue placeholder="Choose Primary Course" />
</SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.title} {c.code ? `| ${c.code}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Assign to additional courses (optional)</FieldLabel>
<FieldDescription>
Select other courses where this playlist should appear.
</FieldDescription>
<div className="space-y-2 mt-2">
{courses
.filter((c) => c.id !== playlistCourseId)
.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={additionalCourseIds.includes(c.id)}
onChange={(e) => {
if (e.target.checked) {
setAdditionalCourseIds([...additionalCourseIds, c.id]);
} else {
setAdditionalCourseIds(additionalCourseIds.filter((id) => id !== c.id));
}
}}
className="w-4 h-4"
/>
<span>
{c.title} {c.code ? `| ${c.code}` : ''}
</span>
</label>
))}
</div>
</Field>
<Field>
<Button type="submit" disabled={loading || !playlistCourseId}>
Create playlist
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Organize Playlist</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 items-end">
<div>
<Field>
<FieldLabel htmlFor="organizePlaylist">Select playlist</FieldLabel>
<Select
value={selectedPlaylistId}
onValueChange={(val) => {
setSelectedPlaylistId(val);
fetchPlaylistVideos(val);
}}
>
<SelectTrigger aria-label="Choose playlist">
<SelectValue placeholder="Choose playlist to organize" />
</SelectTrigger>
<SelectContent>
{playlists.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.title}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
</div>
<div className="mt-4">
{videos.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">No videos loaded</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={videos.map((v) => v.id)} strategy={verticalListSortingStrategy}>
<div className="flex gap-4 overflow-auto py-2">
{videos.map((v) => (
<SortableItem key={v.id} id={v.id} title={v.title} thumbnail={v.thumbnail} />
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Playlists</CardTitle>
</CardHeader>
<CardContent>
{playlists.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No playlists yet
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Course</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{playlists.map((playlist) => (
<TableRow key={playlist.id}>
<TableCell className="font-medium">
{editingId === playlist.id ? (
<div className="flex gap-2 items-center">
<Input
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
className="h-8"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
updatePlaylistTitle(playlist.id, editingTitle);
}
if (e.key === 'Escape') {
setEditingId(null);
setEditingTitle('');
}
}}
/>
<Button
variant="ghost"
size="sm"
onClick={() => updatePlaylistTitle(playlist.id, editingTitle)}
disabled={savingEditId === playlist.id}
>
<Check className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(null);
setEditingTitle('');
}}
disabled={savingEditId === playlist.id}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<div className="flex gap-2 items-center">
<span>{playlist.title}</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(playlist.id);
setEditingTitle(playlist.title);
}}
>
<Edit2 className="w-4 h-4" />
</Button>
</div>
)}
</TableCell>
<TableCell>{getCourseTitle(playlist.courseId)}</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={deletingId === playlist.id || editingId === playlist.id}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Playlist</AlertDialogTitle>
<AlertDialogDescription>
Are you sure? This will delete the playlist and all
associated videos.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-2 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deletePlaylist(playlist.id)}
>
Delete
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function PlaylistsAdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<PlaylistsUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -0,0 +1,22 @@
// app/admin/playlists/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import PlaylistsAdminClient from './admin-client';
export const dynamic = 'force-dynamic';
export default async function PlaylistsAdminPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return <PlaylistsAdminClient />;
}
+298
View File
@@ -0,0 +1,298 @@
// app/admin/stats-client.tsx
'use client';
import React, { useEffect, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle } from 'lucide-react';
interface VideoStats {
id: string;
title: string;
playlistTitle: string;
uploaderName: string;
views: number;
totalViewers: number;
completions: number;
completionRate: string;
likes: number;
comments: number;
engagement: number;
avgPercentWatched: number;
totalSecondsWatched: number;
totalSegments: number;
durationSec: number | null;
createdAt: string;
}
export default function StatsClient() {
const [stats, setStats] = useState<VideoStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<keyof VideoStats>('views');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
useEffect(() => {
const fetchStats = async () => {
try {
setLoading(true);
const response = await fetch('/api/admin/stats');
if (!response.ok) throw new Error('Failed to fetch stats');
const data = await response.json();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
const handleSort = (column: keyof VideoStats) => {
if (sortBy === column) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(column);
setSortOrder('desc');
}
};
const sortedStats = [...stats].sort((a, b) => {
const aVal = a[sortBy];
const bVal = b[sortBy];
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortOrder === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
const aNum = typeof aVal === 'number' ? aVal : 0;
const bNum = typeof bVal === 'number' ? bVal : 0;
return sortOrder === 'asc' ? aNum - bNum : bNum - aNum;
});
const formatDuration = (seconds: number | null) => {
if (!seconds) return 'N/A';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours}h ${minutes}m ${secs}s`;
};
const SortHeader = ({ column, label }: { column: keyof VideoStats; label: string }) => (
<TableHead
className="cursor-pointer hover:bg-muted whitespace-nowrap"
onClick={() => handleSort(column)}
>
<div className="flex items-center gap-1">
{label}
{sortBy === column && (
<span className="text-xs">
{sortOrder === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</TableHead>
);
if (error) {
return (
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
<AlertCircle className="w-5 h-5" />
<div>
<p className="font-semibold">Error loading stats</p>
<p className="text-sm">{error}</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Video Statistics</h1>
<p className="text-muted-foreground">
Performance metrics for all videos in the system
</p>
</div>
<Badge variant="outline" className="text-sm">
{stats.length} Videos
</Badge>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Views
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.reduce((sum, s) => sum + s.views, 0).toLocaleString()}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Unique Viewers
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.reduce((sum, s) => sum + s.totalViewers, 0).toLocaleString()}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Engagements
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.reduce((sum, s) => sum + s.engagement, 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground mt-1">
Likes + Comments
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Avg. Completion Rate
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{(
stats.reduce((sum, s) => sum + parseFloat(s.completionRate), 0) /
(stats.length || 1)
).toFixed(1)}
%
</div>
</CardContent>
</Card>
</div>
{/* Data Table */}
<Card>
<CardHeader>
<CardTitle>Video Performance Details</CardTitle>
</CardHeader>
<CardContent>
<div className="w-full overflow-x-auto">
{loading ? (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : stats.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
No videos found
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<SortHeader column="title" label="Title" />
<SortHeader column="uploaderName" label="Uploader" />
<SortHeader column="views" label="Unlocks" />
<SortHeader column="totalViewers" label="Viewers" />
<SortHeader column="completions" label="Completions" />
<SortHeader column="completionRate" label="Completion %" />
<SortHeader column="avgPercentWatched" label="Avg % Watched" />
<SortHeader column="engagement" label="Engagement" />
<SortHeader column="likes" label="Likes" />
<SortHeader column="comments" label="Comments" />
<SortHeader column="totalSecondsWatched" label="Total Seconds Watched" />
<SortHeader column="durationSec" label="Duration" />
</TableRow>
</TableHeader>
<TableBody>
{sortedStats.map((video) => (
<TableRow key={video.id}>
<TableCell className="font-medium whitespace-nowrap">
<div className="flex flex-col">
<span className="max-w-xs truncate">{video.title}</span>
<span className="text-xs text-muted-foreground">
{video.playlistTitle}
</span>
</div>
</TableCell>
<TableCell className="whitespace-nowrap text-sm">
{video.uploaderName}
</TableCell>
<TableCell className="text-sm font-medium">
{video.views}
</TableCell>
<TableCell className="text-sm font-medium">
{video.totalViewers}
</TableCell>
<TableCell className="text-sm">
{video.completions}
</TableCell>
<TableCell className="text-sm font-semibold">
<Badge
variant={
parseFloat(video.completionRate) >= 50
? 'default'
: parseFloat(video.completionRate) >= 25
? 'secondary'
: 'destructive'
}
>
{video.completionRate}%
</Badge>
</TableCell>
<TableCell className="text-sm">
{video.avgPercentWatched.toFixed(1)}%
</TableCell>
<TableCell className="text-sm font-bold">
{video.engagement}
</TableCell>
<TableCell className="text-sm">
<Badge variant="outline">{video.likes}</Badge>
</TableCell>
<TableCell className="text-sm">
<Badge variant="outline">{video.comments}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{video.totalSecondsWatched.toLocaleString()}s
</TableCell>
<TableCell className="text-sm whitespace-nowrap">
{formatDuration(video.durationSec)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
// app/admin/stats/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import StatsClient from '../stats-client';
export const dynamic = 'force-dynamic';
export default async function StatsPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<StatsClient />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
// app/admin/users/[userId]/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import UserDetailClient from './user-detail-client';
export default async function UserDetailPage({
params,
}: {
params: Promise<{ userId: string }>;
}) {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
const { userId } = await params;
return <UserDetailClient userId={userId} />;
}
@@ -0,0 +1,564 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { SegmentedProgressBar, WatchSegment } from '@/components/segmented-progress-bar';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatDistanceToNow } from 'date-fns';
import { ArrowLeft, Trash2, Save } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
type Video = {
id: string;
title: string;
};
type UserProgress = {
id: string;
videoId: string;
video: Video;
watchedSec: number;
lastPos: number | null;
percent: number;
completed: boolean;
durationSec: number | null;
updatedAt: string;
createdAt: string;
};
type UserComment = {
id: string;
content: string;
createdAt: string;
video: Video;
replies: Array<{ id: string }>;
};
type UserDetail = {
id: string;
email: string;
name: string | null;
image: string | null;
role: string;
createdAt: string;
enrollments: Array<{
course: {
id: string;
title: string;
};
}>;
progress: UserProgress[];
comments: UserComment[];
};
interface UserDetailClientProps {
userId: string;
}
function UserDetailClientUI({ userId }: UserDetailClientProps) {
const { data: session } = useSession();
const [user, setUser] = useState<UserDetail | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [progressSegments, setProgressSegments] = useState<Record<string, WatchSegment[]>>({});
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [selectedRole, setSelectedRole] = useState<string>('');
const [isRoleChangeAlertOpen, setIsRoleChangeAlertOpen] = useState(false);
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
const router = useRouter();
const isSuperadmin = (session?.user as any)?.role === 'superadmin';
useEffect(() => {
const fetchUserDetail = async () => {
setIsLoading(true);
try {
const res = await fetch(`/api/admin/users/${userId}`);
if (res.ok) {
const data = await res.json();
setUser(data);
setSelectedRole(data.role);
// Fetch segments for all progress entries
const segments: Record<string, WatchSegment[]> = {};
for (const prog of data.progress) {
try {
const segRes = await fetch(`/api/admin/users/${userId}/progress/${prog.videoId}/segments`);
if (segRes.ok) {
const segData = await segRes.json();
segments[prog.videoId] = segData.segments ?? [];
}
} catch (err) {
console.error(`Failed to fetch segments for video ${prog.videoId}:`, err);
}
}
setProgressSegments(segments);
}
} catch (err) {
console.error('Failed to fetch user detail:', err);
} finally {
setIsLoading(false);
}
};
fetchUserDetail();
}, [userId]);
const handleDeleteUser = async () => {
setIsDeleting(true);
try {
const res = await fetch(`/api/admin/users/${userId}`, {
method: 'DELETE',
});
if (res.ok) {
toast.success('User deleted successfully');
router.push('/admin/users');
} else {
const data = await res.json();
toast.error(data.error || 'Failed to delete user');
}
} catch (err) {
console.error('Failed to delete user:', err);
toast.error('Error deleting user');
} finally {
setIsDeleting(false);
setIsDeleteAlertOpen(false);
}
};
const handleUpdateRole = async () => {
if (!user || selectedRole === user.role) {
setIsRoleChangeAlertOpen(false);
return;
}
setIsUpdatingRole(true);
try {
const res = await fetch('/api/admin/update-user-role', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
newRole: selectedRole,
}),
});
if (res.ok) {
const data = await res.json();
setUser({ ...user, role: selectedRole });
toast.success(`User role updated to ${selectedRole}`);
setIsRoleChangeAlertOpen(false);
} else {
const data = await res.json();
toast.error(data.error || 'Failed to update user role');
setSelectedRole(user.role);
}
} catch (err) {
console.error('Failed to update user role:', err);
toast.error('Error updating user role');
setSelectedRole(user.role);
} finally {
setIsUpdatingRole(false);
}
};
if (isLoading) {
return (
<div className="p-4 text-sm text-muted-foreground">Loading user details...</div>
);
}
if (!user) {
return (
<div className="p-4">
<div className="text-red-500">User not found</div>
<Button onClick={() => router.back()} className="mt-4">
Go back
</Button>
</div>
);
}
const getInitials = (name: string | null, email: string) => {
if (!name) {
return email.substring(0, 2).toUpperCase();
}
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase();
};
const formatSeconds = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
}
return `${minutes}m ${secs}s`;
};
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
>
<ArrowLeft className="w-4 h-4" />
Back
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setIsDeleteAlertOpen(true)}
disabled={isDeleting}
className="gap-2"
>
<Trash2 className="w-4 h-4" />
Delete User
</Button>
</div>
{/* User Header */}
<Card>
<CardHeader>
<div className="flex items-start gap-4">
<Avatar className="w-16 h-16">
<AvatarImage src={user.image || undefined} alt={user.name || user.email} />
<AvatarFallback>
{getInitials(user.name, user.email)}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">
{user.name || user.email}
</h1>
<Badge variant={user.role === 'admin' ? 'secondary' : user.role === 'superadmin' ? 'default' : 'outline'}>
{user.role}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{user.email}</p>
<p className="text-xs text-muted-foreground">
Joined {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
</p>
</div>
</div>
</CardHeader>
</Card>
{/* Role Assignment Card (Superadmin Only) */}
{isSuperadmin && (
<Card>
<CardHeader>
<CardTitle>Assign Role</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-end gap-4">
<div className="flex-1">
<label className="block text-sm font-medium mb-2">User Role</label>
<Select value={selectedRole} onValueChange={setSelectedRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User (Student)</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="superadmin">Superadmin</SelectItem>
</SelectContent>
</Select>
</div>
<Button
onClick={() => setIsRoleChangeAlertOpen(true)}
disabled={selectedRole === user.role || isUpdatingRole}
className="gap-2"
>
<Save className="w-4 h-4" />
{isUpdatingRole ? 'Updating...' : 'Update Role'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
<strong>User:</strong> Regular student with access to enrolled courses
<br />
<strong>Admin:</strong> Can manage courses, playlists, videos, and users
<br />
<strong>Superadmin:</strong> Full access + can assign roles to other admins
</p>
</CardContent>
</Card>
)}
{/* Enrollments */}
{user.enrollments.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Enrollments ({user.enrollments.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{user.enrollments.map((enrollment, idx) => (
<Badge key={idx} variant="outline">
{enrollment.course.title}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Tabs for Watch History and Comments */}
<Tabs defaultValue="history" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="history">
Watch History ({user.progress.length})
</TabsTrigger>
<TabsTrigger value="comments">
Comments ({user.comments.length})
</TabsTrigger>
</TabsList>
{/* Watch History Tab */}
<TabsContent value="history">
<Card>
<CardContent className="p-0">
{user.progress.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No watch history
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Video</TableHead>
<TableHead>Watched</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{user.progress.map((progress) => (
<TableRow key={progress.id}>
<TableCell className="font-medium">
{progress.video.title}
</TableCell>
<TableCell>
{formatSeconds(progress.watchedSec)}
</TableCell>
<TableCell>
{progress.durationSec
? formatSeconds(progress.durationSec)
: '-'}
</TableCell>
<TableCell className="max-w-md">
<SegmentedProgressBar
segments={progressSegments[progress.videoId] ?? []}
duration={progress.durationSec ?? 1}
percent={progress.percent}
height="sm"
showTooltip={true}
/>
</TableCell>
<TableCell>
{progress.completed ? (
<Badge>Completed</Badge>
) : (
<Badge variant="outline">In Progress</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(progress.updatedAt), {
addSuffix: true,
})}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Comments Tab */}
<TabsContent value="comments">
<Card>
<CardContent className="p-4">
{user.comments.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No comments
</div>
) : (
<div className="space-y-4">
{user.comments.map((comment) => (
<div key={comment.id} className="border rounded-lg p-3 space-y-2">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium">
Video: {comment.video.title}
</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(comment.createdAt), {
addSuffix: true,
})}
</p>
</div>
{comment.replies.length > 0 && (
<Badge variant="outline" className="text-xs">
{comment.replies.length} replies
</Badge>
)}
</div>
<p className="text-sm text-foreground">{comment.content}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Delete User Alert Dialog */}
<AlertDialog open={isDeleteAlertOpen} onOpenChange={setIsDeleteAlertOpen}>
<AlertDialogContent>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-3">
<p>
Are you sure you want to delete <span className="font-semibold">{user?.email}</span>?
</p>
<p className="text-sm text-destructive">
This will permanently delete:
</p>
<ul className="text-sm list-disc list-inside space-y-1 ml-2 text-destructive">
<li>User account and profile</li>
<li>All enrollments</li>
<li>All progress and watch history</li>
<li>All comments and replies</li>
<li>All video likes</li>
<li>All video unlocks</li>
</ul>
<p className="text-xs text-muted-foreground mt-3">
This action cannot be undone.
</p>
</div>
</AlertDialogDescription>
<div className="flex justify-end gap-2">
<AlertDialogCancel disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteUser}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? 'Deleting...' : 'Delete User'}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
{/* Role Change Confirmation Dialog */}
<AlertDialog open={isRoleChangeAlertOpen} onOpenChange={setIsRoleChangeAlertOpen}>
<AlertDialogContent>
<AlertDialogTitle>Update User Role</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-3">
<p>
Change <span className="font-semibold">{user?.email}</span>'s role from{' '}
<span className="font-semibold">{user?.role}</span> to{' '}
<span className="font-semibold">{selectedRole}</span>?
</p>
{selectedRole === 'superadmin' && (
<p className="text-sm text-amber-600">
⚠️ This user will have full access including the ability to assign roles to other users.
</p>
)}
{selectedRole === 'user' && user?.role !== 'user' && (
<p className="text-sm text-blue-600">
️ This user will lose admin access but will still have access to enrolled courses.
</p>
)}
</div>
</AlertDialogDescription>
<div className="flex justify-end gap-2">
<AlertDialogCancel disabled={isUpdatingRole}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleUpdateRole}
disabled={isUpdatingRole}
>
{isUpdatingRole ? 'Updating...' : 'Update Role'}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
export default function UserDetailClient({ userId }: UserDetailClientProps) {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<UserDetailClientUI userId={userId} />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+185
View File
@@ -0,0 +1,185 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { formatDistanceToNow } from 'date-fns';
import { Eye } from 'lucide-react';
type User = {
id: string;
email: string;
name: string | null;
image: string | null;
role: string;
createdAt: Date;
enrollments: Array<{
course: {
id: string;
title: string;
};
}>;
lastActivity: Date | null;
};
function UsersAdminClientUI() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
useEffect(() => {
const fetchUsers = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/admin/users');
if (res.ok) {
const data = await res.json();
setUsers(data);
}
} catch (err) {
console.error('Failed to fetch users:', err);
} finally {
setIsLoading(false);
}
};
fetchUsers();
}, []);
const getRoleBadgeVariant = (role: string) => {
switch (role) {
case 'superadmin':
return 'default';
case 'admin':
return 'secondary';
default:
return 'outline';
}
};
if (isLoading) {
return (
<div className="p-4 text-sm text-muted-foreground">Loading users...</div>
);
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader>
<CardTitle>Total: {users.length} users</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Name</TableHead>
<TableHead>Role</TableHead>
<TableHead>Courses</TableHead>
<TableHead>Last Activity</TableHead>
<TableHead>Joined</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No users found
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.email}</TableCell>
<TableCell>{user.name || '-'}</TableCell>
<TableCell>
<Badge variant={getRoleBadgeVariant(user.role)}>
{user.role}
</Badge>
</TableCell>
<TableCell>
{user.enrollments.length > 0 ? (
<div className="flex gap-1 flex-wrap">
{user.enrollments.map((enrollment, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{enrollment.course.title}
</Badge>
))}
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
)}
</TableCell>
<TableCell>
{user.lastActivity ? (
formatDistanceToNow(new Date(user.lastActivity), { addSuffix: true })
) : (
<span className="text-muted-foreground text-sm">No activity</span>
)}
</TableCell>
<TableCell>
{formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/users/${user.id}`)}
>
<Eye className="w-4 h-4" />
View
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default function sersAdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<UsersAdminClientUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -0,0 +1,22 @@
// app/admin/users/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import UsersAdminClient from './admin-client';
export const dynamic = 'force-dynamic';
export default async function UsersAdminPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return <UsersAdminClient />;
}
@@ -0,0 +1,268 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import Image from 'next/image';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldGroup,
FieldLabel,
FieldDescription,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
interface VideoData {
id: string;
title: string;
description: string;
thumbnail?: string;
playlistTitle: string;
courseTitle: string;
restrictedCourseIds?: string[];
}
interface CourseData {
id: string;
title: string;
}
export default function EditVideoClient({ video }: { video: VideoData }) {
const router = useRouter();
const [title, setTitle] = useState(video.title);
const [description, setDescription] = useState(video.description);
const [thumbFile, setThumbFile] = useState<File | null>(null);
const [thumbPreview, setThumbPreview] = useState(video.thumbnail);
const [loading, setLoading] = useState(false);
const [courses, setCourses] = useState<CourseData[]>([]);
const [coursesLoading, setCoursesLoading] = useState(true);
const [restrictedCourseIds, setRestrictedCourseIds] = useState<string[]>(
video.restrictedCourseIds ?? []
);
useEffect(() => {
let cancelled = false;
setCoursesLoading(true);
fetch('/api/admin/meta')
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!cancelled) {
setCourses(json.courses ?? []);
}
})
.catch((err) => {
console.error('Failed to load courses', err);
if (!cancelled) {
setCourses([]);
}
})
.finally(() => {
if (!cancelled) setCoursesLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setThumbFile(file);
const reader = new FileReader();
reader.onload = (event) => {
setThumbPreview(event.target?.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const formData = new FormData();
formData.append('videoId', video.id);
formData.append('title', title);
formData.append('description', description);
if (thumbFile) {
formData.append('thumbnail', thumbFile);
}
formData.append('restrictedCourseIds', JSON.stringify(restrictedCourseIds));
const res = await fetch('/api/admin/update-video', {
method: 'POST',
body: formData,
});
if (res.ok) {
toast.success('Video updated successfully');
router.push('/admin/videos');
} else {
const err = await res.text();
toast.error('Failed to update video: ' + err);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setLoading(false);
}
};
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">
Edit Video
</h1>
<p className="text-muted-foreground">
{video.courseTitle} {video.playlistTitle}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>{video.title}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="title">Video Title</FieldLabel>
<Input
id="title"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter video title"
/>
</Field>
<Field>
<FieldLabel htmlFor="description">
Description
</FieldLabel>
<FieldDescription>
Optional description for the video
</FieldDescription>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter video description"
rows={4}
/>
</Field>
<Field>
<FieldLabel>Restricted courses</FieldLabel>
<FieldDescription>
Only users enrolled in the checked courses will see this video. Leave
empty to allow all enrolled courses to access it.
</FieldDescription>
{coursesLoading ? (
<div className="text-sm text-muted-foreground">Loading courses</div>
) : courses.length === 0 ? (
<div className="text-sm text-muted-foreground">No courses available.</div>
) : (
<div className="grid gap-2 text-sm">
{courses.map((course) => (
<label
key={course.id}
className="flex items-center gap-2 text-sm font-medium"
>
<input
type="checkbox"
checked={restrictedCourseIds.includes(course.id)}
onChange={() => {
setRestrictedCourseIds((prev) =>
prev.includes(course.id)
? prev.filter((id) => id !== course.id)
: [...prev, course.id]
);
}}
className="accent-primary"
/>
<span>{course.title}</span>
</label>
))}
</div>
)}
</Field>
<Field>
<FieldLabel htmlFor="thumbnail">Thumbnail</FieldLabel>
<FieldDescription>
Upload a new thumbnail image (optional)
</FieldDescription>
<div className="grid w-full max-w-sm items-center gap-3">
<Input
id="thumbnail"
type="file"
accept="image/*"
onChange={handleThumbnailChange}
className="block w-full text-sm"
/>
{thumbPreview ? (
<div className="relative w-40 h-24">
<Image
src={thumbPreview}
alt="Thumbnail preview"
fill
unoptimized
className="object-cover rounded"
/>
</div>
) : null}
</div>
</Field>
<div className="flex gap-2 pt-4">
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push('/admin/videos')}
>
Cancel
</Button>
</div>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+57
View File
@@ -0,0 +1,57 @@
// app/admin/videos/[videoId]/edit/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { redirect } from 'next/navigation';
import EditVideoClient from './edit-video-client';
export default async function EditVideoPage({
params,
}: {
params: Promise<{ videoId: string }>;
}) {
const { videoId } = await params;
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
const video = await prisma.video.findUnique({
where: { id: videoId },
include: {
playlist: {
include: {
course: true,
},
},
videoCourses: true,
},
});
if (!video) {
redirect('/admin/videos');
}
return (
<EditVideoClient
video={{
id: video.id,
title: video.title,
description: (video as any).description || '',
thumbnail: video.thumbnail ?? undefined,
playlistTitle: video.playlist.title,
courseTitle: video.playlist.course.title,
restrictedCourseIds: video.videoCourses
.filter((vc) => vc.exclusive)
.map((vc) => vc.courseId),
}}
/>
);
}
+815
View File
@@ -0,0 +1,815 @@
'use client';
import React, { useEffect, useState } from 'react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { Badge } from '@/components/ui/badge';
import { SiteHeader } from '@/components/site-header';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Progress } from '@/components/ui/progress';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldGroup,
FieldLabel,
FieldDescription,
} from '@/components/ui/field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Trash2Icon, Edit, Check, X } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { getVideoDuration } from '@/utils/getVideoDuration';
type Course = { id: string; title: string; code?: string };
type Playlist = { id: string; title: string; courseId: string };
type Video = {
id: string;
title: string;
durationSec?: number;
thumbnail?: string;
url: string;
index: number;
locked: boolean;
instantAccess: boolean;
playlistId: string;
playlist: {
id: string;
title: string;
course: {
id: string;
title: string;
};
};
restrictedCourseIds?: string[];
};
function VideosUI() {
const router = useRouter();
const [courses, setCourses] = useState<Course[]>([]);
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
// form state
const [videoTitle, setVideoTitle] = useState('');
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoPlaylistId, setVideoPlaylistId] = useState('');
const [thumbFile, setThumbFile] = useState<File | null>(null);
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [savingEditId, setSavingEditId] = useState<string | null>(null);
// Manual file copy mode
const [useManualFileCopy, setUseManualFileCopy] = useState(false);
const [pendingVideoId, setPendingVideoId] = useState<string | null>(null);
const [finalizingVideoId, setFinalizingVideoId] = useState<string | null>(null);
useEffect(() => {
fetchData();
}, []);
async function toggleInstantAccess(videoId: string, currentValue: boolean) {
setTogglingId(videoId);
try {
const res = await fetch(`/api/admin/videos/${videoId}/instant-access`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instantAccess: !currentValue }),
});
if (res.ok) {
const data = await res.json();
// Update the video in state
setVideos((prev) =>
prev.map((v) =>
v.id === videoId ? { ...v, instantAccess: data.video.instantAccess } : v
)
);
toast.success(`Video ${!currentValue ? 'set to' : 'removed from'} instant access`);
} else {
toast.error('Failed to toggle instant access');
}
} catch (err) {
console.error('Error toggling instant access:', err);
toast.error('Error toggling instant access');
} finally {
setTogglingId(null);
}
}
async function toggleLocked(videoId: string, currentValue: boolean) {
setTogglingId(videoId);
try {
const res = await fetch(`/api/admin/videos/${videoId}/locked`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locked: !currentValue }),
});
if (res.ok) {
const data = await res.json();
// Update the video in state
setVideos((prev) =>
prev.map((v) =>
v.id === videoId ? { ...v, locked: data.video.locked } : v
)
);
toast.success(`Video ${!currentValue ? 'locked' : 'unlocked'}`);
} else {
toast.error('Failed to toggle lock status');
}
} catch (err) {
console.error('Error toggling locked:', err);
toast.error('Error toggling lock status');
} finally {
setTogglingId(null);
}
}
async function fetchData() {
try {
const [metaRes, videosRes] = await Promise.all([
fetch('/api/admin/meta'),
fetch('/api/admin/videos'),
]);
if (metaRes.ok) {
const json = await metaRes.json();
setCourses(json.courses || []);
setPlaylists(json.playlists || []);
if (!videoPlaylistId && json.playlists?.[0]) {
setVideoPlaylistId(json.playlists[0].id);
}
}
if (videosRes.ok) {
const json = await videosRes.json();
// Ensure data is serialized properly
setVideos(json.map((v: any) => ({
id: v.id,
title: v.title,
durationSec: v.durationSec,
thumbnail: v.thumbnail,
url: v.url,
index: v.index,
locked: v.locked,
instantAccess: v.instantAccess,
playlistId: v.playlistId,
playlist: {
id: v.playlist.id,
title: v.playlist.title,
course: {
id: v.playlist.course.id,
title: v.playlist.course.title,
},
},
restrictedCourseIds: (v.videoCourses ?? [])
.filter((assignment: any) => assignment.exclusive)
.map((assignment: any) => assignment.courseId),
})));
}
} catch (err) {
console.error('Failed to fetch data', err);
toast.error('Failed to fetch data');
}
}
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0] ?? null;
setVideoFile(f);
setVideoDurationSec(null);
if (!f) return;
try {
const secs = await getVideoDuration(f);
setVideoDurationSec(secs);
toast.success(`Duration: ${secs}s`);
} catch (err) {
console.warn('duration read failed', err);
toast.error('Could not read duration (server will measure)');
}
}
async function uploadVideo(e: React.FormEvent) {
e.preventDefault();
if (!videoPlaylistId) {
toast.error('Select a playlist');
return;
}
// Manual file copy mode
if (useManualFileCopy) {
if (!videoFile) {
toast.error('Select a file to get the filename');
return;
}
// Create video entry without uploading the actual file
setLoading(true);
try {
const form = new FormData();
form.append('title', videoTitle);
form.append('playlistId', videoPlaylistId);
form.append('manualFileCopy', 'true'); // Flag to skip file upload
if (videoDurationSec) form.append('durationSec', String(videoDurationSec));
if (thumbFile) form.append('thumbnail', thumbFile);
const res = await fetch('/api/admin/upload', {
method: 'POST',
body: form,
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error ?? 'Upload failed');
return;
}
// Show modal with the video ID
setPendingVideoId(data.video.id);
toast.success(`Video created! Copy file as: ${data.video.id}.mp4`);
} catch (err: any) {
console.error('upload error', err);
toast.error(String(err?.message ?? 'Upload failed'));
} finally {
setLoading(false);
}
return;
}
// Normal upload mode
if (!videoFile) {
toast.error('Pick a file first');
return;
}
setLoading(true);
setUploadProgress(0);
try {
const form = new FormData();
form.append('file', videoFile);
form.append('title', videoTitle);
form.append('playlistId', videoPlaylistId);
if (videoDurationSec) form.append('durationSec', String(videoDurationSec));
if (thumbFile) form.append('thumbnail', thumbFile);
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/admin/upload');
xhr.upload.onprogress = (ev) => {
if (ev.lengthComputable) {
const pct = Math.round((ev.loaded / ev.total) * 100);
setUploadProgress(pct);
}
};
xhr.onload = async () => {
let body: any = null;
try {
body = xhr.responseText ? JSON.parse(xhr.responseText) : null;
} catch (err) {
body = { error: xhr.responseText };
}
if (xhr.status >= 200 && xhr.status < 300) {
toast.success('Upload complete');
setUploadProgress(null);
setVideoFile(null);
setVideoTitle('');
setVideoDurationSec(null);
setThumbFile(null);
await fetchData();
router.refresh();
resolve();
} else {
const errMsg = body?.error ?? `Upload failed (${xhr.status})`;
toast.error(errMsg);
setUploadProgress(null);
reject(new Error(errMsg));
}
};
xhr.onerror = () => {
toast.error('Upload failed (network)');
setUploadProgress(null);
reject(new Error('network error'));
};
// Timeout for 2GB uploads over Tailscale: 50 minutes
xhr.timeout = 50 * 60 * 1000;
xhr.ontimeout = () => {
toast.error('Upload timed out');
setUploadProgress(null);
reject(new Error('timeout'));
};
xhr.send(form);
});
} catch (err: any) {
console.error('upload error', err);
if (!uploadProgress) setUploadProgress(null);
toast.error(String(err?.message ?? 'Upload failed'));
} finally {
setLoading(false);
}
}
async function finalizeManualUpload(videoId: string) {
setFinalizingVideoId(videoId);
try {
const res = await fetch('/api/admin/upload/finalize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId }),
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error ?? 'Finalization failed');
return;
}
toast.success(`Video finalized! ${data.message}`);
setPendingVideoId(null);
setVideoFile(null);
setVideoTitle('');
setVideoDurationSec(null);
setThumbFile(null);
setUseManualFileCopy(false);
await fetchData();
router.refresh();
} catch (err: any) {
console.error('finalize error', err);
toast.error(String(err?.message ?? 'Finalization failed'));
} finally {
setFinalizingVideoId(null);
}
}
async function deleteVideo(videoId: string) {
setDeletingId(videoId);
try {
const res = await fetch(`/api/admin/delete-video`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId }),
});
if (res.ok) {
toast.success('Video deleted');
await fetchData();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to delete video: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setDeletingId(null);
}
}
async function updateVideoTitle(videoId: string, newTitle: string) {
if (!newTitle.trim()) {
toast.error('Title cannot be empty');
return;
}
setSavingEditId(videoId);
try {
const res = await fetch(`/api/admin/update-video`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId, title: newTitle }),
});
if (res.ok) {
toast.success('Video updated');
setEditingId(null);
setEditingTitle('');
await fetchData();
router.refresh();
} else {
const txt = await res.text();
toast.error('Failed to update video: ' + txt);
}
} catch (err: any) {
toast.error('Error: ' + String(err.message ?? err));
} finally {
setSavingEditId(null);
}
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card>
<CardHeader>
<CardTitle>Upload Video</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={uploadVideo}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="videoTitle">Video title</FieldLabel>
<FieldDescription>Title shown in the playlist.</FieldDescription>
<Input
id="videoTitle"
required
value={videoTitle}
onChange={(e) => setVideoTitle(e.target.value)}
placeholder="e.g. Lesson 1 — Intro to the Rig"
/>
</Field>
<Field>
<div className="flex items-center gap-3">
<Switch
id="manualFileCopy"
checked={useManualFileCopy}
onCheckedChange={setUseManualFileCopy}
/>
<label htmlFor="manualFileCopy" className="text-sm cursor-pointer">
Manual file copy mode
</label>
</div>
<FieldDescription>
{useManualFileCopy
? 'Creates DB entry and thumbnail. You will copy the file manually to the server.'
: 'Upload file directly from browser.'}
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="videoPlaylist">Playlist</FieldLabel>
<FieldDescription>
Select which playlist this video belongs to.
</FieldDescription>
<Select
value={videoPlaylistId}
onValueChange={(val) => setVideoPlaylistId(val)}
>
<SelectTrigger aria-label="Choose playlist">
<SelectValue placeholder="Select playlist" />
</SelectTrigger>
<SelectContent>
{playlists.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.title}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="videoFile">Video file</FieldLabel>
<FieldDescription>
{useManualFileCopy
? 'Select the file to get its name, then copy manually to: /uploads/videos/{videoId}.mp4'
: 'Large files will be uploaded to the server.'}
</FieldDescription>
<div className="grid w-full max-w-sm items-center gap-3">
<Input
id="videoFile"
type="file"
accept="video/*"
onChange={handleFileChange}
className="block w-full text-sm"
required={!useManualFileCopy}
/>
{videoDurationSec ? (
<div className="text-sm text-muted-foreground">
Duration: {videoDurationSec}s
</div>
) : null}
{uploadProgress !== null && !useManualFileCopy ? (
<div className="w-full">
<div className="flex items-center justify-between mb-1">
<div className="text-sm">Uploading</div>
<div className="text-xs text-muted-foreground">
{uploadProgress}%
</div>
</div>
<Progress value={uploadProgress} />
</div>
) : null}
</div>
</Field>
<Field>
<FieldLabel htmlFor="thumbFile">Thumbnail</FieldLabel>
<div className="grid w-full max-w-sm items-center gap-3">
<Input
id="thumbFile"
type="file"
accept="image/*"
onChange={(e) => setThumbFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm"
/>
{thumbFile ? (
<img
src={URL.createObjectURL(thumbFile)}
className="w-32 h-20 rounded object-cover border mt-2"
/>
) : null}
</div>
</Field>
<Field>
<Button
type="submit"
disabled={loading || (!useManualFileCopy && !videoFile) || !videoPlaylistId}
>
{loading
? useManualFileCopy
? 'Creating…'
: 'Uploading…'
: useManualFileCopy
? 'Create & Setup Manual Copy'
: 'Upload video'}
</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
{/* Manual file copy modal */}
{pendingVideoId && (
<Card className="border-blue-200 bg-blue-50">
<CardHeader>
<CardTitle className="text-blue-900">File Ready for Manual Copy</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-white p-4 rounded border border-blue-200">
<p className="text-sm text-muted-foreground mb-2">
Copy your video file to the following location on your server:
</p>
<code className="block bg-muted p-3 rounded text-sm font-mono mb-2">
/uploads/videos/{pendingVideoId}.mp4
</code>
<p className="text-sm text-muted-foreground">
The file must be named exactly as shown above (using the video ID).
</p>
</div>
<Button
onClick={() => finalizeManualUpload(pendingVideoId)}
disabled={finalizingVideoId !== null}
className="w-full"
>
{finalizingVideoId ? 'Checking file…' : 'I have placed the file'}
</Button>
<Button
variant="outline"
onClick={() => setPendingVideoId(null)}
disabled={finalizingVideoId !== null}
className="w-full"
>
Cancel
</Button>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Videos</CardTitle>
</CardHeader>
<CardContent>
{videos.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No videos yet
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Thumbnail</TableHead>
<TableHead>Title</TableHead>
<TableHead>Playlist</TableHead>
<TableHead>Course</TableHead>
<TableHead className="text-center">Locked</TableHead>
<TableHead className="text-center">Instant Access</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{videos.map((video) => (
<TableRow key={video.id}>
<TableCell>
{video.thumbnail ? (
<img
src={video.thumbnail}
alt={video.title}
className="w-16 h-9 object-cover rounded text-xs"
/>
) : (
<div className="w-16 h-9 bg-muted rounded flex items-center justify-center">
<span className="text-xs text-muted-foreground">
No image
</span>
</div>
)}
</TableCell>
<TableCell className="font-medium max-w-xs">
{editingId === video.id ? (
<div className="flex gap-2 items-center">
<Input
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
className="h-8"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
updateVideoTitle(video.id, editingTitle);
}
if (e.key === 'Escape') {
setEditingId(null);
setEditingTitle('');
}
}}
/>
<Button
variant="ghost"
size="sm"
onClick={() => updateVideoTitle(video.id, editingTitle)}
disabled={savingEditId === video.id}
>
<Check className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(null);
setEditingTitle('');
}}
disabled={savingEditId === video.id}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center">
<span className="truncate">{video.title}</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(video.id);
setEditingTitle(video.title);
}}
>
<Edit className="w-4 h-4" />
</Button>
</div>
{video.restrictedCourseIds?.length ? (
<div className="flex flex-wrap gap-1">
{video.restrictedCourseIds.map((courseId) => {
const course = courses.find((c) => c.id === courseId);
const label = course?.code ?? course?.title ?? 'Course';
return (
<Badge
key={`${video.id}-${courseId}`}
variant="secondary"
>
{label}
</Badge>
);
})}
</div>
) : null}
</div>
)}
</TableCell>
<TableCell>{video.playlist.title}</TableCell>
<TableCell>{video.playlist.course.title}</TableCell>
<TableCell className="text-center">
<Switch
checked={video.locked}
onCheckedChange={() => toggleLocked(video.id, video.locked)}
disabled={togglingId === video.id}
aria-label="Toggle lock status"
/>
</TableCell>
<TableCell className="text-center">
<Switch
checked={video.instantAccess}
onCheckedChange={() => toggleInstantAccess(video.id, video.instantAccess)}
disabled={togglingId === video.id}
aria-label="Toggle instant access"
/>
</TableCell>
<TableCell className="text-right">
<div className="flex gap-1 justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/videos/${video.id}/edit`)}
>
<Edit className="w-4 h-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={deletingId === video.id}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Video</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{video.title}"?
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-2 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteVideo(video.id)}
>
Delete
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function VideosAdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<VideosUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+22
View File
@@ -0,0 +1,22 @@
// app/admin/videos/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import VideosAdminClient from './admin-client';
export const dynamic = 'force-dynamic';
export default async function VideosAdminPage() {
const session = await getServerSession(authOptions);
const role = (session as any)?.user?.role ?? null;
if (!session?.user) {
redirect('/login');
}
if (!(role === 'admin' || role === 'superadmin')) {
redirect('/dashboard');
}
return <VideosAdminClient />;
}
@@ -0,0 +1,156 @@
// app/api/admin/allowed-students/import/route.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { normalizeEmail } from '@/lib/normalize-email';
// Check if user is admin
async function checkAdmin(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return null;
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return null;
}
return session;
}
// POST import students from CSV
export async function POST(req: NextRequest) {
try {
const session = await checkAdmin(req);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const formData = await req.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// Read file
const text = await file.text();
const lines = text.split('\n').filter((line) => line.trim().length > 0);
if (lines.length === 0) {
return NextResponse.json({ error: 'CSV file is empty' }, { status: 400 });
}
// Skip header row if it exists (assume first line is header if email doesn't look like email)
let startIndex = 0;
if (lines[0].toLowerCase().includes('email')) {
startIndex = 1;
}
const results = {
imported: 0,
updated: 0,
errors: [] as Array<{ row: number; email: string; error: string }>,
};
for (let i = startIndex; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const [email, levels] = line.split(',').map((v) => v.trim());
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail || !levels) {
results.errors.push({
row: i + 1,
email: email || 'N/A',
error: 'Invalid format: expected "email,courseCodes"',
});
continue;
}
// Validate email format
if (!email.includes('@')) {
results.errors.push({
row: i + 1,
email,
error: 'Invalid email format',
});
continue;
}
// Validate levels are valid course codes
const levelArray = levels.split('|').map((l) => l.trim());
let invalidLevel = null;
for (const level of levelArray) {
const course = await prisma.course.findUnique({ where: { code: level } });
if (!course) {
invalidLevel = level;
break;
}
}
if (invalidLevel) {
results.errors.push({
row: i + 1,
email,
error: `Course code '${invalidLevel}' not found`,
});
continue;
}
try {
// Check if student already exists
const existing = await prisma.allowedStudent.findFirst({
where: {
email: {
equals: normalizedEmail,
mode: 'insensitive',
},
},
});
if (existing) {
// Update existing
await prisma.allowedStudent.update({
where: { id: existing.id },
data: {
email: normalizedEmail,
levels: levelArray.join(','),
active: true,
},
});
results.updated++;
} else {
// Create new
await prisma.allowedStudent.create({
data: {
email: normalizedEmail,
levels: levelArray.join(','),
active: true,
},
});
results.imported++;
}
} catch (err) {
results.errors.push({
row: i + 1,
email,
error: `Database error: ${(err as any)?.message || 'Unknown error'}`,
});
}
}
return NextResponse.json(results);
} catch (err) {
console.error('POST /api/admin/allowed-students/import error', err);
return NextResponse.json(
{ error: 'Failed to import students' },
{ status: 500 }
);
}
}
+192
View File
@@ -0,0 +1,192 @@
// app/api/admin/allowed-students/route.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { normalizeEmail } from '@/lib/normalize-email';
// Check if user is admin
async function checkAdmin(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return null;
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return null;
}
return session;
}
// GET all allowed students
export async function GET(req: NextRequest) {
try {
const session = await checkAdmin(req);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const students = await prisma.allowedStudent.findMany({
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(students);
} catch (err) {
console.error('GET /api/admin/allowed-students error', err);
return NextResponse.json(
{ error: 'Failed to fetch allowed students' },
{ status: 500 }
);
}
}
// POST create new allowed student
export async function POST(req: NextRequest) {
try {
const session = await checkAdmin(req);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { email, levels } = body;
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail || !levels) {
return NextResponse.json(
{ error: 'Email and levels required' },
{ status: 400 }
);
}
// Validate levels are valid course codes
const levelArray = levels.split(',').map((l: string) => l.trim());
for (const level of levelArray) {
const course = await prisma.course.findUnique({ where: { code: level } });
if (!course) {
return NextResponse.json(
{ error: `Course code '${level}' not found` },
{ status: 400 }
);
}
}
// Check if student already exists
const existing = await prisma.allowedStudent.findFirst({
where: {
email: {
equals: normalizedEmail,
mode: 'insensitive',
},
},
});
if (existing) {
return NextResponse.json(
{ error: 'Email already registered' },
{ status: 409 }
);
}
const student = await prisma.allowedStudent.create({
data: {
email: normalizedEmail,
levels: levelArray.join(','),
active: true,
},
});
return NextResponse.json(student, { status: 201 });
} catch (err) {
console.error('POST /api/admin/allowed-students error', err);
return NextResponse.json(
{ error: 'Failed to create allowed student' },
{ status: 500 }
);
}
}
// PUT update allowed student
export async function PUT(req: NextRequest) {
try {
const session = await checkAdmin(req);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { id, email, levels, active } = body;
const normalizedEmail = email ? normalizeEmail(email) : null;
if (!id) {
return NextResponse.json({ error: 'Student ID required' }, { status: 400 });
}
if (email && !normalizedEmail) {
return NextResponse.json({ error: 'Valid email required' }, { status: 400 });
}
// Validate levels if provided
if (levels) {
const levelArray = levels.split(',').map((l: string) => l.trim());
for (const level of levelArray) {
const course = await prisma.course.findUnique({ where: { code: level } });
if (!course) {
return NextResponse.json(
{ error: `Course code '${level}' not found` },
{ status: 400 }
);
}
}
}
const student = await prisma.allowedStudent.update({
where: { id },
data: {
...(normalizedEmail && { email: normalizedEmail }),
...(levels && { levels }),
...(active !== undefined && { active }),
},
});
return NextResponse.json(student);
} catch (err) {
console.error('PUT /api/admin/allowed-students error', err);
return NextResponse.json(
{ error: 'Failed to update allowed student' },
{ status: 500 }
);
}
}
// DELETE allowed student
export async function DELETE(req: NextRequest) {
try {
const session = await checkAdmin(req);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Student ID required' }, { status: 400 });
}
await prisma.allowedStudent.delete({
where: { id },
});
return NextResponse.json({ message: 'Student removed' });
} catch (err) {
console.error('DELETE /api/admin/allowed-students error', err);
return NextResponse.json(
{ error: 'Failed to delete allowed student' },
{ status: 500 }
);
}
}
+27
View File
@@ -0,0 +1,27 @@
// app/api/admin/create-course/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
async function checkAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) throw { status: 401, message: 'Unauthorized' };
const allowed = (process.env.ALLOWED_ADMINS || '').split(',').map(s=>s.trim()).filter(Boolean);
if (allowed.length && !allowed.includes(session.user.email)) throw { status: 403, message: 'Forbidden' };
}
export async function POST(req: Request) {
try {
await checkAdmin();
const body = await req.json();
const { title, code } = body;
if (!title) return NextResponse.json({ error: 'missing title' }, { status: 400 });
const course = await prisma.course.create({ data: { title, code } });
return NextResponse.json({ course });
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
}
}
+42
View File
@@ -0,0 +1,42 @@
// app/api/admin/create-playlist/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
async function checkAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) throw { status: 401, message: 'Unauthorized' };
const allowed = (process.env.ALLOWED_ADMINS || '').split(',').map(s=>s.trim()).filter(Boolean);
if (allowed.length && !allowed.includes(session.user.email)) throw { status: 403, message: 'Forbidden' };
}
export async function POST(req: Request) {
try {
await checkAdmin();
const { title, courseId, additionalCourseIds } = await req.json();
if (!title || !courseId) return NextResponse.json({ error: 'missing fields' }, { status: 400 });
const playlist = await prisma.playlist.create({
data: {
title,
courseId,
// Create CoursePlaylist mappings for additional courses
courses: {
create: (additionalCourseIds || []).map((cid: string) => ({
courseId: cid,
})),
},
},
include: {
courses: {
include: { course: true },
},
},
});
return NextResponse.json({ playlist });
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
}
}
+40
View File
@@ -0,0 +1,40 @@
// app/api/admin/delete-course/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function DELETE(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const { courseId } = body ?? {};
if (!courseId)
return NextResponse.json(
{ error: 'courseId required' },
{ status: 400 }
);
// Delete course (cascade will handle playlists and videos via DB constraints)
await prisma.course.delete({
where: { id: courseId },
});
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('delete course error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+40
View File
@@ -0,0 +1,40 @@
// app/api/admin/delete-playlist/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function DELETE(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const { playlistId } = body ?? {};
if (!playlistId)
return NextResponse.json(
{ error: 'playlistId required' },
{ status: 400 }
);
// Delete playlist (cascade will handle videos via DB constraints)
await prisma.playlist.delete({
where: { id: playlistId },
});
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('delete playlist error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+37
View File
@@ -0,0 +1,37 @@
// app/api/admin/delete-video/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function DELETE(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const { videoId } = body ?? {};
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
// Delete video (cascade will handle progress records)
await prisma.video.delete({
where: { id: videoId },
});
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('delete video error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,108 @@
// app/api/admin/manage-playlist-courses/route.ts
// Assign or remove a playlist from additional courses
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
async function checkAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) throw { status: 401, message: 'Unauthorized' };
const allowed = (process.env.ALLOWED_ADMINS || '').split(',').map(s=>s.trim()).filter(Boolean);
if (allowed.length && !allowed.includes(session.user.email)) throw { status: 403, message: 'Forbidden' };
}
// GET: Get all courses a playlist is assigned to
export async function GET(req: Request) {
try {
await checkAdmin();
const { searchParams } = new URL(req.url);
const playlistId = searchParams.get('playlistId');
if (!playlistId) return NextResponse.json({ error: 'playlistId required' }, { status: 400 });
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
include: {
course: true,
courses: {
include: { course: true },
},
},
});
if (!playlist) return NextResponse.json({ error: 'Playlist not found' }, { status: 404 });
// Return primary course + all additional courses
const allCourses = [
playlist.course,
...playlist.courses.map(cp => cp.course),
];
return NextResponse.json({ playlist, allCourses });
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
}
}
// POST: Assign playlist to an additional course
export async function POST(req: Request) {
try {
await checkAdmin();
const { playlistId, courseId } = await req.json();
if (!playlistId || !courseId) return NextResponse.json({ error: 'missing fields' }, { status: 400 });
// Check if already assigned
const existing = await prisma.coursePlaylist.findFirst({
where: { playlistId, courseId },
});
if (existing) return NextResponse.json({ error: 'Already assigned' }, { status: 400 });
const assignment = await prisma.coursePlaylist.create({
data: { playlistId, courseId },
include: { course: true },
});
return NextResponse.json({ assignment });
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
}
}
// DELETE: Remove playlist from a course
export async function DELETE(req: Request) {
try {
await checkAdmin();
const { searchParams } = new URL(req.url);
const playlistId = searchParams.get('playlistId');
const courseId = searchParams.get('courseId');
if (!playlistId || !courseId) return NextResponse.json({ error: 'missing fields' }, { status: 400 });
// Don't allow deleting the primary course assignment
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist) return NextResponse.json({ error: 'Playlist not found' }, { status: 404 });
if (playlist.courseId === courseId) {
return NextResponse.json({ error: 'Cannot remove primary course' }, { status: 400 });
}
await prisma.coursePlaylist.delete({
where: {
courseId_playlistId: { playlistId, courseId },
},
});
return NextResponse.json({ success: true });
} catch (err: any) {
console.error(err);
return NextResponse.json({ error: err?.message ?? 'server' }, { status: err?.status ?? 500 });
}
}
+19
View File
@@ -0,0 +1,19 @@
// app/api/admin/meta/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
export async function GET() {
// Basic protection: ensure signed-in user is admin. Modify as needed.
// If you want stricter, get session from context or check roles.
// Here we skip session check to let dev access; but production should check.
try {
const courses = await prisma.course.findMany({ orderBy: { title: 'asc' } });
const playlists = await prisma.playlist.findMany({ orderBy: { title: 'asc' } });
return NextResponse.json({ courses, playlists });
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'server' }, { status: 500 });
}
}
+168
View File
@@ -0,0 +1,168 @@
// app/api/admin/notifications/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Get current user
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Get all videos uploaded by this admin or superadmin
const uploadedVideos = await prisma.video.findMany({
where: { userId: user.id },
select: { id: true, title: true },
});
const videoIds = uploadedVideos.map((v) => v.id);
if (videoIds.length === 0) {
return NextResponse.json({
likes: [],
comments: [],
total: 0,
});
}
// Fetch recent likes on uploaded videos
const likes = await prisma.videoLike.findMany({
where: { videoId: { in: videoIds } },
include: {
user: {
select: {
id: true,
name: true,
image: true,
email: true,
},
},
video: {
select: {
id: true,
title: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 50,
});
// Fetch recent comments on uploaded videos
const comments = await prisma.comment.findMany({
where: { videoId: { in: videoIds } },
include: {
user: {
select: {
id: true,
name: true,
image: true,
email: true,
},
},
video: {
select: {
id: true,
title: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 50,
});
// Fetch recently created courses by this user
const createdCourses = await prisma.course.findMany({
where: { userId: user.id },
select: {
id: true,
title: true,
code: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 50,
});
// Fetch recently created playlists by this user
const createdPlaylists = await prisma.playlist.findMany({
where: { userId: user.id },
include: {
course: {
select: {
id: true,
title: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 50,
});
// Combine and sort by date
const allNotifications = [
...likes.map((like) => ({
id: like.id,
type: 'like' as const,
user: like.user,
video: like.video,
content: null,
createdAt: like.createdAt,
})),
...comments.map((comment) => ({
id: comment.id,
type: 'comment' as const,
user: comment.user,
video: comment.video,
content: comment.content,
createdAt: comment.createdAt,
})),
...createdCourses.map((course) => ({
id: course.id,
type: 'course_created' as const,
user: null,
course: { id: course.id, title: course.title, code: course.code },
content: null,
createdAt: course.createdAt,
})),
...createdPlaylists.map((playlist) => ({
id: playlist.id,
type: 'playlist_created' as const,
user: null,
playlist: { id: playlist.id, title: playlist.title, courseTitle: playlist.course.title },
content: null,
createdAt: playlist.createdAt,
})),
].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return NextResponse.json({
likes,
comments,
notifications: allNotifications,
total: allNotifications.length,
});
} catch (err: any) {
console.error('get notifications error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+34
View File
@@ -0,0 +1,34 @@
// app/api/admin/playlist-videos/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
const playlistId = searchParams.get('playlistId');
if (!playlistId)
return NextResponse.json({ error: 'playlistId required' }, { status: 400 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const videos = await prisma.video.findMany({
where: { playlistId },
select: { id: true, title: true, thumbnail: true, index: true, durationSec: true },
orderBy: { index: 'asc' },
});
return NextResponse.json(videos);
} catch (err: any) {
console.error('playlist videos GET error', err);
return NextResponse.json({ error: err?.message ?? 'server error' }, { status: 500 });
}
}
+55
View File
@@ -0,0 +1,55 @@
// app/api/admin/reorder-videos/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const { playlistId, orderedIds } = body ?? {};
if (!playlistId || !Array.isArray(orderedIds))
return NextResponse.json({ error: 'playlistId and orderedIds required' }, { status: 400 });
// Validate that all video ids belong to the playlist (optional)
const vids = await prisma.video.findMany({ where: { playlistId }, select: { id: true } });
const validIds = new Set(vids.map((v) => v.id));
const invalid = orderedIds.find((id: string) => !validIds.has(id));
if (invalid)
return NextResponse.json({ error: 'Invalid video id in orderedIds' }, { status: 400 });
// Use two-phase update to avoid unique constraint violation:
// Phase 1: Set all indices to negative temporary values
// Phase 2: Set to final positive values
await prisma.$transaction([
// Phase 1: Set temporary negative indices to avoid conflicts
...orderedIds.map((id: string, idx: number) =>
prisma.video.update({
where: { id },
data: { index: -(idx + 1) }, // Use negative values: -1, -2, -3, etc.
})
),
// Phase 2: Set to final indices
...orderedIds.map((id: string, idx: number) =>
prisma.video.update({
where: { id },
data: { index: idx },
})
),
]);
return NextResponse.json({ success: true });
} catch (err: any) {
console.error('reorder videos error', err);
return NextResponse.json({ error: err?.message ?? 'server error' }, { status: 500 });
}
}
+106
View File
@@ -0,0 +1,106 @@
// app/api/admin/stats/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { Prisma } from '@prisma/client';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Fetch all videos with aggregated stats using raw queries for better performance
const videos = await prisma.video.findMany({
include: {
playlist: {
select: {
title: true,
},
},
uploader: {
select: {
id: true,
name: true,
},
},
_count: {
select: {
likes: true,
comments: true,
unlocks: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
// Fetch detailed stats for each video using raw queries for better performance
const statsData = await prisma.$queryRaw<
Array<{
videoId: string;
totalViewers: number;
totalSecondsWatched: bigint;
avgPercentWatched: number | null;
totalSegments: number;
completionCount: number;
}>
>`
SELECT
p."videoId",
COUNT(DISTINCT p."userId") as "totalViewers",
CAST(COALESCE(SUM(p."watchedSec"), 0) AS BIGINT) as "totalSecondsWatched",
ROUND(CAST(AVG(p."percent") AS NUMERIC), 2) as "avgPercentWatched",
(SELECT COUNT(*) FROM "VideoWatchSegment" ws WHERE ws."videoId" = p."videoId") as "totalSegments",
COUNT(CASE WHEN p."completed" = true THEN 1 END) as "completionCount"
FROM "VideoProgress" p
GROUP BY p."videoId"
`;
// Create a map for quick lookup
const statsMap = new Map(statsData.map(item => [item.videoId, item]));
// Combine video data with stats
const videosWithStats = videos.map(video => {
const stats = statsMap.get(video.id);
const views = video._count.unlocks || 0;
const totalViewers = stats ? Number(stats.totalViewers) : 0;
const completions = stats ? Number(stats.completionCount) : 0;
const engagement = (video._count.likes || 0) + (video._count.comments || 0);
const avgWatched = stats?.avgPercentWatched ? Number(stats.avgPercentWatched) : 0;
return {
id: video.id,
title: video.title,
playlistTitle: video.playlist.title,
uploaderName: video.uploader?.name || 'Unknown',
views,
totalViewers,
completions,
completionRate: totalViewers > 0 ? ((completions / totalViewers) * 100).toFixed(2) : '0.00',
likes: video._count.likes,
comments: video._count.comments,
engagement,
avgPercentWatched: avgWatched,
totalSecondsWatched: stats ? Number(stats.totalSecondsWatched) : 0,
totalSegments: stats ? Number(stats.totalSegments) : 0,
durationSec: video.durationSec,
createdAt: video.createdAt,
};
});
return NextResponse.json(videosWithStats);
} catch (err: any) {
console.error('get stats error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+48
View File
@@ -0,0 +1,48 @@
// app/api/admin/update-playlist/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function PATCH(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { playlistId, title } = await req.json();
if (!playlistId) {
return NextResponse.json(
{ error: 'playlistId required' },
{ status: 400 }
);
}
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return NextResponse.json(
{ error: 'title required and must be non-empty' },
{ status: 400 }
);
}
const updatedPlaylist = await prisma.playlist.update({
where: { id: playlistId },
data: { title: title.trim() },
});
return NextResponse.json(updatedPlaylist, { status: 200 });
} catch (err: any) {
console.error('update playlist error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+77
View File
@@ -0,0 +1,77 @@
// app/api/admin/update-user-role/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function PATCH(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Only superadmins can assign roles
const role = (session as any)?.user?.role ?? null;
if (role !== 'superadmin') {
return NextResponse.json(
{ error: 'Only superadmins can assign roles' },
{ status: 403 }
);
}
const body = await req.json();
const { userId, newRole } = body;
if (!userId || !newRole) {
return NextResponse.json(
{ error: 'userId and newRole required' },
{ status: 400 }
);
}
// Validate newRole
const validRoles = ['user', 'admin', 'superadmin'];
if (!validRoles.includes(newRole)) {
return NextResponse.json(
{ error: `Invalid role. Must be one of: ${validRoles.join(', ')}` },
{ status: 400 }
);
}
// Prevent self-demotion from superadmin
const currentUser = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (currentUser?.id === userId && newRole !== 'superadmin') {
return NextResponse.json(
{ error: 'Cannot demote yourself from superadmin' },
{ status: 400 }
);
}
// Update user role
const updatedUser = await prisma.user.update({
where: { id: userId },
data: { role: newRole },
select: {
id: true,
email: true,
name: true,
role: true,
},
});
return NextResponse.json({
message: 'User role updated successfully',
user: updatedUser,
});
} catch (err: any) {
console.error('update user role error:', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+141
View File
@@ -0,0 +1,141 @@
// app/api/admin/update-video/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
export async function PATCH(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { videoId, title } = await req.json();
if (!videoId) {
return NextResponse.json(
{ error: 'videoId required' },
{ status: 400 }
);
}
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return NextResponse.json(
{ error: 'title required and must be non-empty' },
{ status: 400 }
);
}
const updatedVideo = await prisma.video.update({
where: { id: videoId },
data: { title: title.trim() },
});
return NextResponse.json(updatedVideo, { status: 200 });
} catch (err: any) {
console.error('update video error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const formData = await req.formData().catch(() => null);
if (!formData) {
return NextResponse.json({ error: 'Invalid form data' }, { status: 400 });
}
const videoId = formData.get('videoId') as string;
const title = formData.get('title') as string;
const description = formData.get('description') as string | null;
const thumbnail = formData.get('thumbnail') as File | null;
const restricted = formData.get('restrictedCourseIds') as string | null;
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
// Build update data
const updateData: any = {};
if (title) updateData.title = title;
if (description !== null) updateData.description = description;
let restrictedCourseIds: string[] | null = null;
if (restricted) {
try {
const parsed = JSON.parse(restricted);
if (Array.isArray(parsed)) {
restrictedCourseIds = parsed.filter((id) => typeof id === 'string' && id);
}
} catch (err) {
console.warn('Invalid restrictedCourseIds payload', err);
}
}
// Handle thumbnail upload if provided
if (thumbnail && thumbnail.size > 0) {
const bytes = await thumbnail.arrayBuffer();
const buffer = Buffer.from(bytes);
// Save to public/thumbnails
const uploadDir = join(process.cwd(), 'public', 'thumbnails');
await mkdir(uploadDir, { recursive: true });
const filename = `${videoId}-${Date.now()}.${thumbnail.type.split('/')[1] || 'jpg'}`;
const filepath = join(uploadDir, filename);
await writeFile(filepath, buffer);
updateData.thumbnail = `/thumbnails/${filename}`;
}
if (Object.keys(updateData).length === 0 && !thumbnail && restrictedCourseIds === null) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
const updated = await prisma.video.update({
where: { id: videoId },
data: updateData,
});
if (restrictedCourseIds !== null) {
await prisma.videoCourse.deleteMany({ where: { videoId } });
if (restrictedCourseIds.length > 0) {
await prisma.videoCourse.createMany({
data: restrictedCourseIds.map((courseId) => ({
videoId,
courseId,
exclusive: true,
})),
skipDuplicates: true,
});
}
}
return NextResponse.json({ success: true, video: updated });
} catch (err: any) {
console.error('update video error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+150
View File
@@ -0,0 +1,150 @@
// app/api/admin/upload/finalize/route.ts
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { spawn } from "child_process";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
const UPLOADS_DIR = process.env.UPLOADS_DIR || "/uploads";
async function getVideoDurationFromFile(filePath: string): Promise<number | null> {
return new Promise((resolve) => {
try {
const ffprobe = spawn("ffprobe", [
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1:nokey=1",
filePath,
]);
let output = "";
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
ffprobe.kill();
resolve(null);
}, 30000);
ffprobe.stdout.on("data", (data) => {
output += data.toString();
});
ffprobe.on("close", (code) => {
clearTimeout(timeoutId);
if (!timedOut && code === 0) {
const duration = parseFloat(output.trim());
if (!isNaN(duration) && isFinite(duration) && duration > 0) {
resolve(Math.round(duration));
} else {
resolve(null);
}
} else {
resolve(null);
}
});
ffprobe.on("error", () => {
clearTimeout(timeoutId);
resolve(null);
});
} catch (err) {
resolve(null);
}
});
}
async function checkAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) throw { status: 401, message: "Unauthorized" };
const allowed = (process.env.ALLOWED_ADMINS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (allowed.length && !allowed.includes(session.user.email))
throw { status: 403, message: "Forbidden" };
return session;
}
export async function POST(req: Request) {
try {
console.log('[Upload/Finalize] Request received');
await checkAdmin();
const body = await req.json();
const { videoId } = body;
if (!videoId) {
return NextResponse.json({ error: "videoId required" }, { status: 400 });
}
console.log('[Upload/Finalize] Checking for video:', videoId);
// Check if video exists in DB
const video = await prisma.video.findUnique({
where: { id: videoId },
});
if (!video) {
return NextResponse.json({ error: "video not found" }, { status: 404 });
}
// Check if file exists at the expected path
const videoPath = path.join(UPLOADS_DIR, "videos", `${videoId}.mp4`);
console.log('[Upload/Finalize] Checking file at:', videoPath);
try {
await fs.promises.access(videoPath, fs.constants.F_OK);
} catch {
return NextResponse.json(
{ error: `File not found at ${videoPath}. Please copy the file there first.` },
{ status: 404 }
);
}
console.log('[Upload/Finalize] File found, extracting duration');
// Extract duration if not already set
let durationSec = video.durationSec;
if (!durationSec) {
try {
const extractedDuration = await getVideoDurationFromFile(videoPath);
if (extractedDuration !== null) {
durationSec = extractedDuration;
console.log('[Upload/Finalize] Extracted duration:', durationSec);
} else {
console.warn('[Upload/Finalize] Could not extract duration');
}
} catch (err) {
console.warn('[Upload/Finalize] Duration extraction error:', err);
}
}
// Update video: set URL, duration, and transcoding status to trigger processing
const videoUrl = `/uploads/videos/${videoId}.mp4`;
const updatedVideo = await prisma.video.update({
where: { id: videoId },
data: {
url: videoUrl,
transcodingStatus: 'uploaded', // Mark as ready for transcoding
...(durationSec !== null && { durationSec }),
},
});
console.log('[Upload/Finalize] Video updated successfully with URL:', videoUrl);
return NextResponse.json({
success: true,
video: updatedVideo,
message: `Video finalized${durationSec ? ` (${durationSec}s)` : ''}. HLS transcoding will start shortly.`,
}, { status: 200 });
} catch (err: any) {
console.error("[Upload/Finalize] Error:", err);
const status = err?.status ?? 500;
const message = err?.message ?? "server error";
return NextResponse.json({ error: message }, { status });
}
}
+394
View File
@@ -0,0 +1,394 @@
// app/api/admin/upload/route.ts
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { IncomingMessage } from "http";
import { spawn } from "child_process";
import { pipeline } from "stream/promises";
import formidable, { File as FormidableFile } from "formidable";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
// For 2GB uploads over Tailscale (~10-50 Mbps), need 30-45 minutes
export const maxDuration = 2700; // 45 minutes for very large uploads over slow connections
// Use ConfigurableUPLOADS_DIR from environment or default to /uploads
const UPLOADS_DIR = process.env.UPLOADS_DIR || "/uploads";
// Helper to extract video duration using FFprobe
async function getVideoDurationFromFile(filePath: string): Promise<number | null> {
return new Promise((resolve) => {
try {
const ffprobe = spawn("ffprobe", [
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1:nokey=1",
filePath,
]);
let output = "";
let timedOut = false;
// Large 2GB files may take longer to scan; timeout after 30s
const timeoutId = setTimeout(() => {
timedOut = true;
ffprobe.kill();
resolve(null); // Return null instead of blocking
}, 30000);
ffprobe.stdout.on("data", (data) => {
output += data.toString();
});
ffprobe.on("close", (code) => {
clearTimeout(timeoutId);
if (!timedOut && code === 0) {
const duration = parseFloat(output.trim());
if (!isNaN(duration) && isFinite(duration) && duration > 0) {
resolve(Math.round(duration));
} else {
resolve(null);
}
} else {
resolve(null);
}
});
ffprobe.on("error", () => {
clearTimeout(timeoutId);
resolve(null);
});
} catch (err) {
resolve(null);
}
});
}
function parseForm(req: IncomingMessage): Promise<{ fields: any; files: any }> {
const form = formidable({
multiples: false,
maxFileSize: 2.5 * 1024 * 1024 * 1024, // 2.5GB max file size
maxFieldsSize: 10 * 1024 * 1024, // 10MB for all fields combined
maxFields: 50,
keepExtensions: true,
});
return new Promise((resolve, reject) => {
form.parse(req, (err, fields, files) => {
if (err) {
console.error('[Upload] Formidable parse error:', err.code, err.message);
reject(err);
}
else resolve({ fields, files });
});
});
}
async function checkAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) throw { status: 401, message: "Unauthorized" };
const allowed = (process.env.ALLOWED_ADMINS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (allowed.length && !allowed.includes(session.user.email))
throw { status: 403, message: "Forbidden" };
return session;
}
export async function POST(req: Request) {
// track saved paths so we can cleanup on error
let savedVideoDest: string | undefined;
let savedThumbDest: string | undefined;
try {
console.log('[Upload] Request received, content-type:', req.headers.get("content-type"));
const nodeReq = (req as any).req ?? (globalThis as any).__NEXT_INIT?.req ?? null;
const session = await checkAdmin();
console.log('[Upload] Admin check passed for:', session.user?.email);
// helper to stream a File directly to disk (handles large files efficiently)
async function saveVideoStream(file: File, origName?: string) {
const filename = `${Date.now()}-${String(origName ?? "upload.mp4")}`;
const dest = path.join(UPLOADS_DIR, "videos", filename);
// Ensure directory exists
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
// Create write stream to destination
const writeStream = fs.createWriteStream(dest);
try {
// Stream the file directly to disk without buffering
// file.stream() returns a Web ReadableStream, convert to Node.js stream
const nodeStream = file.stream() as any;
await pipeline(nodeStream, writeStream);
// Ensure the file is readable by all processes (mode 644)
await fs.promises.chmod(dest, 0o644);
return { filename, dest };
} catch (err) {
// Clean up the partially written file if stream fails
try {
await fs.promises.unlink(dest);
} catch (_) {}
throw err;
}
}
// helper to save a thumbnail buffer to UPLOADS_DIR/thumbnails
async function saveThumbBuffer(buffer: Buffer, ext = "jpg") {
const filename = `${Date.now()}-thumb.${ext.replace(/^\./, "")}`;
const dest = path.join(UPLOADS_DIR, "thumbnails", filename);
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
await fs.promises.writeFile(dest, buffer);
// Ensure the file is readable by all processes (mode 644)
await fs.promises.chmod(dest, 0o644);
return { filename, dest };
}
let title: string | undefined;
let playlistId: string | undefined;
let durationSec: number | null = null;
let savedFilename: string | undefined;
// **IMPORTANT**: single thumbnailUrl used in both branches
let thumbnailUrl: string | null = null;
if (!nodeReq) {
// Request.formData() flow (some Next.js environments)
let formData: FormData | null = null;
try {
formData = await req.formData();
} catch (e: any) {
console.error("Failed to parse body as FormData.", e, "content-type=", req.headers.get("content-type"));
return NextResponse.json(
{ error: "Failed to parse body as FormData. Ensure request is sent with multipart/form-data." },
{ status: 400 }
);
}
const isManualCopy = formData.get("manualFileCopy") === "true";
const file = isManualCopy ? null : (formData.get("file") as Blob | null);
title = (formData.get("title") as string) || undefined;
playlistId = (formData.get("playlistId") as string) || undefined;
const durationField = formData.get("durationSec") as string | null;
if (durationField) {
const n = Number(durationField);
if (!isNaN(n)) durationSec = Math.round(n);
}
// thumbnail (optional)
const thumb = formData.get("thumbnail") as Blob | null;
if (thumb) {
try {
const thumbArrayBuffer = await thumb.arrayBuffer();
const thumbBuffer = Buffer.from(thumbArrayBuffer);
// try to infer extension from name, fallback to jpg
const fName = (thumb as any).name ?? "";
const extMatch = fName.match(/\.([a-z0-9]+)$/i);
const ext = extMatch ? extMatch[1] : "jpg";
const saved = await saveThumbBuffer(thumbBuffer, ext);
thumbnailUrl = `/api/thumbnails/${saved.filename}`;
savedThumbDest = saved.dest;
} catch (err) {
console.warn("thumbnail save failed (formData)", err);
// not fatal — we simply leave thumbnailUrl null
}
}
if (!isManualCopy) {
if (!file) return NextResponse.json({ error: "no file" }, { status: 400 });
// Stream the large file directly to disk without buffering
const origName = (file as any).name ?? `upload-${Date.now()}.mp4`;
const saved = await saveVideoStream(file as File, origName);
savedFilename = saved.filename;
savedVideoDest = saved.dest;
} else {
// Manual copy mode: don't save file, just mark for manual copy
console.log('[Upload] Manual file copy mode enabled');
savedFilename = undefined;
savedVideoDest = undefined;
}
} else {
// formidable flow (Node IncomingMessage available)
console.log('[Upload] Using formidable flow for file upload');
const { fields, files } = await parseForm(nodeReq as IncomingMessage);
console.log('[Upload] Formidable parsing complete, fields:', Object.keys(fields), 'files:', Object.keys(files));
const f: FormidableFile | undefined =
(files && (files.file as FormidableFile)) || (files && Object.values(files)[0]);
if (!f) return NextResponse.json({ error: "no file found" }, { status: 400 });
// handle thumbnail file if present in formidable files
const thumbFile = (files && (files.thumbnail as FormidableFile)) || undefined;
if (thumbFile) {
try {
const tPath = (thumbFile as any).filepath || (thumbFile as any).path;
const originalThumbName = (thumbFile as any).originalFilename || path.basename(tPath);
const tExt = path.extname(originalThumbName) || ".jpg";
const thumbFilename = `${Date.now()}-thumb${tExt}`;
const thumbDest = path.join(UPLOADS_DIR, "thumbnails", thumbFilename);
await fs.promises.mkdir(path.dirname(thumbDest), { recursive: true });
await fs.promises.copyFile(tPath, thumbDest);
// Ensure the file is readable by all processes (mode 644)
await fs.promises.chmod(thumbDest, 0o644);
thumbnailUrl = `/api/thumbnails/${thumbFilename}`;
savedThumbDest = thumbDest;
} catch (err) {
console.warn("thumbnail save failed (formidable)", err);
}
}
// copy video file to UPLOADS_DIR/videos
const filePath = (f as any).filepath || (f as any).path;
const originalFilename = (f as any).originalFilename || (f as any).name || path.basename(filePath);
const filename = `${Date.now()}-${originalFilename}`;
const dest = path.join(UPLOADS_DIR, "videos", filename);
console.log('[Upload] Copying video file', { size: (f as any).size, path: filePath, dest });
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
await fs.promises.copyFile(filePath, dest);
// Ensure the file is readable by all processes (mode 644)
await fs.promises.chmod(dest, 0o644);
console.log('[Upload] Video file copied successfully');
title = fields.title ?? originalFilename;
playlistId = fields.playlistId;
const durationField = fields.durationSec ?? fields.duration ?? null;
if (durationField) {
const n = Number(durationField);
if (!isNaN(n)) durationSec = Math.round(n);
}
savedFilename = filename;
savedVideoDest = dest;
}
// validate playlist: avoid FK errors
if (!playlistId) {
// cleanup if needed
if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
return NextResponse.json({ error: "playlistId required" }, { status: 400 });
}
const playlist = await prisma.playlist.findUnique({ where: { id: playlistId } });
if (!playlist) {
if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
return NextResponse.json({ error: "playlist not found" }, { status: 400 });
}
// Get current user
const user = await prisma.user.findUnique({
where: { email: session.user?.email ?? "" },
select: { id: true },
});
if (!user) {
if (savedVideoDest) await fs.promises.unlink(savedVideoDest).catch(() => {});
if (savedThumbDest) await fs.promises.unlink(savedThumbDest).catch(() => {});
return NextResponse.json({ error: "user not found" }, { status: 400 });
}
// If duration not provided by client, try to extract it from the saved video file
if (durationSec === null && savedVideoDest) {
try {
const extractedDuration = await getVideoDurationFromFile(savedVideoDest);
if (extractedDuration !== null) {
durationSec = extractedDuration;
console.log(`[Upload] Extracted duration: ${durationSec}s from ${savedVideoDest}`);
} else {
console.warn(`[Upload] Could not extract duration from ${savedVideoDest}`);
}
} catch (err) {
console.warn(`[Upload] Duration extraction error:`, err);
}
}
// For manual copy mode, check if we have a file
const isManualMode = !savedVideoDest;
// Calculate index: find max index in playlist and add 1
const maxIndexVideo = await prisma.video.findFirst({
where: { playlistId },
orderBy: { index: 'desc' },
select: { index: true },
});
const desiredIndex = (maxIndexVideo?.index ?? -1) + 1;
// First, create the video record to get its ID
const video = await prisma.video.create({
data: {
title: title ?? savedFilename ?? 'Untitled',
url: '', // Will be set below or after rename
thumbnail: thumbnailUrl,
index: desiredIndex,
playlistId,
userId: user.id,
transcodingStatus: isManualMode ? 'pending_manual_file' : 'uploaded',
...(durationSec !== null && { durationSec }),
},
});
// If manual copy mode, set the URL and return
if (isManualMode) {
const videoUrl = `/uploads/videos/${video.id}.mp4`;
await prisma.video.update({
where: { id: video.id },
data: { url: videoUrl },
});
console.log('[Upload] Manual copy mode: video created with ID', video.id, 'URL:', videoUrl);
return NextResponse.json({ video: { ...video, url: videoUrl } }, { status: 201 });
}
// Now rename the uploaded file to use the video ID
const finalFilename = `${video.id}.mp4`;
const finalDest = path.join(UPLOADS_DIR, "videos", finalFilename);
try {
if (savedVideoDest) {
await fs.promises.rename(savedVideoDest, finalDest);
}
// Update the URL in the database to reflect the final filename
await prisma.video.update({
where: { id: video.id },
data: { url: `/uploads/videos/${finalFilename}` },
});
} catch (err) {
console.error("Error renaming video file:", err);
// If rename fails, cleanup and delete the record
await prisma.video.delete({ where: { id: video.id } });
throw err;
}
return NextResponse.json({ video: { ...video, url: `/uploads/videos/${finalFilename}` } }, { status: 201 });
} catch (err: any) {
console.error("upload error", {
code: err?.code,
message: err?.message,
errno: err?.errno,
}, err);
// cleanup saved files if something failed
if (savedVideoDest) {
try {
await fs.promises.unlink(savedVideoDest);
} catch (_) {}
}
if (savedThumbDest) {
try {
await fs.promises.unlink(savedThumbDest);
} catch (_) {}
}
const status = err?.status ?? 500;
const message = err?.message ?? (err?.toString ? err.toString() : "server error");
return NextResponse.json({ error: message }, { status });
}
}
@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(
req: Request,
{ params }: { params: Promise<{ userId: string; videoId: string }> }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Check if user is admin
const admin = await prisma.user.findUnique({ where: { email: session.user.email } });
if (!admin || (admin.role !== 'admin' && admin.role !== 'superadmin'))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const { userId, videoId } = await params;
// Fetch all watch segments for the specified user+video
const segments = await prisma.videoWatchSegment.findMany({
where: { userId, videoId },
select: { startSec: true, endSec: true, watchedAt: true },
orderBy: { createdAt: 'asc' },
});
return NextResponse.json({ segments });
} catch (err: any) {
console.error('admin segments GET error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+150
View File
@@ -0,0 +1,150 @@
// app/api/admin/users/[userId]/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(
req: Request,
{ params }: { params: Promise<{ userId: string }> }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { userId } = await params;
// Fetch user with all related data
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
image: true,
role: true,
createdAt: true,
enrollments: {
select: {
course: {
select: {
id: true,
title: true,
},
},
},
},
progress: {
select: {
id: true,
videoId: true,
watchedSec: true,
lastPos: true,
percent: true,
completed: true,
durationSec: true,
updatedAt: true,
createdAt: true,
video: {
select: {
id: true,
title: true,
},
},
},
orderBy: {
updatedAt: 'desc',
},
},
comments: {
select: {
id: true,
content: true,
createdAt: true,
video: {
select: {
id: true,
title: true,
},
},
replies: {
select: {
id: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json(user);
} catch (err: any) {
console.error('fetch user detail error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
export async function DELETE(
req: Request,
{ params }: { params: Promise<{ userId: string }> }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { userId } = await params;
// Prevent deleting yourself
const currentUser = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (currentUser?.id === userId) {
return NextResponse.json(
{ error: 'Cannot delete your own account' },
{ status: 400 }
);
}
// Delete user with cascading deletes handled by Prisma schema
// The schema has onDelete: Cascade for most relations
const deletedUser = await prisma.user.delete({
where: { id: userId },
});
return NextResponse.json({
message: 'User deleted successfully',
deleted: deletedUser.email,
});
} catch (err: any) {
console.error('delete user error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+68
View File
@@ -0,0 +1,68 @@
// app/api/admin/users/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Fetch all users with enrollments and last activity
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
image: true,
role: true,
createdAt: true,
enrollments: {
select: {
course: {
select: {
id: true,
title: true,
},
},
},
},
progress: {
select: {
updatedAt: true,
},
orderBy: {
updatedAt: 'desc',
},
take: 1,
},
},
orderBy: {
createdAt: 'desc',
},
});
// Map to include last activity
const usersWithActivity = users.map((user) => ({
...user,
lastActivity: user.progress[0]?.updatedAt ?? null,
progress: undefined, // Remove the progress array
}));
return NextResponse.json(usersWithActivity);
} catch (err: any) {
console.error('fetch users error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,96 @@
// app/api/admin/videos/[videoId]/instant-access/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function PATCH(req: Request, context: any) {
try {
// Unwrap params
let params = context?.params;
if (typeof params?.then === 'function') params = await params;
const videoId = params?.videoId;
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Check admin role
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true, role: true },
});
if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json().catch(() => ({}));
const { instantAccess } = body ?? {};
if (typeof instantAccess !== 'boolean')
return NextResponse.json(
{ error: 'instantAccess boolean value required' },
{ status: 400 }
);
// Update the video
const video = await prisma.video.update({
where: { id: videoId },
data: { instantAccess },
select: { id: true, title: true, instantAccess: true, locked: true },
});
return NextResponse.json({ ok: true, video });
} catch (err: any) {
console.error('admin instant-access PATCH error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
export async function GET(req: Request, context: any) {
try {
// Unwrap params
let params = context?.params;
if (typeof params?.then === 'function') params = await params;
const videoId = params?.videoId;
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Check admin role
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true, role: true },
});
if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
// Get the video
const video = await prisma.video.findUnique({
where: { id: videoId },
select: { id: true, title: true, instantAccess: true, locked: true },
});
if (!video)
return NextResponse.json({ error: 'video not found' }, { status: 404 });
return NextResponse.json({ video });
} catch (err: any) {
console.error('admin instant-access GET error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,96 @@
// app/api/admin/videos/[videoId]/locked/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function PATCH(req: Request, context: any) {
try {
// Unwrap params
let params = context?.params;
if (typeof params?.then === 'function') params = await params;
const videoId = params?.videoId;
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Check admin role
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true, role: true },
});
if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const body = await req.json().catch(() => ({}));
const { locked } = body ?? {};
if (typeof locked !== 'boolean')
return NextResponse.json(
{ error: 'locked boolean value required' },
{ status: 400 }
);
// Update the video
const video = await prisma.video.update({
where: { id: videoId },
data: { locked },
select: { id: true, title: true, locked: true, instantAccess: true },
});
return NextResponse.json({ ok: true, video });
} catch (err: any) {
console.error('admin locked PATCH error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
export async function GET(req: Request, context: any) {
try {
// Unwrap params
let params = context?.params;
if (typeof params?.then === 'function') params = await params;
const videoId = params?.videoId;
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// Check admin role
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true, role: true },
});
if (!user || (user.role !== 'admin' && user.role !== 'superadmin'))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
// Get the video
const video = await prisma.video.findUnique({
where: { id: videoId },
select: { id: true, title: true, locked: true, instantAccess: true },
});
if (!video)
return NextResponse.json({ error: 'video not found' }, { status: 404 });
return NextResponse.json({ video });
} catch (err: any) {
console.error('admin locked GET error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+53
View File
@@ -0,0 +1,53 @@
// app/api/admin/videos/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const role = (session as any)?.user?.role ?? null;
if (!(role === 'admin' || role === 'superadmin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Fetch all videos with their playlist and course info
const videos = await prisma.video.findMany({
include: {
playlist: {
include: {
course: {
select: {
id: true,
title: true,
},
},
},
},
uploader: {
select: {
id: true,
name: true,
image: true,
},
},
videoCourses: {
include: { course: true },
},
},
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(videos);
} catch (err: any) {
console.error('get videos error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+7
View File
@@ -0,0 +1,7 @@
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth-options";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+57
View File
@@ -0,0 +1,57 @@
// app/api/auth/check-student.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
import { normalizeEmail } from '@/lib/normalize-email';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const email = body?.email;
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail) {
return NextResponse.json({ error: 'Email required' }, { status: 400 });
}
const allowedStudent = await prisma.allowedStudent.findFirst({
where: {
email: {
equals: normalizedEmail,
mode: 'insensitive',
},
},
});
if (!allowedStudent || !allowedStudent.active) {
return NextResponse.json(
{
allowed: false,
message: 'Email not registered for access'
},
{ status: 200 }
);
}
// Parse course levels
const levels = allowedStudent.levels
.split(',')
.map((level) => level.trim())
.filter((level) => level.length > 0);
return NextResponse.json(
{
allowed: true,
levels,
studentId: allowedStudent.id,
},
{ status: 200 }
);
} catch (err) {
console.error('POST /api/auth/check-student error', err);
return NextResponse.json(
{ error: 'Failed to check student status' },
{ status: 500 }
);
}
}
@@ -0,0 +1,62 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ commentId: string }> }
) {
const { commentId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { content } = await request.json();
if (!content || typeof content !== 'string' || content.trim().length === 0) {
return NextResponse.json(
{ error: 'Non-empty content is required' },
{ status: 400 }
);
}
// Verify comment exists
const comment = await prisma.comment.findUnique({
where: { id: commentId },
});
if (!comment) {
return NextResponse.json({ error: 'Comment not found' }, { status: 404 });
}
const reply = await prisma.commentReply.create({
data: {
commentId,
userId: (session.user as any).id,
content: content.trim(),
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true,
},
},
},
});
return NextResponse.json(reply, { status: 201 });
} catch (error) {
console.error('Failed to create reply:', error);
return NextResponse.json(
{ error: 'Failed to create reply' },
{ status: 500 }
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { NextRequest, NextResponse } from 'next/server';
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ commentId: string }> }
) {
const { commentId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session.user as any).role;
if (role !== 'admin' && role !== 'superadmin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
try {
// Delete the comment and its replies (cascade via Prisma)
const comment = await prisma.comment.delete({
where: { id: commentId },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete comment:', error);
return NextResponse.json(
{ error: 'Failed to delete comment' },
{ status: 500 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { NextRequest, NextResponse } from 'next/server';
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ replyId: string }> }
) {
const { replyId } = await params;
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = (session.user as any).role;
if (role !== 'admin' && role !== 'superadmin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
try {
const reply = await prisma.commentReply.delete({
where: { id: replyId },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete reply:', error);
return NextResponse.json(
{ error: 'Failed to delete reply' },
{ status: 500 }
);
}
}
+123
View File
@@ -0,0 +1,123 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const videoId = request.nextUrl.searchParams.get('videoId');
if (!videoId) {
return NextResponse.json(
{ error: 'videoId is required' },
{ status: 400 }
);
}
try {
const comments = await prisma.comment.findMany({
where: { videoId },
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true,
},
},
replies: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true,
},
},
},
orderBy: {
createdAt: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return NextResponse.json(comments);
} catch (error) {
console.error('Failed to fetch comments:', error);
return NextResponse.json(
{ error: 'Failed to fetch comments' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { videoId, content } = await request.json();
if (!videoId || !content || typeof content !== 'string' || content.trim().length === 0) {
return NextResponse.json(
{ error: 'videoId and non-empty content are required' },
{ status: 400 }
);
}
// Verify video exists
const video = await prisma.video.findUnique({
where: { id: videoId },
});
if (!video) {
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
}
const comment = await prisma.comment.create({
data: {
videoId,
userId: (session.user as any).id,
content: content.trim(),
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true,
},
},
replies: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
},
});
return NextResponse.json(comment, { status: 201 });
} catch (error) {
console.error('Failed to create comment:', error);
return NextResponse.json(
{ error: 'Failed to create comment' },
{ status: 500 }
);
}
}
+8
View File
@@ -0,0 +1,8 @@
// app/api/users/route.ts
import { NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
export async function GET() {
const courses = await prisma.course.findMany({ select: { id: true, title: true, code: true } });
return NextResponse.json(courses);
}
+94
View File
@@ -0,0 +1,94 @@
// /app/api/enrollments/route.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// --- GET --------------------------------------------------------------------
export async function GET() {
try {
const enrollments = await prisma.enrollment.findMany({
include: {
user: { select: { id: true, name: true, email: true } },
course: { select: { id: true, title: true, code: true, } },
},
orderBy: { createdAt: 'asc' },
});
return NextResponse.json(enrollments);
} catch (err) {
console.error('GET /api/enrollments error', err);
return NextResponse.json({ error: 'Failed to fetch enrollments' }, { status: 500 });
}
}
// --- POST --------------------------------------------------------------------
export async function POST(req: NextRequest) {
try {
const body = await req.json();
// incoming payload example:
// { userId: "cmicsu2030000uaec0sp56gso", courseId: "cmicrx1b70000uahwilu0gh9u", role: "student" }
const userId: string | undefined = body.userId;
const courseId: string | undefined = body.courseId;
// const role: string = body.role ?? 'student';
if (!userId || !courseId) {
return NextResponse.json({ error: 'Missing userId or courseId' }, { status: 400 });
}
// Optional: Prevent duplicate enrolments
const existing = await prisma.enrollment.findFirst({
where: { userId: userId, courseId: courseId },
});
if (existing) {
return NextResponse.json(
{ error: 'Enrollment already exists' },
{ status: 409 }
);
}
// Create enrollment using STRING IDs
const enrollment = await prisma.enrollment.create({
data: {
user: { connect: { id: userId } },
course: { connect: { id: courseId } },
// role: role,
},
include: {
user: { select: { id: true, name: true, email: true } },
course: { select: { id: true, title: true } },
},
});
return NextResponse.json(enrollment, { status: 201 });
} catch (err) {
console.error('POST /api/enrollments error', err);
return NextResponse.json({ error: 'Failed to create enrollment' }, { status: 500 });
}
}
// --- DELETE --------------------------------------------------------------------
export async function DELETE(req: NextRequest) {
try {
const body = await req.json();
const id = body?.id;
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
await prisma.enrollment.delete({
where: { id: id }, // STRING — not Number(id)
});
return NextResponse.json({ success: true });
} catch (err) {
console.error('DELETE /api/enrollments error', err);
return NextResponse.json({ error: 'Failed to delete enrollment' }, { status: 500 });
}
}
+122
View File
@@ -0,0 +1,122 @@
// app/api/enrollments/sync.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function POST(req: NextRequest) {
console.log('🔥 [SYNC-ENROLLMENTS] POST endpoint called');
console.log('🔥 [SYNC-ENROLLMENTS] Request method:', req.method);
console.log('🔥 [SYNC-ENROLLMENTS] Request URL:', req.url);
try {
const session = await getServerSession(authOptions);
console.log('🔥 [SYNC-ENROLLMENTS] Session check:', !!session?.user?.email);
console.log('🔥 [SYNC-ENROLLMENTS] Session user:', session?.user);
if (!session?.user?.email) {
console.log('🔥 [SYNC-ENROLLMENTS] No session or email, returning 401');
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userEmail = session.user.email;
console.log('🔥 [SYNC-ENROLLMENTS] About to parse request body...');
const body = await req.json();
console.log('🔥 [SYNC-ENROLLMENTS] Raw body received:', JSON.stringify(body, null, 2));
const levels: string[] = body?.levels || [];
console.log('🔥 [SYNC-ENROLLMENTS] Extracted levels:', levels);
console.log('🔥 [SYNC-ENROLLMENTS] Levels type check - isArray:', Array.isArray(levels));
console.log('🔥 [SYNC-ENROLLMENTS] Levels length:', levels.length);
console.log('🔥 [SYNC-ENROLLMENTS] User:', userEmail);
console.log('🔥 [SYNC-ENROLLMENTS] Received levels:', levels);
if (!Array.isArray(levels) || levels.length === 0) {
console.log('🔥 [SYNC-ENROLLMENTS] VALIDATION FAILED!');
console.log('🔥 [SYNC-ENROLLMENTS] levels isArray:', Array.isArray(levels));
console.log('🔥 [SYNC-ENROLLMENTS] levels length:', levels?.length);
console.log('🔥 [SYNC-ENROLLMENTS] levels value:', levels);
console.log('🔥 [SYNC-ENROLLMENTS] Returning 400 - Levels array required');
return NextResponse.json(
{ error: 'Levels array required' },
{ status: 400 }
);
}
// Get user
const user = await prisma.user.findUnique({
where: { email: userEmail },
});
if (!user) {
console.log('[SYNC-ENROLLMENTS] User not found in database:', userEmail);
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
console.log('[SYNC-ENROLLMENTS] Found user:', user.id, user.email);
const createdEnrollments = [];
// For each course level, find the course and create enrollment
for (const level of levels) {
try {
console.log('[SYNC-ENROLLMENTS] Looking for course with code:', level);
const course = await prisma.course.findUnique({
where: { code: level },
});
if (!course) {
console.warn(`[SYNC-ENROLLMENTS] Course code not found: ${level}`);
continue;
}
console.log('[SYNC-ENROLLMENTS] Found course:', course.id, course.code, course.title);
// Check if enrollment already exists
const existing = await prisma.enrollment.findFirst({
where: { userId: user.id, courseId: course.id },
});
if (existing) {
console.log('[SYNC-ENROLLMENTS] Enrollment already exists:', existing.id);
} else {
const enrollment = await prisma.enrollment.create({
data: {
userId: user.id,
courseId: course.id,
},
});
console.log('[SYNC-ENROLLMENTS] Created new enrollment:', enrollment.id);
createdEnrollments.push({
courseCode: level,
courseId: course.id,
enrollmentId: enrollment.id,
});
}
} catch (err) {
console.error(`[SYNC-ENROLLMENTS] Failed to create enrollment for level ${level}:`, err);
}
}
console.log('[SYNC-ENROLLMENTS] Summary - Created enrollments:', createdEnrollments.length);
console.log('[SYNC-ENROLLMENTS] Details:', createdEnrollments);
return NextResponse.json(
{
message: 'Enrollments synced',
created: createdEnrollments,
},
{ status: 200 }
);
} catch (err) {
console.error('🔥 [SYNC-ENROLLMENTS] FATAL ERROR:', err);
console.error('🔥 [SYNC-ENROLLMENTS] Error stack:', err instanceof Error ? err.stack : 'No stack');
return NextResponse.json(
{ error: 'Failed to sync enrollments' },
{ status: 500 }
);
}
}
+122
View File
@@ -0,0 +1,122 @@
// app/api/enrollments/sync/route.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function POST(req: NextRequest) {
console.log('🔥 [SYNC-ENROLLMENTS] POST endpoint called');
console.log('🔥 [SYNC-ENROLLMENTS] Request method:', req.method);
console.log('🔥 [SYNC-ENROLLMENTS] Request URL:', req.url);
try {
const session = await getServerSession(authOptions);
console.log('🔥 [SYNC-ENROLLMENTS] Session check:', !!session?.user?.email);
console.log('🔥 [SYNC-ENROLLMENTS] Session user:', session?.user);
if (!session?.user?.email) {
console.log('🔥 [SYNC-ENROLLMENTS] No session or email, returning 401');
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userEmail = session.user.email;
console.log('🔥 [SYNC-ENROLLMENTS] About to parse request body...');
const body = await req.json();
console.log('🔥 [SYNC-ENROLLMENTS] Raw body received:', JSON.stringify(body, null, 2));
const levels: string[] = body?.levels || [];
console.log('🔥 [SYNC-ENROLLMENTS] Extracted levels:', levels);
console.log('🔥 [SYNC-ENROLLMENTS] Levels type check - isArray:', Array.isArray(levels));
console.log('🔥 [SYNC-ENROLLMENTS] Levels length:', levels.length);
console.log('🔥 [SYNC-ENROLLMENTS] User:', userEmail);
console.log('🔥 [SYNC-ENROLLMENTS] Received levels:', levels);
if (!Array.isArray(levels) || levels.length === 0) {
console.log('🔥 [SYNC-ENROLLMENTS] VALIDATION FAILED!');
console.log('🔥 [SYNC-ENROLLMENTS] levels isArray:', Array.isArray(levels));
console.log('🔥 [SYNC-ENROLLMENTS] levels length:', levels?.length);
console.log('🔥 [SYNC-ENROLLMENTS] levels value:', levels);
console.log('🔥 [SYNC-ENROLLMENTS] Returning 400 - Levels array required');
return NextResponse.json(
{ error: 'Levels array required' },
{ status: 400 }
);
}
// Get user
const user = await prisma.user.findUnique({
where: { email: userEmail },
});
if (!user) {
console.log('[SYNC-ENROLLMENTS] User not found in database:', userEmail);
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
console.log('[SYNC-ENROLLMENTS] Found user:', user.id, user.email);
const createdEnrollments = [];
// For each course level, find the course and create enrollment
for (const level of levels) {
try {
console.log('[SYNC-ENROLLMENTS] Looking for course with code:', level);
const course = await prisma.course.findUnique({
where: { code: level },
});
if (!course) {
console.warn(`[SYNC-ENROLLMENTS] Course code not found: ${level}`);
continue;
}
console.log('[SYNC-ENROLLMENTS] Found course:', course.id, course.code, course.title);
// Check if enrollment already exists
const existing = await prisma.enrollment.findFirst({
where: { userId: user.id, courseId: course.id },
});
if (existing) {
console.log('[SYNC-ENROLLMENTS] Enrollment already exists:', existing.id);
} else {
const enrollment = await prisma.enrollment.create({
data: {
userId: user.id,
courseId: course.id,
},
});
console.log('[SYNC-ENROLLMENTS] Created new enrollment:', enrollment.id);
createdEnrollments.push({
courseCode: level,
courseId: course.id,
enrollmentId: enrollment.id,
});
}
} catch (err) {
console.error(`[SYNC-ENROLLMENTS] Failed to create enrollment for level ${level}:`, err);
}
}
console.log('[SYNC-ENROLLMENTS] Summary - Created enrollments:', createdEnrollments.length);
console.log('[SYNC-ENROLLMENTS] Details:', createdEnrollments);
return NextResponse.json(
{
message: 'Enrollments synced',
created: createdEnrollments,
},
{ status: 200 }
);
} catch (err) {
console.error('🔥 [SYNC-ENROLLMENTS] FATAL ERROR:', err);
console.error('🔥 [SYNC-ENROLLMENTS] Error stack:', err instanceof Error ? err.stack : 'No stack');
return NextResponse.json(
{ error: 'Failed to sync enrollments' },
{ status: 500 }
);
}
}
+53
View File
@@ -0,0 +1,53 @@
// app/api/likes/all/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const user = await prisma.user.findUnique({ where: { email: session.user.email } });
if (!user)
return NextResponse.json({ error: 'user not found' }, { status: 404 });
// Fetch all videos liked by user, sorted by most recent
const likes = await prisma.videoLike.findMany({
where: { userId: user.id },
include: {
video: {
select: {
id: true,
title: true,
thumbnail: true,
durationSec: true,
url: true,
playlist: {
select: {
title: true,
course: {
select: {
id: true,
title: true,
},
},
},
},
},
},
},
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(likes);
} catch (err: any) {
console.error('get liked videos error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+88
View File
@@ -0,0 +1,88 @@
// app/api/likes/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json().catch(() => ({}));
const { videoId, isLiked } = body ?? {};
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
const user = await prisma.user.findUnique({ where: { email: session.user.email } });
if (!user)
return NextResponse.json({ error: 'user not found' }, { status: 404 });
if (isLiked) {
// Add like
const existingLike = await prisma.videoLike.findUnique({
where: { userId_videoId: { userId: user.id, videoId } },
});
if (existingLike) {
return NextResponse.json({ success: true, message: 'Already liked' });
}
await prisma.videoLike.create({
data: {
userId: user.id,
videoId,
},
});
return NextResponse.json({ success: true, message: 'Video liked' });
} else {
// Remove like
await prisma.videoLike.deleteMany({
where: { userId: user.id, videoId },
});
return NextResponse.json({ success: true, message: 'Video unliked' });
}
} catch (err: any) {
console.error('like toggle error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
const videoId = searchParams.get('videoId');
const user = await prisma.user.findUnique({ where: { email: session.user.email } });
if (!user)
return NextResponse.json({ error: 'user not found' }, { status: 404 });
if (videoId) {
// Check if a specific video is liked
const like = await prisma.videoLike.findUnique({
where: { userId_videoId: { userId: user.id, videoId } },
});
return NextResponse.json({ isLiked: !!like });
} else {
// Get all liked videos for user
return NextResponse.json({ error: 'videoId required for check' }, { status: 400 });
}
} catch (err: any) {
console.error('like check error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+59
View File
@@ -0,0 +1,59 @@
// app/api/playlists/[id]/route.ts
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "../../../../lib/auth-options";
export async function GET(req: Request, context: any) {
try {
// Unwrap params (Next may provide a Promise)
let params = context?.params;
if (typeof params?.then === "function") params = await params;
const id = params?.id;
if (!id) return NextResponse.json({ error: "Missing playlist id" }, { status: 400 });
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const playlist = await prisma.playlist.findUnique({
where: { id },
include: {
videos: {
include: {
uploader: {
select: {
id: true,
name: true,
image: true,
},
},
},
orderBy: { index: "asc" },
},
course: true,
},
});
if (!playlist) return NextResponse.json({ error: "Not found" }, { status: 404 });
const enrolled = await prisma.enrollment.findUnique({
where: { userId_courseId: { userId: user.id, courseId: playlist.courseId } },
});
if (!enrolled) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
return NextResponse.json({ playlist });
} catch (err: any) {
console.error("GET /api/playlists/[id] error:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
+133
View File
@@ -0,0 +1,133 @@
// app/api/playlists/route.ts
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { prisma } from "@/lib/prisma";
type VideoWithCourses = {
videoCourses?: Array<{ courseId: string; exclusive: boolean }>
};
function mapRestrictedCourseIds(video: VideoWithCourses) {
if (!video?.videoCourses?.length) return [];
return video.videoCourses
.filter((assignment) => assignment.exclusive)
.map((assignment) => assignment.courseId);
}
function filterRestrictedVideos(videos: any[], userCourseIds: string[]) {
return videos
.filter((video) => {
const restrictedCourseIds = mapRestrictedCourseIds(video);
if (restrictedCourseIds.length === 0) return true;
return restrictedCourseIds.some((courseId: string) =>
userCourseIds.includes(courseId)
);
})
.map((video) => ({
...video,
restrictedCourseIds: mapRestrictedCourseIds(video),
}));
}
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: {
enrollments: {
include: { course: true },
},
},
});
const courseIds = user?.enrollments.map((e) => e.courseId) ?? [];
// Get playlists directly assigned to enrolled courses
const coursesWithPlaylists = await prisma.course.findMany({
where: { id: { in: courseIds } },
include: {
playlists: {
include: {
videos: {
include: {
uploader: {
select: {
id: true,
name: true,
image: true,
},
},
videoCourses: true,
},
orderBy: { index: "asc" },
},
courses: {
include: { course: true },
},
},
orderBy: { sortOrder: "asc" },
},
},
orderBy: { title: "asc" },
});
// Also get playlists assigned to courses via CoursePlaylist mapping
const additionalPlaylists = await prisma.coursePlaylist.findMany({
where: { courseId: { in: courseIds } },
include: {
playlist: {
include: {
videos: {
include: {
uploader: {
select: {
id: true,
name: true,
image: true,
},
},
videoCourses: true,
},
orderBy: { index: "asc" },
},
courses: {
include: { course: true },
},
},
},
course: true,
},
});
// Merge results: add additional playlists to their respective courses
const playlistMap = new Map();
additionalPlaylists.forEach(({ course, playlist }) => {
if (!playlistMap.has(course.id)) {
playlistMap.set(course.id, []);
}
playlist.videos = filterRestrictedVideos(playlist.videos, courseIds);
playlistMap.get(course.id).push(playlist);
});
coursesWithPlaylists.forEach((course) => {
course.playlists.forEach((playlist) => {
playlist.videos = filterRestrictedVideos(playlist.videos, courseIds);
});
const additional = playlistMap.get(course.id) || [];
const existingIds = new Set(course.playlists.map((p) => p.id));
const newPlaylists = additional.filter((p: any) => !existingIds.has(p.id));
newPlaylists.forEach((playlist: any) => {
playlist.videos = filterRestrictedVideos(playlist.videos, courseIds);
});
course.playlists.push(...newPlaylists);
course.playlists.sort((a, b) => a.sortOrder - b.sortOrder);
});
return NextResponse.json({ subjects: coursesWithPlaylists });
}
+225
View File
@@ -0,0 +1,225 @@
// app/api/progress/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const userEmail = session.user.email;
const { searchParams } = new URL(req.url);
const videoId = searchParams.get('videoId');
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
const user = await prisma.user.findUnique({ where: { email: userEmail } });
if (!user)
return NextResponse.json({ error: 'user not found' }, { status: 404 });
// Validate that the video exists
const video = await prisma.video.findUnique({ where: { id: videoId } });
if (!video) {
console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`);
return NextResponse.json({ error: 'video not found' }, { status: 404 });
}
const progress = await prisma.videoProgress.findUnique({
where: { userId_videoId: { userId: user.id, videoId } },
select: { percent: true, lastPos: true, watchedSec: true },
});
return NextResponse.json({
percent: progress?.percent ?? 0,
lastPos: progress?.lastPos ?? 0,
watchedSec: progress?.watchedSec ?? 0,
});
} catch (err: any) {
console.error('progress GET error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
// Helper function to calculate unique watched seconds from segments
function calculateWatchedSeconds(segments: Array<{ startSec: number; endSec: number }>): number {
if (segments.length === 0) return 0;
// Sort and merge overlapping ranges
const sorted = segments.sort((a, b) => a.startSec - b.startSec);
const merged: Array<[number, number]> = [];
for (const seg of sorted) {
if (merged.length === 0) {
merged.push([seg.startSec, seg.endSec]);
} else {
const last = merged[merged.length - 1];
if (seg.startSec <= last[1] + 0.5) {
// Overlapping or adjacent, merge
last[1] = Math.max(last[1], seg.endSec);
} else {
// Gap, new range
merged.push([seg.startSec, seg.endSec]);
}
}
}
let total = 0;
for (const [start, end] of merged) {
total += Math.max(0, end - start);
}
return Math.round(total);
}
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const userEmail = session.user.email;
const body = await req.json().catch(() => ({}));
const { videoId, playlistId, watchedSec, lastPos, duration } = body ?? {};
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
if (!duration || Number(duration) <= 0)
return NextResponse.json({ error: 'duration required' }, { status: 400 });
const watched = Number(watchedSec ?? 0);
const lastPosition = Number(lastPos ?? 0);
const dur = Number(duration);
// look up user id
const user = await prisma.user.findUnique({ where: { email: userEmail } });
if (!user)
return NextResponse.json({ error: 'user not found' }, { status: 404 });
// Validate that the video exists
const video = await prisma.video.findUnique({ where: { id: videoId } });
if (!video) {
// Log at debug level since this is expected when users have old cached references
console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`);
return NextResponse.json({ error: 'video not found' }, { status: 404 });
}
// Ensure VideoProgress exists or create it
let videoProgress = await prisma.videoProgress.findUnique({
where: { userId_videoId: { userId: user.id, videoId } },
});
if (!videoProgress) {
videoProgress = await prisma.videoProgress.create({
data: {
userId: user.id,
videoId,
watchedSec: 0,
lastPos: 0,
percent: 0,
durationSec: dur,
},
});
}
// Record the watch segment if watched seconds > 0
let newSegment = null;
if (watched > 0) {
newSegment = await prisma.videoWatchSegment.create({
data: {
userId: user.id,
videoId,
startSec: Math.max(0, lastPosition - watched),
endSec: lastPosition,
},
});
}
// Fetch all segments for this user+video to recalculate totals
const allSegments = await prisma.videoWatchSegment.findMany({
where: { userId: user.id, videoId },
select: { startSec: true, endSec: true },
orderBy: { createdAt: 'asc' },
});
// Calculate total unique watched seconds
const totalWatchedSec = calculateWatchedSeconds(allSegments);
// Calculate completion percentage
const ratio = dur > 0 ? totalWatchedSec / dur : 0;
const percentInt = Math.min(100, Math.round(ratio * 100));
const completed = percentInt >= 80; // Changed from 90 to 80 for unlock threshold
// Update VideoProgress with recalculated values
const upserted = await prisma.videoProgress.update({
where: { userId_videoId: { userId: user.id, videoId } },
data: {
watchedSec: totalWatchedSec,
lastPos: Math.max(videoProgress.lastPos ?? 0, lastPosition),
percent: percentInt,
durationSec: dur,
completed,
updatedAt: new Date(),
},
});
// If 80% watched, unlock next video in playlist for this user
let unlockedNext = null;
if (completed && playlistId) {
// find current video
const current = await prisma.video.findUnique({ where: { id: videoId } });
if (current && current.playlistId === playlistId) {
// Find next video that is NOT instant access and NOT globally locked
// Skip any instant access videos in the sequence
const nextVideos = await prisma.video.findMany({
where: {
playlistId,
index: { gt: current.index },
locked: false,
instantAccess: false,
},
orderBy: { index: 'asc' },
take: 1,
});
if (nextVideos.length > 0) {
const next = nextVideos[0];
// Create a VideoUnlock record for this user (per-user unlock tracking)
const unlock = await prisma.videoUnlock.upsert({
where: { userId_videoId: { userId: user.id, videoId: next.id } },
update: {}, // if already exists, do nothing
create: { userId: user.id, videoId: next.id },
});
unlockedNext = { id: next.id, title: next.title };
}
}
}
return NextResponse.json({ ok: true, progress: upserted, unlockedNext, segment: newSegment });
} catch (err: any) {
console.error('progress error', err);
// Handle foreign key constraint violations
if (err.code === 'P2003') {
const constraint = err.meta?.constraint_name;
if (constraint?.includes('videoId')) {
console.error(`[PROGRESS] Foreign key violation - invalid videoId: ${err.meta}`);
return NextResponse.json(
{ error: 'Invalid video reference' },
{ status: 400 }
);
}
}
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const userEmail = session.user.email;
const { searchParams } = new URL(req.url);
const videoId = searchParams.get('videoId');
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
const user = await prisma.user.findUnique({ where: { email: userEmail } });
if (!user)
return NextResponse.json({ error: 'user not found' }, { status: 404 });
// Validate that the video exists
const video = await prisma.video.findUnique({ where: { id: videoId } });
if (!video) {
console.debug(`[PROGRESS] Video not found for segments: ${videoId} (likely deleted video with cached browser reference)`);
return NextResponse.json({ error: 'video not found' }, { status: 404 });
}
// Fetch all watch segments for this user+video
const segments = await prisma.videoWatchSegment.findMany({
where: { userId: user.id, videoId },
select: { startSec: true, endSec: true, watchedAt: true },
orderBy: { createdAt: 'asc' },
});
return NextResponse.json({ segments });
} catch (err: any) {
console.error('segments GET error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+117
View File
@@ -0,0 +1,117 @@
// /api/thumbnails/[...path]/route.ts
// This endpoint serves thumbnails from the thumbnails directory
import { NextResponse } from 'next/server';
import * as fs from 'fs';
import * as path from 'path';
// Path to thumbnails directory (should match UPLOADS_DIR/thumbnails)
const THUMBNAILS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'thumbnails');
export async function GET(
request: Request,
context: { params: Promise<{ path?: string[] }> }
) {
try {
const params = await context.params;
const pathSegments = params?.path || [];
if (pathSegments.length === 0) {
return NextResponse.json(
{ error: 'Invalid thumbnail request' },
{ status: 400 }
);
}
// Construct the file path and validate it
const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
// Security: prevent directory traversal attacks
if (!requestedPath.startsWith(THUMBNAILS_DIR)) {
return NextResponse.json(
{ error: 'Invalid request' },
{ status: 403 }
);
}
// Check if file exists
if (!fs.existsSync(requestedPath)) {
console.log(`[Thumbnails] File not found: ${requestedPath}`);
return NextResponse.json(
{ error: 'Not found' },
{ status: 404 }
);
}
// Read the file
const fileContent = fs.readFileSync(requestedPath);
// Determine content type based on file extension
let contentType = 'application/octet-stream';
if (requestedPath.endsWith('.jpg') || requestedPath.endsWith('.jpeg')) {
contentType = 'image/jpeg';
} else if (requestedPath.endsWith('.png')) {
contentType = 'image/png';
} else if (requestedPath.endsWith('.webp')) {
contentType = 'image/webp';
} else if (requestedPath.endsWith('.gif')) {
contentType = 'image/gif';
}
// Return the file with appropriate headers
return new NextResponse(fileContent, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year (thumbnails don't change)
'Access-Control-Allow-Origin': '*', // Allow CORS if needed
},
});
} catch (error) {
console.error('[Thumbnails] Error serving thumbnail file:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// HEAD request support for thumbnail validation
export async function HEAD(
request: Request,
context: { params: Promise<{ path?: string[] }> }
) {
try {
const params = await context.params;
const pathSegments = params?.path || [];
if (pathSegments.length === 0) {
return new NextResponse(null, { status: 400 });
}
const requestedPath = path.join(THUMBNAILS_DIR, ...pathSegments);
// Security: prevent directory traversal attacks
if (!requestedPath.startsWith(THUMBNAILS_DIR)) {
return new NextResponse(null, { status: 403 });
}
// Check if file exists
if (!fs.existsSync(requestedPath)) {
return new NextResponse(null, { status: 404 });
}
// Return headers only
return new NextResponse(null, {
status: 200,
headers: {
'Content-Type': 'image/jpeg', // Default for HEAD requests
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch (error) {
console.error('[Thumbnails] Error in HEAD request:', error);
return new NextResponse(null, { status: 500 });
}
}
+60
View File
@@ -0,0 +1,60 @@
// app/api/transcoder/claim/route.ts
// Atomically claims the next available transcoding job.
// Uses SELECT … FOR UPDATE SKIP LOCKED so multiple workers never race on the
// same video.
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@prisma/client";
import { verifyTranscoderToken } from "@/lib/transcoder-auth";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!verifyTranscoderToken(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const video = await prisma.$transaction(async (tx) => {
// Atomically lock the oldest uploaded video, skipping rows already
// locked by concurrent workers.
const rows = await tx.$queryRaw<{ id: string }[]>(
Prisma.sql`
SELECT id
FROM "Video"
WHERE "transcodingStatus" = 'uploaded'
ORDER BY "createdAt" ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`
);
if (rows.length === 0) return null;
return tx.video.update({
where: { id: rows[0].id },
data: { transcodingStatus: "processing" },
});
});
if (!video) {
// No work available return null body so the worker knows to stop.
return NextResponse.json(null, { status: 200 });
}
// Build the download URL from the public CMS base URL.
const cmsBase =
process.env.NEXTAUTH_URL?.replace(/\/$/, "") ??
process.env.CMS_PUBLIC_URL?.replace(/\/$/, "") ??
"";
return NextResponse.json({
videoId: video.id,
downloadUrl: `${cmsBase}/api/transcoder/download/${video.id}`,
});
} catch (err) {
console.error("[Transcoder Claim] Error:", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
@@ -0,0 +1,72 @@
// app/api/transcoder/download/[videoId]/route.ts
// Streams the original MP4 directly to the remote transcoder worker.
// Never buffers the file in memory.
import { NextResponse } from "next/server";
import * as fssync from "fs";
import * as fs from "fs/promises";
import * as path from "path";
import { Readable } from "stream";
import { prisma } from "@/lib/prisma";
import { verifyTranscoderToken } from "@/lib/transcoder-auth";
// Allow up to 45 minutes for large file transfers.
export const maxDuration = 2700;
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? "/uploads";
const ORIGINALS_DIR = path.join(UPLOADS_DIR, "videos");
// Narrow character set CUIDs are alphanumeric plus underscore/dash.
const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/;
export async function GET(
request: Request,
context: { params: Promise<{ videoId: string }> }
) {
if (!verifyTranscoderToken(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { videoId } = await context.params;
if (!SAFE_ID.test(videoId)) {
return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
}
const video = await prisma.video.findUnique({ where: { id: videoId } });
if (!video) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Only allow download while the job is actively claimed.
if (video.transcodingStatus !== "processing") {
return NextResponse.json(
{ error: "Video is not in processing state" },
{ status: 409 }
);
}
const filePath = path.join(ORIGINALS_DIR, `${videoId}.mp4`);
// Security: ensure the resolved path stays within ORIGINALS_DIR.
const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(ORIGINALS_DIR))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const stat = await fs.stat(resolved);
const nodeStream = fssync.createReadStream(resolved);
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
return new Response(webStream, {
headers: {
"Content-Type": "video/mp4",
"Content-Length": stat.size.toString(),
"Content-Disposition": `attachment; filename="${videoId}.mp4"`,
},
});
} catch {
return NextResponse.json({ error: "File not found on disk" }, { status: 404 });
}
}
@@ -0,0 +1,54 @@
// app/api/transcoder/fail/[videoId]/route.ts
// Marks a video job as failed. Called by the remote worker when transcoding
// or upload encounters an unrecoverable error.
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verifyTranscoderToken } from "@/lib/transcoder-auth";
const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/;
export async function POST(
request: Request,
context: { params: Promise<{ videoId: string }> }
) {
if (!verifyTranscoderToken(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { videoId } = await context.params;
if (!SAFE_ID.test(videoId)) {
return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
}
const video = await prisma.video.findUnique({ where: { id: videoId } });
if (!video) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
try {
await prisma.video.update({
where: { id: videoId },
data: { transcodingStatus: "failed" },
});
// Log the error message from the worker if provided.
let workerError = "";
try {
const body = await request.json();
workerError = typeof body?.error === "string" ? body.error : "";
} catch {
// Body may be empty that's fine.
}
console.error(
`[Transcoder Fail] ${videoId}${workerError ? ` ${workerError}` : ""}`
);
return NextResponse.json({ success: true });
} catch (err) {
console.error(`[Transcoder Fail] DB error for ${videoId}:`, err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
@@ -0,0 +1,140 @@
// app/api/transcoder/upload/[videoId]/route.ts
// Receives a ZIP archive of the completed HLS package from the remote worker,
// extracts it to disk, validates it, renames the temp dir to its final name,
// and marks the video as transcoded.
//
// The request body must be raw application/zip (no multipart wrapper).
// The file is streamed to disk before extraction never fully buffered in RAM.
import { NextResponse } from "next/server";
import * as fssync from "fs";
import * as fs from "fs/promises";
import * as path from "path";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import * as unzipper from "unzipper";
import { prisma } from "@/lib/prisma";
import { verifyTranscoderToken } from "@/lib/transcoder-auth";
// Allow up to 45 minutes for very large uploads.
export const maxDuration = 2700;
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? "/uploads";
const HLS_ROOT = path.join(UPLOADS_DIR, "hls");
const SAFE_ID = /^[a-zA-Z0-9_-]{1,64}$/;
export async function POST(
request: Request,
context: { params: Promise<{ videoId: string }> }
) {
if (!verifyTranscoderToken(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { videoId } = await context.params;
if (!SAFE_ID.test(videoId)) {
return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
}
const video = await prisma.video.findUnique({ where: { id: videoId } });
if (!video) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (video.transcodingStatus !== "processing") {
return NextResponse.json(
{ error: "Video is not in processing state" },
{ status: 409 }
);
}
await fs.mkdir(HLS_ROOT, { recursive: true });
const tempZipPath = path.join(HLS_ROOT, `${videoId}.incoming.zip`);
const tempDir = path.join(HLS_ROOT, `${videoId}.tmp`);
const finalDir = path.join(HLS_ROOT, videoId);
// Security: path traversal guard.
for (const p of [tempZipPath, tempDir, finalDir]) {
if (!path.resolve(p).startsWith(path.resolve(HLS_ROOT))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
}
try {
if (!request.body) {
return NextResponse.json({ error: "Empty request body" }, { status: 400 });
}
// 1. Stream upload body to a temp zip file on disk.
const nodeReadable = Readable.fromWeb(
request.body as ReadableStream<Uint8Array>
);
const writeStream = fssync.createWriteStream(tempZipPath);
await pipeline(nodeReadable, writeStream);
// 2. Prepare extraction directory.
if (fssync.existsSync(tempDir)) {
await fs.rm(tempDir, { recursive: true, force: true });
}
await fs.mkdir(tempDir, { recursive: true });
// 3. Stream-extract the zip.
await fssync
.createReadStream(tempZipPath)
.pipe(unzipper.Extract({ path: tempDir }))
.promise();
// 4. Remove temp zip.
await fs.unlink(tempZipPath).catch(() => {});
// 5. Validate contents.
const masterPath = path.join(tempDir, "master.m3u8");
if (!fssync.existsSync(masterPath)) {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
return NextResponse.json(
{ error: "master.m3u8 not found in archive" },
{ status: 422 }
);
}
const files = await fs.readdir(tempDir);
const hasVariant = files.some(
(f) => f.endsWith(".m3u8") && f !== "master.m3u8"
);
if (!hasVariant) {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
return NextResponse.json(
{ error: "No variant playlist found in archive" },
{ status: 422 }
);
}
// 6. Remove any stale final directory and atomically rename.
if (fssync.existsSync(finalDir)) {
await fs.rm(finalDir, { recursive: true, force: true });
}
await fs.rename(tempDir, finalDir);
// 7. Mark as transcoded in the database.
await prisma.video.update({
where: { id: videoId },
data: { transcodingStatus: "transcoded" },
});
console.log(`[Transcoder Upload] ${videoId} success (${files.length} files)`);
return NextResponse.json({ success: true });
} catch (err) {
console.error(`[Transcoder Upload] Error for ${videoId}:`, err);
// Best-effort cleanup.
await fs.unlink(tempZipPath).catch(() => {});
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
return NextResponse.json(
{ error: "Upload processing failed" },
{ status: 500 }
);
}
}
+104
View File
@@ -0,0 +1,104 @@
// app/api/user/unlocks/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const userEmail = session.user.email;
const user = await prisma.user.findUnique({
where: { email: userEmail },
select: { id: true },
});
if (!user)
return NextResponse.json({ error: 'user not found' }, { status: 404 });
// Fetch all VideoUnlock records for this user
const unlocks = await prisma.videoUnlock.findMany({
where: { userId: user.id },
select: { id: true, videoId: true, unlockedAt: true, createdAt: true },
orderBy: { unlockedAt: 'desc' },
});
return NextResponse.json({ unlocks });
} catch (err: any) {
console.error('user unlocks GET error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const userEmail = session.user.email;
const body = await req.json();
const { videoId } = body;
if (!videoId) {
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { email: userEmail },
select: { id: true },
});
if (!user)
return NextResponse.json({ error: 'user not found' }, { status: 404 });
// Verify video exists
const video = await prisma.video.findUnique({
where: { id: videoId },
select: { id: true, locked: true, instantAccess: true },
});
if (!video) {
return NextResponse.json({ error: 'video not found' }, { status: 404 });
}
// Check if already unlocked
const existingUnlock = await prisma.videoUnlock.findUnique({
where: { userId_videoId: { userId: user.id, videoId } },
});
if (existingUnlock) {
return NextResponse.json({
message: 'Video already unlocked',
unlock: existingUnlock
});
}
// Create the unlock record
const unlock = await prisma.videoUnlock.create({
data: {
userId: user.id,
videoId: videoId,
},
});
return NextResponse.json({
message: 'Video unlocked successfully',
unlock
});
} catch (err: any) {
console.error('user unlocks POST error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+9
View File
@@ -0,0 +1,9 @@
// app/api/users/route.ts
import { NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
export async function GET() {
const users = await prisma.user.findMany({ select: { id: true, name: true, email: true } });
return NextResponse.json(users);
}
+109
View File
@@ -0,0 +1,109 @@
// app/api/videos/[id]/route.ts
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "../../../../lib/auth-options";
import { getVideoUrls } from "../../../../lib/video-urls";
export async function GET(req: Request, context: any) {
try {
// unwrap params
let params = context?.params;
if (typeof params?.then === "function") params = await params;
const id = params?.id;
if (!id) return NextResponse.json({ error: "Missing video id" }, { status: 400 });
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: {
enrollments: true,
},
});
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const video = await prisma.video.findUnique({
where: { id },
include: {
playlist: {
include: {
course: true,
courses: true,
},
},
uploader: {
select: {
id: true,
name: true,
image: true,
},
},
videoCourses: true,
},
});
if (!video) return NextResponse.json({ error: "Not found" }, { status: 404 });
const userCourseIds = user.enrollments.map((enrollment) => enrollment.courseId);
const playlistCourseIds = new Set<string>([video.playlist.courseId]);
(video.playlist.courses || []).forEach((mapping) => {
playlistCourseIds.add(mapping.courseId);
});
const hasPlaylistAccess = [...playlistCourseIds].some((courseId) =>
userCourseIds.includes(courseId)
);
if (!hasPlaylistAccess) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const restrictedCourseIds = (video.videoCourses || [])
.filter((assignment) => assignment.exclusive)
.map((assignment) => assignment.courseId);
if (
restrictedCourseIds.length > 0 &&
!restrictedCourseIds.some((courseId) => userCourseIds.includes(courseId))
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Find the next video (index = current.index + 1)
const next = await prisma.video.findFirst({
where: {
playlistId: video.playlistId,
index: video.index + 1,
},
include: {
uploader: {
select: {
id: true,
name: true,
image: true,
},
},
},
});
// Add video URLs with HLS support
const videoUrls = getVideoUrls(video.id, video.url, video.transcodingStatus as any);
const responseVideo = {
...video,
videoUrls,
transcodingStatus: video.transcodingStatus,
restrictedCourseIds,
};
// Ensure we return the URL field explicitly (player expects `video.url`).
return NextResponse.json({ video: responseVideo, next });
} catch (err: any) {
console.error("GET /api/videos/[id] error:", err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
+109
View File
@@ -0,0 +1,109 @@
// /api/videos/hls/[...path]/route.ts
// This endpoint serves HLS playlists and segments from the HLS directory
import { NextResponse } from 'next/server';
import * as fs from 'fs';
import * as path from 'path';
// Path to HLS directory (should match UPLOADS_DIR/hls)
const HLS_DIR = path.join(process.env.UPLOADS_DIR || '/uploads', 'hls');
export async function GET(
request: Request,
context: { params: Promise<{ path?: string[] }> }
) {
try {
const params = await context.params;
const pathSegments = params?.path || [];
if (pathSegments.length === 0) {
return NextResponse.json(
{ error: 'Invalid HLS request' },
{ status: 400 }
);
}
// Construct the file path and validate it
const requestedPath = path.join(HLS_DIR, ...pathSegments);
// Security: prevent directory traversal attacks
if (!requestedPath.startsWith(HLS_DIR)) {
return NextResponse.json(
{ error: 'Invalid request' },
{ status: 403 }
);
}
// Check if file exists
if (!fs.existsSync(requestedPath)) {
console.log(`[HLS] File not found: ${requestedPath}`);
return NextResponse.json(
{ error: 'Not found' },
{ status: 404 }
);
}
// Read the file
const fileContent = fs.readFileSync(requestedPath);
// Determine content type based on file extension
let contentType = 'application/octet-stream';
if (requestedPath.endsWith('.m3u8')) {
contentType = 'application/vnd.apple.mpegurl';
} else if (requestedPath.endsWith('.ts')) {
contentType = 'video/mp2t';
} else if (requestedPath.endsWith('.mp4')) {
contentType = 'video/mp4';
}
// Return the file with appropriate headers
return new NextResponse(fileContent, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
'Access-Control-Allow-Origin': '*', // Allow CORS if needed
'Accept-Ranges': 'bytes',
},
});
} catch (error) {
console.error('[HLS] Error serving HLS file:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// HEAD request support for playlist validation
export async function HEAD(
request: Request,
context: { params: Promise<{ path?: string[] }> }
) {
try {
const params = await context.params;
const pathSegments = params?.path || [];
if (pathSegments.length === 0) {
return new NextResponse(null, { status: 400 });
}
const requestedPath = path.join(HLS_DIR, ...pathSegments);
// Security: prevent directory traversal attacks
if (!requestedPath.startsWith(HLS_DIR)) {
return new NextResponse(null, { status: 403 });
}
// Check if file exists
if (!fs.existsSync(requestedPath)) {
return new NextResponse(null, { status: 404 });
}
return new NextResponse(null, { status: 200 });
} catch (error) {
console.error('[HLS] Error in HEAD request:', error);
return new NextResponse(null, { status: 500 });
}
}
+81
View File
@@ -0,0 +1,81 @@
// app/api/videos/latest/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Get all courses the user is enrolled in
const enrolledCourses = await prisma.enrollment.findMany({
where: { userId: user.id },
select: { courseId: true },
});
const enrolledCourseIds = enrolledCourses.map((e) => e.courseId);
if (enrolledCourseIds.length === 0) {
return NextResponse.json({ videos: [] });
}
// Get the latest 10 videos from those courses
// Videos can be in playlists that belong to enrolled courses
const videos = await prisma.video.findMany({
where: {
playlist: {
OR: [
{ courseId: { in: enrolledCourseIds } },
{
courses: {
some: { courseId: { in: enrolledCourseIds } },
},
},
],
},
},
select: {
id: true,
title: true,
durationSec: true,
thumbnail: true,
createdAt: true,
uploader: {
select: {
id: true,
name: true,
image: true,
},
},
playlist: {
select: {
id: true,
title: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 10,
});
return NextResponse.json({ videos });
} catch (err: any) {
console.error('GET /api/videos/latest error', err);
return NextResponse.json(
{ error: err?.message ?? 'Server error' },
{ status: 500 }
);
}
}
+44
View File
@@ -0,0 +1,44 @@
// app/api/watch-history/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const userEmail = session.user.email;
const user = await prisma.user.findUnique({ where: { email: userEmail } });
if (!user)
return NextResponse.json({ error: 'user not found' }, { status: 404 });
// Fetch all videos watched by user, sorted by most recently updated
const watchHistory = await prisma.videoProgress.findMany({
where: { userId: user.id },
include: {
video: {
select: {
id: true,
title: true,
thumbnail: true,
durationSec: true,
url: true,
},
},
},
orderBy: { updatedAt: 'desc' },
});
return NextResponse.json(watchHistory);
} catch (err: any) {
console.error('watch history GET error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+484
View File
@@ -0,0 +1,484 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import { usePlaylists } from '@/hooks/usePlaylists';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { Badge } from '@/components/ui/badge';
import { SegmentedProgressBar, WatchSegment } from '@/components/segmented-progress-bar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Lock } from 'lucide-react';
import Image from 'next/image';
type VideoItem = {
id: string;
title: string;
durationSec?: number;
thumbnail?: string;
locked?: boolean;
instantAccess?: boolean;
uploader?: {
id: string;
name?: string;
image?: string;
};
};
type VideoProgressData = {
percent: number;
watchedSec: number;
segments: WatchSegment[];
};
export default function DashboardClient() {
const router = useRouter();
const { data: session } = useSession();
const { subjects, isLoading } = usePlaylists();
const [videoProgress, setVideoProgress] = React.useState<Record<string, VideoProgressData>>({});
const [userUnlocks, setUserUnlocks] = React.useState<Set<string>>(new Set());
const [latestVideos, setLatestVideos] = React.useState<any[]>([]);
const [latestVideosLoading, setLatestVideosLoading] = React.useState(true);
const playlistsToShow = React.useMemo(() => {
const playlistMap = new Map<
string,
{
playlist: any;
courses: Map<string, { id: string; title?: string; code?: string }>;
}
>();
subjects.forEach((subject: any) => {
(subject.playlists || []).forEach((playlist: any) => {
const entry = playlistMap.get(playlist.id);
const courseInfo = {
id: subject.id,
title: subject.title,
code: subject.code,
};
if (entry) {
entry.courses.set(courseInfo.id, courseInfo);
} else {
const coursesMap = new Map<string, { id: string; title?: string; code?: string }>();
coursesMap.set(courseInfo.id, courseInfo);
playlistMap.set(playlist.id, { playlist, courses: coursesMap });
}
});
});
return Array.from(playlistMap.values()).map(({ playlist, courses }) => ({
...playlist,
coursesForDisplay: Array.from(courses.values()),
}));
}, [subjects]);
// Fetch user's unlocks
React.useEffect(() => {
const fetchUserUnlocks = async () => {
try {
const res = await fetch('/api/user/unlocks');
if (res.ok) {
const data = await res.json();
const unlockedVideoIds = new Set((data.unlocks?.map((u: any) => u.videoId) ?? []) as string[]);
setUserUnlocks(unlockedVideoIds);
}
} catch (err) {
console.error('Failed to fetch user unlocks:', err);
}
};
fetchUserUnlocks();
}, []);
// Fetch latest videos from enrolled courses
React.useEffect(() => {
const fetchLatestVideos = async () => {
try {
setLatestVideosLoading(true);
const res = await fetch('/api/videos/latest');
if (res.ok) {
const data = await res.json();
setLatestVideos(data.videos ?? []);
}
} catch (err) {
console.error('Failed to fetch latest videos:', err);
} finally {
setLatestVideosLoading(false);
}
};
fetchLatestVideos();
}, []);
// Auto-sync enrollments for allowed students on first login
React.useEffect(() => {
const syncEnrollments = async () => {
if (!session?.user) return;
const userLevels = (session.user as any)?.levels;
console.log('[DASHBOARD] User levels from session:', userLevels);
if (!userLevels) {
console.log('[DASHBOARD] No levels found in session');
return;
}
const levels = userLevels
.split(',')
.map((level: string) => level.trim())
.filter((level: string) => level.length > 0);
console.log('[DASHBOARD] Parsed levels:', levels);
if (levels.length === 0) {
console.log('[DASHBOARD] No valid levels after parsing');
return;
}
try {
console.log('[DASHBOARD] Calling sync-enrollments with:', { levels });
const res = await fetch('/api/enrollments/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ levels }),
});
if (!res.ok) {
console.error('[DASHBOARD] Failed to sync enrollments, status:', res.status);
} else {
const result = await res.json();
console.log('[DASHBOARD] Sync enrollments result:', result);
}
} catch (err) {
console.error('[DASHBOARD] Error syncing enrollments:', err);
}
};
syncEnrollments();
}, [session]);
React.useEffect(() => {
const fetchProgress = async () => {
const progressMap: Record<string, VideoProgressData> = {};
// Fetch progress for playlist videos
for (const playlist of playlistsToShow) {
for (const video of playlist.videos || []) {
try {
const [progressRes, segmentsRes] = await Promise.all([
fetch(`/api/progress?videoId=${video.id}`),
fetch(`/api/progress/segments?videoId=${video.id}`),
]);
if (progressRes.ok) {
const progressData = await progressRes.json();
const segmentsData = segmentsRes.ok ? await segmentsRes.json() : { segments: [] };
progressMap[video.id] = {
percent: progressData.percent ?? 0,
watchedSec: progressData.watchedSec ?? 0,
segments: segmentsData.segments ?? [],
};
}
} catch (err) {
console.error(`Failed to fetch progress for video ${video.id}`, err);
progressMap[video.id] = { percent: 0, watchedSec: 0, segments: [] };
}
}
}
// Fetch progress for latest videos
for (const video of latestVideos) {
try {
const [progressRes, segmentsRes] = await Promise.all([
fetch(`/api/progress?videoId=${video.id}`),
fetch(`/api/progress/segments?videoId=${video.id}`),
]);
if (progressRes.ok) {
const progressData = await progressRes.json();
const segmentsData = segmentsRes.ok ? await segmentsRes.json() : { segments: [] };
progressMap[video.id] = {
percent: progressData.percent ?? 0,
watchedSec: progressData.watchedSec ?? 0,
segments: segmentsData.segments ?? [],
};
}
} catch (err) {
console.error(`Failed to fetch progress for video ${video.id}`, err);
progressMap[video.id] = { percent: 0, watchedSec: 0, segments: [] };
}
}
setVideoProgress(progressMap);
};
if (playlistsToShow.length > 0 || latestVideos.length > 0) {
fetchProgress();
}
}, [playlistsToShow, latestVideos]);
const handleOpenVideo = (playlistId: string, videoId: string) => {
// navigate to videoplayer page using query params (keeps your current structure)
router.push(`/videoplayer?playlistId=${playlistId}&videoId=${videoId}`);
};
const formatDuration = (s?: number) => {
if (!s && s !== 0) return '';
const mins = Math.floor(s! / 60);
const secs = Math.floor(s! % 60)
.toString()
.padStart(2, '0');
return `${mins}:${secs}`;
};
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">
{isLoading && latestVideosLoading ? (
<div className="px-4 lg:px-16">Loading</div>
) : (
<div className="space-y-8">
{/* Latest Videos Section */}
{!latestVideosLoading && latestVideos.length > 0 && (
<div className="px-4 lg:px-16">
<div className="mb-4">
<h2 className="text-base font-medium">Latest Videos</h2>
<p className="text-xs text-muted-foreground">
Recently uploaded from your enrolled courses
</p>
</div>
<Carousel opts={{ align: 'start' }} className="w-full">
<CarouselContent>
{latestVideos.map((v: any) => (
<CarouselItem
key={v.id}
className="md:basis-1/3 lg:basis-1/5"
>
<Card
className="@container/card overflow-hidden cursor-pointer"
onClick={() => {
if (v.playlist?.id) {
handleOpenVideo(v.playlist.id, v.id);
}
}}
>
<CardHeader className='px-2'>
<div className="relative overflow-hidden rounded-sm aspect-video bg-gray-100">
<img
src={v.thumbnail ?? '/01.jpg'}
alt={v.title}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
<div className="flex gap-2 items-start pt-2">
{v.uploader?.image && (
<div className="relative w-9 h-9 rounded-full overflow-hidden shrink-0">
<Image
src={v.uploader.image}
alt={v.uploader.name ?? 'Uploader'}
fill
unoptimized
className="object-cover"
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<CardTitle className="text-sm font-medium truncate">
{v.title}
</CardTitle>
<Badge className="shrink-0">
{formatDuration(v.durationSec)}
</Badge>
</div>
{v.uploader?.name && (
<p className="text-xs text-muted-foreground truncate">
{v.uploader.name}
</p>
)}
{v.playlist?.title && (
<p className="text-xs text-muted-foreground truncate">
{v.playlist.title}
</p>
)}
<SegmentedProgressBar
className='mt-2'
segments={videoProgress[v.id]?.segments ?? []}
duration={v.durationSec ?? 1}
percent={videoProgress[v.id]?.percent ?? 0}
height="sm"
showTooltip={false}
/>
</div>
</div>
</CardHeader>
</Card>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
)}
{/* Playlists Section */}
{playlistsToShow.length === 0 && latestVideos.length === 0 ? (
<div className="px-4 lg:px-16">No content available.</div>
) : (
<>
{playlistsToShow.length > 0 && (
<>
<div className="px-4 lg:px-16">
<h2 className="text-base font-medium">Playlists</h2>
<p className="text-xs text-muted-foreground">
Showing {playlistsToShow.length} playlist{playlistsToShow.length === 1 ? '' : 's'}
</p>
</div>
{playlistsToShow.map((pl: any) => (
<div key={pl.id} className="px-4 lg:px-16 mb-8">
<div className="flex items-center justify-between mb-2">
<div>
<h3 className="text-sm font-semibold">{pl.title}</h3>
<div className="text-xs text-muted-foreground">
{pl.description}
</div>
{pl.coursesForDisplay?.length ? (
<div className="flex flex-wrap gap-1 mt-1">
{pl.coursesForDisplay.map((course: any) => (
<Badge key={course.id} variant="outline">
{course.code ?? course.title}
</Badge>
))}
</div>
) : null}
</div>
</div>
<Carousel opts={{ align: 'start' }} className="w-full">
<CarouselContent>
{pl.videos?.map((v: VideoItem) => (
<CarouselItem
key={v.id}
className="md:basis-1/3 lg:basis-1/5"
>
<Card
className="@container/card overflow-hidden cursor-pointer"
onClick={() => {
if ((v as any).locked) return;
if ((v as any).instantAccess) {
handleOpenVideo(pl.id, v.id);
return;
}
if ((v as any).index === 0) {
handleOpenVideo(pl.id, v.id);
return;
}
if (userUnlocks.has(v.id)) {
handleOpenVideo(pl.id, v.id);
}
}}
>
<CardHeader className='px-2'>
<div className="relative overflow-hidden rounded-sm aspect-video bg-gray-100">
<img
src={v.thumbnail ?? '/01.jpg'}
alt={v.title}
className="w-full h-full object-cover"
loading="lazy"
/>
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Lock className="w-5 h-5 text-white" />
</div>
) : null}
</div>
<div className="flex gap-2 items-start pt-2">
{(v as any).uploader?.image && (
<div className="relative w-9 h-9 rounded-full overflow-hidden shrink-0">
<Image
src={(v as any).uploader.image}
alt={(v as any).uploader.name ?? 'Uploader'}
fill
unoptimized
className="object-cover"
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<CardTitle className="text-sm font-medium truncate">
{v.title}
</CardTitle>
<Badge className="shrink-0">
{formatDuration(v.durationSec)}
</Badge>
</div>
{(v as any).uploader?.name && (
<p className="text-xs text-muted-foreground truncate">
{(v as any).uploader.name}
</p>
)}
<SegmentedProgressBar
className='mt-2'
segments={videoProgress[v.id]?.segments ?? []}
duration={v.durationSec ?? 1}
percent={videoProgress[v.id]?.percent ?? 0}
height="sm"
showTooltip={false}
/>
</div>
</div>
</CardHeader>
</Card>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
))}
</>
)}
</>
)}
</div>
)}
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+225
View File
@@ -0,0 +1,225 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
interface LikedVideoItem {
id: string;
videoId: string;
createdAt: string;
video: {
id: string;
title: string;
thumbnail?: string;
durationSec?: number;
url: string;
playlist: {
title: string;
course: {
id: string;
title: string;
};
};
};
}
export default function LikedVideosClient() {
const router = useRouter();
const [likes, setLikes] = React.useState<LikedVideoItem[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
const fetchLikes = async () => {
try {
const res = await fetch('/api/likes/all');
if (res.ok) {
const data = await res.json();
setLikes(data);
}
} catch (err) {
console.error('Failed to fetch liked videos', err);
} finally {
setIsLoading(false);
}
};
fetchLikes();
}, []);
const handleVideoClick = (videoId: string) => {
router.push(`/videoplayer?videoId=${videoId}`);
};
const formatTime = (seconds?: number) => {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
if (isLoading) {
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 gap-4 p-4 md:gap-8 md:p-8">
<Card>
<CardHeader>
<CardTitle>Liked Videos</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">Loading...</p>
</div>
</CardContent>
</Card>
</div>
</SidebarInset>
</SidebarProvider>
);
}
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 gap-4 p-4 md:gap-8 md:p-8">
<Card>
<CardHeader>
<CardTitle>Liked Videos</CardTitle>
</CardHeader>
<CardContent>
{likes.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">No liked videos yet</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Thumbnail</TableHead>
<TableHead>Title</TableHead>
<TableHead>Playlist</TableHead>
<TableHead>Course</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Liked On</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{likes.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div
className="cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleVideoClick(item.video.id)}
>
{item.video.thumbnail ? (
<div className="relative w-24 h-14">
<Image
src={item.video.thumbnail}
alt={item.video.title}
fill
unoptimized
className="object-cover rounded"
/>
</div>
) : (
<div className="w-24 h-14 bg-muted rounded flex items-center justify-center">
<span className="text-xs text-muted-foreground">
No image
</span>
</div>
)}
</div>
</TableCell>
<TableCell>
<p
className="font-medium max-w-xs truncate cursor-pointer hover:underline"
onClick={() => handleVideoClick(item.video.id)}
>
{item.video.title}
</p>
</TableCell>
<TableCell>
<span className="text-sm">
{item.video.playlist.title}
</span>
</TableCell>
<TableCell>
<span className="text-sm">
{item.video.playlist.course.title}
</span>
</TableCell>
<TableCell>
<span className="text-sm">
{formatTime(item.video.durationSec)}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{formatDate(item.createdAt)}
</span>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => handleVideoClick(item.video.id)}
>
Watch
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+20
View File
@@ -0,0 +1,20 @@
// app/dashboard/liked-videos/page.tsx
import { Metadata } from 'next';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import LikedVideosClient from '../liked-videos-client';
export const metadata: Metadata = {
title: 'Liked Videos | CMS',
description: 'View your liked videos',
};
export default async function LikedVideosPage() {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect('/login');
}
return <LikedVideosClient />;
}
+11
View File
@@ -0,0 +1,11 @@
// app/dashboard/page.tsx (server component)
import { requireUser } from '@/lib/auth-check';
import DashboardClient from './dashboard-client'; // will be your existing client UI
export default async function DashboardPage() {
// will redirect to /login if not authenticated
const session = await requireUser('/login');
// you can optionally pass session to client via props if desired:
return <DashboardClient />;
}
+210
View File
@@ -0,0 +1,210 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
interface WatchHistoryItem {
id: string;
videoId: string;
lastPos?: number;
updatedAt: string;
video: {
id: string;
title: string;
thumbnail?: string;
durationSec?: number;
url: string;
};
}
export default function WatchHistoryClient() {
const router = useRouter();
const [history, setHistory] = React.useState<WatchHistoryItem[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
const fetchHistory = async () => {
try {
const res = await fetch('/api/watch-history');
if (res.ok) {
const data = await res.json();
setHistory(data);
}
} catch (err) {
console.error('Failed to fetch watch history', err);
} finally {
setIsLoading(false);
}
};
fetchHistory();
}, []);
const handleVideoClick = (videoId: string) => {
router.push(`/videoplayer?videoId=${videoId}`);
};
const formatTime = (seconds?: number) => {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (isLoading) {
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 gap-4 p-4 md:gap-8 md:p-8">
<Card>
<CardHeader>
<CardTitle>Watch History</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">Loading...</p>
</div>
</CardContent>
</Card>
</div>
</SidebarInset>
</SidebarProvider>
);
}
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 gap-4 p-4 md:gap-8 md:p-8">
<Card>
<CardHeader>
<CardTitle>Watch History</CardTitle>
</CardHeader>
<CardContent>
{history.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground">No videos watched yet</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Thumbnail</TableHead>
<TableHead>Title</TableHead>
<TableHead>Last Position</TableHead>
<TableHead>Last Watched</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{history.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div
className="cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleVideoClick(item.video.id)}
>
{item.video.thumbnail ? (
<div className="relative w-24 h-14">
<Image
src={item.video.thumbnail}
alt={item.video.title}
fill
unoptimized
className="object-cover rounded"
/>
</div>
) : (
<div className="w-24 h-14 bg-muted rounded flex items-center justify-center">
<span className="text-xs text-muted-foreground">
No image
</span>
</div>
)}
</div>
</TableCell>
<TableCell>
<p
className="font-medium max-w-xs truncate cursor-pointer hover:underline"
onClick={() => handleVideoClick(item.video.id)}
>
{item.video.title}
</p>
</TableCell>
<TableCell>
<span className="text-sm">
{formatTime(item.lastPos)} /{' '}
{formatTime(item.video.durationSec)}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{formatDate(item.updatedAt)}
</span>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => handleVideoClick(item.video.id)}
>
Resume
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+20
View File
@@ -0,0 +1,20 @@
// app/dashboard/watch-history/page.tsx
import { Metadata } from 'next';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { redirect } from 'next/navigation';
import WatchHistoryClient from '../watch-history-client';
export const metadata: Metadata = {
title: 'Watch History | CMS',
description: 'View your video watch history',
};
export default async function WatchHistoryPage() {
const session = await getServerSession(authOptions);
if (!session?.user) {
redirect('/login');
}
return <WatchHistoryClient />;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+122
View File
@@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.646 0.222 41.116);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.75 0.183 55.934);
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.75 0.183 55.934);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.705 0.213 47.604);
--primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.408 0.123 38.172);
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.408 0.123 38.172);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+27
View File
@@ -0,0 +1,27 @@
// app/layout.tsx (server)
import './globals.css';
import { Providers } from './providers';
import type { ReactNode } from 'react';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { Toaster } from "@/components/ui/sonner"
export const metadata = {
title: 'OW ANIMATION ARTS VAULT',
description: '...',
};
export default async function RootLayout({ children }: { children: ReactNode }) {
// get server session and pass into client SessionProvider for identical initial markup
const session = await getServerSession(authOptions);
return (
<html lang="en">
<body className="antialiased bg-background text-foreground">
{/* pass session to Providers to avoid client/server mismatch */}
<Providers session={session}>{children}</Providers>
<Toaster />
</body>
</html>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { GalleryVerticalEnd } from "lucide-react"
import { LoginForm } from "@/components/login-form"
export default function LoginPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<a href="#" className="flex items-center gap-2 self-center font-medium">
<img
src="/icon.svg" // put your svg here
alt=""
className="size-5" // same sizing as your icon
/>
OW ANIMATION ARTS VAULT
</a>
<img
src="/vault.png"
alt="Vault"
className="w-full"
/>
<LoginForm />
</div>
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
// app/login/unauthorized/page.tsx
'use client';
import { AlertCircle } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { signOut } from 'next-auth/react';
import { useEffect } from 'react';
export default function UnauthorizedPage() {
// Clear any partial session data when landing on this page
useEffect(() => {
signOut({ redirect: false });
}, []);
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex items-center gap-2 self-center font-medium">
<AlertCircle className="h-6 w-6 text-destructive" />
<span>Access Denied</span>
</div>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Not Registered</CardTitle>
<CardDescription>
Your email is not registered to access this platform.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="rounded-lg bg-muted p-4 text-sm">
<p className="mb-2 font-medium">What does this mean?</p>
<p className="text-muted-foreground">
This is a university-only platform. If you believe you should have access, please contact your administrator or department coordinator.
</p>
</div>
<div className="flex gap-2">
<Link href="/login" className="flex-1">
<Button type="button" variant="outline" className="w-full">
Try Another Email
</Button>
</Link>
<Link href="/" className="flex-1">
<Button type="button" className="w-full">
Go Home
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { GalleryVerticalEnd } from "lucide-react"
import { LoginForm } from "@/components/login-form"
export default function LoginPage() {
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<a href="#" className="flex items-center gap-2 self-center font-medium">
<img
src="/icon.svg" // put your svg here
alt=""
className="size-5" // same sizing as your icon
/>
OW ANIMATION ARTS VAULT
</a>
<img
src="/vault.png"
alt="Vault"
className="w-full"
/>
<LoginForm />
</div>
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
// app/providers.tsx (client)
'use client';
import { SessionProvider } from 'next-auth/react';
import { ThemeProvider } from 'next-themes';
import type { PropsWithChildren } from 'react';
type Props = PropsWithChildren<{ session?: any }>;
export function Providers({ children, session }: Props) {
return (
<SessionProvider session={session}>
<ThemeProvider
attribute="class" // toggles `class="dark"` on <html>
defaultTheme="dark" // default to dark to match server if you want dark by default
enableSystem={false}
>
{children}
</ThemeProvider>
</SessionProvider>
);
}
+785
View File
@@ -0,0 +1,785 @@
'use client';
import React from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { usePlaylist } from '@/hooks/usePlaylist';
import { useVideo } from '@/hooks/useVideo';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { SegmentedProgressBar, WatchSegment } from '@/components/segmented-progress-bar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { CommentsSection } from '@/components/comments-section';
import { formatDistanceToNow } from 'date-fns';
import { Lock, Heart, Edit } from 'lucide-react';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
import { HlsPlayer } from '@/components/hls';
import { useSession } from 'next-auth/react';
import Image from 'next/image';
type VideoFromApi = {
id: string;
title: string;
description?: string | null;
durationSec?: number | null;
duration?: string | null;
thumbnail?: string | null;
url?: string | null;
locked?: boolean;
createdAt?: string | null;
transcodingStatus?: string;
videoUrls?: {
hlsUrl: string | null;
mp4Url: string;
};
uploader?: {
id: string;
name?: string;
image?: string;
} | null;
};
export function useWatchTracker(
videoRef: React.RefObject<HTMLVideoElement | null>,
opts: { sendIntervalMs?: number; commitIntervalSec?: number; debug?: boolean } = {}
) {
const sendIntervalMs = opts.sendIntervalMs ?? 5000;
const commitIntervalSec = opts.commitIntervalSec ?? 3;
const debug = !!opts.debug;
const rangesRef = React.useRef<Array<[number, number]>>([]);
const lastTimeRef = React.useRef<number | null>(null);
const currentRangeStartRef = React.useRef<number | null>(null);
const sendTimerRef = React.useRef<number | null>(null);
const commitTimerRef = React.useRef<number | null>(null);
// merging helper
function mergeRanges(ranges: Array<[number, number]>) {
if (!ranges.length) return [];
ranges.sort((a, b) => a[0] - b[0]);
const merged: Array<[number, number]> = [];
for (const [s, e] of ranges) {
if (!merged.length) merged.push([s, e]);
else {
const last = merged[merged.length - 1];
if (s <= last[1] + 0.5) last[1] = Math.max(last[1], e);
else merged.push([s, e]);
}
}
return merged;
}
function addRange(s: number, e: number) {
if (e <= s) return;
if (debug) console.debug("[tracker] addRange", s, e);
rangesRef.current.push([s, e]);
rangesRef.current = mergeRanges(rangesRef.current);
}
function getUniqueWatchedSec() {
let sum = 0;
for (const [a, b] of rangesRef.current) sum += Math.max(0, b - a);
return Math.round(sum);
}
// commit the "current" playing segment to ranges (useful while playing)
function commitCurrentRange() {
const el = videoRef.current;
if (!el) return;
const start = currentRangeStartRef.current;
const now = el.currentTime;
if (start === null) return;
// only commit if we've moved forward at least 0.5s to avoid noise
if (now - start >= 0.5) {
addRange(start, now);
// keep currentRangeStart open at 'now' so we continue accumulating
currentRangeStartRef.current = now;
}
}
// send progress to server (same signature you had)
async function sendProgress(args: { videoId: string; playlistId?: string; duration: number; extra?: Record<string, any> }) {
const { videoId, playlistId, duration, extra } = args;
const watchedSec = getUniqueWatchedSec();
const el = videoRef.current;
const lastPos = Math.floor(el?.currentTime ?? 0);
if (!duration || duration <= 0) return null;
if (watchedSec === 0 && (el?.paused ?? true)) return null;
const payload = { videoId, playlistId, watchedSec, lastPos, duration, ...extra };
try {
if (debug) console.debug("[tracker] sendProgress", payload);
const res = await fetch("/api/progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
// If video not found, stop trying to track progress
if (!res.ok) {
if (res.status === 404) {
console.warn(`[tracker] Video ${videoId} not found, stopping progress tracking`);
stopAutoSend(); // Stop automatic progress tracking for deleted video
return null;
}
return null;
}
return res.ok ? await res.json().catch(() => null) : null;
} catch (err) {
if (debug) console.warn("[tracker] sendProgress error", err);
return null;
}
}
// start/stop auto-send
function startAutoSend(args: { videoId: string; playlistId?: string; duration: number }) {
stopAutoSend(); // clear existing
// immediate send (best-effort)
sendProgress(args).catch(() => {});
// periodic send
sendTimerRef.current = window.setInterval(() => {
const watched = getUniqueWatchedSec();
const el = videoRef.current;
const isPlaying = !!(el && !el.paused && !el.ended);
if (watched > 0 || isPlaying) {
sendProgress(args).catch(() => {});
}
}, sendIntervalMs) as unknown as number;
// commit currently playing ranges periodically (so continuous play is captured)
commitTimerRef.current = window.setInterval(() => {
commitCurrentRange();
}, commitIntervalSec * 1000) as unknown as number;
if (debug) console.debug("[tracker] startAutoSend", { sendIntervalMs, commitIntervalSec });
}
function stopAutoSend(opts?: { sendFinal?: boolean; videoId?: string; playlistId?: string; duration?: number }) {
if (sendTimerRef.current) {
window.clearInterval(sendTimerRef.current);
sendTimerRef.current = null;
}
if (commitTimerRef.current) {
window.clearInterval(commitTimerRef.current);
commitTimerRef.current = null;
}
if (opts?.sendFinal && opts.videoId && opts.duration) {
sendProgress({ videoId: opts.videoId, playlistId: opts.playlistId, duration: opts.duration }).catch(() => {});
}
if (debug) console.debug("[tracker] stopAutoSend");
}
// attach listeners to populate rangesRef
React.useEffect(() => {
const el = videoRef.current;
if (!el) {
if (debug) console.debug("[tracker] no video element to attach");
return;
}
currentRangeStartRef.current = null;
lastTimeRef.current = el.currentTime ?? 0;
const onPlay = () => {
currentRangeStartRef.current = el.currentTime;
lastTimeRef.current = el.currentTime;
if (debug) console.debug("[tracker] play", currentRangeStartRef.current);
};
const onPause = () => {
if (currentRangeStartRef.current !== null) {
addRange(currentRangeStartRef.current, el.currentTime);
currentRangeStartRef.current = null;
}
if (debug) console.debug("[tracker] pause, ranges:", rangesRef.current);
};
const onTimeUpdate = () => {
const now = el.currentTime;
const last = lastTimeRef.current ?? now;
// detect seek (big jump)
if (Math.abs(now - last) > 2.5) {
if (currentRangeStartRef.current !== null) {
addRange(currentRangeStartRef.current, last);
}
currentRangeStartRef.current = now;
if (debug) console.debug("[tracker] seek detected, start:", now);
}
lastTimeRef.current = now;
// we do not addRange here to avoid flooding; commit timer handles mid-play commits
};
const onEnded = () => {
if (currentRangeStartRef.current !== null) {
addRange(currentRangeStartRef.current, el.duration ?? lastTimeRef.current ?? 0);
currentRangeStartRef.current = null;
}
if (debug) console.debug("[tracker] ended, ranges:", rangesRef.current);
};
el.addEventListener("play", onPlay);
el.addEventListener("pause", onPause);
el.addEventListener("timeupdate", onTimeUpdate);
el.addEventListener("ended", onEnded);
return () => {
el.removeEventListener("play", onPlay);
el.removeEventListener("pause", onPause);
el.removeEventListener("timeupdate", onTimeUpdate);
el.removeEventListener("ended", onEnded);
stopAutoSend();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoRef.current]);
return {
getUniqueWatchedSec,
addRange,
sendProgress,
startAutoSend,
stopAutoSend,
};
}
export default function Page() {
const search = useSearchParams();
const router = useRouter();
const playlistId = search.get('playlistId') ?? undefined;
const videoId = search.get('videoId') ?? undefined;
const { playlist, isLoading: playlistLoading } = usePlaylist(playlistId);
const { video, next, isLoading: videoLoading } = useVideo(videoId);
// Segments state for current video and playlist videos
const [currentSegments, setCurrentSegments] = React.useState<WatchSegment[]>([]);
const [playlistSegments, setPlaylistSegments] = React.useState<Record<string, WatchSegment[]>>({});
const [currentProgress, setCurrentProgress] = React.useState(0);
// Per-user unlock state
const [userUnlocks, setUserUnlocks] = React.useState<Set<string>>(new Set()); // Set of unlocked videoIds
const [videoInstantAccess, setVideoInstantAccess] = React.useState<Record<string, boolean>>({}); // videoId -> instantAccess
const [unlockingVideo, setUnlockingVideo] = React.useState<string | null>(null); // videoId being unlocked
// inside your videoplayer component
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const tracker = useWatchTracker(videoRef, { sendIntervalMs: 5000 });
// Fetch segments for current video
React.useEffect(() => {
if (!videoId) return;
const fetchSegments = async () => {
try {
const res = await fetch(`/api/progress/segments?videoId=${videoId}`);
if (res.ok) {
const data = await res.json();
setCurrentSegments(data.segments ?? []);
}
const progressRes = await fetch(`/api/progress?videoId=${videoId}`);
if (progressRes.ok) {
const data = await progressRes.json();
setCurrentProgress(data.percent ?? 0);
}
} catch (err) {
console.error('Failed to fetch segments:', err);
}
};
fetchSegments();
// Refresh segments every 5 seconds
const interval = setInterval(fetchSegments, 5000);
return () => clearInterval(interval);
}, [videoId]);
// Fetch segments for all playlist videos
React.useEffect(() => {
if (!playlist?.videos || playlist.videos.length === 0) return;
const fetchPlaylistSegments = async () => {
const segments: Record<string, WatchSegment[]> = {};
const instantAccess: Record<string, boolean> = {};
for (const video of playlist.videos) {
// Fetch segments
try {
const res = await fetch(`/api/progress/segments?videoId=${video.id}`);
if (res.ok) {
const data = await res.json();
segments[video.id] = data.segments ?? [];
}
} catch (err) {
console.error(`Failed to fetch segments for video ${video.id}:`, err);
}
// Track instantAccess status from video object
instantAccess[video.id] = (video as any).instantAccess ?? false;
}
setPlaylistSegments(segments);
setVideoInstantAccess(instantAccess);
};
fetchPlaylistSegments();
}, [playlist?.videos]);
// Fetch user's VideoUnlock records
React.useEffect(() => {
const fetchUserUnlocks = async () => {
try {
const res = await fetch('/api/user/unlocks');
if (res.ok) {
const data = await res.json();
const unlockedVideoIds = new Set((data.unlocks?.map((u: any) => u.videoId) ?? []) as string[]);
setUserUnlocks(unlockedVideoIds);
}
} catch (err) {
console.error('Failed to fetch user unlocks:', err);
}
};
fetchUserUnlocks();
}, []);
React.useEffect(() => {
if (!video) return;
let didCancel = false;
let poll: number | null = null;
const startWhenReady = () => {
if (didCancel) return;
const el = videoRef.current;
const duration = (video.durationSec ?? Math.floor(el?.duration ?? 0)) || 0;
if (el) {
tracker.startAutoSend({
videoId: String(video.id),
playlistId: playlist?.id,
duration,
});
} else {
// poll until the video element mounts (should be quick)
poll = window.setInterval(() => {
if (videoRef.current) {
if (poll) {
window.clearInterval(poll);
poll = null;
}
tracker.startAutoSend({
videoId: String(video.id),
playlistId: playlist?.id,
duration: (video.durationSec ?? Math.floor(videoRef.current?.duration ?? 0)) || 0,
});
}
}, 150) as unknown as number;
}
};
startWhenReady();
return () => {
didCancel = true;
if (poll) {
window.clearInterval(poll);
poll = null;
}
const duration = video?.durationSec ?? Math.floor(videoRef.current?.duration ?? 0);
// send final snapshot
tracker.stopAutoSend({ sendFinal: true, videoId: String(video?.id ?? ''), playlistId: playlist?.id, duration });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [video?.id, playlist?.id]);
const current: VideoFromApi = (video as VideoFromApi) ?? {
id: 'loading',
title: 'Loading…',
duration: '0:00',
thumbnail: '/01.jpg',
url: '',
};
const fmt = (v: VideoFromApi) => {
if (v.duration) return v.duration;
if (!v.durationSec && v.durationSec !== 0) return '';
const mins = Math.floor((v.durationSec ?? 0) / 60);
const secs = Math.floor((v.durationSec ?? 0) % 60)
.toString()
.padStart(2, '0');
return `${mins}:${secs}`;
};
const handleUnlock = async (v: VideoFromApi) => {
console.log('unlock request for', v.id);
// Prevent multiple unlock attempts
if (unlockingVideo === v.id) return;
setUnlockingVideo(v.id);
try {
// Can't unlock if globally locked
if ((v as any).locked) {
console.log('Video is globally locked, cannot unlock');
return;
}
// Can't unlock if already unlocked or has instant access
if ((v as any).instantAccess || userUnlocks.has(v.id)) {
console.log('Video already accessible');
router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
return;
}
// Check if previous video in sequence is completed
if (!playlist?.videos) return;
const videoIndex = (v as any).index;
if (videoIndex <= 0) {
// First video should always be accessible
router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
return;
}
// Find previous video
const previousVideo = playlist.videos.find((pv: any) => pv.index === videoIndex - 1);
if (!previousVideo) {
console.log('Previous video not found');
return;
}
// Check if previous video is completed
const progressRes = await fetch(`/api/progress?videoId=${previousVideo.id}`);
if (!progressRes.ok) {
console.error('Failed to fetch previous video progress');
alert('Error checking unlock requirements. Please try again.');
return;
}
const progressData = await progressRes.json();
const isCompleted = progressData.percent >= 90; // Consider 90% as completed
if (!isCompleted) {
console.log('Previous video not completed yet - need', Math.ceil(90 - progressData.percent), '% more');
// TODO: Show toast notification explaining unlock requirements
alert(`You need to complete ${Math.ceil(90 - progressData.percent)}% more of the previous video to unlock this one.`);
return;
}
// Previous video is completed, unlock this video
const unlockRes = await fetch('/api/user/unlocks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: v.id }),
});
if (unlockRes.ok) {
// Update local state
setUserUnlocks(prev => new Set(prev).add(v.id));
console.log('Video unlocked successfully');
// Redirect to the unlocked video
router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
} else {
const errorData = await unlockRes.json().catch(() => ({ error: 'Unknown error' }));
console.error('Failed to unlock video:', errorData.error);
alert(`Failed to unlock video: ${errorData.error}`);
}
} catch (err) {
console.error('Error checking unlock conditions:', err);
alert('Error checking if video can be unlocked. Please try again.');
} finally {
setUnlockingVideo(null);
}
};
const [isLiked, setIsLiked] = React.useState(false);
const { data: session } = useSession();
const isAdmin = (session as any)?.user?.role === 'admin' || (session as any)?.user?.role === 'superadmin';
React.useEffect(() => {
// Check if current video is liked on load
if (current.id) {
const checkLike = async () => {
try {
const res = await fetch(`/api/likes?videoId=${current.id}`);
if (res.ok) {
const data = await res.json();
setIsLiked(data.isLiked);
}
} catch (err) {
console.error('Failed to check if video is liked', err);
}
};
checkLike();
}
}, [current.id]);
const handleLike = async () => {
const newLikedState = !isLiked;
setIsLiked(newLikedState);
try {
const res = await fetch('/api/likes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoId: current.id,
isLiked: newLikedState,
}),
});
if (!res.ok) {
// Revert on error
setIsLiked(!newLikedState);
console.error('Failed to toggle like');
}
} catch (err) {
// Revert on error
setIsLiked(!newLikedState);
console.error('Error toggling like', err);
}
};
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 gap-4 p-4 lg:p-6">
<div className="flex-1 min-w-0">
<Card className="mb-4">
<CardContent className="p-0">
<div className="relative w-full overflow-hidden">
{videoLoading ? (
<div className="flex items-center justify-center h-80">Loading video</div>
) : current.url ? (
// HLS player with MP4 fallback
// Prefers HLS if transcoding is complete, falls back to MP4
<HlsPlayer
src={current.videoUrls?.hlsUrl || current.url}
fallbackSrc={current.url}
videoId={current.id}
ref={videoRef}
/>
) : (
<div className="flex items-center justify-center h-80">Video not available</div>
)}
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<div className="flex items-start justify-between w-full gap-4">
<div className="flex-1 flex gap-3">
{current.uploader?.image && (
<div className="relative w-12 h-12 rounded-full overflow-hidden shrink-0">
<Image
src={current.uploader.image}
alt={current.uploader.name ?? 'Uploader'}
fill
unoptimized
className="object-cover"
/>
</div>
)}
<div className="flex-1 min-w-0">
<CardTitle className="text-lg">{current.title}</CardTitle>
{current.uploader?.name && (
<p className="text-sm text-muted-foreground mt-1">
{current.uploader.name}
</p>
)}
{current.createdAt && (
<p className="text-xs text-muted-foreground">
Uploaded {formatDistanceToNow(new Date(current.createdAt), { addSuffix: true })}
</p>
)}
</div>
</div>
<div className="flex gap-2 shrink-0">
{isAdmin && (
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/videos/${current.id}/edit`)}
>
<Edit className="w-4 h-4" />
Edit
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={handleLike}
className={isLiked ? 'text-red-500' : ''}
>
Like
<Heart
className="w-5 h-5"
fill={isLiked ? 'currentColor' : 'none'}
/>
</Button>
</div>
</div>
{current.description && (
<p className="text-sm text-muted-foreground w-full">
{current.description}
</p>
)}
<div className="w-full mt-4 pt-4 border-t">
<SegmentedProgressBar
segments={currentSegments}
duration={current.durationSec ?? 1}
percent={currentProgress}
height="md"
showTooltip={true}
/>
</div>
</CardFooter>
</Card>
<Card>
<CardContent>
<CommentsSection videoId={current.id} />
</CardContent>
</Card>
{/* <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardContent>
<h3 className="font-medium">Notes</h3>
<p className="mt-2 text-sm text-muted-foreground">
Keep lesson notes, transcript links, or chapter markers here.
</p>
</CardContent>
</Card>
<Card>
<CardContent>
<h3 className="font-medium">Resources</h3>
<ul className="mt-2 text-sm text-muted-foreground list-disc ml-5">
<li>Model files</li>
<li>Reference sheets</li>
<li>Assignments</li>
</ul>
</CardContent>
</Card>
</div> */}
</div>
<aside className="w-80 hidden lg:block">
<h2 className="text-sm font-semibold mb-3">Playlist</h2>
<div className="flex flex-col gap-3">
{playlistLoading ? (
<div>Loading playlist</div>
) : (
playlist?.videos?.map((v: VideoFromApi) => {
const active = String(v.id) === String(current.id);
const watchedPercent = 0; // Replace with actual watched % from your state/API
return (
<div
key={v.id}
className={`relative rounded-md border p-2 flex flex-col gap-2 cursor-pointer transition
${
active ? 'border-primary bg-muted' : 'border-transparent hover:border-border'
}`}
onClick={() => {
// Check if video is accessible based on the logic:
// 1. If locked at schema level, always deny
if ((v as any).locked) return;
// 2. If instantAccess, always allow
if ((v as any).instantAccess) {
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
return;
}
// 3. If first video (index 0), always allow
if ((v as any).index === 0) {
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
return;
}
// 4. Otherwise, check if user has unlocked it
if (userUnlocks.has(v.id)) {
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
}
}}
>
<div className="flex items-center gap-3">
<div className="relative w-20 h-12 shrink-0 overflow-hidden rounded">
<img src={v.thumbnail ?? '/01.jpg'} alt={v.title} className="w-full h-full object-cover" loading="lazy" />
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Lock className="w-5 h-5 text-white" />
</div>
) : null}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="text-sm font-medium truncate">{v.title}</div>
<Badge>{fmt(v)}</Badge>
</div>
<div className="text-xs text-muted-foreground truncate mt-1">
{(v as any).locked ? 'Locked' : (v as any).instantAccess || (v as any).index === 0 ? 'Available' : userUnlocks.has(v.id) ? 'Available' : active ? 'Now playing' : 'Locked'}
</div>
</div>
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
<div className="ml-2">
<Button
size="sm"
variant={(v as any).locked ? "secondary" : "default"}
disabled={(v as any).locked || unlockingVideo === v.id}
onClick={(e) => {
e.stopPropagation();
handleUnlock(v);
}}
>
{unlockingVideo === v.id ? 'Unlocking...' : (v as any).locked ? 'Locked' : 'Unlock'}
</Button>
</div>
) : null}
</div>
<SegmentedProgressBar
segments={playlistSegments[v.id] ?? []}
duration={v.durationSec ?? 1}
percent={0}
height="sm"
showTooltip={false}
/>
</div>
);
})
)}
</div>
</aside>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}