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 { useState, useTransition } from "react"; | |
| import { YoutubeVideo, DetectedObject, MarketplaceMatch } from "@/lib/db/schema"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Card, CardContent, CardHeader, CardTitle, CardFooter, CardDescription } from "@/components/ui/card"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | |
| import { | |
| Play, | |
| Plus, | |
| CheckCircle2, | |
| XCircle, | |
| ArrowLeft, | |
| ChevronRight, | |
| Copy, | |
| ExternalLink, | |
| Video, | |
| Package, | |
| History, | |
| Save | |
| } from "lucide-react"; | |
| import Link from "next/link"; | |
| import { CrossVaultSuggestions } from "./cross-vault-suggestions"; | |
| import { VideoEmbed } from "@/features/vault/components/video-embed"; | |
| import { AnalysisHistory } from "./analysis-history"; | |
| import { | |
| triggerAIAnalysisAction, | |
| approveDetectionAction, | |
| rejectDetectionAction, | |
| completeVideoReviewAction, | |
| addManualProductAction | |
| } from "../actions/analysis"; | |
| import { toast } from "sonner"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Label } from "@/components/ui/label"; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogHeader, | |
| DialogTitle, | |
| DialogTrigger, | |
| DialogFooter | |
| } from "@/components/ui/dialog"; | |
| type ExtendedVideo = YoutubeVideo & { | |
| channel?: { channelName: string }; | |
| detections: (DetectedObject & { marketplaceMatches: MarketplaceMatch[] })[]; | |
| }; | |
| export function AnalysisInterface({ video }: { video: ExtendedVideo }) { | |
| const [isPending, startTransition] = useTransition(); | |
| const [activeTab, setActiveTab] = useState("review"); | |
| const [isAddProductOpen, setIsAddProductOpen] = useState(false); | |
| const handleTriggerAI = async () => { | |
| startTransition(async () => { | |
| const result = await triggerAIAnalysisAction(video.id); | |
| if (result.success) { | |
| toast.success("AI Analysis triggered"); | |
| } else { | |
| toast.error("Failed to trigger analysis"); | |
| } | |
| }); | |
| }; | |
| const handleApprove = async (id: string) => { | |
| startTransition(async () => { | |
| await approveDetectionAction(id); | |
| toast.success("Product approved"); | |
| }); | |
| }; | |
| const handleReject = async (id: string) => { | |
| startTransition(async () => { | |
| await rejectDetectionAction(id); | |
| toast.success("Product rejected"); | |
| }); | |
| }; | |
| const handleComplete = async () => { | |
| startTransition(async () => { | |
| await completeVideoReviewAction(video.id); | |
| toast.success("Video review completed"); | |
| }); | |
| }; | |
| return ( | |
| <div className="space-y-6"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex flex-col gap-1"> | |
| <div className="flex items-center gap-2 text-xs text-muted-foreground mb-1"> | |
| <Link href="/admin/dashboard" className="hover:text-primary transition-colors">Dashboard</Link> | |
| <span>/</span> | |
| <Link href="/admin/analysis-queue" className="hover:text-primary transition-colors">Video Hub</Link> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| <Button variant="ghost" size="icon" className="h-8 w-8" asChild> | |
| <Link href="/admin/analysis-queue"> | |
| <ArrowLeft className="h-4 w-4" /> | |
| </Link> | |
| </Button> | |
| <div> | |
| <h1 className="text-2xl font-bold line-clamp-1">{video.title}</h1> | |
| <p className="text-sm text-muted-foreground">{video.channel?.channelName}</p> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="secondary" | |
| onClick={handleTriggerAI} | |
| disabled={isPending || video.scanStatus === 'in_progress'} | |
| > | |
| <Play className="mr-2 h-4 w-4" /> | |
| {video.scanStatus === 'pending_analysis' ? 'Run AI Analysis' : 'Re-Run AI'} | |
| </Button> | |
| <Button | |
| variant="default" | |
| className="bg-emerald-600 hover:bg-emerald-700" | |
| onClick={handleComplete} | |
| disabled={isPending} | |
| > | |
| <CheckCircle2 className="mr-2 h-4 w-4" /> | |
| Mark as Done | |
| </Button> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> | |
| {/* Left Side: Video Player & Overview */} | |
| <div className="lg:col-span-2 space-y-6"> | |
| <VideoEmbed | |
| videoId={video.videoId} | |
| title={video.title} | |
| platform={video.platform as any} | |
| url={video.url} | |
| width={video.width} | |
| height={video.height} | |
| /> | |
| <Tabs value={activeTab} onValueChange={setActiveTab}> | |
| <TabsList className="grid w-full grid-cols-3"> | |
| <TabsTrigger value="review">Review Objects</TabsTrigger> | |
| <TabsTrigger value="suggestions">Cross-Vault suggestions</TabsTrigger> | |
| <TabsTrigger value="history">History</TabsTrigger> | |
| </TabsList> | |
| <TabsContent value="review" className="space-y-4 pt-4"> | |
| <div className="flex items-center justify-between"> | |
| <h3 className="text-lg font-semibold">Detected Objects ({video.detections.length})</h3> | |
| <Dialog open={isAddProductOpen} onOpenChange={setIsAddProductOpen}> | |
| <DialogTrigger asChild> | |
| <Button size="sm"> | |
| <Plus className="h-4 w-4 mr-2" /> | |
| Add Manual Product | |
| </Button> | |
| </DialogTrigger> | |
| <AddProductDialog | |
| videoId={video.id} | |
| onSuccess={() => setIsAddProductOpen(false)} | |
| /> | |
| </Dialog> | |
| </div> | |
| <div className="grid gap-4"> | |
| {video.detections.map((obj) => ( | |
| <Card key={obj.id} className="bg-card/40 backdrop-blur-md border-border/40"> | |
| <div className="p-4 flex gap-4"> | |
| <div className="w-24 h-24 bg-muted rounded-md overflow-hidden flex-shrink-0"> | |
| {obj.thumbnailUrl ? ( | |
| <img src={obj.thumbnailUrl} className="w-full h-full object-cover" /> | |
| ) : ( | |
| <Package className="w-full h-full p-6 text-muted-foreground opacity-20" /> | |
| )} | |
| </div> | |
| <div className="flex-1 flex flex-col gap-1"> | |
| <div className="flex items-center justify-between"> | |
| <h4 className="font-semibold">{obj.objectName}</h4> | |
| <Badge variant={obj.moderationStatus === 'APPROVED' ? 'default' : obj.moderationStatus === 'REJECTED' ? 'destructive' : 'outline'}> | |
| {obj.moderationStatus} | |
| </Badge> | |
| </div> | |
| <p className="text-xs text-muted-foreground">Category: {obj.category}</p> | |
| {obj.marketplaceMatches?.[0] && ( | |
| <div className="mt-2 text-sm flex items-center gap-2 text-emerald-500"> | |
| <ExternalLink className="h-3 w-3" /> | |
| <span className="line-clamp-1">{obj.marketplaceMatches[0].affiliateUrl}</span> | |
| </div> | |
| )} | |
| <div className="mt-4 flex items-center gap-2"> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| className="h-8 border-emerald-500/50 text-emerald-500 hover:bg-emerald-500/10" | |
| onClick={() => handleApprove(obj.id)} | |
| disabled={isPending || obj.moderationStatus === 'APPROVED'} | |
| > | |
| Approve | |
| </Button> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| className="h-8 border-rose-500/50 text-rose-500 hover:bg-rose-500/10" | |
| onClick={() => handleReject(obj.id)} | |
| disabled={isPending || obj.moderationStatus === 'REJECTED'} | |
| > | |
| Reject | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| </Card> | |
| ))} | |
| </div> | |
| </TabsContent> | |
| <TabsContent value="suggestions" className="pt-4"> | |
| <CrossVaultSuggestions videoId={video.videoId} videoInternalId={video.id} /> | |
| </TabsContent> | |
| <TabsContent value="history" className="pt-4"> | |
| <AnalysisHistory videoInternalId={video.id} /> | |
| </TabsContent> | |
| </Tabs> | |
| </div> | |
| {/* Right Side: Metadata & Status */} | |
| <div className="space-y-6"> | |
| <Card> | |
| <CardHeader> | |
| <CardTitle>Video Details</CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| <div className="space-y-1"> | |
| <Label className="text-xs text-muted-foreground">Platform</Label> | |
| <p className="text-sm font-medium uppercase">{video.platform}</p> | |
| </div> | |
| <div className="space-y-1"> | |
| <Label className="text-xs text-muted-foreground">Status</Label> | |
| <p className="text-sm font-medium capitalize">{video.scanStatus.replace('_', ' ')}</p> | |
| </div> | |
| <div className="space-y-1"> | |
| <Label className="text-xs text-muted-foreground">Video URL</Label> | |
| <div className="flex items-center gap-2"> | |
| <Input value={video.url || ''} readOnly className="h-8 text-xs" /> | |
| <Button variant="ghost" size="icon" className="h-8 w-8"> | |
| <ExternalLink className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Card className="bg-primary/5 border-primary/20"> | |
| <CardHeader> | |
| <CardTitle className="text-sm">Admin Instructions</CardTitle> | |
| </CardHeader> | |
| <CardContent className="text-xs space-y-2 text-muted-foreground"> | |
| <p>1. Run AI Analysis if not already done.</p> | |
| <p>2. Approve items that are correctly identified.</p> | |
| <p>3. Add any missing products manually.</p> | |
| <p>4. Use the Suggestions tab to reuse products from other creators who shared this video.</p> | |
| <p>5. Mark as Done to notify the creator.</p> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function AddProductDialog({ videoId, onSuccess }: { videoId: string, onSuccess: () => void }) { | |
| const [isPending, startTransition] = useTransition(); | |
| const [name, setName] = useState(""); | |
| const [category, setCategory] = useState("Apparel"); | |
| const [url, setUrl] = useState(""); | |
| const handleSubmit = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| startTransition(async () => { | |
| const result = await addManualProductAction(videoId, { | |
| objectName: name, | |
| category: category as any, | |
| affiliateUrl: url | |
| }); | |
| if (result.success) { | |
| toast.success("Product added manually"); | |
| onSuccess(); | |
| } else { | |
| toast.error("Failed to add product"); | |
| } | |
| }); | |
| }; | |
| return ( | |
| <DialogContent> | |
| <form onSubmit={handleSubmit}> | |
| <DialogHeader> | |
| <DialogTitle>Add Manual Product</DialogTitle> | |
| </DialogHeader> | |
| <div className="grid gap-4 py-4"> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="name">Product Name</Label> | |
| <Input id="name" value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Sony WH-1000XM4" required /> | |
| </div> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="category">Category</Label> | |
| <Input id="category" value={category} onChange={e => setCategory(e.target.value)} required /> | |
| </div> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="url">Affiliate/Product URL</Label> | |
| <Input id="url" value={url} onChange={e => setUrl(e.target.value)} placeholder="https://amazon.com/..." /> | |
| </div> | |
| </div> | |
| <DialogFooter> | |
| <Button type="submit" disabled={isPending}> | |
| {isPending ? "Adding..." : "Add Product"} | |
| </Button> | |
| </DialogFooter> | |
| </form> | |
| </DialogContent> | |
| ); | |
| } | |