Spaces:
Runtime error
Runtime error
Commit ·
931547f
1
Parent(s): 792119a
feat: Implement a new product form for creating and editing product details, including image uploads and marketplace metadata fetching, and refine video analysis skipping logic.
Browse files- src/features/moderation/actions/edit-detection.ts +35 -0
- src/features/moderation/components/add-product-dialog.tsx +29 -454
- src/features/moderation/components/edit-detection-dialog.tsx +77 -160
- src/features/moderation/components/moderation-queue.tsx +167 -135
- src/features/moderation/components/product-form.tsx +483 -0
- src/features/moderation/services/moderation.service.ts +2 -1
- src/features/vault/services/showcase.service.ts +10 -1
src/features/moderation/actions/edit-detection.ts
CHANGED
|
@@ -22,10 +22,18 @@ export interface EditDetectionData {
|
|
| 22 |
category?: 'Tech' | 'Fashion' | 'Furniture' | 'Audio' | 'Other';
|
| 23 |
thumbnailUrl?: string;
|
| 24 |
marketplaceLinkOverrides?: Record<string, string>; // matchId → new affiliateUrl
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
newMarketplaceMatch?: NewMarketplaceMatch;
|
| 26 |
}
|
| 27 |
|
| 28 |
export async function editDetection(detectionId: string, data: EditDetectionData) {
|
|
|
|
|
|
|
| 29 |
try {
|
| 30 |
const session = await auth.api.getSession({
|
| 31 |
headers: await headers(),
|
|
@@ -74,6 +82,12 @@ export async function editDetection(detectionId: string, data: EditDetectionData
|
|
| 74 |
editHistory: [...(currentMetadata.editHistory || []), historyEntry]
|
| 75 |
};
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
// Update detection
|
| 78 |
await db
|
| 79 |
.update(detectedObjects)
|
|
@@ -101,6 +115,27 @@ export async function editDetection(detectionId: string, data: EditDetectionData
|
|
| 101 |
}
|
| 102 |
}
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
// Insert a new marketplace match if provided
|
| 105 |
if (data.newMarketplaceMatch) {
|
| 106 |
await db
|
|
|
|
| 22 |
category?: 'Tech' | 'Fashion' | 'Furniture' | 'Audio' | 'Other';
|
| 23 |
thumbnailUrl?: string;
|
| 24 |
marketplaceLinkOverrides?: Record<string, string>; // matchId → new affiliateUrl
|
| 25 |
+
marketplaceMatchUpdates?: Record<string, {
|
| 26 |
+
marketplace?: 'amazon' | 'ebay' | 'etsy';
|
| 27 |
+
productName?: string;
|
| 28 |
+
price?: number;
|
| 29 |
+
affiliateUrl?: string;
|
| 30 |
+
}>;
|
| 31 |
newMarketplaceMatch?: NewMarketplaceMatch;
|
| 32 |
}
|
| 33 |
|
| 34 |
export async function editDetection(detectionId: string, data: EditDetectionData) {
|
| 35 |
+
console.log(`[editDetection] Input for ${detectionId}:`, JSON.stringify(data, null, 2));
|
| 36 |
+
console.log(`[editDetection] Starting update for ${detectionId}`, JSON.stringify(data, null, 2));
|
| 37 |
try {
|
| 38 |
const session = await auth.api.getSession({
|
| 39 |
headers: await headers(),
|
|
|
|
| 82 |
editHistory: [...(currentMetadata.editHistory || []), historyEntry]
|
| 83 |
};
|
| 84 |
|
| 85 |
+
console.log(`[editDetection] Updating detection:`, {
|
| 86 |
+
objectName: data.objectName,
|
| 87 |
+
category: data.category,
|
| 88 |
+
thumbnailUrl: data.thumbnailUrl
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
// Update detection
|
| 92 |
await db
|
| 93 |
.update(detectedObjects)
|
|
|
|
| 115 |
}
|
| 116 |
}
|
| 117 |
|
| 118 |
+
// Update detection matches with full details
|
| 119 |
+
if (data.marketplaceMatchUpdates) {
|
| 120 |
+
for (const [matchId, updates] of Object.entries(data.marketplaceMatchUpdates)) {
|
| 121 |
+
await db
|
| 122 |
+
.update(marketplaceMatches)
|
| 123 |
+
.set({
|
| 124 |
+
...(updates.marketplace ? { marketplace: updates.marketplace } : {}),
|
| 125 |
+
...(updates.productName ? { productName: updates.productName } : {}),
|
| 126 |
+
...(updates.price !== undefined ? { price: updates.price } : {}),
|
| 127 |
+
...(updates.affiliateUrl ? { affiliateUrl: updates.affiliateUrl } : {}),
|
| 128 |
+
updatedAt: new Date(),
|
| 129 |
+
})
|
| 130 |
+
.where(
|
| 131 |
+
and(
|
| 132 |
+
eq(marketplaceMatches.id, matchId),
|
| 133 |
+
eq(marketplaceMatches.objectId, detectionId)
|
| 134 |
+
)
|
| 135 |
+
);
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
// Insert a new marketplace match if provided
|
| 140 |
if (data.newMarketplaceMatch) {
|
| 141 |
await db
|
src/features/moderation/components/add-product-dialog.tsx
CHANGED
|
@@ -1,22 +1,17 @@
|
|
| 1 |
-
import React, { useState, useTransition
|
| 2 |
import {
|
| 3 |
Dialog,
|
| 4 |
DialogContent,
|
| 5 |
DialogDescription,
|
| 6 |
-
DialogFooter,
|
| 7 |
DialogHeader,
|
| 8 |
DialogTitle,
|
| 9 |
DialogTrigger,
|
| 10 |
} from '@/components/ui/dialog';
|
| 11 |
import { Button } from '@/components/ui/button';
|
| 12 |
-
import {
|
| 13 |
-
import { Label } from '@/components/ui/label';
|
| 14 |
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
| 15 |
-
import { Plus, Save, Search, Loader2, Upload, Trash2, Image as ImageIcon, PlaySquare } from 'lucide-react';
|
| 16 |
import { addDetection } from '../actions/add-detection';
|
| 17 |
-
import { fetchProductMetadata } from '../actions/fetch-product-metadata';
|
| 18 |
-
import { uploadProductImage } from '../actions/upload-product-image';
|
| 19 |
import { toast } from 'sonner';
|
|
|
|
| 20 |
|
| 21 |
interface Video {
|
| 22 |
id: string;
|
|
@@ -39,218 +34,40 @@ interface AddProductDialogProps {
|
|
| 39 |
|
| 40 |
export function AddProductDialog({ videos, onAdd }: AddProductDialogProps) {
|
| 41 |
const [open, setOpen] = useState(false);
|
| 42 |
-
const [videoId, setVideoId] = useState(videos[0]?.id || '');
|
| 43 |
-
const [objectName, setObjectName] = useState('');
|
| 44 |
-
const [category, setCategory] = useState('Tech');
|
| 45 |
-
const [thumbnailUrl, setThumbnailUrl] = useState('');
|
| 46 |
-
const [frameTimestamp, setFrameTimestamp] = useState('0');
|
| 47 |
-
const [marketplace, setMarketplace] = useState('');
|
| 48 |
-
const [productName, setProductName] = useState('');
|
| 49 |
-
const [price, setPrice] = useState('');
|
| 50 |
-
const [affiliateUrl, setAffiliateUrl] = useState('');
|
| 51 |
-
const [snapshotUrl, setSnapshotUrl] = useState('');
|
| 52 |
const [isPending, startTransition] = useTransition();
|
| 53 |
-
const [isFetching, setIsFetching] = useState(false);
|
| 54 |
-
const [isUploading, setIsUploading] = useState(false);
|
| 55 |
-
const [isUploadingSnapshot, setIsUploadingSnapshot] = useState(false);
|
| 56 |
-
|
| 57 |
-
// Auto-select video if videos list changes or when opening
|
| 58 |
-
useEffect(() => {
|
| 59 |
-
if (open && videos.length === 1) {
|
| 60 |
-
setVideoId(videos[0].id);
|
| 61 |
-
}
|
| 62 |
-
}, [open, videos]);
|
| 63 |
-
|
| 64 |
-
// Clipboard Paste Listener
|
| 65 |
-
useEffect(() => {
|
| 66 |
-
const handlePaste = async (e: ClipboardEvent) => {
|
| 67 |
-
if (!open) return;
|
| 68 |
-
|
| 69 |
-
// Only paste into the snapshot if we are not currently focused on an input
|
| 70 |
-
// that might want the paste data (like the image URL input)
|
| 71 |
-
const target = e.target as HTMLElement;
|
| 72 |
-
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA';
|
| 73 |
-
|
| 74 |
-
// If it's an image in the clipboard, always prioritize snapshot
|
| 75 |
-
const items = e.clipboardData?.items;
|
| 76 |
-
if (!items) return;
|
| 77 |
-
|
| 78 |
-
for (let i = 0; i < items.length; i++) {
|
| 79 |
-
if (items[i].type.indexOf('image') !== -1) {
|
| 80 |
-
let file = items[i].getAsFile();
|
| 81 |
-
if (!file) continue;
|
| 82 |
-
|
| 83 |
-
// Ensure file has a name for storage, as clipboard blobs often don't
|
| 84 |
-
if (!file.name || file.name === 'blob') {
|
| 85 |
-
const extension = file.type.split('/')[1] || 'png';
|
| 86 |
-
file = new File([file], `paste-${Date.now()}.${extension}`, { type: file.type });
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
if (file.size > 10 * 1024 * 1024) {
|
| 90 |
-
toast.error('Image too large (max 10MB)');
|
| 91 |
-
continue;
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
setIsUploadingSnapshot(true);
|
| 95 |
-
const formData = new FormData();
|
| 96 |
-
formData.append('file', file);
|
| 97 |
-
|
| 98 |
-
try {
|
| 99 |
-
const result = await uploadProductImage(formData);
|
| 100 |
-
if (result.success && result.url) {
|
| 101 |
-
setSnapshotUrl(result.url);
|
| 102 |
-
toast.success('Snapshot pasted from clipboard!');
|
| 103 |
-
} else {
|
| 104 |
-
toast.error(result.error || 'Failed to upload pasted image');
|
| 105 |
-
}
|
| 106 |
-
} catch (err: any) {
|
| 107 |
-
console.error('Paste upload error:', err);
|
| 108 |
-
toast.error(`Error uploading pasted image: ${err.message || 'Unknown error'}`);
|
| 109 |
-
} finally {
|
| 110 |
-
setIsUploadingSnapshot(false);
|
| 111 |
-
}
|
| 112 |
-
break;
|
| 113 |
-
}
|
| 114 |
-
}
|
| 115 |
-
};
|
| 116 |
-
|
| 117 |
-
window.addEventListener('paste', handlePaste);
|
| 118 |
-
return () => window.removeEventListener('paste', handlePaste);
|
| 119 |
-
}, [open]);
|
| 120 |
-
|
| 121 |
-
const resetForm = () => {
|
| 122 |
-
setVideoId(videos[0]?.id || '');
|
| 123 |
-
setObjectName('');
|
| 124 |
-
setCategory('Tech');
|
| 125 |
-
setThumbnailUrl('');
|
| 126 |
-
setFrameTimestamp('0');
|
| 127 |
-
setMarketplace('');
|
| 128 |
-
setProductName('');
|
| 129 |
-
setPrice('');
|
| 130 |
-
setAffiliateUrl('');
|
| 131 |
-
};
|
| 132 |
-
|
| 133 |
-
const handleFetchMetadata = async () => {
|
| 134 |
-
if (!affiliateUrl) return;
|
| 135 |
-
|
| 136 |
-
setIsFetching(true);
|
| 137 |
-
try {
|
| 138 |
-
const result = await fetchProductMetadata(affiliateUrl);
|
| 139 |
-
if (result.success && result.data) {
|
| 140 |
-
const { productName, price, marketplace, imageUrl } = result.data;
|
| 141 |
-
setObjectName(productName);
|
| 142 |
-
setProductName(productName);
|
| 143 |
-
setPrice(price.toString());
|
| 144 |
-
setMarketplace(marketplace);
|
| 145 |
-
if (imageUrl) {
|
| 146 |
-
console.log('FETCH_DEBUG: Received imageUrl', imageUrl);
|
| 147 |
-
setThumbnailUrl(imageUrl);
|
| 148 |
-
} else {
|
| 149 |
-
console.warn('FETCH_DEBUG: No imageUrl returned');
|
| 150 |
-
}
|
| 151 |
-
toast.success('Product info fetched!');
|
| 152 |
-
} else {
|
| 153 |
-
toast.error(result.error || 'Could not fetch product info');
|
| 154 |
-
}
|
| 155 |
-
} catch (err) {
|
| 156 |
-
toast.error('Error fetching product info');
|
| 157 |
-
} finally {
|
| 158 |
-
setIsFetching(false);
|
| 159 |
-
}
|
| 160 |
-
};
|
| 161 |
-
|
| 162 |
-
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 163 |
-
const file = e.target.files?.[0];
|
| 164 |
-
if (!file) return;
|
| 165 |
-
|
| 166 |
-
setIsUploading(true);
|
| 167 |
-
const formData = new FormData();
|
| 168 |
-
formData.append('file', file);
|
| 169 |
-
|
| 170 |
-
try {
|
| 171 |
-
const result = await uploadProductImage(formData);
|
| 172 |
-
if (result.success && result.url) {
|
| 173 |
-
setThumbnailUrl(result.url);
|
| 174 |
-
toast.success('Image uploaded!');
|
| 175 |
-
} else {
|
| 176 |
-
toast.error(result.error || 'Failed to upload image');
|
| 177 |
-
}
|
| 178 |
-
} catch (err) {
|
| 179 |
-
toast.error('Error uploading image');
|
| 180 |
-
} finally {
|
| 181 |
-
setIsUploading(false);
|
| 182 |
-
}
|
| 183 |
-
};
|
| 184 |
-
|
| 185 |
-
const handleSnapshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 186 |
-
const file = e.target.files?.[0];
|
| 187 |
-
if (!file) return;
|
| 188 |
-
|
| 189 |
-
setIsUploadingSnapshot(true);
|
| 190 |
-
const formData = new FormData();
|
| 191 |
-
formData.append('file', file);
|
| 192 |
-
|
| 193 |
-
try {
|
| 194 |
-
const result = await uploadProductImage(formData);
|
| 195 |
-
if (result.success && result.url) {
|
| 196 |
-
setSnapshotUrl(result.url);
|
| 197 |
-
toast.success('Snapshot uploaded!');
|
| 198 |
-
} else {
|
| 199 |
-
toast.error(result.error || 'Failed to upload snapshot');
|
| 200 |
-
}
|
| 201 |
-
} catch (err) {
|
| 202 |
-
toast.error('Error uploading snapshot');
|
| 203 |
-
} finally {
|
| 204 |
-
setIsUploadingSnapshot(false);
|
| 205 |
-
}
|
| 206 |
-
};
|
| 207 |
-
|
| 208 |
-
const handleSave = () => {
|
| 209 |
-
if (!videoId || !objectName || !category) {
|
| 210 |
-
toast.error('Please fill in required fields');
|
| 211 |
-
return;
|
| 212 |
-
}
|
| 213 |
|
|
|
|
| 214 |
startTransition(async () => {
|
| 215 |
const result = await addDetection({
|
| 216 |
-
videoId,
|
| 217 |
-
objectName,
|
| 218 |
-
category: category as any,
|
| 219 |
-
thumbnailUrl: snapshotUrl || undefined, //
|
| 220 |
-
frameTimestamp:
|
| 221 |
-
marketplaceMatch:
|
| 222 |
? {
|
| 223 |
-
marketplace: marketplace as any,
|
| 224 |
-
productName: productName
|
| 225 |
-
price: parseFloat(price) || 0,
|
| 226 |
-
affiliateUrl: affiliateUrl
|
| 227 |
-
imageUrl: thumbnailUrl || undefined, //
|
| 228 |
}
|
| 229 |
: undefined,
|
| 230 |
});
|
| 231 |
|
| 232 |
if (result.success) {
|
| 233 |
-
const selectedVideo = videos.find(v => v.id === videoId);
|
| 234 |
onAdd({
|
| 235 |
id: result.detectionId!,
|
| 236 |
-
videoId,
|
| 237 |
videoTitle: selectedVideo?.title ?? '',
|
| 238 |
-
objectName,
|
| 239 |
-
category,
|
| 240 |
-
frameTimestamp:
|
| 241 |
-
thumbnailUrl: snapshotUrl || thumbnailUrl || undefined,
|
| 242 |
-
hasMarketplaceMatch: !!
|
| 243 |
});
|
| 244 |
setOpen(false);
|
| 245 |
-
// Reset form
|
| 246 |
-
setObjectName('');
|
| 247 |
-
setCategory('Tech');
|
| 248 |
-
setThumbnailUrl('');
|
| 249 |
-
setSnapshotUrl('');
|
| 250 |
-
setProductName('');
|
| 251 |
-
setPrice('');
|
| 252 |
-
setAffiliateUrl('');
|
| 253 |
-
setMarketplace('');
|
| 254 |
toast.success('Product added successfully');
|
| 255 |
} else {
|
| 256 |
toast.error(result.error || 'Failed to add product');
|
|
@@ -276,255 +93,13 @@ export function AddProductDialog({ videos, onAdd }: AddProductDialogProps) {
|
|
| 276 |
</DialogDescription>
|
| 277 |
</DialogHeader>
|
| 278 |
|
| 279 |
-
<
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
id="affiliate-url"
|
| 287 |
-
value={affiliateUrl}
|
| 288 |
-
onChange={(e) => setAffiliateUrl(e.target.value)}
|
| 289 |
-
placeholder="Paste Amazon, eBay, or Etsy link..."
|
| 290 |
-
className="bg-background/50"
|
| 291 |
-
onBlur={() => {
|
| 292 |
-
if (affiliateUrl && !objectName) handleFetchMetadata();
|
| 293 |
-
}}
|
| 294 |
-
/>
|
| 295 |
-
<Button
|
| 296 |
-
size="icon"
|
| 297 |
-
onClick={handleFetchMetadata}
|
| 298 |
-
disabled={isFetching || !affiliateUrl}
|
| 299 |
-
className="shrink-0"
|
| 300 |
-
>
|
| 301 |
-
{isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
| 302 |
-
</Button>
|
| 303 |
-
</div>
|
| 304 |
-
<p className="text-[10px] text-muted-foreground">
|
| 305 |
-
Supported: Amazon, eBay, Etsy. We'll try to fetch price, name, and image.
|
| 306 |
-
</p>
|
| 307 |
-
</div>
|
| 308 |
-
|
| 309 |
-
{marketplace && (
|
| 310 |
-
<div className="grid grid-cols-2 gap-4 animate-in fade-in slide-in-from-top-2">
|
| 311 |
-
<div className="grid gap-2">
|
| 312 |
-
<Label className="text-xs">Marketplace</Label>
|
| 313 |
-
<Select value={marketplace} onValueChange={setMarketplace}>
|
| 314 |
-
<SelectTrigger className="h-8 text-xs bg-background/50">
|
| 315 |
-
<SelectValue />
|
| 316 |
-
</SelectTrigger>
|
| 317 |
-
<SelectContent>
|
| 318 |
-
<SelectItem value="amazon">Amazon</SelectItem>
|
| 319 |
-
<SelectItem value="ebay">eBay</SelectItem>
|
| 320 |
-
<SelectItem value="etsy">Etsy</SelectItem>
|
| 321 |
-
</SelectContent>
|
| 322 |
-
</Select>
|
| 323 |
-
</div>
|
| 324 |
-
<div className="grid gap-2">
|
| 325 |
-
<Label htmlFor="price" className="text-xs">Price ($)</Label>
|
| 326 |
-
<Input
|
| 327 |
-
id="price"
|
| 328 |
-
type="number"
|
| 329 |
-
value={price}
|
| 330 |
-
onChange={(e) => setPrice(e.target.value)}
|
| 331 |
-
className="h-8 text-xs bg-background/50"
|
| 332 |
-
/>
|
| 333 |
-
</div>
|
| 334 |
-
</div>
|
| 335 |
-
)}
|
| 336 |
-
</div>
|
| 337 |
-
|
| 338 |
-
<div className="grid gap-4 px-1">
|
| 339 |
-
<div className="grid gap-2">
|
| 340 |
-
<Label htmlFor="object-name">Object / Product Name *</Label>
|
| 341 |
-
<Input
|
| 342 |
-
id="object-name"
|
| 343 |
-
value={objectName}
|
| 344 |
-
onChange={(e) => setObjectName(e.target.value)}
|
| 345 |
-
placeholder="e.g. Sony WH-1000XM5"
|
| 346 |
-
/>
|
| 347 |
-
</div>
|
| 348 |
-
|
| 349 |
-
<div className="grid grid-cols-1 gap-4">
|
| 350 |
-
<div className="grid gap-2">
|
| 351 |
-
<Label>Video *</Label>
|
| 352 |
-
<Select value={videoId} onValueChange={setVideoId}>
|
| 353 |
-
<SelectTrigger className="w-full">
|
| 354 |
-
<SelectValue placeholder="Select video" />
|
| 355 |
-
</SelectTrigger>
|
| 356 |
-
<SelectContent>
|
| 357 |
-
{videos.map(v => (
|
| 358 |
-
<SelectItem key={v.id} value={v.id}>{v.title}</SelectItem>
|
| 359 |
-
))}
|
| 360 |
-
</SelectContent>
|
| 361 |
-
</Select>
|
| 362 |
-
</div>
|
| 363 |
-
<div className="grid gap-2">
|
| 364 |
-
<Label>Category *</Label>
|
| 365 |
-
<Select value={category} onValueChange={setCategory}>
|
| 366 |
-
<SelectTrigger className="w-full">
|
| 367 |
-
<SelectValue />
|
| 368 |
-
</SelectTrigger>
|
| 369 |
-
<SelectContent>
|
| 370 |
-
<SelectItem value="Tech">Tech</SelectItem>
|
| 371 |
-
<SelectItem value="Fashion">Fashion</SelectItem>
|
| 372 |
-
<SelectItem value="Furniture">Furniture</SelectItem>
|
| 373 |
-
<SelectItem value="Audio">Audio</SelectItem>
|
| 374 |
-
<SelectItem value="Other">Other</SelectItem>
|
| 375 |
-
</SelectContent>
|
| 376 |
-
</Select>
|
| 377 |
-
</div>
|
| 378 |
-
</div>
|
| 379 |
-
|
| 380 |
-
<div className="grid grid-cols-1 gap-4">
|
| 381 |
-
<div className="grid gap-2">
|
| 382 |
-
<Label htmlFor="timestamp">Video Timestamp (sec)</Label>
|
| 383 |
-
<Input
|
| 384 |
-
id="timestamp"
|
| 385 |
-
type="number"
|
| 386 |
-
value={frameTimestamp}
|
| 387 |
-
onChange={(e) => setFrameTimestamp(e.target.value)}
|
| 388 |
-
/>
|
| 389 |
-
</div>
|
| 390 |
-
<div className="grid grid-cols-2 gap-6">
|
| 391 |
-
{/* Product Image Section */}
|
| 392 |
-
<div className="space-y-3">
|
| 393 |
-
<Label className="text-sm font-semibold flex items-center gap-2 h-6">
|
| 394 |
-
<ImageIcon className="h-4 w-4 text-primary" />
|
| 395 |
-
Product Image
|
| 396 |
-
</Label>
|
| 397 |
-
<div className="space-y-3">
|
| 398 |
-
<div className="relative aspect-square w-full rounded-xl overflow-hidden border border-white/10 bg-white/5 group">
|
| 399 |
-
{thumbnailUrl ? (
|
| 400 |
-
<>
|
| 401 |
-
<img
|
| 402 |
-
src={thumbnailUrl}
|
| 403 |
-
alt="Product"
|
| 404 |
-
className="w-full h-full object-contain"
|
| 405 |
-
onError={() => {
|
| 406 |
-
console.error('IMAGE_LOAD_ERROR: Failed to load', thumbnailUrl);
|
| 407 |
-
// Optional: toast.error('Check your internet or image URL');
|
| 408 |
-
}}
|
| 409 |
-
/>
|
| 410 |
-
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-all duration-200">
|
| 411 |
-
<Button variant="destructive" size="icon" onClick={() => setThumbnailUrl('')} className="rounded-full shadow-xl transform scale-90 hover:scale-100 transition-transform">
|
| 412 |
-
<Trash2 className="h-4 w-4" />
|
| 413 |
-
</Button>
|
| 414 |
-
</div>
|
| 415 |
-
</>
|
| 416 |
-
) : (
|
| 417 |
-
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground p-4 text-center">
|
| 418 |
-
<ImageIcon className="h-8 w-8 mb-2 opacity-20" />
|
| 419 |
-
<span className="text-[10px]">Marketplace or Custom Image</span>
|
| 420 |
-
</div>
|
| 421 |
-
)}
|
| 422 |
-
{isUploading && (
|
| 423 |
-
<div className="absolute inset-0 bg-black/60 backdrop-blur-[2px] flex items-center justify-center">
|
| 424 |
-
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
| 425 |
-
</div>
|
| 426 |
-
)}
|
| 427 |
-
</div>
|
| 428 |
-
<div className="flex gap-2">
|
| 429 |
-
<div className="relative flex-1">
|
| 430 |
-
<Input
|
| 431 |
-
value={thumbnailUrl}
|
| 432 |
-
onChange={(e) => setThumbnailUrl(e.target.value)}
|
| 433 |
-
placeholder="Image URL"
|
| 434 |
-
className="h-9 text-xs pr-8 bg-background/50"
|
| 435 |
-
/>
|
| 436 |
-
</div>
|
| 437 |
-
<div className="relative">
|
| 438 |
-
<input type="file" id="prod-upload" className="hidden" accept="image/*" onChange={handleImageUpload} disabled={isUploading} />
|
| 439 |
-
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" asChild disabled={isUploading}>
|
| 440 |
-
<label htmlFor="prod-upload" className="cursor-pointer flex items-center justify-center w-full h-full">
|
| 441 |
-
<Upload className="h-4 w-4" />
|
| 442 |
-
</label>
|
| 443 |
-
</Button>
|
| 444 |
-
</div>
|
| 445 |
-
</div>
|
| 446 |
-
</div>
|
| 447 |
-
</div>
|
| 448 |
-
|
| 449 |
-
{/* In-Video Snapshot Section */}
|
| 450 |
-
<div className="space-y-3">
|
| 451 |
-
<Label className="text-sm font-semibold flex items-center gap-2 h-6">
|
| 452 |
-
<PlaySquare className="h-4 w-4 text-secondary" />
|
| 453 |
-
In-Video Snapshot
|
| 454 |
-
<span className="ml-auto text-[10px] font-normal text-muted-foreground bg-white/5 px-1.5 py-0.5 rounded border border-white/10 animate-pulse">
|
| 455 |
-
Cmd+V to Paste
|
| 456 |
-
</span>
|
| 457 |
-
</Label>
|
| 458 |
-
<div className="space-y-3">
|
| 459 |
-
<div className="relative aspect-square w-full rounded-xl overflow-hidden border border-white/10 bg-white/5 group">
|
| 460 |
-
{snapshotUrl ? (
|
| 461 |
-
<>
|
| 462 |
-
<img src={snapshotUrl} alt="Snapshot" className="w-full h-full object-cover" />
|
| 463 |
-
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-all duration-200">
|
| 464 |
-
<Button variant="destructive" size="icon" onClick={() => setSnapshotUrl('')} className="rounded-full shadow-xl transform scale-90 hover:scale-100 transition-transform">
|
| 465 |
-
<Trash2 className="h-4 w-4" />
|
| 466 |
-
</Button>
|
| 467 |
-
</div>
|
| 468 |
-
</>
|
| 469 |
-
) : (
|
| 470 |
-
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground p-4 text-center">
|
| 471 |
-
<PlaySquare className="h-8 w-8 mb-2 opacity-20" />
|
| 472 |
-
<span className="text-[10px]">Actual moment from video</span>
|
| 473 |
-
</div>
|
| 474 |
-
)}
|
| 475 |
-
{isUploadingSnapshot && (
|
| 476 |
-
<div className="absolute inset-0 bg-black/60 backdrop-blur-[2px] flex items-center justify-center">
|
| 477 |
-
<Loader2 className="h-6 w-6 animate-spin text-secondary" />
|
| 478 |
-
</div>
|
| 479 |
-
)}
|
| 480 |
-
</div>
|
| 481 |
-
<div className="flex gap-2">
|
| 482 |
-
<div className="relative flex-1">
|
| 483 |
-
<Input
|
| 484 |
-
value={snapshotUrl}
|
| 485 |
-
onChange={(e) => setSnapshotUrl(e.target.value)}
|
| 486 |
-
placeholder="Snapshot URL"
|
| 487 |
-
className="h-9 text-xs pr-8 bg-background/50"
|
| 488 |
-
/>
|
| 489 |
-
</div>
|
| 490 |
-
<div className="relative">
|
| 491 |
-
<input type="file" id="snap-upload" className="hidden" accept="image/*" onChange={handleSnapshotUpload} disabled={isUploadingSnapshot} />
|
| 492 |
-
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" asChild disabled={isUploadingSnapshot}>
|
| 493 |
-
<label htmlFor="snap-upload" className="cursor-pointer flex items-center justify-center w-full h-full">
|
| 494 |
-
<Upload className="h-4 w-4" />
|
| 495 |
-
</label>
|
| 496 |
-
</Button>
|
| 497 |
-
</div>
|
| 498 |
-
</div>
|
| 499 |
-
</div>
|
| 500 |
-
</div>
|
| 501 |
-
</div>
|
| 502 |
-
</div>
|
| 503 |
-
</div>
|
| 504 |
-
</div>
|
| 505 |
-
|
| 506 |
-
<DialogFooter className="gap-2 sm:gap-0">
|
| 507 |
-
<Button variant="ghost" onClick={() => { setOpen(false); resetForm(); }} disabled={isPending}>
|
| 508 |
-
Cancel
|
| 509 |
-
</Button>
|
| 510 |
-
<Button
|
| 511 |
-
onClick={handleSave}
|
| 512 |
-
disabled={isPending || !videoId || !objectName || !category}
|
| 513 |
-
className="bg-primary hover:bg-primary/90 shadow-lg shadow-primary/20"
|
| 514 |
-
>
|
| 515 |
-
{isPending ? (
|
| 516 |
-
<>
|
| 517 |
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
| 518 |
-
Adding...
|
| 519 |
-
</>
|
| 520 |
-
) : (
|
| 521 |
-
<>
|
| 522 |
-
<Save className="w-4 h-4 mr-2" />
|
| 523 |
-
Add to Vault
|
| 524 |
-
</>
|
| 525 |
-
)}
|
| 526 |
-
</Button>
|
| 527 |
-
</DialogFooter>
|
| 528 |
</DialogContent>
|
| 529 |
</Dialog>
|
| 530 |
);
|
|
|
|
| 1 |
+
import React, { useState, useTransition } from 'react';
|
| 2 |
import {
|
| 3 |
Dialog,
|
| 4 |
DialogContent,
|
| 5 |
DialogDescription,
|
|
|
|
| 6 |
DialogHeader,
|
| 7 |
DialogTitle,
|
| 8 |
DialogTrigger,
|
| 9 |
} from '@/components/ui/dialog';
|
| 10 |
import { Button } from '@/components/ui/button';
|
| 11 |
+
import { Plus } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
| 12 |
import { addDetection } from '../actions/add-detection';
|
|
|
|
|
|
|
| 13 |
import { toast } from 'sonner';
|
| 14 |
+
import { ProductForm, ProductFormData } from './product-form';
|
| 15 |
|
| 16 |
interface Video {
|
| 17 |
id: string;
|
|
|
|
| 34 |
|
| 35 |
export function AddProductDialog({ videos, onAdd }: AddProductDialogProps) {
|
| 36 |
const [open, setOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
const [isPending, startTransition] = useTransition();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
const handleSubmit = (data: ProductFormData) => {
|
| 40 |
startTransition(async () => {
|
| 41 |
const result = await addDetection({
|
| 42 |
+
videoId: data.videoId,
|
| 43 |
+
objectName: data.objectName,
|
| 44 |
+
category: data.category as any,
|
| 45 |
+
thumbnailUrl: data.snapshotUrl || data.thumbnailUrl || undefined, // Prioritize snapshot, fallback to product image
|
| 46 |
+
frameTimestamp: data.frameTimestamp,
|
| 47 |
+
marketplaceMatch: data.marketplaceData
|
| 48 |
? {
|
| 49 |
+
marketplace: data.marketplaceData.marketplace as any,
|
| 50 |
+
productName: data.marketplaceData.productName,
|
| 51 |
+
price: parseFloat(data.marketplaceData.price) || 0,
|
| 52 |
+
affiliateUrl: data.marketplaceData.affiliateUrl,
|
| 53 |
+
imageUrl: data.thumbnailUrl || undefined, // Provide product image for marketplace match
|
| 54 |
}
|
| 55 |
: undefined,
|
| 56 |
});
|
| 57 |
|
| 58 |
if (result.success) {
|
| 59 |
+
const selectedVideo = videos.find(v => v.id === data.videoId);
|
| 60 |
onAdd({
|
| 61 |
id: result.detectionId!,
|
| 62 |
+
videoId: data.videoId,
|
| 63 |
videoTitle: selectedVideo?.title ?? '',
|
| 64 |
+
objectName: data.objectName,
|
| 65 |
+
category: data.category,
|
| 66 |
+
frameTimestamp: data.frameTimestamp, // Ensure int
|
| 67 |
+
thumbnailUrl: data.snapshotUrl || data.thumbnailUrl || undefined,
|
| 68 |
+
hasMarketplaceMatch: !!data.marketplaceData,
|
| 69 |
});
|
| 70 |
setOpen(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
toast.success('Product added successfully');
|
| 72 |
} else {
|
| 73 |
toast.error(result.error || 'Failed to add product');
|
|
|
|
| 93 |
</DialogDescription>
|
| 94 |
</DialogHeader>
|
| 95 |
|
| 96 |
+
<ProductForm
|
| 97 |
+
mode="create"
|
| 98 |
+
videos={videos}
|
| 99 |
+
onSubmit={handleSubmit}
|
| 100 |
+
onCancel={() => setOpen(false)}
|
| 101 |
+
isSubmitting={isPending}
|
| 102 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
</DialogContent>
|
| 104 |
</Dialog>
|
| 105 |
);
|
src/features/moderation/components/edit-detection-dialog.tsx
CHANGED
|
@@ -5,32 +5,23 @@ import {
|
|
| 5 |
Dialog,
|
| 6 |
DialogContent,
|
| 7 |
DialogDescription,
|
| 8 |
-
DialogFooter,
|
| 9 |
DialogHeader,
|
| 10 |
DialogTitle,
|
| 11 |
DialogTrigger,
|
| 12 |
} from '@/components/ui/dialog';
|
| 13 |
-
import { Badge } from '@/components/ui/badge';
|
| 14 |
import { Button } from '@/components/ui/button';
|
| 15 |
-
import {
|
| 16 |
-
import { Label } from '@/components/ui/label';
|
| 17 |
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
| 18 |
-
import { Edit2, Save, Plus } from 'lucide-react';
|
| 19 |
import { editDetection } from '../actions/edit-detection';
|
| 20 |
import { toast } from 'sonner';
|
|
|
|
| 21 |
|
| 22 |
interface MarketplaceMatch {
|
| 23 |
id: string;
|
| 24 |
marketplace: string;
|
| 25 |
productName: string;
|
| 26 |
affiliateUrl: string;
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
interface NewMatch {
|
| 30 |
-
marketplace: string;
|
| 31 |
-
productName: string;
|
| 32 |
-
price: string;
|
| 33 |
-
affiliateUrl: string;
|
| 34 |
}
|
| 35 |
|
| 36 |
interface EditDetectionDialogProps {
|
|
@@ -39,6 +30,7 @@ interface EditDetectionDialogProps {
|
|
| 39 |
objectName: string;
|
| 40 |
category: string;
|
| 41 |
thumbnailUrl?: string | null;
|
|
|
|
| 42 |
};
|
| 43 |
marketplaceMatches: MarketplaceMatch[];
|
| 44 |
onSave: (updated: { objectName: string; category: string; thumbnailUrl?: string }) => void;
|
|
@@ -46,35 +38,57 @@ interface EditDetectionDialogProps {
|
|
| 46 |
|
| 47 |
export function EditDetectionDialog({ detection, marketplaceMatches, onSave }: EditDetectionDialogProps) {
|
| 48 |
const [open, setOpen] = useState(false);
|
| 49 |
-
const [name, setName] = useState(detection.objectName);
|
| 50 |
-
const [category, setCategory] = useState(detection.category);
|
| 51 |
-
const [thumbnailUrl, setThumbnailUrl] = useState(detection.thumbnailUrl ?? '');
|
| 52 |
-
const [linkOverrides, setLinkOverrides] = useState<Record<string, string>>({});
|
| 53 |
-
const [newMatch, setNewMatch] = useState<NewMatch>({ marketplace: '', productName: '', price: '', affiliateUrl: '' });
|
| 54 |
-
const [showNewMatch, setShowNewMatch] = useState(false);
|
| 55 |
const [isPending, startTransition] = useTransition();
|
| 56 |
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
| 58 |
startTransition(async () => {
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
if (result.success) {
|
| 70 |
-
onSave({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
setOpen(false);
|
| 72 |
-
setLinkOverrides({});
|
| 73 |
-
setNewMatch({ marketplace: '', productName: '', price: '', affiliateUrl: '' });
|
| 74 |
-
setShowNewMatch(false);
|
| 75 |
toast.success('Detection updated');
|
| 76 |
} else {
|
| 77 |
-
toast.error(result.error);
|
| 78 |
}
|
| 79 |
});
|
| 80 |
};
|
|
@@ -86,137 +100,40 @@ export function EditDetectionDialog({ detection, marketplaceMatches, onSave }: E
|
|
| 86 |
<Edit2 className="w-4 h-4" />
|
| 87 |
</Button>
|
| 88 |
</DialogTrigger>
|
| 89 |
-
<DialogContent className="sm:max-w-[
|
| 90 |
<DialogHeader>
|
| 91 |
-
<DialogTitle
|
|
|
|
|
|
|
| 92 |
<DialogDescription>
|
| 93 |
Manually override the AI-detected object details.
|
| 94 |
</DialogDescription>
|
| 95 |
</DialogHeader>
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
/
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
<div className="grid gap-2">
|
| 122 |
-
<Label htmlFor="thumbnail-url">Thumbnail URL</Label>
|
| 123 |
-
<Input
|
| 124 |
-
id="thumbnail-url"
|
| 125 |
-
value={thumbnailUrl}
|
| 126 |
-
onChange={(e) => setThumbnailUrl(e.target.value)}
|
| 127 |
-
placeholder="https://... (object detection crop image)"
|
| 128 |
-
/>
|
| 129 |
-
</div>
|
| 130 |
-
{marketplaceMatches.length > 0 && (
|
| 131 |
-
<div className="grid gap-2">
|
| 132 |
-
<Label>Marketplace Links</Label>
|
| 133 |
-
<div className="space-y-2 max-h-32 overflow-y-auto pr-1">
|
| 134 |
-
{marketplaceMatches.map((match) => (
|
| 135 |
-
<div key={match.id} className="flex items-center gap-2">
|
| 136 |
-
<Badge variant="outline" className="text-xs whitespace-nowrap shrink-0">{match.marketplace}</Badge>
|
| 137 |
-
<Input
|
| 138 |
-
value={linkOverrides[match.id] ?? match.affiliateUrl}
|
| 139 |
-
onChange={(e) => setLinkOverrides(prev => ({ ...prev, [match.id]: e.target.value }))}
|
| 140 |
-
placeholder="Affiliate URL"
|
| 141 |
-
className="text-xs"
|
| 142 |
-
/>
|
| 143 |
-
</div>
|
| 144 |
-
))}
|
| 145 |
-
</div>
|
| 146 |
-
</div>
|
| 147 |
-
)}
|
| 148 |
-
<div className="grid gap-2">
|
| 149 |
-
<button
|
| 150 |
-
type="button"
|
| 151 |
-
onClick={() => setShowNewMatch(!showNewMatch)}
|
| 152 |
-
className="flex items-center gap-1.5 text-sm text-primary hover:text-primary/80 transition-colors w-fit"
|
| 153 |
-
>
|
| 154 |
-
<Plus className="w-3.5 h-3.5" />
|
| 155 |
-
{showNewMatch ? 'Hide new link' : 'Add marketplace link'}
|
| 156 |
-
</button>
|
| 157 |
-
{showNewMatch && (
|
| 158 |
-
<div className="border border-border/50 rounded-lg p-3 space-y-3 bg-card/50">
|
| 159 |
-
<div className="grid gap-2">
|
| 160 |
-
<Label className="text-xs">Marketplace</Label>
|
| 161 |
-
<Select value={newMatch.marketplace} onValueChange={(v) => setNewMatch(prev => ({ ...prev, marketplace: v }))}>
|
| 162 |
-
<SelectTrigger className="text-xs">
|
| 163 |
-
<SelectValue placeholder="Select marketplace" />
|
| 164 |
-
</SelectTrigger>
|
| 165 |
-
<SelectContent>
|
| 166 |
-
<SelectItem value="amazon">Amazon</SelectItem>
|
| 167 |
-
<SelectItem value="ebay">eBay</SelectItem>
|
| 168 |
-
<SelectItem value="etsy">Etsy</SelectItem>
|
| 169 |
-
</SelectContent>
|
| 170 |
-
</Select>
|
| 171 |
-
</div>
|
| 172 |
-
<div className="grid gap-2">
|
| 173 |
-
<Label htmlFor="new-product-name" className="text-xs">Product Name</Label>
|
| 174 |
-
<Input
|
| 175 |
-
id="new-product-name"
|
| 176 |
-
value={newMatch.productName}
|
| 177 |
-
onChange={(e) => setNewMatch(prev => ({ ...prev, productName: e.target.value }))}
|
| 178 |
-
placeholder="e.g. Sony WH-1000XM5"
|
| 179 |
-
className="text-xs"
|
| 180 |
-
/>
|
| 181 |
-
</div>
|
| 182 |
-
<div className="grid grid-cols-2 gap-2">
|
| 183 |
-
<div className="grid gap-2">
|
| 184 |
-
<Label htmlFor="new-price" className="text-xs">Price ($)</Label>
|
| 185 |
-
<Input
|
| 186 |
-
id="new-price"
|
| 187 |
-
type="number"
|
| 188 |
-
min="0"
|
| 189 |
-
step="0.01"
|
| 190 |
-
value={newMatch.price}
|
| 191 |
-
onChange={(e) => setNewMatch(prev => ({ ...prev, price: e.target.value }))}
|
| 192 |
-
placeholder="29.99"
|
| 193 |
-
className="text-xs"
|
| 194 |
-
/>
|
| 195 |
-
</div>
|
| 196 |
-
</div>
|
| 197 |
-
<div className="grid gap-2">
|
| 198 |
-
<Label htmlFor="new-affiliate-url" className="text-xs">Affiliate URL</Label>
|
| 199 |
-
<Input
|
| 200 |
-
id="new-affiliate-url"
|
| 201 |
-
value={newMatch.affiliateUrl}
|
| 202 |
-
onChange={(e) => setNewMatch(prev => ({ ...prev, affiliateUrl: e.target.value }))}
|
| 203 |
-
placeholder="https://amazon.com/..."
|
| 204 |
-
className="text-xs"
|
| 205 |
-
/>
|
| 206 |
-
</div>
|
| 207 |
-
</div>
|
| 208 |
-
)}
|
| 209 |
-
</div>
|
| 210 |
-
</div>
|
| 211 |
-
<DialogFooter>
|
| 212 |
-
<Button variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
|
| 213 |
-
Cancel
|
| 214 |
-
</Button>
|
| 215 |
-
<Button onClick={handleSave} disabled={isPending}>
|
| 216 |
-
{isPending ? 'Saving...' : 'Save Changes'}
|
| 217 |
-
{!isPending && <Save className="w-4 h-4 ml-2" />}
|
| 218 |
-
</Button>
|
| 219 |
-
</DialogFooter>
|
| 220 |
</DialogContent>
|
| 221 |
</Dialog>
|
| 222 |
);
|
|
|
|
| 5 |
Dialog,
|
| 6 |
DialogContent,
|
| 7 |
DialogDescription,
|
|
|
|
| 8 |
DialogHeader,
|
| 9 |
DialogTitle,
|
| 10 |
DialogTrigger,
|
| 11 |
} from '@/components/ui/dialog';
|
|
|
|
| 12 |
import { Button } from '@/components/ui/button';
|
| 13 |
+
import { Edit2 } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
| 14 |
import { editDetection } from '../actions/edit-detection';
|
| 15 |
import { toast } from 'sonner';
|
| 16 |
+
import { ProductForm, ProductFormData } from './product-form';
|
| 17 |
|
| 18 |
interface MarketplaceMatch {
|
| 19 |
id: string;
|
| 20 |
marketplace: string;
|
| 21 |
productName: string;
|
| 22 |
affiliateUrl: string;
|
| 23 |
+
price?: number; // Optional in interface, but helps if available
|
| 24 |
+
imageUrl?: string; // Product Image from marketplace
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
interface EditDetectionDialogProps {
|
|
|
|
| 30 |
objectName: string;
|
| 31 |
category: string;
|
| 32 |
thumbnailUrl?: string | null;
|
| 33 |
+
frameTimestamp?: number; // Add if available in detection object from parent, usually it is
|
| 34 |
};
|
| 35 |
marketplaceMatches: MarketplaceMatch[];
|
| 36 |
onSave: (updated: { objectName: string; category: string; thumbnailUrl?: string }) => void;
|
|
|
|
| 38 |
|
| 39 |
export function EditDetectionDialog({ detection, marketplaceMatches, onSave }: EditDetectionDialogProps) {
|
| 40 |
const [open, setOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
const [isPending, startTransition] = useTransition();
|
| 42 |
|
| 43 |
+
// Determine primary match (first one)
|
| 44 |
+
const primaryMatch = marketplaceMatches[0];
|
| 45 |
+
|
| 46 |
+
const handleSubmit = (data: ProductFormData) => {
|
| 47 |
startTransition(async () => {
|
| 48 |
+
// Prepare update payload
|
| 49 |
+
const payload: any = {
|
| 50 |
+
objectName: data.objectName,
|
| 51 |
+
category: data.category as any,
|
| 52 |
+
thumbnailUrl: data.snapshotUrl || data.thumbnailUrl || undefined,
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
// Handle Marketplace Data
|
| 56 |
+
if (data.marketplaceData) {
|
| 57 |
+
if (primaryMatch) {
|
| 58 |
+
// Update existing primary match
|
| 59 |
+
payload.marketplaceMatchUpdates = {
|
| 60 |
+
[primaryMatch.id]: {
|
| 61 |
+
marketplace: data.marketplaceData.marketplace as any,
|
| 62 |
+
productName: data.marketplaceData.productName,
|
| 63 |
+
price: parseFloat(data.marketplaceData.price) || 0,
|
| 64 |
+
affiliateUrl: data.marketplaceData.affiliateUrl,
|
| 65 |
+
}
|
| 66 |
+
};
|
| 67 |
+
} else {
|
| 68 |
+
// Create new match
|
| 69 |
+
payload.newMarketplaceMatch = {
|
| 70 |
+
marketplace: data.marketplaceData.marketplace as any,
|
| 71 |
+
productName: data.marketplaceData.productName,
|
| 72 |
+
price: parseFloat(data.marketplaceData.price) || 0,
|
| 73 |
+
affiliateUrl: data.marketplaceData.affiliateUrl,
|
| 74 |
+
};
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
console.log('[EditDetectionDialog] Submitting payload:', payload);
|
| 79 |
+
|
| 80 |
+
const result = await editDetection(detection.id, payload);
|
| 81 |
|
| 82 |
if (result.success) {
|
| 83 |
+
onSave({
|
| 84 |
+
objectName: data.objectName,
|
| 85 |
+
category: data.category,
|
| 86 |
+
thumbnailUrl: data.snapshotUrl || data.thumbnailUrl || undefined
|
| 87 |
+
});
|
| 88 |
setOpen(false);
|
|
|
|
|
|
|
|
|
|
| 89 |
toast.success('Detection updated');
|
| 90 |
} else {
|
| 91 |
+
toast.error(result.error || 'Failed to update detection');
|
| 92 |
}
|
| 93 |
});
|
| 94 |
};
|
|
|
|
| 100 |
<Edit2 className="w-4 h-4" />
|
| 101 |
</Button>
|
| 102 |
</DialogTrigger>
|
| 103 |
+
<DialogContent className="sm:max-w-[540px] bg-card/95 backdrop-blur-xl border-white/10 max-h-[90vh] overflow-y-auto">
|
| 104 |
<DialogHeader>
|
| 105 |
+
<DialogTitle className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-amber-400">
|
| 106 |
+
Edit Detection
|
| 107 |
+
</DialogTitle>
|
| 108 |
<DialogDescription>
|
| 109 |
Manually override the AI-detected object details.
|
| 110 |
</DialogDescription>
|
| 111 |
</DialogHeader>
|
| 112 |
+
|
| 113 |
+
<ProductForm
|
| 114 |
+
mode="edit"
|
| 115 |
+
initialData={{
|
| 116 |
+
videoId: '', // Not needed for edit
|
| 117 |
+
objectName: detection.objectName,
|
| 118 |
+
category: detection.category,
|
| 119 |
+
// If we have a marketplace match with an image, use that as "Product Image"
|
| 120 |
+
// Otherwise, fallback to empty string (user can upload one)
|
| 121 |
+
thumbnailUrl: primaryMatch?.imageUrl || '',
|
| 122 |
+
// The detection thumbnail is actually the snapshot from the video
|
| 123 |
+
// If it matches the product image (fallback), show empty snapshot to encourage upload
|
| 124 |
+
snapshotUrl: (detection.thumbnailUrl && detection.thumbnailUrl === primaryMatch?.imageUrl) ? '' : (detection.thumbnailUrl || ''),
|
| 125 |
+
frameTimestamp: detection.frameTimestamp || 0,
|
| 126 |
+
marketplaceData: primaryMatch ? {
|
| 127 |
+
marketplace: primaryMatch.marketplace,
|
| 128 |
+
productName: primaryMatch.productName,
|
| 129 |
+
price: primaryMatch.price?.toString() || '',
|
| 130 |
+
affiliateUrl: primaryMatch.affiliateUrl,
|
| 131 |
+
} : undefined
|
| 132 |
+
}}
|
| 133 |
+
onSubmit={handleSubmit}
|
| 134 |
+
onCancel={() => setOpen(false)}
|
| 135 |
+
isSubmitting={isPending}
|
| 136 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
</DialogContent>
|
| 138 |
</Dialog>
|
| 139 |
);
|
src/features/moderation/components/moderation-queue.tsx
CHANGED
|
@@ -61,14 +61,14 @@ export function ModerationQueue({ initialDetections, userId, videos, stats, init
|
|
| 61 |
// Status and confidence filter
|
| 62 |
if (d.detection.moderationStatus !== filterStatus) return false;
|
| 63 |
if (d.detection.confidenceScore < minConfidence) return false;
|
| 64 |
-
|
| 65 |
// Video filter
|
| 66 |
if (filterVideo !== 'all' && d.video.id !== filterVideo) return false;
|
| 67 |
-
|
| 68 |
// Affiliate link filter
|
| 69 |
if (filterHasLink === 'with-link' && d.marketplaceMatches.length === 0) return false;
|
| 70 |
if (filterHasLink === 'without-link' && d.marketplaceMatches.length > 0) return false;
|
| 71 |
-
|
| 72 |
return true;
|
| 73 |
});
|
| 74 |
|
|
@@ -187,9 +187,8 @@ export function ModerationQueue({ initialDetections, userId, videos, stats, init
|
|
| 187 |
key={status}
|
| 188 |
type="button"
|
| 189 |
onClick={() => setFilterStatus(status)}
|
| 190 |
-
className={`text-left p-4 rounded-lg border transition-all group ${
|
| 191 |
-
|
| 192 |
-
}`}
|
| 193 |
>
|
| 194 |
<div className="flex items-center justify-between pb-2">
|
| 195 |
<span className="text-sm font-medium">{title}</span>
|
|
@@ -279,41 +278,41 @@ export function ModerationQueue({ initialDetections, userId, videos, stats, init
|
|
| 279 |
Showing {filteredDetections.length} of {detections.filter(d => d.detection.moderationStatus === filterStatus).length} items
|
| 280 |
</div>
|
| 281 |
<AddProductDialog
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
</div>
|
| 306 |
</div>
|
| 307 |
|
| 308 |
{selectedIds.length > 0 && (
|
| 309 |
<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">
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
</div>
|
| 318 |
)}
|
| 319 |
|
|
@@ -328,112 +327,145 @@ export function ModerationQueue({ initialDetections, userId, videos, stats, init
|
|
| 328 |
|
| 329 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
| 330 |
{filteredDetections.length > 0 ? (
|
| 331 |
-
filteredDetections.map((d) =>
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
<div className="
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
<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">
|
| 359 |
-
<MousePointerClick className="h-3 w-3" />
|
| 360 |
-
{d.clickCount}
|
| 361 |
-
</Badge>
|
| 362 |
)}
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
)}
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
</Badge>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
</div>
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
</CardTitle>
|
| 384 |
-
<EditDetectionDialog
|
| 385 |
-
detection={d.detection}
|
| 386 |
-
marketplaceMatches={d.marketplaceMatches}
|
| 387 |
-
onSave={(updated: { objectName: string; category: string; thumbnailUrl?: string }) => {
|
| 388 |
-
setDetections(prev => prev.map(item =>
|
| 389 |
-
item.detection.id === d.detection.id ? { ...item, detection: { ...item.detection, ...updated } } : item
|
| 390 |
-
));
|
| 391 |
-
}}
|
| 392 |
-
/>
|
| 393 |
-
</div>
|
| 394 |
-
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
| 395 |
-
<Badge variant="outline" className="text-[10px] uppercase">
|
| 396 |
-
{d.detection.category}
|
| 397 |
-
</Badge>
|
| 398 |
-
<span className="text-[10px] truncate">• {d.video.title}</span>
|
| 399 |
</div>
|
| 400 |
-
</CardHeader>
|
| 401 |
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
>
|
| 419 |
-
|
| 420 |
-
</
|
| 421 |
-
|
| 422 |
-
) : (
|
| 423 |
-
<div className="flex items-center text-sm font-medium gap-2 py-2 text-muted-foreground">
|
| 424 |
-
{filterStatus === 'APPROVED' ? (
|
| 425 |
-
<><CheckCircle2 className="w-4 h-4 text-emerald-500" /> Approved</>
|
| 426 |
-
) : (
|
| 427 |
-
<><X className="w-4 h-4 text-rose-500" /> Rejected</>
|
| 428 |
-
)}
|
| 429 |
-
<Button variant="link" size="sm" className="h-auto p-0 ml-auto" onClick={() => handleReset(d.detection.id)} disabled={isPending}>
|
| 430 |
-
Reset
|
| 431 |
-
</Button>
|
| 432 |
</div>
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
) : (
|
| 438 |
<div className="col-span-full py-20 text-center space-y-4 bg-muted/20 rounded-xl border-2 border-dashed border-border/50">
|
| 439 |
<div className="flex justify-center">
|
|
|
|
| 61 |
// Status and confidence filter
|
| 62 |
if (d.detection.moderationStatus !== filterStatus) return false;
|
| 63 |
if (d.detection.confidenceScore < minConfidence) return false;
|
| 64 |
+
|
| 65 |
// Video filter
|
| 66 |
if (filterVideo !== 'all' && d.video.id !== filterVideo) return false;
|
| 67 |
+
|
| 68 |
// Affiliate link filter
|
| 69 |
if (filterHasLink === 'with-link' && d.marketplaceMatches.length === 0) return false;
|
| 70 |
if (filterHasLink === 'without-link' && d.marketplaceMatches.length > 0) return false;
|
| 71 |
+
|
| 72 |
return true;
|
| 73 |
});
|
| 74 |
|
|
|
|
| 187 |
key={status}
|
| 188 |
type="button"
|
| 189 |
onClick={() => setFilterStatus(status)}
|
| 190 |
+
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'
|
| 191 |
+
}`}
|
|
|
|
| 192 |
>
|
| 193 |
<div className="flex items-center justify-between pb-2">
|
| 194 |
<span className="text-sm font-medium">{title}</span>
|
|
|
|
| 278 |
Showing {filteredDetections.length} of {detections.filter(d => d.detection.moderationStatus === filterStatus).length} items
|
| 279 |
</div>
|
| 280 |
<AddProductDialog
|
| 281 |
+
videos={videos}
|
| 282 |
+
onAdd={(newItem) => {
|
| 283 |
+
setDetections(prev => [{
|
| 284 |
+
detection: {
|
| 285 |
+
id: newItem.id,
|
| 286 |
+
objectName: newItem.objectName,
|
| 287 |
+
category: newItem.category,
|
| 288 |
+
confidenceScore: 1,
|
| 289 |
+
moderationStatus: 'PENDING',
|
| 290 |
+
thumbnailUrl: newItem.thumbnailUrl ?? null,
|
| 291 |
+
createdAt: new Date(),
|
| 292 |
+
},
|
| 293 |
+
video: {
|
| 294 |
+
id: newItem.videoId,
|
| 295 |
+
title: newItem.videoTitle,
|
| 296 |
+
thumbnailUrl: '',
|
| 297 |
+
},
|
| 298 |
+
marketplaceMatches: newItem.hasMarketplaceMatch ? [{ id: 'new', marketplace: 'amazon', productName: '', affiliateUrl: '' }] : [],
|
| 299 |
+
clickCount: 0,
|
| 300 |
+
interestPledgeCount: 0,
|
| 301 |
+
}, ...prev]);
|
| 302 |
+
}}
|
| 303 |
+
/>
|
| 304 |
</div>
|
| 305 |
</div>
|
| 306 |
|
| 307 |
{selectedIds.length > 0 && (
|
| 308 |
<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">
|
| 309 |
+
<span className="text-sm font-medium mr-2">{selectedIds.length} selected</span>
|
| 310 |
+
<Button size="sm" onClick={handleBulkApprove} disabled={isPending}>
|
| 311 |
+
<Check className="w-4 h-4 mr-1" /> Approve
|
| 312 |
+
</Button>
|
| 313 |
+
<Button size="sm" variant="destructive" onClick={handleBulkReject} disabled={isPending}>
|
| 314 |
+
<X className="w-4 h-4 mr-1" /> Reject
|
| 315 |
+
</Button>
|
| 316 |
</div>
|
| 317 |
)}
|
| 318 |
|
|
|
|
| 327 |
|
| 328 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
| 329 |
{filteredDetections.length > 0 ? (
|
| 330 |
+
filteredDetections.map((d) => {
|
| 331 |
+
const primaryMatch = d.marketplaceMatches[0];
|
| 332 |
+
const productImage = primaryMatch?.imageUrl;
|
| 333 |
+
const snapshotImage = d.detection.thumbnailUrl;
|
| 334 |
+
|
| 335 |
+
// Main image priority: Product Image -> Snapshot
|
| 336 |
+
const mainImage = productImage || snapshotImage;
|
| 337 |
+
|
| 338 |
+
// PiP Logic: Show Snapshot if we have BOTH images and they are different
|
| 339 |
+
// (i.e. snapshot is not just a fallback copy of product image)
|
| 340 |
+
const isSnapshotFallback = snapshotImage === productImage;
|
| 341 |
+
const showPip = snapshotImage && productImage && !isSnapshotFallback;
|
| 342 |
+
|
| 343 |
+
return (
|
| 344 |
+
<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'}`}>
|
| 345 |
+
<div className="relative aspect-square overflow-hidden bg-muted">
|
| 346 |
+
{mainImage ? (
|
| 347 |
+
<Image
|
| 348 |
+
src={mainImage}
|
| 349 |
+
alt={d.detection.objectName}
|
| 350 |
+
fill
|
| 351 |
+
className="object-contain transition-transform group-hover:scale-105"
|
| 352 |
+
/>
|
| 353 |
+
) : (
|
| 354 |
+
<div className="w-full h-full bg-muted flex items-center justify-center">
|
| 355 |
+
<Filter className="w-8 h-8 text-muted-foreground" />
|
| 356 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
)}
|
| 358 |
+
|
| 359 |
+
{/* Video Snapshot Inset (PiP) */}
|
| 360 |
+
{showPip && (
|
| 361 |
+
<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">
|
| 362 |
+
<Image
|
| 363 |
+
src={snapshotImage!}
|
| 364 |
+
alt="In video"
|
| 365 |
+
fill
|
| 366 |
+
className="object-cover"
|
| 367 |
+
sizes="64px"
|
| 368 |
+
/>
|
| 369 |
+
<div className="absolute inset-0 bg-black/20" />
|
| 370 |
+
<div className="absolute bottom-0 left-0 right-0 bg-black/60 py-0.5 px-0.5">
|
| 371 |
+
<span className="text-[6px] font-bold text-white uppercase tracking-tighter block text-center">
|
| 372 |
+
IN VIDEO
|
| 373 |
+
</span>
|
| 374 |
+
</div>
|
| 375 |
+
</div>
|
| 376 |
)}
|
| 377 |
+
<div className="absolute top-2 left-2">
|
| 378 |
+
<Checkbox
|
| 379 |
+
checked={selectedIds.includes(d.detection.id)}
|
| 380 |
+
onCheckedChange={() => toggleSelection(d.detection.id)}
|
| 381 |
+
className="bg-background/80 backdrop-blur-sm"
|
| 382 |
+
/>
|
| 383 |
+
</div>
|
| 384 |
+
<div className="absolute top-2 right-2 flex flex-col gap-1 items-end">
|
| 385 |
+
<Badge variant="secondary" className="bg-background/80 backdrop-blur-sm">
|
| 386 |
+
{Math.round(d.detection.confidenceScore * 100)}%
|
| 387 |
</Badge>
|
| 388 |
+
{d.clickCount > 0 && (
|
| 389 |
+
<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">
|
| 390 |
+
<MousePointerClick className="h-3 w-3" />
|
| 391 |
+
{d.clickCount}
|
| 392 |
+
</Badge>
|
| 393 |
+
)}
|
| 394 |
+
{d.interestPledgeCount > 0 && (
|
| 395 |
+
<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">
|
| 396 |
+
<Heart className="h-3 w-3" />
|
| 397 |
+
{d.interestPledgeCount}
|
| 398 |
+
</Badge>
|
| 399 |
+
)}
|
| 400 |
</div>
|
| 401 |
+
{d.marketplaceMatches.length > 0 && (
|
| 402 |
+
<div className="absolute bottom-2 right-2">
|
| 403 |
+
<Badge variant="default" className="bg-emerald-500/90 gap-1">
|
| 404 |
+
<ShoppingCart className="w-3 h-3" /> {d.marketplaceMatches.length} matches
|
| 405 |
+
</Badge>
|
| 406 |
+
</div>
|
| 407 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
</div>
|
|
|
|
| 409 |
|
| 410 |
+
<CardHeader className="p-4 py-3 space-y-1">
|
| 411 |
+
<div className="flex justify-between items-start">
|
| 412 |
+
<CardTitle className="text-lg font-bold truncate pr-2">
|
| 413 |
+
{d.detection.objectName}
|
| 414 |
+
</CardTitle>
|
| 415 |
+
<EditDetectionDialog
|
| 416 |
+
detection={d.detection}
|
| 417 |
+
marketplaceMatches={d.marketplaceMatches}
|
| 418 |
+
onSave={(updated: { objectName: string; category: string; thumbnailUrl?: string }) => {
|
| 419 |
+
setDetections(prev => prev.map(item =>
|
| 420 |
+
item.detection.id === d.detection.id ? { ...item, detection: { ...item.detection, ...updated } } : item
|
| 421 |
+
));
|
| 422 |
+
}}
|
| 423 |
+
/>
|
| 424 |
+
</div>
|
| 425 |
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
| 426 |
+
<Badge variant="outline" className="text-[10px] uppercase">
|
| 427 |
+
{d.detection.category}
|
| 428 |
+
</Badge>
|
| 429 |
+
<span className="text-[10px] truncate">• {d.video.title}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
</div>
|
| 431 |
+
</CardHeader>
|
| 432 |
+
|
| 433 |
+
<CardFooter className="p-4 pt-0 gap-2">
|
| 434 |
+
{filterStatus === 'PENDING' ? (
|
| 435 |
+
<>
|
| 436 |
+
<Button
|
| 437 |
+
variant="secondary"
|
| 438 |
+
className="grow bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500 border-none"
|
| 439 |
+
onClick={() => handleApprove(d.detection.id)}
|
| 440 |
+
disabled={isPending}
|
| 441 |
+
>
|
| 442 |
+
<Check className="w-4 h-4 mr-2" /> Approve
|
| 443 |
+
</Button>
|
| 444 |
+
<Button
|
| 445 |
+
variant="secondary"
|
| 446 |
+
className="grow bg-rose-500/10 hover:bg-rose-500/20 text-rose-500 border-none"
|
| 447 |
+
onClick={() => handleReject(d.detection.id)}
|
| 448 |
+
disabled={isPending}
|
| 449 |
+
>
|
| 450 |
+
<X className="w-4 h-4 mr-2" /> Reject
|
| 451 |
+
</Button>
|
| 452 |
+
</>
|
| 453 |
+
) : (
|
| 454 |
+
<div className="flex items-center text-sm font-medium gap-2 py-2 text-muted-foreground">
|
| 455 |
+
{filterStatus === 'APPROVED' ? (
|
| 456 |
+
<><CheckCircle2 className="w-4 h-4 text-emerald-500" /> Approved</>
|
| 457 |
+
) : (
|
| 458 |
+
<><X className="w-4 h-4 text-rose-500" /> Rejected</>
|
| 459 |
+
)}
|
| 460 |
+
<Button variant="link" size="sm" className="h-auto p-0 ml-auto" onClick={() => handleReset(d.detection.id)} disabled={isPending}>
|
| 461 |
+
Reset
|
| 462 |
+
</Button>
|
| 463 |
+
</div>
|
| 464 |
+
)}
|
| 465 |
+
</CardFooter>
|
| 466 |
+
</Card>
|
| 467 |
+
);
|
| 468 |
+
})
|
| 469 |
) : (
|
| 470 |
<div className="col-span-full py-20 text-center space-y-4 bg-muted/20 rounded-xl border-2 border-dashed border-border/50">
|
| 471 |
<div className="flex justify-center">
|
src/features/moderation/components/product-form.tsx
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Button, buttonVariants } from '@/components/ui/button';
|
| 3 |
+
import { Input } from '@/components/ui/input';
|
| 4 |
+
import { Label } from '@/components/ui/label';
|
| 5 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
| 6 |
+
import { Search, Loader2, Upload, Trash2, Image as ImageIcon, PlaySquare, Save } from 'lucide-react';
|
| 7 |
+
import { fetchProductMetadata } from '../actions/fetch-product-metadata';
|
| 8 |
+
import { uploadProductImage } from '../actions/upload-product-image';
|
| 9 |
+
import { toast } from 'sonner';
|
| 10 |
+
|
| 11 |
+
export interface ProductFormData {
|
| 12 |
+
videoId: string;
|
| 13 |
+
objectName: string;
|
| 14 |
+
category: string;
|
| 15 |
+
frameTimestamp: number;
|
| 16 |
+
thumbnailUrl: string; // Product Image
|
| 17 |
+
snapshotUrl: string; // In-Video Snapshot
|
| 18 |
+
marketplaceData?: {
|
| 19 |
+
marketplace: string;
|
| 20 |
+
productName: string;
|
| 21 |
+
price: string;
|
| 22 |
+
affiliateUrl: string;
|
| 23 |
+
};
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
interface ProductFormProps {
|
| 27 |
+
mode: 'create' | 'edit';
|
| 28 |
+
initialData?: Partial<ProductFormData>;
|
| 29 |
+
videos?: { id: string; title: string }[];
|
| 30 |
+
onSubmit: (data: ProductFormData) => void;
|
| 31 |
+
onCancel: () => void;
|
| 32 |
+
isSubmitting: boolean;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function ProductForm({ mode, initialData, videos = [], onSubmit, onCancel, isSubmitting }: ProductFormProps) {
|
| 36 |
+
const [videoId, setVideoId] = useState(initialData?.videoId || videos[0]?.id || '');
|
| 37 |
+
const [objectName, setObjectName] = useState(initialData?.objectName || '');
|
| 38 |
+
const [category, setCategory] = useState(initialData?.category || 'Tech');
|
| 39 |
+
const [frameTimestamp, setFrameTimestamp] = useState(initialData?.frameTimestamp?.toString() || '0');
|
| 40 |
+
|
| 41 |
+
// Images
|
| 42 |
+
const [thumbnailUrl, setThumbnailUrl] = useState(initialData?.thumbnailUrl || '');
|
| 43 |
+
const [snapshotUrl, setSnapshotUrl] = useState(initialData?.snapshotUrl || '');
|
| 44 |
+
|
| 45 |
+
// Marketplace
|
| 46 |
+
const [affiliateUrl, setAffiliateUrl] = useState(initialData?.marketplaceData?.affiliateUrl || '');
|
| 47 |
+
const [marketplace, setMarketplace] = useState(initialData?.marketplaceData?.marketplace || '');
|
| 48 |
+
const [productName, setProductName] = useState(initialData?.marketplaceData?.productName || '');
|
| 49 |
+
const [price, setPrice] = useState(initialData?.marketplaceData?.price || '');
|
| 50 |
+
|
| 51 |
+
const [isFetching, setIsFetching] = useState(false);
|
| 52 |
+
const [isUploading, setIsUploading] = useState(false);
|
| 53 |
+
const [isUploadingSnapshot, setIsUploadingSnapshot] = useState(false);
|
| 54 |
+
|
| 55 |
+
// Auto-select video if only one
|
| 56 |
+
useEffect(() => {
|
| 57 |
+
if (mode === 'create' && videos.length === 1 && !videoId) {
|
| 58 |
+
setVideoId(videos[0].id);
|
| 59 |
+
}
|
| 60 |
+
}, [mode, videos, videoId]);
|
| 61 |
+
|
| 62 |
+
// Clipboard Paste Listener
|
| 63 |
+
useEffect(() => {
|
| 64 |
+
const handlePaste = async (e: ClipboardEvent) => {
|
| 65 |
+
// Only paste into the snapshot if we are not currently focused on an input
|
| 66 |
+
const target = e.target as HTMLElement;
|
| 67 |
+
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA';
|
| 68 |
+
|
| 69 |
+
// Allow pasting URL into URL inputs, but intercept images for upload
|
| 70 |
+
const items = e.clipboardData?.items;
|
| 71 |
+
if (!items) return;
|
| 72 |
+
|
| 73 |
+
for (let i = 0; i < items.length; i++) {
|
| 74 |
+
if (items[i].type.indexOf('image') !== -1) {
|
| 75 |
+
if (isInput) return; // Don't interrupt paste if user is typing
|
| 76 |
+
|
| 77 |
+
let file = items[i].getAsFile();
|
| 78 |
+
if (!file) continue;
|
| 79 |
+
|
| 80 |
+
if (!file.name || file.name === 'blob') {
|
| 81 |
+
const extension = file.type.split('/')[1] || 'png';
|
| 82 |
+
file = new File([file], `paste-${Date.now()}.${extension}`, { type: file.type });
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
if (file.size > 10 * 1024 * 1024) {
|
| 86 |
+
toast.error('Image too large (max 10MB)');
|
| 87 |
+
continue;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
setIsUploadingSnapshot(true);
|
| 91 |
+
const formData = new FormData();
|
| 92 |
+
formData.append('file', file);
|
| 93 |
+
|
| 94 |
+
try {
|
| 95 |
+
const result = await uploadProductImage(formData);
|
| 96 |
+
if (result.success && result.url) {
|
| 97 |
+
setSnapshotUrl(result.url);
|
| 98 |
+
toast.success('Snapshot pasted from clipboard!');
|
| 99 |
+
} else {
|
| 100 |
+
toast.error(result.error || 'Failed to upload pasted image');
|
| 101 |
+
}
|
| 102 |
+
} catch (err: any) {
|
| 103 |
+
toast.error(`Error uploading pasted image: ${err.message || 'Unknown error'}`);
|
| 104 |
+
} finally {
|
| 105 |
+
setIsUploadingSnapshot(false);
|
| 106 |
+
}
|
| 107 |
+
break;
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
window.addEventListener('paste', handlePaste);
|
| 113 |
+
return () => window.removeEventListener('paste', handlePaste);
|
| 114 |
+
}, []);
|
| 115 |
+
|
| 116 |
+
const handleFetchMetadata = async () => {
|
| 117 |
+
if (!affiliateUrl) return;
|
| 118 |
+
|
| 119 |
+
setIsFetching(true);
|
| 120 |
+
try {
|
| 121 |
+
const result = await fetchProductMetadata(affiliateUrl);
|
| 122 |
+
if (result.success && result.data) {
|
| 123 |
+
const { productName: fetchedName, price: fetchedPrice, marketplace: fetchedMarket, imageUrl } = result.data;
|
| 124 |
+
|
| 125 |
+
// Only update fields if they are empty or we want to overwrite
|
| 126 |
+
if (!objectName) setObjectName(fetchedName);
|
| 127 |
+
if (!productName) setProductName(fetchedName);
|
| 128 |
+
setPrice(fetchedPrice.toString());
|
| 129 |
+
setMarketplace(fetchedMarket);
|
| 130 |
+
if (imageUrl && !thumbnailUrl) {
|
| 131 |
+
setThumbnailUrl(imageUrl);
|
| 132 |
+
}
|
| 133 |
+
toast.success('Product info fetched!');
|
| 134 |
+
} else {
|
| 135 |
+
toast.error(result.error || 'Could not fetch product info');
|
| 136 |
+
}
|
| 137 |
+
} catch (err) {
|
| 138 |
+
toast.error('Error fetching product info');
|
| 139 |
+
} finally {
|
| 140 |
+
setIsFetching(false);
|
| 141 |
+
}
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
const handleUpload = async (file: File, type: 'product' | 'snapshot') => {
|
| 145 |
+
const setUrl = type === 'product' ? setThumbnailUrl : setSnapshotUrl;
|
| 146 |
+
const setLoading = type === 'product' ? setIsUploading : setIsUploadingSnapshot;
|
| 147 |
+
|
| 148 |
+
setLoading(true);
|
| 149 |
+
const formData = new FormData();
|
| 150 |
+
formData.append('file', file);
|
| 151 |
+
|
| 152 |
+
try {
|
| 153 |
+
const result = await uploadProductImage(formData);
|
| 154 |
+
if (result.success && result.url) {
|
| 155 |
+
setUrl(result.url);
|
| 156 |
+
toast.success('Image uploaded!');
|
| 157 |
+
} else {
|
| 158 |
+
toast.error(result.error || 'Failed to upload image');
|
| 159 |
+
}
|
| 160 |
+
} catch (err) {
|
| 161 |
+
toast.error('Error uploading image');
|
| 162 |
+
} finally {
|
| 163 |
+
setLoading(false);
|
| 164 |
+
}
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
const handleSubmit = () => {
|
| 168 |
+
if (mode === 'create' && !videoId) {
|
| 169 |
+
toast.error('Please select a video');
|
| 170 |
+
return;
|
| 171 |
+
}
|
| 172 |
+
if (!objectName || !category) {
|
| 173 |
+
toast.error('Please fill in required fields');
|
| 174 |
+
return;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
const data: ProductFormData = {
|
| 178 |
+
videoId,
|
| 179 |
+
objectName,
|
| 180 |
+
category,
|
| 181 |
+
frameTimestamp: parseInt(frameTimestamp, 10) || 0,
|
| 182 |
+
thumbnailUrl,
|
| 183 |
+
snapshotUrl,
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
+
if (marketplace && (affiliateUrl || productName)) {
|
| 187 |
+
data.marketplaceData = {
|
| 188 |
+
marketplace,
|
| 189 |
+
productName: productName || objectName,
|
| 190 |
+
price,
|
| 191 |
+
affiliateUrl
|
| 192 |
+
};
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
onSubmit(data);
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
return (
|
| 199 |
+
<div className="grid gap-6 py-4">
|
| 200 |
+
{/* Link Section - Prioritized */}
|
| 201 |
+
<div className="space-y-4 p-4 rounded-xl bg-primary/5 border border-primary/10">
|
| 202 |
+
<div className="grid gap-2">
|
| 203 |
+
<Label htmlFor="affiliate-url" className="text-sm font-medium text-primary">Affiliate or Product URL</Label>
|
| 204 |
+
<div className="flex gap-2">
|
| 205 |
+
<Input
|
| 206 |
+
id="affiliate-url"
|
| 207 |
+
value={affiliateUrl}
|
| 208 |
+
onChange={(e) => setAffiliateUrl(e.target.value)}
|
| 209 |
+
placeholder="Paste Amazon, eBay, or Etsy link..."
|
| 210 |
+
className="bg-background/50"
|
| 211 |
+
onBlur={() => {
|
| 212 |
+
if (affiliateUrl && !objectName) handleFetchMetadata();
|
| 213 |
+
}}
|
| 214 |
+
/>
|
| 215 |
+
<Button
|
| 216 |
+
size="icon"
|
| 217 |
+
onClick={handleFetchMetadata}
|
| 218 |
+
disabled={isFetching || !affiliateUrl}
|
| 219 |
+
className="shrink-0"
|
| 220 |
+
type="button"
|
| 221 |
+
>
|
| 222 |
+
{isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
| 223 |
+
</Button>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
{(marketplace || affiliateUrl) && (
|
| 228 |
+
<div className="grid grid-cols-2 gap-4 animate-in fade-in slide-in-from-top-2">
|
| 229 |
+
<div className="grid gap-2">
|
| 230 |
+
<Label className="text-xs">Marketplace</Label>
|
| 231 |
+
<Select value={marketplace} onValueChange={setMarketplace}>
|
| 232 |
+
<SelectTrigger className="h-8 text-xs bg-background/50">
|
| 233 |
+
<SelectValue placeholder="Select..." />
|
| 234 |
+
</SelectTrigger>
|
| 235 |
+
<SelectContent>
|
| 236 |
+
<SelectItem value="amazon">Amazon</SelectItem>
|
| 237 |
+
<SelectItem value="ebay">eBay</SelectItem>
|
| 238 |
+
<SelectItem value="etsy">Etsy</SelectItem>
|
| 239 |
+
</SelectContent>
|
| 240 |
+
</Select>
|
| 241 |
+
</div>
|
| 242 |
+
<div className="grid gap-2">
|
| 243 |
+
<Label htmlFor="price" className="text-xs">Price ($)</Label>
|
| 244 |
+
<Input
|
| 245 |
+
id="price"
|
| 246 |
+
type="number"
|
| 247 |
+
value={price}
|
| 248 |
+
onChange={(e) => setPrice(e.target.value)}
|
| 249 |
+
className="h-8 text-xs bg-background/50"
|
| 250 |
+
/>
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
)}
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
<div className="grid gap-4 px-1">
|
| 257 |
+
<div className="grid gap-2">
|
| 258 |
+
<Label htmlFor="object-name">Object / Product Name *</Label>
|
| 259 |
+
<Input
|
| 260 |
+
id="object-name"
|
| 261 |
+
value={objectName}
|
| 262 |
+
onChange={(e) => setObjectName(e.target.value)}
|
| 263 |
+
placeholder="e.g. Sony WH-1000XM5"
|
| 264 |
+
/>
|
| 265 |
+
</div>
|
| 266 |
+
|
| 267 |
+
<div className="grid grid-cols-1 gap-4">
|
| 268 |
+
<div className="grid gap-2">
|
| 269 |
+
<Label>Video {mode === 'create' ? '*' : ''}</Label>
|
| 270 |
+
{mode === 'create' ? (
|
| 271 |
+
<Select value={videoId} onValueChange={setVideoId}>
|
| 272 |
+
<SelectTrigger className="w-full">
|
| 273 |
+
<SelectValue placeholder="Select video" />
|
| 274 |
+
</SelectTrigger>
|
| 275 |
+
<SelectContent>
|
| 276 |
+
{videos.map(v => (
|
| 277 |
+
<SelectItem key={v.id} value={v.id}>{v.title}</SelectItem>
|
| 278 |
+
))}
|
| 279 |
+
</SelectContent>
|
| 280 |
+
</Select>
|
| 281 |
+
) : (
|
| 282 |
+
<div className="p-2 rounded border border-input bg-muted/50 text-sm text-muted-foreground">
|
| 283 |
+
{videos.find(v => v.id === videoId)?.title || 'Current Video'}
|
| 284 |
+
</div>
|
| 285 |
+
)}
|
| 286 |
+
</div>
|
| 287 |
+
<div className="grid gap-2">
|
| 288 |
+
<Label>Category *</Label>
|
| 289 |
+
<Select value={category} onValueChange={setCategory}>
|
| 290 |
+
<SelectTrigger className="w-full">
|
| 291 |
+
<SelectValue />
|
| 292 |
+
</SelectTrigger>
|
| 293 |
+
<SelectContent>
|
| 294 |
+
<SelectItem value="Tech">Tech</SelectItem>
|
| 295 |
+
<SelectItem value="Fashion">Fashion</SelectItem>
|
| 296 |
+
<SelectItem value="Furniture">Furniture</SelectItem>
|
| 297 |
+
<SelectItem value="Audio">Audio</SelectItem>
|
| 298 |
+
<SelectItem value="Other">Other</SelectItem>
|
| 299 |
+
</SelectContent>
|
| 300 |
+
</Select>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
<div className="grid grid-cols-1 gap-4">
|
| 305 |
+
<div className="grid gap-2">
|
| 306 |
+
<Label htmlFor="timestamp">Video Timestamp (sec)</Label>
|
| 307 |
+
<Input
|
| 308 |
+
id="timestamp"
|
| 309 |
+
type="number"
|
| 310 |
+
value={frameTimestamp}
|
| 311 |
+
onChange={(e) => setFrameTimestamp(e.target.value)}
|
| 312 |
+
/>
|
| 313 |
+
</div>
|
| 314 |
+
<div className="grid grid-cols-2 gap-6">
|
| 315 |
+
{/* Product Image Section */}
|
| 316 |
+
<ImageUpload
|
| 317 |
+
label="Product Image"
|
| 318 |
+
icon={<ImageIcon className="h-4 w-4 text-primary" />}
|
| 319 |
+
url={thumbnailUrl}
|
| 320 |
+
onChange={setThumbnailUrl}
|
| 321 |
+
onUpload={(file) => handleUpload(file, 'product')}
|
| 322 |
+
isUploading={isUploading}
|
| 323 |
+
placeholderText="Marketplace or Custom Image"
|
| 324 |
+
/>
|
| 325 |
+
|
| 326 |
+
{/* In-Video Snapshot Section */}
|
| 327 |
+
<ImageUpload
|
| 328 |
+
label="In-Video Snapshot"
|
| 329 |
+
icon={<PlaySquare className="h-4 w-4 text-secondary" />}
|
| 330 |
+
url={snapshotUrl}
|
| 331 |
+
onChange={setSnapshotUrl}
|
| 332 |
+
onUpload={(file) => handleUpload(file, 'snapshot')}
|
| 333 |
+
isUploading={isUploadingSnapshot}
|
| 334 |
+
placeholderText="Actual moment from video"
|
| 335 |
+
extraLabel={
|
| 336 |
+
<span className="ml-auto text-[10px] font-normal text-muted-foreground bg-white/5 px-1.5 py-0.5 rounded border border-white/10 animate-pulse">
|
| 337 |
+
Cmd+V to Paste
|
| 338 |
+
</span>
|
| 339 |
+
}
|
| 340 |
+
/>
|
| 341 |
+
</div>
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
|
| 345 |
+
<div className="flex justify-end gap-2 pt-4">
|
| 346 |
+
<Button variant="ghost" onClick={onCancel} disabled={isSubmitting}>
|
| 347 |
+
Cancel
|
| 348 |
+
</Button>
|
| 349 |
+
<Button
|
| 350 |
+
onClick={handleSubmit}
|
| 351 |
+
disabled={isSubmitting || !objectName || !category}
|
| 352 |
+
className="bg-primary hover:bg-primary/90 shadow-lg shadow-primary/20"
|
| 353 |
+
>
|
| 354 |
+
{isSubmitting ? (
|
| 355 |
+
<>
|
| 356 |
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
| 357 |
+
{mode === 'create' ? 'Adding...' : 'Saving...'}
|
| 358 |
+
</>
|
| 359 |
+
) : (
|
| 360 |
+
<>
|
| 361 |
+
<Save className="w-4 h-4 mr-2" />
|
| 362 |
+
{mode === 'create' ? 'Add to Vault' : 'Save Changes'}
|
| 363 |
+
</>
|
| 364 |
+
)}
|
| 365 |
+
</Button>
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
);
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
// Helper component for Image sections
|
| 372 |
+
function ImageUpload({
|
| 373 |
+
label, icon, url, onChange, onUpload, isUploading, placeholderText, extraLabel
|
| 374 |
+
}: {
|
| 375 |
+
label: string,
|
| 376 |
+
icon: React.ReactNode,
|
| 377 |
+
url: string,
|
| 378 |
+
onChange: (val: string) => void,
|
| 379 |
+
onUpload: (file: File) => void,
|
| 380 |
+
isUploading: boolean,
|
| 381 |
+
placeholderText: string,
|
| 382 |
+
extraLabel?: React.ReactNode
|
| 383 |
+
}) {
|
| 384 |
+
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
| 385 |
+
|
| 386 |
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 387 |
+
if (e.target.files?.[0]) {
|
| 388 |
+
onUpload(e.target.files[0]);
|
| 389 |
+
}
|
| 390 |
+
// Reset input value to allow selecting same file again
|
| 391 |
+
if (fileInputRef.current) {
|
| 392 |
+
fileInputRef.current.value = '';
|
| 393 |
+
}
|
| 394 |
+
};
|
| 395 |
+
|
| 396 |
+
return (
|
| 397 |
+
<div className="space-y-3">
|
| 398 |
+
<Label className="text-sm font-semibold flex items-center gap-2 h-6">
|
| 399 |
+
{icon}
|
| 400 |
+
{label}
|
| 401 |
+
{extraLabel}
|
| 402 |
+
</Label>
|
| 403 |
+
<div className="space-y-3">
|
| 404 |
+
<div className="relative aspect-square w-full rounded-xl overflow-hidden border border-white/10 bg-white/5 group">
|
| 405 |
+
{url ? (
|
| 406 |
+
<>
|
| 407 |
+
<img
|
| 408 |
+
src={url}
|
| 409 |
+
alt={label}
|
| 410 |
+
className="w-full h-full object-contain bg-black/20"
|
| 411 |
+
onError={(e) => console.error('Image load error', e)}
|
| 412 |
+
/>
|
| 413 |
+
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 flex items-center justify-center gap-2 transition-all duration-200">
|
| 414 |
+
<Button
|
| 415 |
+
variant="secondary"
|
| 416 |
+
size="sm"
|
| 417 |
+
onClick={() => fileInputRef.current?.click()}
|
| 418 |
+
className="h-8 px-3 rounded-full shadow-xl transform scale-90 hover:scale-100 transition-transform font-medium text-xs bg-white text-black hover:bg-white/90"
|
| 419 |
+
>
|
| 420 |
+
<Upload className="h-3 w-3 mr-1.5" />
|
| 421 |
+
Replace
|
| 422 |
+
</Button>
|
| 423 |
+
<Button
|
| 424 |
+
variant="destructive"
|
| 425 |
+
size="icon"
|
| 426 |
+
onClick={() => onChange('')}
|
| 427 |
+
className="h-8 w-8 rounded-full shadow-xl transform scale-90 hover:scale-100 transition-transform"
|
| 428 |
+
>
|
| 429 |
+
<Trash2 className="h-4 w-4" />
|
| 430 |
+
</Button>
|
| 431 |
+
</div>
|
| 432 |
+
</>
|
| 433 |
+
) : (
|
| 434 |
+
<div
|
| 435 |
+
className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground p-4 text-center cursor-pointer hover:bg-white/5 transition-colors"
|
| 436 |
+
onClick={() => fileInputRef.current?.click()}
|
| 437 |
+
>
|
| 438 |
+
{React.isValidElement(icon)
|
| 439 |
+
? React.cloneElement(icon as React.ReactElement<{ className?: string }>, { className: "h-8 w-8 mb-2 opacity-20" })
|
| 440 |
+
: icon}
|
| 441 |
+
<span className="text-[10px]">{placeholderText}</span>
|
| 442 |
+
<span className="mt-2 text-[10px] text-primary/70 font-medium">Click to Upload</span>
|
| 443 |
+
</div>
|
| 444 |
+
)}
|
| 445 |
+
{isUploading && (
|
| 446 |
+
<div className="absolute inset-0 bg-black/60 backdrop-blur-[2px] flex items-center justify-center z-10">
|
| 447 |
+
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
| 448 |
+
</div>
|
| 449 |
+
)}
|
| 450 |
+
</div>
|
| 451 |
+
<div className="flex gap-2">
|
| 452 |
+
<div className="relative flex-1">
|
| 453 |
+
<Input
|
| 454 |
+
value={url}
|
| 455 |
+
onChange={(e) => onChange(e.target.value)}
|
| 456 |
+
placeholder="Image URL"
|
| 457 |
+
className="h-9 text-xs pr-8 bg-background/50"
|
| 458 |
+
/>
|
| 459 |
+
</div>
|
| 460 |
+
<div className="relative">
|
| 461 |
+
<Input
|
| 462 |
+
ref={fileInputRef}
|
| 463 |
+
type="file"
|
| 464 |
+
className="hidden"
|
| 465 |
+
accept="image/*"
|
| 466 |
+
onChange={handleFileChange}
|
| 467 |
+
disabled={isUploading}
|
| 468 |
+
/>
|
| 469 |
+
<Button
|
| 470 |
+
variant="outline"
|
| 471 |
+
size="icon"
|
| 472 |
+
className="h-9 w-9 shrink-0"
|
| 473 |
+
onClick={() => fileInputRef.current?.click()}
|
| 474 |
+
disabled={isUploading}
|
| 475 |
+
>
|
| 476 |
+
<Upload className="h-4 w-4" />
|
| 477 |
+
</Button>
|
| 478 |
+
</div>
|
| 479 |
+
</div>
|
| 480 |
+
</div>
|
| 481 |
+
</div>
|
| 482 |
+
);
|
| 483 |
+
}
|
src/features/moderation/services/moderation.service.ts
CHANGED
|
@@ -33,7 +33,8 @@ export class ModerationService {
|
|
| 33 |
'productName', ${marketplaceMatches.productName},
|
| 34 |
'price', ${marketplaceMatches.price},
|
| 35 |
'availability', ${marketplaceMatches.availabilityStatus},
|
| 36 |
-
'affiliateUrl', ${marketplaceMatches.affiliateUrl}
|
|
|
|
| 37 |
)
|
| 38 |
) FILTER (WHERE ${marketplaceMatches.id} IS NOT NULL),
|
| 39 |
'[]'
|
|
|
|
| 33 |
'productName', ${marketplaceMatches.productName},
|
| 34 |
'price', ${marketplaceMatches.price},
|
| 35 |
'availability', ${marketplaceMatches.availabilityStatus},
|
| 36 |
+
'affiliateUrl', ${marketplaceMatches.affiliateUrl},
|
| 37 |
+
'imageUrl', ${marketplaceMatches.imageUrl}
|
| 38 |
)
|
| 39 |
) FILTER (WHERE ${marketplaceMatches.id} IS NOT NULL),
|
| 40 |
'[]'
|
src/features/vault/services/showcase.service.ts
CHANGED
|
@@ -392,7 +392,15 @@ export class ShowcaseService {
|
|
| 392 |
.where(eq(youtubeChannels.id, currentChannel.id));
|
| 393 |
}
|
| 394 |
|
| 395 |
-
// If
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
if (!options.skipAnalysis && (existing.scanStatus === 'failed' || existing.scanStatus === 'pending')) {
|
| 397 |
console.log(`Re-triggering analysis for ${videoId} (status: ${existing.scanStatus})`);
|
| 398 |
await inngest.send({
|
|
@@ -461,6 +469,7 @@ export class ShowcaseService {
|
|
| 461 |
viewCount: 0,
|
| 462 |
publishedAt: new Date(),
|
| 463 |
availabilityStatus: 'available',
|
|
|
|
| 464 |
}).returning();
|
| 465 |
|
| 466 |
// 6. Trigger Inngest
|
|
|
|
| 392 |
.where(eq(youtubeChannels.id, currentChannel.id));
|
| 393 |
}
|
| 394 |
|
| 395 |
+
// If skipAnalysis is requested, force status to completed to avoid stuck pending state
|
| 396 |
+
if (options.skipAnalysis && (existing.scanStatus === 'pending' || existing.scanStatus === 'in_progress')) {
|
| 397 |
+
console.log(`Forcing scanStatus to 'completed' for existing video ${videoId} (was ${existing.scanStatus})`);
|
| 398 |
+
await db.update(youtubeVideos)
|
| 399 |
+
.set({ scanStatus: 'completed' })
|
| 400 |
+
.where(eq(youtubeVideos.id, existing.id));
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
// If video is failed or pending, allow re-trigger of analysis (only if NOT skipping)
|
| 404 |
if (!options.skipAnalysis && (existing.scanStatus === 'failed' || existing.scanStatus === 'pending')) {
|
| 405 |
console.log(`Re-triggering analysis for ${videoId} (status: ${existing.scanStatus})`);
|
| 406 |
await inngest.send({
|
|
|
|
| 469 |
viewCount: 0,
|
| 470 |
publishedAt: new Date(),
|
| 471 |
availabilityStatus: 'available',
|
| 472 |
+
scanStatus: options.skipAnalysis ? 'completed' : 'pending',
|
| 473 |
}).returning();
|
| 474 |
|
| 475 |
// 6. Trigger Inngest
|