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