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, useTransition } from 'react'; | |
| import { useRouter } from 'next/navigation'; | |
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Check, X, Link2, ExternalLink, User, Clock, Tv, Search, Filter } from 'lucide-react'; | |
| import { approveProposal, rejectProposal } from '@/features/moderation/actions/handle-proposal'; | |
| import { toast } from 'sonner'; | |
| import { formatDistanceToNow } from 'date-fns'; | |
| import { Label } from '@/components/ui/label'; | |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; | |
| import { Input } from '@/components/ui/input'; | |
| interface Proposal { | |
| id: string; | |
| productName: string; | |
| productUrl: string; | |
| affiliateUrl: string; | |
| submitterName: string | null; | |
| submitterEmail: string | null; | |
| note: string | null; | |
| status: 'PENDING' | 'APPROVED' | 'REJECTED'; | |
| createdAt: Date; | |
| video: { | |
| title: string; | |
| channel: { | |
| channelName: string; | |
| }; | |
| }; | |
| } | |
| interface AdminProposalListProps { | |
| proposals: Proposal[]; | |
| forcedStatus?: string; | |
| hideFilters?: boolean; | |
| } | |
| export function AdminProposalList({ proposals: initialProposals, forcedStatus, hideFilters }: AdminProposalListProps) { | |
| const router = useRouter(); | |
| const [proposals, setProposals] = useState(initialProposals); | |
| const [isPending, startTransition] = useTransition(); | |
| const [filterStatus, setFilterStatus] = useState<string>(forcedStatus || 'PENDING'); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const handleApprove = (id: string) => { | |
| startTransition(async () => { | |
| const result = await approveProposal(id); | |
| if (result.success) { | |
| setProposals(prev => prev.map(p => p.id === id ? { ...p, status: 'APPROVED' } : p)); | |
| toast.success('Proposal approved and added to Vault!'); | |
| router.refresh(); | |
| } else { | |
| toast.error(result.error || 'Failed to approve'); | |
| } | |
| }); | |
| }; | |
| const handleReject = (id: string) => { | |
| startTransition(async () => { | |
| const result = await rejectProposal(id); | |
| if (result.success) { | |
| setProposals(prev => prev.map(p => p.id === id ? { ...p, status: 'REJECTED' } : p)); | |
| toast.success('Proposal rejected'); | |
| router.refresh(); | |
| } else { | |
| toast.error(result.error || 'Failed to reject'); | |
| } | |
| }); | |
| }; | |
| // Update internal status if forcedStatus changes | |
| React.useEffect(() => { | |
| if (forcedStatus) { | |
| setFilterStatus(forcedStatus); | |
| } | |
| }, [forcedStatus]); | |
| const filteredProposals = proposals.filter(p => { | |
| if (p.status !== filterStatus) return false; | |
| if (searchQuery) { | |
| const query = searchQuery.toLowerCase(); | |
| return ( | |
| p.productName.toLowerCase().includes(query) || | |
| (p.submitterName?.toLowerCase().includes(query) ?? false) || | |
| (p.submitterEmail?.toLowerCase().includes(query) ?? false) || | |
| (p.video.title.toLowerCase().includes(query)) || | |
| (p.video.channel.channelName.toLowerCase().includes(query)) | |
| ); | |
| } | |
| return true; | |
| }); | |
| const stats = { | |
| pending: proposals.filter(p => p.status === 'PENDING').length, | |
| approved: proposals.filter(p => p.status === 'APPROVED').length, | |
| rejected: proposals.filter(p => p.status === 'REJECTED').length, | |
| }; | |
| 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="APPROVED">Approved ({stats.approved})</SelectItem> | |
| <SelectItem value="REJECTED">Rejected ({stats.rejected})</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 product, creator, video, submitter..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="pl-9 h-9 bg-background/50" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div className="grid gap-6"> | |
| {filteredProposals.length > 0 ? ( | |
| filteredProposals.map((proposal) => ( | |
| <Card key={proposal.id} className="glass border-white/10 hover:border-white/20 transition-all overflow-hidden group"> | |
| <CardHeader className="pb-3 border-b border-white/5 bg-white/5"> | |
| <div className="flex items-center justify-between"> | |
| <div className="space-y-1"> | |
| <div className="flex items-center gap-2"> | |
| <CardTitle className="text-lg">{proposal.productName}</CardTitle> | |
| <Badge variant={proposal.status === 'PENDING' ? 'secondary' : proposal.status === 'APPROVED' ? 'default' : 'destructive'} className="text-[10px] uppercase font-bold tracking-wider"> | |
| {proposal.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" /> | |
| {proposal.video.channel.channelName} | |
| </div> | |
| </div> | |
| <p className="text-xs text-muted-foreground flex items-center gap-1"> | |
| <Clock className="h-3 w-3" /> | |
| Submitted {formatDistanceToNow(new Date(proposal.createdAt))} ago • | |
| <span className="text-white/40 ml-1 truncate max-w-[200px] inline-block">{proposal.video.title}</span> | |
| </p> | |
| </div> | |
| {proposal.status === 'PENDING' && ( | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| size="sm" | |
| variant="ghost" | |
| onClick={() => handleReject(proposal.id)} | |
| disabled={isPending} | |
| className="text-red-400 hover:text-red-300 hover:bg-red-400/10" | |
| > | |
| <X className="h-4 w-4 mr-1.5" /> | |
| Reject | |
| </Button> | |
| <Button | |
| size="sm" | |
| onClick={() => handleApprove(proposal.id)} | |
| disabled={isPending} | |
| className="bg-green-600 hover:bg-green-500 text-white" | |
| > | |
| <Check className="h-4 w-4 mr-1.5" /> | |
| Approve & List | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| </CardHeader> | |
| <CardContent className="py-4 grid sm:grid-cols-2 gap-6"> | |
| <div className="space-y-4"> | |
| <div className="space-y-1.5"> | |
| <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Original Link</span> | |
| <a | |
| href={proposal.productUrl} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="flex items-center gap-2 text-sm text-blue-400 hover:underline group" | |
| > | |
| View Product Page | |
| <ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" /> | |
| </a> | |
| </div> | |
| <div className="space-y-1.5"> | |
| <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Affiliate Link</span> | |
| <div className="bg-white/5 border border-white/10 rounded-md p-2 flex items-center justify-between"> | |
| <span className="text-xs font-mono truncate text-gray-300">{proposal.affiliateUrl}</span> | |
| <a href={proposal.affiliateUrl} target="_blank" rel="noopener noreferrer"> | |
| <Button variant="ghost" size="icon" className="h-6 w-6"> | |
| <ExternalLink className="h-3 w-3" /> | |
| </Button> | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="space-y-4"> | |
| <div className="space-y-1.5"> | |
| <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Submitter Details</span> | |
| <div className="flex items-center gap-2 text-sm text-gray-200"> | |
| <User className="h-3.5 w-3.5 text-muted-foreground" /> | |
| {proposal.submitterName || 'Anonymous'} | |
| {proposal.submitterEmail && ( | |
| <span className="text-muted-foreground ml-1">({proposal.submitterEmail})</span> | |
| )} | |
| </div> | |
| </div> | |
| {proposal.note && ( | |
| <div className="space-y-1.5"> | |
| <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Note</span> | |
| <p className="text-sm text-gray-300 italic border-l-2 border-primary/30 pl-3"> | |
| "{proposal.note}" | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )) | |
| ) : ( | |
| <div className="py-20 text-center space-y-4 bg-muted/20 rounded-xl border-2 border-dashed border-border/50"> | |
| <h3 className="text-xl font-semibold text-muted-foreground">No proposals found</h3> | |
| <p className="text-muted-foreground text-sm">Try adjusting your filters or search query.</p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |