Files
Vault/app/admin/users/[userId]/user-detail-client.tsx
T
twotalesanimation 81ad7e4ea9 Initial commit
2026-06-11 10:46:09 +02:00

564 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}