| 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'; |
| }; |
|
|
| |
| 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); |
| |
| |
| |
| const formattedAssets = data.map(asset => { |
| |
| let dateStr = new Date().toISOString().split('T')[0]; |
| if (asset.created_at) { |
| try { |
| |
| 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 { |
| |
| 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); |
| } |
| } |
| |
| |
| |
| let assetIdStr; |
| if (typeof asset.id === 'string') { |
| assetIdStr = asset.id; |
| } else if (typeof asset.id === 'number') { |
| |
| assetIdStr = BigInt(asset.id).toString(); |
| } else { |
| assetIdStr = String(asset.id); |
| } |
| |
| return { |
| id: assetIdStr, |
| 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); |
| setAssets(formattedAssets); |
| } else { |
| const errorText = await response.text(); |
| console.error('Failed to fetch assets:', response.status, errorText); |
| |
| } |
| } catch (error) { |
| console.error('Error fetching assets:', error); |
| |
| } finally { |
| setIsLoadingAssets(false); |
| } |
| }; |
|
|
| |
| 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]; |
| }; |
|
|
| |
| const handleDeleteAsset = async (assetId) => { |
| setIsDeleting(true); |
| try { |
| |
| 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); |
| |
| |
| await fetchAssets(); |
| |
| |
| 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); |
| } |
| }; |
|
|
| |
| const loadPdfPages = async (assetId) => { |
| setIsLoadingPdf(true); |
| setPdfPages(null); |
| try { |
| |
| |
| let assetIdStr; |
| if (typeof assetId === 'string') { |
| assetIdStr = assetId; |
| } else if (typeof assetId === 'number') { |
| |
| 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); |
| } |
| }; |
|
|
| |
| useEffect(() => { |
| fetchAssets(); |
| }, []); |
|
|
| |
| useEffect(() => { |
| if (!uploadDialogOpen && !isUploading) { |
| console.log('useEffect: Refreshing assets (dialog closed, not uploading)'); |
| fetchAssets(); |
| } |
| }, [uploadDialogOpen, isUploading]); |
|
|
| |
| useEffect(() => { |
| if (previewAsset && previewDialogOpen) { |
| |
| 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; |
| let attempts = 0; |
| let consecutive404s = 0; |
| const max404s = 5; |
| |
| |
| 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; |
| setUploadProgress(prev => ({ |
| ...prev, |
| [fileName]: { |
| status: status.status, |
| message: getStatusMessage(status.status), |
| extractedContent: status.extracted_content || null |
| } |
| })); |
| |
| if (status.status === 'completed' || status.status === 'failed') { |
| break; |
| } |
| } else if (response.status === 404) { |
| consecutive404s++; |
| |
| if (consecutive404s >= max404s) { |
| console.warn(`Asset ${assetId} not found after ${max404s} attempts`); |
| setUploadProgress(prev => ({ |
| ...prev, |
| [fileName]: { |
| status: 'pending', |
| message: 'Asset status unavailable' |
| } |
| })); |
| break; |
| } |
| |
| } else { |
| |
| console.error(`Status check failed: ${response.status}`); |
| } |
| } catch (error) { |
| console.error('Error polling status:', error); |
| |
| } |
| |
| await new Promise(resolve => setTimeout(resolve, 1000)); |
| 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 { |
| |
| const initialProgress = {}; |
| selectedFiles.forEach(file => { |
| initialProgress[file.name] = { |
| status: 'pending', |
| message: 'Uploading file...' |
| }; |
| }); |
| setUploadProgress(initialProgress); |
|
|
| |
| 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); |
|
|
| |
| const assetId = String(result.id); |
|
|
| |
| setUploadProgress(prev => ({ |
| ...prev, |
| [file.name]: { |
| status: result.analysis_status || 'processing', |
| message: result.analysis_status === 'processing' |
| ? 'Extracting content with OCR...' |
| : 'Upload complete, analyzing...' |
| } |
| })); |
|
|
| |
| 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); |
| |
| |
| console.log('Refreshing assets after upload...'); |
| await fetchAssets(); |
| |
| |
| setTimeout(async () => { |
| console.log('Refreshing assets again after delay...'); |
| await fetchAssets(); |
| }, 1500); |
| |
| |
| |
| setTimeout(() => { |
| setSelectedFiles([]); |
| setUploadProductCategory(''); |
| setUploadSubCategory(''); |
| setUploadProgress({}); |
| setUploadDialogOpen(false); |
| |
| 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> |
| ); |
| } |
|
|
|
|