Spaces:
Runtime error
Runtime error
| 'use client'; | |
| import React, { useState, useTransition } from 'react'; | |
| import { Card, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { Checkbox } from '@/components/ui/checkbox'; | |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Label } from '@/components/ui/label'; | |
| import { toast } from 'sonner'; | |
| import { approveDetection } from '../actions/approve-detection'; | |
| import { rejectDetection } from '../actions/reject-detection'; | |
| import { bulkApprove } from '../actions/bulk-approve'; | |
| import { bulkReject } from '../actions/bulk-reject'; | |
| import { triggerBulkMarketplaceMatchAction } from '@/features/marketplace/actions/trigger-bulk-marketplace-match'; | |
| import { resetDetection } from '../actions/edit-detection'; | |
| import { EditDetectionDialog } from './edit-detection-dialog'; | |
| import { AddProductDialog } from './add-product-dialog'; | |
| import { Check, X, ShieldCheck, ShoppingCart, Filter, CheckCircle2, Clock, CheckCircle, ShieldAlert, MousePointerClick, Heart, Sparkles } from 'lucide-react'; | |
| import Image from 'next/image'; | |
| interface Detection { | |
| detection: { | |
| id: string; | |
| objectName: string; | |
| category: string; | |
| confidenceScore: number; | |
| moderationStatus: 'PENDING' | 'APPROVED' | 'REJECTED'; | |
| thumbnailUrl?: string | null; | |
| createdAt: Date; | |
| }; | |
| video: { | |
| id: string; | |
| title: string; | |
| thumbnailUrl: string | null; | |
| }; | |
| marketplaceMatches: any[]; | |
| clickCount: number; | |
| interestPledgeCount: number; | |
| } | |
| interface ModerationQueueProps { | |
| initialDetections: Detection[]; | |
| userId: string; | |
| videos: { id: string; title: string }[]; | |
| stats: { pending: number; approved: number; rejected: number }; | |
| initialStatus?: 'PENDING' | 'APPROVED' | 'REJECTED'; | |
| } | |
| export function ModerationQueue({ initialDetections, userId, videos, stats, initialStatus = 'PENDING' }: ModerationQueueProps) { | |
| const [detections, setDetections] = useState(initialDetections); | |
| const [selectedIds, setSelectedIds] = useState<string[]>([]); | |
| const [filterStatus, setFilterStatus] = useState<'PENDING' | 'APPROVED' | 'REJECTED'>(initialStatus); | |
| const [minConfidence, setMinConfidence] = useState(0); | |
| const [filterVideo, setFilterVideo] = useState<string>('all'); | |
| const [filterHasLink, setFilterHasLink] = useState<string>('all'); // 'all' | 'with-link' | 'without-link' | |
| const [sortBy, setSortBy] = useState<string>('date'); // 'date' | 'clicks' | 'interest' | 'confidence' | |
| const [isPending, startTransition] = useTransition(); | |
| let filteredDetections = detections.filter(d => { | |
| // Status and confidence filter | |
| if (d.detection.moderationStatus !== filterStatus) return false; | |
| if (d.detection.confidenceScore < minConfidence) return false; | |
| // Video filter | |
| if (filterVideo !== 'all' && d.video.id !== filterVideo) return false; | |
| // Affiliate link filter | |
| if (filterHasLink === 'with-link' && d.marketplaceMatches.length === 0) return false; | |
| if (filterHasLink === 'without-link' && d.marketplaceMatches.length > 0) return false; | |
| return true; | |
| }); | |
| // Sorting | |
| filteredDetections = [...filteredDetections].sort((a, b) => { | |
| switch (sortBy) { | |
| case 'clicks': | |
| return b.clickCount - a.clickCount; | |
| case 'interest': | |
| return b.interestPledgeCount - a.interestPledgeCount; | |
| case 'confidence': | |
| return b.detection.confidenceScore - a.detection.confidenceScore; | |
| case 'date': | |
| default: | |
| return b.detection.createdAt.getTime() - a.detection.createdAt.getTime(); | |
| } | |
| }); | |
| const toggleSelection = (id: string) => { | |
| setSelectedIds(prev => | |
| prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] | |
| ); | |
| }; | |
| const toggleSelectAll = () => { | |
| if (selectedIds.length === filteredDetections.length) { | |
| setSelectedIds([]); | |
| } else { | |
| setSelectedIds(filteredDetections.map(d => d.detection.id)); | |
| } | |
| }; | |
| const handleApprove = async (id: string) => { | |
| startTransition(async () => { | |
| const result = await approveDetection(id); | |
| if (result.success) { | |
| setDetections(prev => prev.map(d => | |
| d.detection.id === id ? { ...d, detection: { ...d.detection, moderationStatus: 'APPROVED' } } : d | |
| )); | |
| toast.success('Detection approved'); | |
| } else { | |
| toast.error(result.error); | |
| } | |
| }); | |
| }; | |
| const handleReject = async (id: string) => { | |
| startTransition(async () => { | |
| const result = await rejectDetection(id); | |
| if (result.success) { | |
| setDetections(prev => prev.map(d => | |
| d.detection.id === id ? { ...d, detection: { ...d.detection, moderationStatus: 'REJECTED' } } : d | |
| )); | |
| toast.success('Detection rejected'); | |
| } else { | |
| toast.error(result.error); | |
| } | |
| }); | |
| }; | |
| const handleBulkApprove = async () => { | |
| startTransition(async () => { | |
| const result = await bulkApprove(selectedIds); | |
| if (result.success) { | |
| setDetections(prev => prev.map(d => | |
| selectedIds.includes(d.detection.id) ? { ...d, detection: { ...d.detection, moderationStatus: 'APPROVED' } } : d | |
| )); | |
| setSelectedIds([]); | |
| toast.success(`Approved ${result.count} items`); | |
| } else { | |
| toast.error(result.error); | |
| } | |
| }); | |
| }; | |
| const handleBulkReject = async () => { | |
| startTransition(async () => { | |
| const result = await bulkReject(selectedIds); | |
| if (result.success) { | |
| setDetections(prev => prev.map(d => | |
| selectedIds.includes(d.detection.id) ? { ...d, detection: { ...d.detection, moderationStatus: 'REJECTED' } } : d | |
| )); | |
| setSelectedIds([]); | |
| toast.success(`Rejected ${result.count} items`); | |
| } else { | |
| toast.error(result.error); | |
| } | |
| }); | |
| }; | |
| const handleBulkGenerateLinks = async () => { | |
| startTransition(async () => { | |
| const result = await triggerBulkMarketplaceMatchAction(selectedIds); | |
| if (result.success) { | |
| toast.success(`Generation triggered for ${result.count} items. Results will appear in a few moments.`); | |
| setSelectedIds([]); | |
| } else { | |
| toast.error(result.error || 'Failed to trigger link generation'); | |
| } | |
| }); | |
| }; | |
| const handleReset = async (id: string) => { | |
| startTransition(async () => { | |
| const result = await resetDetection(id); | |
| if (result.success) { | |
| setDetections(prev => prev.map(d => | |
| d.detection.id === id ? { ...d, detection: { ...d.detection, moderationStatus: 'PENDING' } } : d | |
| )); | |
| toast.success('Detection reset to pending'); | |
| } else { | |
| toast.error(result.error); | |
| } | |
| }); | |
| }; | |
| const statCards = [ | |
| { status: 'PENDING' as const, title: 'Pending Review', count: stats.pending, icon: <Clock className="w-5 h-5 text-amber-500" />, description: 'Items awaiting your approval', activeClasses: 'ring-2 ring-amber-500 bg-amber-500/10 border-amber-500/50' }, | |
| { status: 'APPROVED' as const, title: 'Approved', count: stats.approved, icon: <CheckCircle className="w-5 h-5 text-emerald-500" />, description: 'Successfully added to Vault', activeClasses: 'ring-2 ring-emerald-500 bg-emerald-500/10 border-emerald-500/50' }, | |
| { status: 'REJECTED' as const, title: 'Rejected', count: stats.rejected, icon: <ShieldAlert className="w-5 h-5 text-rose-500" />, description: 'Hidden from your Vault', activeClasses: 'ring-2 ring-rose-500 bg-rose-500/10 border-rose-500/50' }, | |
| ]; | |
| return ( | |
| <div className="space-y-6"> | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> | |
| {statCards.map(({ status, title, count, icon, description, activeClasses }) => ( | |
| <button | |
| key={status} | |
| type="button" | |
| onClick={() => setFilterStatus(status)} | |
| className={`text-left p-4 rounded-lg border transition-all group ${filterStatus === status ? activeClasses : 'bg-card/40 backdrop-blur-md border-border/40 hover:bg-card/60' | |
| }`} | |
| > | |
| <div className="flex items-center justify-between pb-2"> | |
| <span className="text-sm font-medium">{title}</span> | |
| <div className="p-2 bg-background/50 rounded-lg group-hover:scale-110 transition-transform"> | |
| {icon} | |
| </div> | |
| </div> | |
| <div className="text-3xl font-bold">{count}</div> | |
| <p className="text-xs text-muted-foreground mt-1">{description}</p> | |
| </button> | |
| ))} | |
| </div> | |
| <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={(v: any) => setFilterStatus(v)}> | |
| <SelectTrigger className="w-[150px]"> | |
| <SelectValue placeholder="Status" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="PENDING">Pending</SelectItem> | |
| <SelectItem value="APPROVED">Approved</SelectItem> | |
| <SelectItem value="REJECTED">Rejected</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| <div className="space-y-1"> | |
| <Label htmlFor="min-confidence">Min Confidence ({Math.round(minConfidence * 100)}%)</Label> | |
| <Input | |
| id="min-confidence" | |
| type="range" | |
| min="0" | |
| max="1" | |
| step="0.05" | |
| value={minConfidence} | |
| onChange={(e) => setMinConfidence(parseFloat(e.target.value))} | |
| className="w-[150px]" | |
| /> | |
| </div> | |
| <div className="space-y-1"> | |
| <Label>Video</Label> | |
| <Select value={filterVideo} onValueChange={setFilterVideo}> | |
| <SelectTrigger className="w-[200px]"> | |
| <SelectValue placeholder="All Videos" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="all">All Videos</SelectItem> | |
| {videos.map(v => ( | |
| <SelectItem key={v.id} value={v.id}>{v.title.slice(0, 40)}{v.title.length > 40 ? '...' : ''}</SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| <div className="space-y-1"> | |
| <Label>Affiliate Link</Label> | |
| <Select value={filterHasLink} onValueChange={setFilterHasLink}> | |
| <SelectTrigger className="w-[150px]"> | |
| <SelectValue placeholder="All" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="all">All Items</SelectItem> | |
| <SelectItem value="with-link">With Link</SelectItem> | |
| <SelectItem value="without-link">No Link</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| <div className="space-y-1"> | |
| <Label>Sort By</Label> | |
| <Select value={sortBy} onValueChange={setSortBy}> | |
| <SelectTrigger className="w-[150px]"> | |
| <SelectValue placeholder="Sort By" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="date">Date (Newest)</SelectItem> | |
| <SelectItem value="clicks">Most Clicks</SelectItem> | |
| <SelectItem value="interest">Most Interest</SelectItem> | |
| <SelectItem value="confidence">Confidence</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </div> | |
| <div className="flex items-center justify-between"> | |
| <div className="text-sm text-muted-foreground"> | |
| Showing {filteredDetections.length} of {detections.filter(d => d.detection.moderationStatus === filterStatus).length} items | |
| </div> | |
| <AddProductDialog | |
| videos={videos} | |
| onAdd={(newItem) => { | |
| setDetections(prev => [{ | |
| detection: { | |
| id: newItem.id, | |
| objectName: newItem.objectName, | |
| category: newItem.category, | |
| confidenceScore: 1, | |
| moderationStatus: 'PENDING', | |
| thumbnailUrl: newItem.thumbnailUrl ?? null, | |
| createdAt: new Date(), | |
| }, | |
| video: { | |
| id: newItem.videoId, | |
| title: newItem.videoTitle, | |
| thumbnailUrl: '', | |
| }, | |
| marketplaceMatches: newItem.hasMarketplaceMatch ? [{ id: 'new', marketplace: 'amazon', productName: '', affiliateUrl: '' }] : [], | |
| clickCount: 0, | |
| interestPledgeCount: 0, | |
| }, ...prev]); | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| {selectedIds.length > 0 && ( | |
| <div className="flex items-center gap-2 bg-primary/10 px-4 py-2 rounded-full border border-primary/20 animate-in fade-in slide-in-from-top-2"> | |
| <span className="text-sm font-medium mr-2">{selectedIds.length} selected</span> | |
| <Button size="sm" onClick={handleBulkApprove} disabled={isPending}> | |
| <Check className="w-4 h-4 mr-1" /> Approve | |
| </Button> | |
| <Button size="sm" variant="outline" className="bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500 border-none" onClick={handleBulkGenerateLinks} disabled={isPending}> | |
| <Sparkles className="w-4 h-4 mr-1" /> Generate Links | |
| </Button> | |
| <Button size="sm" variant="destructive" onClick={handleBulkReject} disabled={isPending}> | |
| <X className="w-4 h-4 mr-1" /> Reject | |
| </Button> | |
| </div> | |
| )} | |
| <div className="flex items-center gap-2 mb-4"> | |
| <Checkbox | |
| id="select-all" | |
| checked={selectedIds.length === filteredDetections.length && filteredDetections.length > 0} | |
| onCheckedChange={toggleSelectAll} | |
| /> | |
| <Label htmlFor="select-all" className="cursor-pointer">Select All Visible</Label> | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> | |
| {filteredDetections.length > 0 ? ( | |
| filteredDetections.map((d) => { | |
| const primaryMatch = d.marketplaceMatches[0]; | |
| const productImage = primaryMatch?.imageUrl; | |
| const snapshotImage = d.detection.thumbnailUrl; | |
| // Main image priority: Product Image -> Snapshot | |
| const mainImage = productImage || snapshotImage; | |
| // PiP Logic: Show Snapshot if we have BOTH images and they are different | |
| // (i.e. snapshot is not just a fallback copy of product image) | |
| const isSnapshotFallback = snapshotImage === productImage; | |
| const showPip = snapshotImage && productImage && !isSnapshotFallback; | |
| return ( | |
| <Card key={d.detection.id} className={`group overflow-hidden transition-all hover:ring-2 hover:ring-primary/50 ${selectedIds.includes(d.detection.id) ? 'ring-2 ring-primary' : 'bg-card/40 backdrop-blur-md border-border/40'}`}> | |
| <div className="relative aspect-square overflow-hidden bg-muted"> | |
| {mainImage ? ( | |
| <Image | |
| src={mainImage} | |
| alt={d.detection.objectName} | |
| fill | |
| className="object-contain transition-transform group-hover:scale-105" | |
| /> | |
| ) : ( | |
| <div className="w-full h-full bg-muted flex items-center justify-center"> | |
| <Filter className="w-8 h-8 text-muted-foreground" /> | |
| </div> | |
| )} | |
| {/* Video Snapshot Inset (PiP) */} | |
| {showPip && ( | |
| <div className="absolute bottom-2 left-2 w-16 h-16 rounded-lg overflow-hidden border-2 border-background shadow-xl z-20 transition-transform duration-300 group-hover:translate-x-1 group-hover:-translate-y-1"> | |
| <Image | |
| src={snapshotImage!} | |
| alt="In video" | |
| fill | |
| className="object-cover" | |
| sizes="64px" | |
| /> | |
| <div className="absolute inset-0 bg-black/20" /> | |
| <div className="absolute bottom-0 left-0 right-0 bg-black/60 py-0.5 px-0.5"> | |
| <span className="text-[6px] font-bold text-white uppercase tracking-tighter block text-center"> | |
| IN VIDEO | |
| </span> | |
| </div> | |
| </div> | |
| )} | |
| <div className="absolute top-2 left-2"> | |
| <Checkbox | |
| checked={selectedIds.includes(d.detection.id)} | |
| onCheckedChange={() => toggleSelection(d.detection.id)} | |
| className="bg-background/80 backdrop-blur-sm" | |
| /> | |
| </div> | |
| <div className="absolute top-2 right-2 flex flex-col gap-1 items-end"> | |
| <Badge variant="secondary" className="bg-background/80 backdrop-blur-sm"> | |
| {Math.round(d.detection.confidenceScore * 100)}% | |
| </Badge> | |
| {d.clickCount > 0 && ( | |
| <Badge variant="secondary" className="bg-blue-500/90 text-white backdrop-blur-sm flex items-center gap-1 text-xs px-1.5 py-0.5"> | |
| <MousePointerClick className="h-3 w-3" /> | |
| {d.clickCount} | |
| </Badge> | |
| )} | |
| {d.interestPledgeCount > 0 && ( | |
| <Badge variant="secondary" className="bg-rose-500/90 text-white backdrop-blur-sm flex items-center gap-1 text-xs px-1.5 py-0.5"> | |
| <Heart className="h-3 w-3" /> | |
| {d.interestPledgeCount} | |
| </Badge> | |
| )} | |
| </div> | |
| {d.marketplaceMatches.length > 0 && ( | |
| <div className="absolute bottom-2 right-2"> | |
| <Badge variant="default" className="bg-emerald-500/90 gap-1"> | |
| <ShoppingCart className="w-3 h-3" /> {d.marketplaceMatches.length} matches | |
| </Badge> | |
| </div> | |
| )} | |
| </div> | |
| <CardHeader className="p-4 py-3 space-y-1"> | |
| <div className="flex justify-between items-start"> | |
| <CardTitle className="text-lg font-bold truncate pr-2"> | |
| {d.detection.objectName} | |
| </CardTitle> | |
| <EditDetectionDialog | |
| detection={d.detection} | |
| marketplaceMatches={d.marketplaceMatches} | |
| onSave={(updated: { objectName: string; category: string; thumbnailUrl?: string }) => { | |
| setDetections(prev => prev.map(item => | |
| item.detection.id === d.detection.id ? { ...item, detection: { ...item.detection, ...updated } } : item | |
| )); | |
| }} | |
| /> | |
| </div> | |
| <div className="flex items-center gap-2 text-sm text-muted-foreground"> | |
| <Badge variant="outline" className="text-[10px] uppercase"> | |
| {d.detection.category} | |
| </Badge> | |
| <span className="text-[10px] truncate">• {d.video.title}</span> | |
| </div> | |
| </CardHeader> | |
| <CardFooter className="p-4 pt-0 gap-2"> | |
| {filterStatus === 'PENDING' ? ( | |
| <> | |
| <Button | |
| variant="secondary" | |
| className="grow bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500 border-none" | |
| onClick={() => handleApprove(d.detection.id)} | |
| disabled={isPending} | |
| > | |
| <Check className="w-4 h-4 mr-2" /> Approve | |
| </Button> | |
| <Button | |
| variant="secondary" | |
| className="grow bg-rose-500/10 hover:bg-rose-500/20 text-rose-500 border-none" | |
| onClick={() => handleReject(d.detection.id)} | |
| disabled={isPending} | |
| > | |
| <X className="w-4 h-4 mr-2" /> Reject | |
| </Button> | |
| </> | |
| ) : ( | |
| <div className="flex items-center text-sm font-medium gap-2 py-2 text-muted-foreground"> | |
| {filterStatus === 'APPROVED' ? ( | |
| <><CheckCircle2 className="w-4 h-4 text-emerald-500" /> Approved</> | |
| ) : ( | |
| <><X className="w-4 h-4 text-rose-500" /> Rejected</> | |
| )} | |
| <Button variant="link" size="sm" className="h-auto p-0 ml-auto" onClick={() => handleReset(d.detection.id)} disabled={isPending}> | |
| Reset | |
| </Button> | |
| </div> | |
| )} | |
| </CardFooter> | |
| </Card> | |
| ); | |
| }) | |
| ) : ( | |
| <div className="col-span-full py-20 text-center space-y-4 bg-muted/20 rounded-xl border-2 border-dashed border-border/50"> | |
| <div className="flex justify-center"> | |
| <ShieldCheck className="w-12 h-12 text-muted-foreground opacity-20" /> | |
| </div> | |
| <div className="space-y-2"> | |
| <h3 className="text-xl font-semibold">Queue Clear!</h3> | |
| <p className="text-muted-foreground">No {filterStatus.toLowerCase()} items found with current filters.</p> | |
| </div> | |
| <Button variant="outline" onClick={() => { setFilterStatus('PENDING'); setMinConfidence(0); }}> | |
| View Pending Queue | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |