vault-video-processor / src /features /admin /components /admin-request-list.tsx
dvijaykrishnan's picture
feat: Implement admin moderation history, audit logging for detection actions, and a new admin navigation and opportunity hub.
5480d48
Raw
History Blame Contribute Delete
12.6 kB
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { formatDistanceToNow } from 'date-fns';
import { CheckCircle, Clock, ShieldAlert, Filter, Search, User, Mail, Video as VideoIcon, Tv } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { updateRequestStatus } from '@/actions/submit-request';
import { toast } from 'sonner';
import Image from 'next/image';
interface Request {
id: string;
videoId: string;
creatorId: string;
viewerName: string | null;
viewerEmail: string | null;
note: string;
imageUrl: string | null;
status: string;
createdAt: Date;
video: {
title: string;
channel: {
channelName: string;
};
};
creator: {
name: string | null;
};
}
interface AdminRequestListProps {
requests: Request[];
forcedStatus?: string;
hideFilters?: boolean;
}
export function AdminRequestList({ requests: initialRequests, forcedStatus, hideFilters }: AdminRequestListProps) {
const router = useRouter();
const [requests, setRequests] = useState(initialRequests);
const [filterStatus, setFilterStatus] = useState<string>(forcedStatus || 'PENDING');
const [sortBy, setSortBy] = useState<string>('date');
const [searchQuery, setSearchQuery] = useState('');
const stats = {
pending: requests.filter(r => r.status === 'PENDING').length,
fulfilled: requests.filter(r => r.status === 'FULFILLED').length,
dismissed: requests.filter(r => r.status === 'DISMISSED').length,
};
const filteredRequests = requests.filter(r => {
if (r.status !== filterStatus) return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
r.note.toLowerCase().includes(query) ||
(r.viewerName?.toLowerCase().includes(query) ?? false) ||
(r.viewerEmail?.toLowerCase().includes(query) ?? false) ||
(r.video.title.toLowerCase().includes(query)) ||
(r.video.channel.channelName.toLowerCase().includes(query))
);
}
return true;
}).sort((a, b) => {
if (sortBy === 'date') {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}
return 0;
});
const handleStatusUpdate = async (id: string, newStatus: 'PENDING' | 'FULFILLED' | 'DISMISSED') => {
try {
const result = await updateRequestStatus(id, newStatus);
if (result.success) {
setRequests(prev => prev.map(r => r.id === id ? { ...r, status: newStatus } : r));
toast.success(`Request marked as ${newStatus.toLowerCase()}`);
router.refresh();
} else {
toast.error(result.error || 'Failed to update status');
}
} catch (err) {
toast.error('Failed to update request status');
}
};
// Update internal status if forcedStatus changes
React.useEffect(() => {
if (forcedStatus) {
setFilterStatus(forcedStatus);
}
}, [forcedStatus]);
return (
<div className="space-y-6">
{/* Filter Bar */}
{!hideFilters && (
<div className="flex flex-col gap-4 bg-card/50 p-4 rounded-lg border border-border/50 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-4">
<div className="space-y-1">
<Label>Status</Label>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-[150px] h-9 bg-background/50">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="PENDING">Pending ({stats.pending})</SelectItem>
<SelectItem value="FULFILLED">Fulfilled ({stats.fulfilled})</SelectItem>
<SelectItem value="DISMISSED">Dismissed ({stats.dismissed})</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Sort By</Label>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[150px] h-9 bg-background/50">
<SelectValue placeholder="Sort By" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date">Date (Newest)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1 flex-grow">
<Label>Search</Label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by note, creator, video, name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-background/50"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {filteredRequests.length} of {requests.filter(r => r.status === filterStatus).length} {filterStatus.toLowerCase()} requests
</div>
</div>
</div>
)}
{filteredRequests.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredRequests.map((request) => (
<Card key={request.id} className="bg-card/60 border-white/10 overflow-hidden flex flex-col hover:border-primary/20 transition-all group">
{request.imageUrl && (
<div className="relative aspect-video w-full overflow-hidden border-b border-white/10">
<Image
src={request.imageUrl}
alt="Requested product"
fill
className="object-cover group-hover:scale-105 transition-transform duration-500"
/>
</div>
)}
<CardHeader className="pb-2">
<div className="flex justify-between items-start gap-2">
<div className="flex flex-col gap-1.5">
<Badge variant={request.status === 'PENDING' ? 'secondary' : 'default'} className="w-fit bg-primary/20 text-primary border-primary/30 text-[10px] uppercase font-bold tracking-wider">
{request.status}
</Badge>
<div className="flex items-center gap-1.5 text-[10px] font-bold text-amber-500 uppercase tracking-widest bg-amber-500/10 px-2 py-0.5 rounded-full border border-amber-500/20">
<Tv className="h-3 w-3" />
{request.video.channel.channelName}
</div>
</div>
<span className="text-[10px] text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })}
</span>
</div>
<CardTitle className="text-base mt-3 text-white line-clamp-2 leading-snug">
{request.note}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 flex-grow pb-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-white/5 p-2 rounded-lg">
<VideoIcon className="h-3.5 w-3.5 shrink-0 text-primary/70" />
<span className="truncate italic">"{request.video.title}"</span>
</div>
<div className="space-y-1.5 px-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<User className="h-3.5 w-3.5 shrink-0" />
<span><span className="text-white/60">From:</span> {request.viewerName || 'Anonymous'}</span>
</div>
{request.viewerEmail && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Mail className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{request.viewerEmail}</span>
</div>
)}
</div>
</CardContent>
<CardFooter className="flex gap-2 pt-4 border-t border-white/5 bg-white/5 mt-auto">
<button
onClick={() => handleStatusUpdate(request.id, 'FULFILLED')}
disabled={request.status === 'FULFILLED'}
className="flex-1 h-9 rounded-lg bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 text-xs font-bold hover:bg-emerald-500 hover:text-white transition-all disabled:opacity-50"
>
Mark Fulfilled
</button>
<button
onClick={() => handleStatusUpdate(request.id, 'DISMISSED')}
disabled={request.status === 'DISMISSED'}
className="flex-1 h-9 rounded-lg bg-white/5 text-muted-foreground border border-white/10 text-xs font-bold hover:bg-rose-500/10 hover:text-rose-500 hover:border-rose-500/20 transition-all disabled:opacity-50"
>
Dismiss
</button>
</CardFooter>
</Card>
))}
</div>
) : (
<div className="py-20 text-center space-y-4 bg-muted/20 rounded-xl border-2 border-dashed border-border/50">
<div className="flex justify-center">
<ShieldAlert className="w-12 h-12 text-muted-foreground opacity-20" />
</div>
<div className="space-y-2">
<h3 className="text-xl font-semibold">No requests found</h3>
<p className="text-muted-foreground">Try adjusting your filters or search query.</p>
</div>
</div>
)}
</div>
);
}