Spaces:
Runtime error
Runtime error
feat: Implement admin moderation history, audit logging for detection actions, and a new admin navigation and opportunity hub.
5480d48 | '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> | |
| ); | |
| } | |