vault-video-processor / src /features /admin /components /analysis-interface.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
16.1 kB
"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>
);
}