vault-video-processor / src /features /admin /components /admin-proposal-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
13.6 kB
'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>
);
}