|
|
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> |
|
|
); |
|
|
} |
|
|
|
|
|
|