Spaces:
Sleeping
Sleeping
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { | |
| Upload, | |
| FolderOpen, | |
| Image, | |
| FileText, | |
| Video, | |
| Search, | |
| Filter, | |
| Grid3X3, | |
| List, | |
| MoreVertical, | |
| Download, | |
| Trash2, | |
| Eye, | |
| ChevronDown, | |
| ChevronRight, | |
| Plus, | |
| X, | |
| Check, | |
| File, | |
| Copy | |
| } from 'lucide-react'; | |
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { Label } from '@/components/ui/label'; | |
| import { Progress } from '@/components/ui/progress'; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from '@/components/ui/select'; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuItem, | |
| DropdownMenuTrigger, | |
| } from '@/components/ui/dropdown-menu'; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogHeader, | |
| DialogTitle, | |
| DialogTrigger, | |
| } from '@/components/ui/dialog'; | |
| import { Checkbox } from '@/components/ui/checkbox'; | |
| const products = [ | |
| { | |
| id: 'ocr', | |
| name: 'Intelligent Document Parsing (OCR)', | |
| shortName: 'OCR', | |
| color: 'blue', | |
| subCategories: [] | |
| }, | |
| { | |
| id: 'p2p', | |
| name: 'Purchase To Pay (P2P)', | |
| shortName: 'P2P', | |
| color: 'emerald', | |
| subCategories: [ | |
| 'Budget Approval Workflow', | |
| 'Purchase Request Workflow', | |
| 'Accounts Payable Workflow' | |
| ] | |
| }, | |
| { | |
| id: 'o2c', | |
| name: 'Order to Cash (O2C)', | |
| shortName: 'O2C', | |
| color: 'violet', | |
| subCategories: [ | |
| 'Quotation Workflow', | |
| 'Sales Order Workflow', | |
| 'PickSlip Delivery Workflow', | |
| 'Accounts Receivable Workflow' | |
| ] | |
| } | |
| ]; | |
| const mockAssets = [ | |
| { id: '1', name: 'OCR_Demo_Screenshot.png', type: 'image', product: 'ocr', subCategory: null, size: '2.4 MB', date: '2024-12-20' }, | |
| { id: '2', name: 'P2P_Workflow_Diagram.pdf', type: 'document', product: 'p2p', subCategory: 'Budget Approval Workflow', size: '1.8 MB', date: '2024-12-19' }, | |
| { id: '3', name: 'Invoice_Processing_Video.mp4', type: 'video', product: 'ocr', subCategory: null, size: '45.2 MB', date: '2024-12-18' }, | |
| { id: '4', name: 'Sales_Order_Infographic.png', type: 'image', product: 'o2c', subCategory: 'Sales Order Workflow', size: '3.1 MB', date: '2024-12-17' }, | |
| { id: '5', name: 'AP_Automation_Brochure.pdf', type: 'document', product: 'p2p', subCategory: 'Accounts Payable Workflow', size: '5.6 MB', date: '2024-12-16' }, | |
| { id: '6', name: 'O2C_Product_Banner.png', type: 'image', product: 'o2c', subCategory: 'Quotation Workflow', size: '1.2 MB', date: '2024-12-15' }, | |
| { id: '7', name: 'Document_Parsing_Demo.png', type: 'image', product: 'ocr', subCategory: null, size: '890 KB', date: '2024-12-14' }, | |
| { id: '8', name: 'PR_Workflow_Guide.pdf', type: 'document', product: 'p2p', subCategory: 'Purchase Request Workflow', size: '2.3 MB', date: '2024-12-13' }, | |
| ]; | |
| export default function Repository() { | |
| const [viewMode, setViewMode] = useState('grid'); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [selectedProduct, setSelectedProduct] = useState('all'); | |
| const [expandedProducts, setExpandedProducts] = useState(['ocr', 'p2p', 'o2c']); | |
| const [selectedAssets, setSelectedAssets] = useState([]); | |
| const [uploadDialogOpen, setUploadDialogOpen] = useState(false); | |
| const [dragOver, setDragOver] = useState(false); | |
| const [selectedFiles, setSelectedFiles] = useState([]); | |
| const [uploadProductCategory, setUploadProductCategory] = useState(''); | |
| const [uploadSubCategory, setUploadSubCategory] = useState(''); | |
| const [isUploading, setIsUploading] = useState(false); | |
| const [assets, setAssets] = useState(mockAssets); | |
| const [isLoadingAssets, setIsLoadingAssets] = useState(false); | |
| const [previewAsset, setPreviewAsset] = useState(null); | |
| const [previewDialogOpen, setPreviewDialogOpen] = useState(false); | |
| const [uploadProgress, setUploadProgress] = useState({}); | |
| const [pdfPages, setPdfPages] = useState(null); | |
| const [isLoadingPdf, setIsLoadingPdf] = useState(false); | |
| const [isDeleting, setIsDeleting] = useState(false); | |
| const fileInputRef = useRef(null); | |
| const toggleProduct = (productId) => { | |
| setExpandedProducts(prev => | |
| prev.includes(productId) | |
| ? prev.filter(id => id !== productId) | |
| : [...prev, productId] | |
| ); | |
| }; | |
| const filteredAssets = assets.filter(asset => { | |
| const matchesSearch = asset.name.toLowerCase().includes(searchQuery.toLowerCase()); | |
| const matchesProduct = selectedProduct === 'all' || asset.product === selectedProduct; | |
| return matchesSearch && matchesProduct; | |
| }); | |
| const getAssetsByProduct = (productId) => { | |
| return assets.filter(asset => asset.product === productId); | |
| }; | |
| const getTypeIcon = (type) => { | |
| switch(type) { | |
| case 'image': return <Image className="w-5 h-5 text-pink-500" />; | |
| case 'video': return <Video className="w-5 h-5 text-red-500" />; | |
| case 'document': return <FileText className="w-5 h-5 text-blue-500" />; | |
| default: return <File className="w-5 h-5 text-slate-500" />; | |
| } | |
| }; | |
| const getProductColor = (productId) => { | |
| const product = products.find(p => p.id === productId); | |
| return product?.color || 'slate'; | |
| }; | |
| // Fetch assets from API | |
| const fetchAssets = async () => { | |
| setIsLoadingAssets(true); | |
| try { | |
| const response = await fetch('/api/assets'); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| console.log('Fetched assets from API:', data); // Debug log | |
| // Convert API response to match mockAssets format | |
| // IMPORTANT: Keep IDs as strings to preserve precision for large CockroachDB IDs | |
| const formattedAssets = data.map(asset => { | |
| // Handle date formatting more robustly | |
| let dateStr = new Date().toISOString().split('T')[0]; // Default to today | |
| if (asset.created_at) { | |
| try { | |
| // Handle both string and datetime object | |
| let date; | |
| if (asset.created_at instanceof Date) { | |
| date = asset.created_at; | |
| } else if (typeof asset.created_at === 'string') { | |
| date = new Date(asset.created_at); | |
| } else { | |
| // Handle datetime object from backend | |
| date = new Date(asset.created_at); | |
| } | |
| if (!isNaN(date.getTime())) { | |
| dateStr = date.toISOString().split('T')[0]; | |
| } else { | |
| console.warn('Invalid date:', asset.created_at); | |
| } | |
| } catch (e) { | |
| console.warn('Date parsing error:', e, asset.created_at); | |
| } | |
| } | |
| // CRITICAL: Keep ID as string - never convert to number (precision loss for large IDs) | |
| // If asset.id is already a number, convert it carefully to preserve precision | |
| let assetIdStr; | |
| if (typeof asset.id === 'string') { | |
| assetIdStr = asset.id; | |
| } else if (typeof asset.id === 'number') { | |
| // For very large numbers, use BigInt to preserve precision, then convert to string | |
| assetIdStr = BigInt(asset.id).toString(); | |
| } else { | |
| assetIdStr = String(asset.id); | |
| } | |
| return { | |
| id: assetIdStr, // Always keep as string to preserve precision for large CockroachDB IDs | |
| name: asset.name || 'Unknown', | |
| type: asset.file_type || 'unknown', | |
| product: asset.product_category || 'ocr', | |
| subCategory: asset.sub_category || null, | |
| size: formatFileSize(asset.size || 0), | |
| date: dateStr | |
| }; | |
| }); | |
| console.log('Formatted assets:', formattedAssets); // Debug log | |
| setAssets(formattedAssets); | |
| } else { | |
| const errorText = await response.text(); | |
| console.error('Failed to fetch assets:', response.status, errorText); | |
| // Don't reset to mockAssets - keep what we have | |
| } | |
| } catch (error) { | |
| console.error('Error fetching assets:', error); | |
| // Don't reset to mockAssets - keep what we have | |
| } finally { | |
| setIsLoadingAssets(false); | |
| } | |
| }; | |
| // Format file size | |
| const formatFileSize = (bytes) => { | |
| if (!bytes) return '0 B'; | |
| const k = 1024; | |
| const sizes = ['B', 'KB', 'MB', 'GB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; | |
| }; | |
| // Handle delete asset | |
| const handleDeleteAsset = async (assetId) => { | |
| setIsDeleting(true); | |
| try { | |
| // Ensure assetId is a string to preserve precision | |
| const assetIdStr = String(assetId); | |
| const response = await fetch(`/api/assets/${assetIdStr}`, { | |
| method: 'DELETE', | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json().catch(() => ({ detail: 'Delete failed' })); | |
| throw new Error(errorData.detail || 'Delete failed'); | |
| } | |
| const result = await response.json(); | |
| console.log('Delete result:', result); | |
| // Refresh assets list | |
| await fetchAssets(); | |
| // Close preview if deleted asset was being previewed | |
| if (previewAsset && previewAsset.id === assetId) { | |
| setPreviewDialogOpen(false); | |
| setPreviewAsset(null); | |
| setPdfPages(null); | |
| } | |
| alert('Asset deleted successfully'); | |
| } catch (error) { | |
| console.error('Delete error:', error); | |
| alert(`Delete failed: ${error.message}`); | |
| } finally { | |
| setIsDeleting(false); | |
| } | |
| }; | |
| // Load PDF pages when previewing a PDF | |
| const loadPdfPages = async (assetId) => { | |
| setIsLoadingPdf(true); | |
| setPdfPages(null); | |
| try { | |
| // CRITICAL: Ensure assetId is a string to preserve precision for large IDs | |
| // Never convert to number - JavaScript loses precision for numbers > Number.MAX_SAFE_INTEGER | |
| let assetIdStr; | |
| if (typeof assetId === 'string') { | |
| assetIdStr = assetId; | |
| } else if (typeof assetId === 'number') { | |
| // Use BigInt to preserve precision, then convert to string | |
| assetIdStr = BigInt(assetId).toString(); | |
| } else { | |
| assetIdStr = String(assetId); | |
| } | |
| console.log(`Loading PDF for asset ID: ${assetIdStr} (original type: ${typeof assetId})`); | |
| const response = await fetch(`/api/assets/${assetIdStr}/pdf-pages`); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| setPdfPages(data); | |
| } else { | |
| const errorData = await response.json().catch(() => ({ | |
| detail: response.status === 503 | |
| ? 'PDF preview requires poppler-utils to be installed on the server' | |
| : 'Failed to load PDF preview' | |
| })); | |
| console.error('Failed to load PDF pages:', errorData); | |
| setPdfPages({ error: errorData.detail || 'Failed to load PDF preview' }); | |
| } | |
| } catch (error) { | |
| console.error('Error loading PDF pages:', error); | |
| setPdfPages({ | |
| error: error.message || 'Failed to load PDF preview. Make sure poppler-utils is installed.' | |
| }); | |
| } finally { | |
| setIsLoadingPdf(false); | |
| } | |
| }; | |
| // Fetch assets on component mount and when upload dialog closes | |
| useEffect(() => { | |
| fetchAssets(); | |
| }, []); | |
| // Refresh assets after successful upload | |
| useEffect(() => { | |
| if (!uploadDialogOpen && !isUploading) { | |
| console.log('useEffect: Refreshing assets (dialog closed, not uploading)'); | |
| fetchAssets(); | |
| } | |
| }, [uploadDialogOpen, isUploading]); | |
| // Auto-load PDF pages when preview opens for PDF files | |
| useEffect(() => { | |
| if (previewAsset && previewDialogOpen) { | |
| // Load PDF pages if it's a PDF | |
| if (previewAsset.type === 'document' && previewAsset.name.toLowerCase().endsWith('.pdf')) { | |
| console.log('Auto-loading PDF pages for:', previewAsset.id); | |
| loadPdfPages(String(previewAsset.id)); | |
| } else { | |
| setPdfPages(null); | |
| } | |
| } | |
| }, [previewAsset, previewDialogOpen]); | |
| const handleFileSelect = (files) => { | |
| const fileArray = Array.from(files); | |
| setSelectedFiles(fileArray); | |
| }; | |
| const handleDragDrop = (e) => { | |
| e.preventDefault(); | |
| setDragOver(false); | |
| if (e.dataTransfer.files) { | |
| handleFileSelect(e.dataTransfer.files); | |
| } | |
| }; | |
| const handleFileInputChange = (e) => { | |
| if (e.target.files) { | |
| handleFileSelect(e.target.files); | |
| } | |
| }; | |
| const pollAssetStatus = async (assetId, fileName) => { | |
| const maxAttempts = 60; // Poll for up to 60 seconds | |
| let attempts = 0; | |
| let consecutive404s = 0; | |
| const max404s = 5; // Allow up to 5 consecutive 404s (asset might not be visible yet) | |
| // Small delay before first poll to allow database commit | |
| await new Promise(resolve => setTimeout(resolve, 500)); | |
| while (attempts < maxAttempts) { | |
| try { | |
| const response = await fetch(`/api/assets/${assetId}/status`); | |
| if (response.ok) { | |
| const status = await response.json(); | |
| consecutive404s = 0; // Reset 404 counter on success | |
| setUploadProgress(prev => ({ | |
| ...prev, | |
| [fileName]: { | |
| status: status.status, | |
| message: getStatusMessage(status.status), | |
| extractedContent: status.extracted_content || null // Store extracted JSON | |
| } | |
| })); | |
| if (status.status === 'completed' || status.status === 'failed') { | |
| break; | |
| } | |
| } else if (response.status === 404) { | |
| consecutive404s++; | |
| // If we get too many 404s, stop polling (asset might not exist) | |
| if (consecutive404s >= max404s) { | |
| console.warn(`Asset ${assetId} not found after ${max404s} attempts`); | |
| setUploadProgress(prev => ({ | |
| ...prev, | |
| [fileName]: { | |
| status: 'pending', | |
| message: 'Asset status unavailable' | |
| } | |
| })); | |
| break; | |
| } | |
| // For first few 404s, keep trying (might be replication lag) | |
| } else { | |
| // Other errors - log but keep trying | |
| console.error(`Status check failed: ${response.status}`); | |
| } | |
| } catch (error) { | |
| console.error('Error polling status:', error); | |
| // On network errors, keep trying | |
| } | |
| await new Promise(resolve => setTimeout(resolve, 1000)); // Poll every second | |
| attempts++; | |
| } | |
| }; | |
| const getStatusMessage = (status) => { | |
| switch(status) { | |
| case 'pending': return 'Uploading file...'; | |
| case 'processing': return 'Extracting content with OCR...'; | |
| case 'completed': return 'Content extracted and indexed by AI agent ✓'; | |
| case 'failed': return 'Analysis failed'; | |
| default: return 'Processing...'; | |
| } | |
| }; | |
| const handleUpload = async () => { | |
| if (selectedFiles.length === 0) { | |
| alert('Please select at least one file to upload'); | |
| return; | |
| } | |
| if (!uploadProductCategory) { | |
| alert('Please select a product category'); | |
| return; | |
| } | |
| setIsUploading(true); | |
| setUploadProgress({}); | |
| try { | |
| // Initialize progress for all files | |
| const initialProgress = {}; | |
| selectedFiles.forEach(file => { | |
| initialProgress[file.name] = { | |
| status: 'pending', | |
| message: 'Uploading file...' | |
| }; | |
| }); | |
| setUploadProgress(initialProgress); | |
| // Upload files sequentially to show progress | |
| const results = []; | |
| for (const file of selectedFiles) { | |
| try { | |
| setUploadProgress(prev => ({ | |
| ...prev, | |
| [file.name]: { | |
| status: 'pending', | |
| message: 'Uploading file...' | |
| } | |
| })); | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| formData.append('product_category', uploadProductCategory); | |
| if (uploadSubCategory && uploadSubCategory !== 'none') { | |
| formData.append('sub_category', uploadSubCategory); | |
| } | |
| const response = await fetch('/api/assets/upload', { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json().catch(() => ({ detail: 'Upload failed' })); | |
| throw new Error(errorData.detail || `Upload failed for ${file.name}`); | |
| } | |
| const result = await response.json(); | |
| results.push(result); | |
| // Ensure ID is kept as string to preserve precision | |
| const assetId = String(result.id); | |
| // Update progress - file uploaded, now analyzing | |
| setUploadProgress(prev => ({ | |
| ...prev, | |
| [file.name]: { | |
| status: result.analysis_status || 'processing', | |
| message: result.analysis_status === 'processing' | |
| ? 'Extracting content with OCR...' | |
| : 'Upload complete, analyzing...' | |
| } | |
| })); | |
| // Start polling for analysis status if it's a document/image | |
| if (result.file_type === 'document' || result.file_type === 'image') { | |
| pollAssetStatus(assetId, file.name); | |
| } else { | |
| setUploadProgress(prev => ({ | |
| ...prev, | |
| [file.name]: { | |
| status: 'completed', | |
| message: 'Upload complete ✓' | |
| } | |
| })); | |
| } | |
| } catch (error) { | |
| console.error(`Upload error for ${file.name}:`, error); | |
| setUploadProgress(prev => ({ | |
| ...prev, | |
| [file.name]: { | |
| status: 'failed', | |
| message: `Upload failed: ${error.message}` | |
| } | |
| })); | |
| } | |
| } | |
| console.log('Upload results:', results); | |
| // Refresh assets immediately after upload | |
| console.log('Refreshing assets after upload...'); | |
| await fetchAssets(); | |
| // Wait a bit more for database to commit, then refresh again | |
| setTimeout(async () => { | |
| console.log('Refreshing assets again after delay...'); | |
| await fetchAssets(); | |
| }, 1500); | |
| // Don't close dialog immediately - let user see the progress | |
| // Reset form after a delay | |
| setTimeout(() => { | |
| setSelectedFiles([]); | |
| setUploadProductCategory(''); | |
| setUploadSubCategory(''); | |
| setUploadProgress({}); | |
| setUploadDialogOpen(false); | |
| // Refresh again after dialog closes to ensure latest data | |
| console.log('Refreshing assets after dialog closes...'); | |
| fetchAssets(); | |
| }, 3000); | |
| } catch (error) { | |
| console.error('Upload error:', error); | |
| alert(`Upload failed: ${error.message}`); | |
| } finally { | |
| setIsUploading(false); | |
| } | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50/30"> | |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> | |
| {/* Header */} | |
| <motion.div | |
| initial={{ opacity: 0, y: -20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="mb-8" | |
| > | |
| <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> | |
| <div> | |
| <h1 className="text-3xl font-bold text-slate-900 tracking-tight"> | |
| Asset Repository | |
| </h1> | |
| <p className="text-slate-500 mt-1"> | |
| Manage your marketing materials, screenshots, and documents | |
| </p> | |
| </div> | |
| <Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}> | |
| <DialogTrigger asChild> | |
| <Button className="gap-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-lg shadow-blue-500/25"> | |
| <Upload className="w-4 h-4" /> | |
| Upload Assets | |
| </Button> | |
| </DialogTrigger> | |
| <DialogContent className="sm:max-w-lg"> | |
| <DialogHeader> | |
| <DialogTitle>Upload Assets</DialogTitle> | |
| </DialogHeader> | |
| <div className="space-y-4 pt-4"> | |
| <input | |
| type="file" | |
| ref={fileInputRef} | |
| onChange={handleFileInputChange} | |
| multiple | |
| className="hidden" | |
| accept="image/*,video/*,.pdf,.doc,.docx" | |
| /> | |
| <div | |
| className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${ | |
| dragOver ? 'border-blue-500 bg-blue-50' : 'border-slate-200 hover:border-slate-300' | |
| }`} | |
| onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} | |
| onDragLeave={() => setDragOver(false)} | |
| onDrop={handleDragDrop} | |
| onClick={() => fileInputRef.current?.click()} | |
| > | |
| <Upload className="w-10 h-10 text-slate-400 mx-auto mb-3" /> | |
| <p className="text-sm font-medium text-slate-700"> | |
| Drag and drop files here | |
| </p> | |
| <p className="text-xs text-slate-500 mt-1"> | |
| or click to browse | |
| </p> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="mt-4" | |
| type="button" | |
| onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }} | |
| > | |
| Browse Files | |
| </Button> | |
| {selectedFiles.length > 0 && ( | |
| <div className="mt-4 space-y-3"> | |
| <p className="text-xs font-medium text-slate-600">Selected files:</p> | |
| {selectedFiles.map((file, index) => { | |
| const progress = uploadProgress[file.name]; | |
| const getProgressValue = () => { | |
| if (!progress) return 0; | |
| switch(progress.status) { | |
| case 'pending': return 25; | |
| case 'processing': return 60; | |
| case 'completed': return 100; | |
| case 'failed': return 0; | |
| default: return 0; | |
| } | |
| }; | |
| const getProgressColor = () => { | |
| if (!progress) return 'bg-blue-500'; | |
| switch(progress.status) { | |
| case 'pending': return 'bg-blue-500'; | |
| case 'processing': return 'bg-yellow-500'; | |
| case 'completed': return 'bg-green-500'; | |
| case 'failed': return 'bg-red-500'; | |
| default: return 'bg-blue-500'; | |
| } | |
| }; | |
| return ( | |
| <div key={index} className="space-y-2"> | |
| <div className="flex items-center justify-between text-xs bg-slate-50 p-2 rounded"> | |
| <span className="text-slate-700 truncate flex-1 mr-2">{file.name}</span> | |
| <span className="text-slate-500 whitespace-nowrap">{(file.size / 1024 / 1024).toFixed(2)} MB</span> | |
| </div> | |
| {isUploading && ( | |
| <div className="space-y-1"> | |
| <div className="flex items-center justify-between text-xs"> | |
| <span className={`font-medium ${ | |
| progress?.status === 'completed' ? 'text-green-600' : | |
| progress?.status === 'failed' ? 'text-red-600' : | |
| progress?.status === 'processing' ? 'text-yellow-600' : | |
| 'text-blue-600' | |
| }`}> | |
| {progress?.message || 'Uploading...'} | |
| </span> | |
| {progress?.status === 'completed' && <Check className="w-3 h-3 text-green-600" />} | |
| {progress?.status === 'failed' && <X className="w-3 h-3 text-red-600" />} | |
| </div> | |
| <div className="relative h-1.5 w-full overflow-hidden rounded-full bg-slate-200"> | |
| <div | |
| className={`h-full transition-all ${getProgressColor()}`} | |
| style={{ width: `${getProgressValue()}%` }} | |
| /> | |
| </div> | |
| {/* JSON Output Display */} | |
| {progress?.status === 'completed' && progress?.extractedContent && ( | |
| <div className="mt-3 p-3 bg-slate-50 rounded-lg border border-slate-200"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <p className="text-xs font-medium text-slate-700">Extracted JSON Output:</p> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-6 px-2 text-xs" | |
| onClick={() => { | |
| const jsonStr = JSON.stringify(progress.extractedContent, null, 2); | |
| navigator.clipboard.writeText(jsonStr).then(() => { | |
| alert('JSON copied to clipboard!'); | |
| }).catch(() => { | |
| alert('Failed to copy JSON'); | |
| }); | |
| }} | |
| > | |
| <Copy className="w-3 h-3 mr-1" /> | |
| Copy | |
| </Button> | |
| </div> | |
| <pre className="text-xs text-slate-600 bg-white p-2 rounded border border-slate-200 max-h-40 overflow-auto font-mono"> | |
| {JSON.stringify(progress.extractedContent, null, 2)} | |
| </pre> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| <div className="space-y-3"> | |
| <div> | |
| <Label>Product Category</Label> | |
| <Select value={uploadProductCategory} onValueChange={setUploadProductCategory}> | |
| <SelectTrigger className="mt-1.5"> | |
| <SelectValue placeholder="Select a product" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| {products.map(product => ( | |
| <SelectItem key={product.id} value={product.id}> | |
| {product.name} | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| <div> | |
| <Label>Sub-Category (Optional)</Label> | |
| <Select | |
| value={uploadSubCategory} | |
| onValueChange={setUploadSubCategory} | |
| disabled={!uploadProductCategory || products.find(p => p.id === uploadProductCategory)?.subCategories?.length === 0} | |
| > | |
| <SelectTrigger className="mt-1.5"> | |
| <SelectValue placeholder="Select sub-category" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="none">None</SelectItem> | |
| {uploadProductCategory && products.find(p => p.id === uploadProductCategory)?.subCategories?.map((sub, idx) => ( | |
| <SelectItem key={idx} value={sub}>{sub}</SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </div> | |
| <div className="flex justify-end gap-2 pt-4"> | |
| <Button | |
| variant="outline" | |
| onClick={() => { | |
| setUploadDialogOpen(false); | |
| setSelectedFiles([]); | |
| setUploadProductCategory(''); | |
| setUploadSubCategory(''); | |
| }} | |
| disabled={isUploading} | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| className="bg-blue-600 hover:bg-blue-700" | |
| onClick={handleUpload} | |
| disabled={isUploading || selectedFiles.length === 0 || !uploadProductCategory} | |
| > | |
| {isUploading ? 'Uploading...' : 'Upload'} | |
| </Button> | |
| </div> | |
| </div> | |
| </DialogContent> | |
| </Dialog> | |
| </div> | |
| </motion.div> | |
| <div className="grid lg:grid-cols-4 gap-6"> | |
| {/* Sidebar - Product Categories */} | |
| <motion.div | |
| initial={{ opacity: 0, x: -20 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| className="lg:col-span-1" | |
| > | |
| <Card className="border-0 shadow-lg shadow-slate-200/50 sticky top-8"> | |
| <CardHeader className="pb-3"> | |
| <CardTitle className="text-sm font-semibold text-slate-600 uppercase tracking-wider"> | |
| Product Categories | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="pt-0"> | |
| <div className="space-y-1"> | |
| <button | |
| onClick={() => setSelectedProduct('all')} | |
| className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${ | |
| selectedProduct === 'all' | |
| ? 'bg-blue-50 text-blue-700' | |
| : 'hover:bg-slate-50 text-slate-700' | |
| }`} | |
| > | |
| <FolderOpen className="w-4 h-4" /> | |
| <span className="font-medium text-sm">All Assets</span> | |
| <Badge variant="secondary" className="ml-auto text-xs"> | |
| {assets.length} | |
| </Badge> | |
| </button> | |
| {products.map(product => ( | |
| <div key={product.id}> | |
| <button | |
| onClick={() => { | |
| setSelectedProduct(product.id); | |
| if (product.subCategories.length > 0) { | |
| toggleProduct(product.id); | |
| } | |
| }} | |
| className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${ | |
| selectedProduct === product.id | |
| ? `bg-${product.color}-50 text-${product.color}-700` | |
| : 'hover:bg-slate-50 text-slate-700' | |
| }`} | |
| > | |
| {product.subCategories.length > 0 && ( | |
| <ChevronRight className={`w-4 h-4 transition-transform ${ | |
| expandedProducts.includes(product.id) ? 'rotate-90' : '' | |
| }`} /> | |
| )} | |
| {product.subCategories.length === 0 && <div className="w-4" />} | |
| <span className="font-medium text-sm truncate">{product.shortName}</span> | |
| <Badge variant="secondary" className="ml-auto text-xs"> | |
| {getAssetsByProduct(product.id).length} | |
| </Badge> | |
| </button> | |
| <AnimatePresence> | |
| {expandedProducts.includes(product.id) && product.subCategories.length > 0 && ( | |
| <motion.div | |
| initial={{ height: 0, opacity: 0 }} | |
| animate={{ height: 'auto', opacity: 1 }} | |
| exit={{ height: 0, opacity: 0 }} | |
| className="overflow-hidden" | |
| > | |
| <div className="ml-7 pl-3 border-l border-slate-200 mt-1 space-y-1"> | |
| {product.subCategories.map((sub, idx) => ( | |
| <button | |
| key={idx} | |
| className="w-full text-left px-3 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors" | |
| > | |
| {sub} | |
| </button> | |
| ))} | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| ))} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </motion.div> | |
| {/* Main Content */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="lg:col-span-3" | |
| > | |
| {/* Search and Filters */} | |
| <Card className="border-0 shadow-lg shadow-slate-200/50 mb-6"> | |
| <CardContent className="p-4"> | |
| <div className="flex flex-col sm:flex-row gap-3"> | |
| <div className="relative flex-1"> | |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> | |
| <Input | |
| placeholder="Search assets..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="pl-10 border-slate-200" | |
| /> | |
| </div> | |
| <div className="flex gap-2"> | |
| <Select defaultValue="all"> | |
| <SelectTrigger className="w-32 border-slate-200"> | |
| <Filter className="w-4 h-4 mr-2" /> | |
| <SelectValue placeholder="Type" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="all">All Types</SelectItem> | |
| <SelectItem value="image">Images</SelectItem> | |
| <SelectItem value="document">Documents</SelectItem> | |
| <SelectItem value="video">Videos</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <div className="flex border border-slate-200 rounded-lg overflow-hidden"> | |
| <Button | |
| variant={viewMode === 'grid' ? 'secondary' : 'ghost'} | |
| size="icon" | |
| onClick={() => setViewMode('grid')} | |
| className="rounded-none" | |
| > | |
| <Grid3X3 className="w-4 h-4" /> | |
| </Button> | |
| <Button | |
| variant={viewMode === 'list' ? 'secondary' : 'ghost'} | |
| size="icon" | |
| onClick={() => setViewMode('list')} | |
| className="rounded-none" | |
| > | |
| <List className="w-4 h-4" /> | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Assets Grid/List */} | |
| {viewMode === 'grid' ? ( | |
| <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4"> | |
| {filteredAssets.map((asset, index) => ( | |
| <motion.div | |
| key={asset.id} | |
| initial={{ opacity: 0, scale: 0.95 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| transition={{ delay: index * 0.05 }} | |
| > | |
| <Card className="border-0 shadow-md hover:shadow-lg transition-all duration-300 group overflow-hidden"> | |
| <div className={`h-40 bg-gradient-to-br from-${getProductColor(asset.product)}-100 to-${getProductColor(asset.product)}-50 flex items-center justify-center relative`}> | |
| {asset.type === 'image' ? ( | |
| <Image className={`w-12 h-12 text-${getProductColor(asset.product)}-400`} /> | |
| ) : asset.type === 'video' ? ( | |
| <Video className={`w-12 h-12 text-${getProductColor(asset.product)}-400`} /> | |
| ) : ( | |
| <FileText className={`w-12 h-12 text-${getProductColor(asset.product)}-400`} /> | |
| )} | |
| <div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100"> | |
| <div className="flex gap-2"> | |
| <Button | |
| size="icon" | |
| variant="secondary" | |
| className="h-9 w-9" | |
| onClick={async (e) => { | |
| e.stopPropagation(); | |
| setPreviewAsset(asset); | |
| setPreviewDialogOpen(true); | |
| // Load PDF pages if it's a PDF | |
| if (asset.type === 'document' && asset.name.toLowerCase().endsWith('.pdf')) { | |
| await loadPdfPages(String(asset.id)); | |
| } else { | |
| setPdfPages(null); | |
| } | |
| }} | |
| > | |
| <Eye className="w-4 h-4" /> | |
| </Button> | |
| <Button | |
| size="icon" | |
| variant="secondary" | |
| className="h-9 w-9" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| window.open(`/api/assets/${String(asset.id)}/download`, '_blank'); | |
| }} | |
| > | |
| <Download className="w-4 h-4" /> | |
| </Button> | |
| </div> | |
| </div> | |
| <div className="absolute top-2 right-2"> | |
| <Checkbox className="bg-white border-slate-300" /> | |
| </div> | |
| </div> | |
| <CardContent className="p-4"> | |
| <div className="flex items-start justify-between gap-2"> | |
| <div className="min-w-0"> | |
| <h3 className="font-medium text-slate-900 text-sm truncate"> | |
| {asset.name} | |
| </h3> | |
| <p className="text-xs text-slate-500 mt-1"> | |
| {asset.size} • {asset.date} | |
| </p> | |
| </div> | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild> | |
| <Button variant="ghost" size="icon" className="h-8 w-8 shrink-0"> | |
| <MoreVertical className="w-4 h-4" /> | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent align="end"> | |
| <DropdownMenuItem onClick={async () => { | |
| setPreviewAsset(asset); | |
| setPreviewDialogOpen(true); | |
| // Load PDF pages if it's a PDF | |
| if (asset.type === 'document' && asset.name.toLowerCase().endsWith('.pdf')) { | |
| await loadPdfPages(String(asset.id)); | |
| } else { | |
| setPdfPages(null); | |
| } | |
| }}> | |
| <Eye className="w-4 h-4 mr-2" /> Preview | |
| </DropdownMenuItem> | |
| <DropdownMenuItem onClick={() => { | |
| window.open(`/api/assets/${String(asset.id)}/download`, '_blank'); | |
| }}> | |
| <Download className="w-4 h-4 mr-2" /> Download | |
| </DropdownMenuItem> | |
| <DropdownMenuItem | |
| className="text-red-600" | |
| onClick={async () => { | |
| if (confirm(`Are you sure you want to delete "${asset.name}"?`)) { | |
| await handleDeleteAsset(String(asset.id)); | |
| } | |
| }} | |
| > | |
| <Trash2 className="w-4 h-4 mr-2" /> Delete | |
| </DropdownMenuItem> | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| </div> | |
| <div className="flex gap-2 mt-3"> | |
| <Badge variant="outline" className={`text-xs border-${getProductColor(asset.product)}-200 text-${getProductColor(asset.product)}-700 bg-${getProductColor(asset.product)}-50`}> | |
| {products.find(p => p.id === asset.product)?.shortName} | |
| </Badge> | |
| {asset.subCategory && ( | |
| <Badge variant="outline" className="text-xs border-slate-200 text-slate-600 truncate max-w-[120px]"> | |
| {asset.subCategory} | |
| </Badge> | |
| )} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </motion.div> | |
| ))} | |
| </div> | |
| ) : ( | |
| <Card className="border-0 shadow-lg shadow-slate-200/50"> | |
| <CardContent className="p-0"> | |
| <div className="divide-y divide-slate-100"> | |
| {filteredAssets.map((asset, index) => ( | |
| <motion.div | |
| key={asset.id} | |
| initial={{ opacity: 0, x: -10 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| transition={{ delay: index * 0.03 }} | |
| className="flex items-center gap-4 p-4 hover:bg-slate-50 transition-colors" | |
| > | |
| <Checkbox /> | |
| <div className={`w-10 h-10 rounded-lg bg-${getProductColor(asset.product)}-100 flex items-center justify-center`}> | |
| {getTypeIcon(asset.type)} | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <h3 className="font-medium text-slate-900 text-sm truncate"> | |
| {asset.name} | |
| </h3> | |
| <p className="text-xs text-slate-500 mt-0.5"> | |
| {asset.subCategory || products.find(p => p.id === asset.product)?.name} | |
| </p> | |
| </div> | |
| <div className="hidden sm:block text-sm text-slate-500"> | |
| {asset.size} | |
| </div> | |
| <div className="hidden md:block text-sm text-slate-500"> | |
| {asset.date} | |
| </div> | |
| <Badge variant="outline" className={`text-xs border-${getProductColor(asset.product)}-200 text-${getProductColor(asset.product)}-700`}> | |
| {products.find(p => p.id === asset.product)?.shortName} | |
| </Badge> | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild> | |
| <Button variant="ghost" size="icon" className="h-8 w-8"> | |
| <MoreVertical className="w-4 h-4" /> | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent align="end"> | |
| <DropdownMenuItem onClick={async () => { | |
| setPreviewAsset(asset); | |
| setPreviewDialogOpen(true); | |
| // Load PDF pages if it's a PDF | |
| if (asset.type === 'document' && asset.name.toLowerCase().endsWith('.pdf')) { | |
| await loadPdfPages(String(asset.id)); | |
| } else { | |
| setPdfPages(null); | |
| } | |
| }}> | |
| <Eye className="w-4 h-4 mr-2" /> Preview | |
| </DropdownMenuItem> | |
| <DropdownMenuItem onClick={() => { | |
| window.open(`/api/assets/${String(asset.id)}/download`, '_blank'); | |
| }}> | |
| <Download className="w-4 h-4 mr-2" /> Download | |
| </DropdownMenuItem> | |
| <DropdownMenuItem | |
| className="text-red-600" | |
| onClick={async () => { | |
| if (confirm(`Are you sure you want to delete "${asset.name}"?`)) { | |
| await handleDeleteAsset(String(asset.id)); | |
| } | |
| }} | |
| > | |
| <Trash2 className="w-4 h-4 mr-2" /> Delete | |
| </DropdownMenuItem> | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| </motion.div> | |
| ))} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )} | |
| </motion.div> | |
| </div> | |
| </div> | |
| {/* Preview Dialog */} | |
| <Dialog open={previewDialogOpen} onOpenChange={(open) => { | |
| setPreviewDialogOpen(open); | |
| if (!open) { | |
| setPreviewAsset(null); | |
| setPdfPages(null); | |
| } | |
| }}> | |
| <DialogContent className="max-w-6xl max-h-[90vh] flex flex-col"> | |
| <DialogHeader> | |
| <DialogTitle className="flex items-center justify-between"> | |
| <span>{previewAsset?.name || 'Preview'}</span> | |
| {previewAsset && ( | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50" | |
| onClick={async () => { | |
| if (confirm(`Are you sure you want to delete "${previewAsset.name}"?`)) { | |
| await handleDeleteAsset(String(previewAsset.id)); | |
| } | |
| }} | |
| disabled={isDeleting} | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </Button> | |
| )} | |
| </DialogTitle> | |
| </DialogHeader> | |
| <div className="mt-4 flex-1 overflow-auto"> | |
| {previewAsset && ( | |
| <div className="space-y-4"> | |
| {previewAsset.type === 'image' ? ( | |
| <img | |
| src={`/api/assets/${String(previewAsset.id)}/download`} | |
| alt={previewAsset.name} | |
| className="max-w-full h-auto rounded-lg mx-auto" | |
| /> | |
| ) : previewAsset.type === 'video' ? ( | |
| <video | |
| src={`/api/assets/${String(previewAsset.id)}/download`} | |
| controls | |
| className="max-w-full rounded-lg mx-auto" | |
| > | |
| Your browser does not support the video tag. | |
| </video> | |
| ) : previewAsset.type === 'document' && previewAsset.name.toLowerCase().endsWith('.pdf') ? ( | |
| <div className="space-y-4"> | |
| {isLoadingPdf ? ( | |
| <div className="flex flex-col items-center justify-center p-12 bg-slate-50 rounded-lg"> | |
| <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div> | |
| <p className="text-slate-600">Converting PDF pages to images...</p> | |
| </div> | |
| ) : pdfPages && pdfPages.error ? ( | |
| <div className="flex flex-col items-center justify-center p-12 bg-red-50 rounded-lg border border-red-200"> | |
| <FileText className="w-16 h-16 text-red-400 mb-4" /> | |
| <p className="text-red-600 font-medium mb-2">Failed to load PDF preview</p> | |
| <p className="text-red-500 text-sm mb-4 text-center max-w-md">{pdfPages.error}</p> | |
| <p className="text-slate-600 text-xs mb-4 text-center max-w-md"> | |
| This might be because poppler-utils is not installed. PDFs can still be downloaded. | |
| </p> | |
| <Button | |
| onClick={() => window.open(`/api/assets/${String(previewAsset.id)}/download`, '_blank')} | |
| variant="outline" | |
| className="border-red-200 text-red-600 hover:bg-red-100" | |
| > | |
| <Download className="w-4 h-4 mr-2" /> | |
| Download PDF | |
| </Button> | |
| </div> | |
| ) : pdfPages && pdfPages.pages ? ( | |
| <div className="space-y-6"> | |
| <div className="text-sm text-slate-600 bg-blue-50 p-3 rounded-lg"> | |
| <strong>PDF Viewer:</strong> {pdfPages.total_pages} page{pdfPages.total_pages !== 1 ? 's' : ''} | |
| </div> | |
| <div className="space-y-4 max-h-[70vh] overflow-y-auto pr-2"> | |
| {pdfPages.pages.map((page, index) => ( | |
| <div key={index} className="border border-slate-200 rounded-lg p-4 bg-white shadow-sm"> | |
| <div className="text-xs text-slate-500 mb-2 font-medium"> | |
| Page {page.page_number} of {pdfPages.total_pages} | |
| </div> | |
| <img | |
| src={page.image_data} | |
| alt={`Page ${page.page_number}`} | |
| className="max-w-full h-auto rounded border border-slate-100 shadow-sm" | |
| style={{ maxHeight: '800px' }} | |
| /> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="flex flex-col items-center justify-center p-12 bg-slate-50 rounded-lg"> | |
| <FileText className="w-16 h-16 text-slate-400 mb-4" /> | |
| <p className="text-slate-600 mb-4">Failed to load PDF preview</p> | |
| <Button | |
| onClick={() => window.open(`/api/assets/${String(previewAsset.id)}/download`, '_blank')} | |
| variant="outline" | |
| > | |
| <Download className="w-4 h-4 mr-2" /> | |
| Download to view | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| <div className="flex flex-col items-center justify-center p-12 bg-slate-50 rounded-lg"> | |
| <FileText className="w-16 h-16 text-slate-400 mb-4" /> | |
| <p className="text-slate-600 mb-4">Preview not available for this file type</p> | |
| <Button | |
| onClick={() => window.open(`/api/assets/${String(previewAsset.id)}/download`, '_blank')} | |
| variant="outline" | |
| > | |
| <Download className="w-4 h-4 mr-2" /> | |
| Download to view | |
| </Button> | |
| </div> | |
| )} | |
| <div className="flex items-center justify-between pt-4 border-t sticky bottom-0 bg-white"> | |
| <div className="text-sm text-slate-600"> | |
| <p><strong>Type:</strong> {previewAsset.type}</p> | |
| <p><strong>Size:</strong> {previewAsset.size}</p> | |
| <p><strong>Date:</strong> {previewAsset.date}</p> | |
| </div> | |
| <div className="flex gap-2"> | |
| <Button | |
| onClick={() => window.open(`/api/assets/${String(previewAsset.id)}/download`, '_blank')} | |
| variant="outline" | |
| > | |
| <Download className="w-4 h-4 mr-2" /> | |
| Download | |
| </Button> | |
| <Button | |
| variant="outline" | |
| className="text-red-600 border-red-200 hover:bg-red-50" | |
| onClick={async () => { | |
| if (confirm(`Are you sure you want to delete "${previewAsset.name}"?`)) { | |
| await handleDeleteAsset(String(previewAsset.id)); | |
| } | |
| }} | |
| disabled={isDeleting} | |
| > | |
| <Trash2 className="w-4 h-4 mr-2" /> | |
| {isDeleting ? 'Deleting...' : 'Delete'} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </DialogContent> | |
| </Dialog> | |
| </div> | |
| ); | |
| } | |