724 lines
28 KiB
TypeScript
724 lines
28 KiB
TypeScript
'use client';
|
||
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { toast } from 'react-hot-toast';
|
||
import {
|
||
FolderOpen,
|
||
Upload,
|
||
Download,
|
||
Trash2,
|
||
Search,
|
||
Image as ImageIcon,
|
||
Video,
|
||
Mic,
|
||
FileText,
|
||
Grid,
|
||
List,
|
||
Loader2,
|
||
Eye,
|
||
CheckSquare,
|
||
Square,
|
||
ChevronDown
|
||
} from 'lucide-react';
|
||
import FileUpload from '@/components/FileUpload';
|
||
import api, { assetsApi } from '@/lib/api';
|
||
import { clsx } from 'clsx';
|
||
|
||
interface Asset {
|
||
id: string;
|
||
filename: string;
|
||
file_type: string;
|
||
mime_type: string;
|
||
width?: number;
|
||
height?: number;
|
||
thumbnail_url: string | null;
|
||
file_url: string;
|
||
created_at: string;
|
||
source_module?: string;
|
||
}
|
||
|
||
const FILE_TYPE_ICONS = {
|
||
image: ImageIcon,
|
||
video: Video,
|
||
audio: Mic,
|
||
document: FileText,
|
||
};
|
||
|
||
const FILE_TYPE_COLORS = {
|
||
image: 'text-blue-400',
|
||
video: 'text-purple-400',
|
||
audio: 'text-green-400',
|
||
document: 'text-orange-400',
|
||
};
|
||
|
||
export default function MyFilesPage() {
|
||
const [assets, setAssets] = useState<Asset[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [search, setSearch] = useState('');
|
||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||
const [page, setPage] = useState(1);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const [showUpload, setShowUpload] = useState(false);
|
||
const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [selectedAssetsMap, setSelectedAssetsMap] = useState<Map<string, string>>(new Map()); // id -> file_type
|
||
const router = useRouter();
|
||
|
||
useEffect(() => {
|
||
loadAssets();
|
||
}, [search, selectedType, page]);
|
||
|
||
const loadAssets = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params: Record<string, any> = { page, limit: 24 };
|
||
if (selectedType) params.file_types = selectedType;
|
||
if (search) params.search = search;
|
||
|
||
const response = await api.get('/assets/library', { params });
|
||
setAssets(response.data.items);
|
||
setTotalPages(response.data.pages);
|
||
} catch (error) {
|
||
toast.error('Failed to load files');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleUpload = async (file: File) => {
|
||
setUploading(true);
|
||
try {
|
||
await assetsApi.upload(file);
|
||
toast.success('File uploaded!');
|
||
loadAssets();
|
||
setShowUpload(false);
|
||
} catch (error: any) {
|
||
if (error.response?.status === 409) {
|
||
const shouldOverwrite = window.confirm(
|
||
`File "${file.name}" already exists. \nClick OK to Overwrite, Cancel to Keep Existing.`
|
||
);
|
||
|
||
if (shouldOverwrite) {
|
||
try {
|
||
await assetsApi.upload(file, undefined, false, true); // overwrite=true
|
||
toast.success('File overwritten!');
|
||
loadAssets();
|
||
setShowUpload(false);
|
||
} catch (retryError: any) {
|
||
toast.error('Failed to overwrite file');
|
||
}
|
||
} else {
|
||
// User cancelled, treat as success or ignore
|
||
toast('Upload skipped (file exists)', { icon: 'ℹ️' });
|
||
setShowUpload(false);
|
||
}
|
||
} else {
|
||
toast.error('Failed to upload file');
|
||
}
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleDownload = async (asset: Asset, e?: React.MouseEvent) => {
|
||
e?.stopPropagation();
|
||
|
||
try {
|
||
const response = await assetsApi.download(asset.id);
|
||
|
||
// Create blob and download
|
||
const blob = new Blob([response.data], { type: response.headers['content-type'] || asset.mime_type });
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = asset.filename || 'download';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(url);
|
||
|
||
toast.success('Download started');
|
||
} catch (error) {
|
||
console.error('Download error:', error);
|
||
toast.error('Failed to download file');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (asset: Asset, e?: React.MouseEvent) => {
|
||
e?.stopPropagation();
|
||
|
||
if (!confirm(`Delete "${asset.filename}"?`)) return;
|
||
|
||
try {
|
||
await assetsApi.delete(asset.id);
|
||
toast.success('File deleted');
|
||
loadAssets();
|
||
if (selectedAsset?.id === asset.id) setSelectedAsset(null);
|
||
} catch (error) {
|
||
toast.error('Failed to delete file');
|
||
}
|
||
};
|
||
|
||
const formatDate = (dateStr: string) => {
|
||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
};
|
||
|
||
const formatSize = (bytes?: number) => {
|
||
if (!bytes) return 'Unknown';
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
};
|
||
|
||
const toggleSelection = (asset: Asset) => {
|
||
const newMap = new Map(selectedAssetsMap);
|
||
if (newMap.has(asset.id)) {
|
||
newMap.delete(asset.id);
|
||
} else {
|
||
newMap.set(asset.id, asset.file_type);
|
||
}
|
||
setSelectedAssetsMap(newMap);
|
||
};
|
||
|
||
const toggleAll = () => {
|
||
const allSelected = assets.every(a => selectedAssetsMap.has(a.id));
|
||
const newMap = new Map(selectedAssetsMap);
|
||
|
||
if (allSelected) {
|
||
assets.forEach(a => newMap.delete(a.id));
|
||
} else {
|
||
assets.forEach(a => newMap.set(a.id, a.file_type));
|
||
}
|
||
setSelectedAssetsMap(newMap);
|
||
};
|
||
|
||
const getCommonFileType = (): string | null => {
|
||
if (selectedAssetsMap.size === 0) return null;
|
||
|
||
// Check if checks are mixed
|
||
const types = Array.from(selectedAssetsMap.values());
|
||
const firstType = types[0];
|
||
const allSame = types.every(t => t === firstType);
|
||
|
||
if (allSame) return firstType;
|
||
return 'mixed';
|
||
};
|
||
|
||
const handleBatchAction = async (action: string) => {
|
||
if (selectedAssetsMap.size === 0) return;
|
||
const ids = Array.from(selectedAssetsMap.keys()).join(',');
|
||
|
||
switch (action) {
|
||
case 'download':
|
||
// Download sequentially to avoid browser blocking multiple popups
|
||
for (const id of selectedAssetsMap.keys()) {
|
||
const asset = assets.find(a => a.id === id);
|
||
if (asset) await handleDownload(asset);
|
||
await new Promise(res => setTimeout(res, 500));
|
||
}
|
||
break;
|
||
case 'delete':
|
||
if (confirm(`Delete ${selectedAssetsMap.size} files?`)) {
|
||
for (const id of selectedAssetsMap.keys()) {
|
||
try { await assetsApi.delete(id); } catch (e) { }
|
||
}
|
||
toast.success('Files deleted');
|
||
setSelectedAssetsMap(new Map());
|
||
loadAssets();
|
||
}
|
||
break;
|
||
case 'upscale_image':
|
||
router.push(`/image/upscale?assetIds=${ids}`);
|
||
break;
|
||
case 'remove_bg':
|
||
router.push(`/image/remove-bg?assetIds=${ids}`);
|
||
break;
|
||
case 'upscale_video':
|
||
router.push(`/video/upscale?assetIds=${ids}`);
|
||
break;
|
||
case 'subtitles':
|
||
router.push(`/video/subtitles?assetIds=${ids}`);
|
||
break;
|
||
case 'transcribe':
|
||
router.push(`/audio/voice-to-text?assetIds=${ids}`);
|
||
break;
|
||
case 'alt_text':
|
||
router.push(`/text/alt-text?assetIds=${ids}`);
|
||
break;
|
||
case 'img_to_video':
|
||
router.push(`/video/generate?assetIds=${ids}`);
|
||
break;
|
||
case 'extract_frame':
|
||
router.push(`/video/extract?assetIds=${ids}`);
|
||
break;
|
||
}
|
||
};
|
||
|
||
const [showActionMenu, setShowActionMenu] = useState(false);
|
||
|
||
return (
|
||
<div className="max-w-7xl mx-auto space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-12 h-12 bg-forge-yellow/10 rounded-lg flex items-center justify-center">
|
||
<FolderOpen className="w-6 h-6 text-forge-yellow" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-white">My Files</h1>
|
||
<p className="text-gray-500">Manage your uploaded and generated assets</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowUpload(!showUpload)}
|
||
className="btn-primary flex items-center gap-2"
|
||
>
|
||
<Upload className="w-4 h-4" />
|
||
Upload
|
||
</button>
|
||
</div>
|
||
|
||
{/* Upload Area */}
|
||
{showUpload && (
|
||
<div className="bg-forge-dark rounded-xl border border-gray-800 p-6">
|
||
<FileUpload
|
||
onUpload={handleUpload}
|
||
accept={{
|
||
'image/*': ['.png', '.jpg', '.jpeg', '.webp', '.gif'],
|
||
'video/*': ['.mp4', '.webm', '.mov'],
|
||
'audio/*': ['.mp3', '.wav', '.ogg'],
|
||
}}
|
||
label="Drop files here or click to upload"
|
||
/>
|
||
{uploading && (
|
||
<div className="mt-4 flex items-center gap-2 text-forge-yellow">
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
Uploading...
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Filters */}
|
||
<div className="flex items-center gap-4 flex-wrap">
|
||
{/* Search */}
|
||
<div className="relative flex-1 min-w-[200px]">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||
<input
|
||
type="text"
|
||
placeholder="Search files..."
|
||
value={search}
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
setSearch(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
className="w-full pl-10 pr-4 py-2 bg-forge-dark border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-forge-yellow focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
{/* Type Filters */}
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => {
|
||
setSelectedType(null);
|
||
setPage(1);
|
||
}}
|
||
className={clsx(
|
||
'px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||
!selectedType
|
||
? 'bg-forge-yellow text-black'
|
||
: 'bg-forge-dark border border-gray-700 text-gray-400 hover:text-white'
|
||
)}
|
||
>
|
||
All
|
||
</button>
|
||
{['image', 'video', 'audio'].map((type) => {
|
||
const Icon = FILE_TYPE_ICONS[type as keyof typeof FILE_TYPE_ICONS];
|
||
return (
|
||
<button
|
||
key={type}
|
||
onClick={() => {
|
||
setSelectedType(type);
|
||
setPage(1);
|
||
}}
|
||
className={clsx(
|
||
'px-3 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2',
|
||
selectedType === type
|
||
? 'bg-forge-yellow text-black'
|
||
: 'bg-forge-dark border border-gray-700 text-gray-400 hover:text-white'
|
||
)}
|
||
>
|
||
<Icon className="w-4 h-4" />
|
||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* View Toggle */}
|
||
<div className="flex items-center bg-forge-dark border border-gray-700 rounded-lg overflow-hidden">
|
||
<button
|
||
onClick={() => setViewMode('grid')}
|
||
className={clsx(
|
||
'p-2 transition-colors',
|
||
viewMode === 'grid' ? 'bg-forge-yellow text-black' : 'text-gray-400 hover:text-white'
|
||
)}
|
||
>
|
||
<Grid className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode('list')}
|
||
className={clsx(
|
||
'p-2 transition-colors',
|
||
viewMode === 'list' ? 'bg-forge-yellow text-black' : 'text-gray-400 hover:text-white'
|
||
)}
|
||
>
|
||
<List className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Batch Actions Toolbar */}
|
||
{selectedAssetsMap.size > 0 && (
|
||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-forge-dark border border-gray-700 rounded-xl shadow-2xl p-2 flex items-center gap-2 z-40 animate-in slide-in-from-bottom-4">
|
||
<div className="px-3 text-sm font-medium text-white border-r border-gray-700">
|
||
{selectedAssetsMap.size} Selected
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => handleBatchAction('download')}
|
||
className="p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||
title="Download All"
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
</button>
|
||
|
||
<div className="h-6 w-px bg-gray-700 mx-1" />
|
||
|
||
<div className="h-6 w-px bg-gray-700 mx-1" />
|
||
|
||
{/* App Actions Dropdown */}
|
||
{getCommonFileType() !== 'mixed' && getCommonFileType() !== null && (
|
||
<div className="relative">
|
||
<button
|
||
onClick={() => setShowActionMenu(!showActionMenu)}
|
||
className="flex items-center gap-2 px-3 py-1.5 bg-forge-yellow text-black rounded-lg hover:bg-yellow-400 font-medium text-sm transition-colors"
|
||
>
|
||
Send to App <ChevronDown className={`w-3 h-3 transition-transform ${showActionMenu ? 'rotate-180' : ''}`} />
|
||
</button>
|
||
|
||
{showActionMenu && (
|
||
<>
|
||
{/* Backdrop to close menu */}
|
||
<div className="fixed inset-0 z-10" onClick={() => setShowActionMenu(false)} />
|
||
|
||
<div className="absolute bottom-full mb-2 left-0 w-48 bg-forge-dark border border-gray-700 rounded-lg shadow-xl py-1 z-20 animate-in fade-in zoom-in-95 duration-100">
|
||
{getCommonFileType() === 'image' && (
|
||
<>
|
||
<button onClick={() => { handleBatchAction('upscale_image'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Upscale Image</button>
|
||
<button onClick={() => { handleBatchAction('remove_bg'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Remove Background</button>
|
||
<div className="h-px bg-gray-700 my-1" />
|
||
<button onClick={() => { handleBatchAction('img_to_video'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Image to Video</button>
|
||
<button onClick={() => { handleBatchAction('alt_text'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Generate Alt Text</button>
|
||
</>
|
||
)}
|
||
{getCommonFileType() === 'video' && (
|
||
<>
|
||
<button onClick={() => { handleBatchAction('upscale_video'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Upscale Video</button>
|
||
<button onClick={() => { handleBatchAction('subtitles'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Generate Subtitles</button>
|
||
<div className="h-px bg-gray-700 my-1" />
|
||
<button onClick={() => { handleBatchAction('extract_frame'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Extract Frame</button>
|
||
</>
|
||
)}
|
||
{getCommonFileType() === 'audio' && (
|
||
<button onClick={() => { handleBatchAction('transcribe'); setShowActionMenu(false); }} className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors">Transcribe Audio</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="h-6 w-px bg-gray-700 mx-1" />
|
||
|
||
<button
|
||
onClick={() => handleBatchAction('delete')}
|
||
className="p-2 text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
|
||
title="Delete Selected"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Content */}
|
||
{loading ? (
|
||
<div className="flex items-center justify-center h-64">
|
||
<Loader2 className="w-8 h-8 text-forge-yellow animate-spin" />
|
||
</div>
|
||
) : assets.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
|
||
<FolderOpen className="w-16 h-16 mb-4" />
|
||
<p className="text-lg">No files found</p>
|
||
<p className="text-sm">Upload files or generate content to see them here</p>
|
||
</div>
|
||
) : viewMode === 'grid' ? (
|
||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||
{assets.map((asset) => {
|
||
const Icon = FILE_TYPE_ICONS[asset.file_type as keyof typeof FILE_TYPE_ICONS] || FileText;
|
||
const colorClass = FILE_TYPE_COLORS[asset.file_type as keyof typeof FILE_TYPE_COLORS] || 'text-gray-400';
|
||
|
||
return (
|
||
<div
|
||
key={asset.id}
|
||
draggable="true"
|
||
onDragStart={(e) => {
|
||
// Set asset ID for drag-and-drop to file inputs
|
||
e.dataTransfer.setData('application/x-asset-id', asset.id);
|
||
e.dataTransfer.effectAllowed = 'copy';
|
||
|
||
// Visual feedback
|
||
e.currentTarget.style.opacity = '0.5';
|
||
}}
|
||
onDragEnd={(e) => {
|
||
// Reset visual feedback
|
||
e.currentTarget.style.opacity = '1';
|
||
}}
|
||
className="bg-forge-dark rounded-lg border border-gray-800 overflow-hidden hover:border-gray-700 transition-colors group cursor-move"
|
||
>
|
||
{/* Thumbnail */}
|
||
<div
|
||
className={`aspect-square relative cursor-pointer group/item ${selectedAssetsMap.has(asset.id) ? 'ring-2 ring-forge-yellow' : ''}`}
|
||
onClick={(e) => {
|
||
// Main click opens preview
|
||
setSelectedAsset(asset);
|
||
}}
|
||
>
|
||
<div
|
||
className="absolute top-0 left-0 p-2 z-20"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
toggleSelection(asset);
|
||
}}
|
||
>
|
||
<div className={`w-5 h-5 rounded border ${selectedAssetsMap.has(asset.id) ? 'bg-forge-yellow border-forge-yellow' : 'bg-black/50 border-white/50 hover:bg-white/20'} flex items-center justify-center transition-colors`}>
|
||
{selectedAssetsMap.has(asset.id) && <CheckSquare className="w-3 h-3 text-black" />}
|
||
</div>
|
||
</div>
|
||
{asset.thumbnail_url || asset.file_type === 'image' ? (
|
||
<img
|
||
src={`/api/v1/assets/${asset.id}/download`}
|
||
alt={asset.filename}
|
||
className="w-full h-full object-cover"
|
||
onError={(e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||
(e.target as HTMLImageElement).style.display = 'none';
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center bg-forge-gray">
|
||
<Icon className={clsx('w-12 h-12', colorClass)} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Hover Actions */}
|
||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||
<button
|
||
onClick={(e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
setSelectedAsset(asset);
|
||
}}
|
||
className="p-2 bg-white/20 rounded-full hover:bg-white/30"
|
||
>
|
||
<Eye className="w-4 h-4 text-white" />
|
||
</button>
|
||
<button
|
||
onClick={(e: React.MouseEvent) => handleDownload(asset, e)}
|
||
className="p-2 bg-white/20 rounded-full hover:bg-white/30"
|
||
>
|
||
<Download className="w-4 h-4 text-white" />
|
||
</button>
|
||
<button
|
||
onClick={(e: React.MouseEvent) => handleDelete(asset, e)}
|
||
className="p-2 bg-red-500/50 rounded-full hover:bg-red-500/70"
|
||
>
|
||
<Trash2 className="w-4 h-4 text-white" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Info */}
|
||
<div className="p-2">
|
||
<p className="text-sm text-white truncate">{asset.filename}</p>
|
||
<p className="text-xs text-gray-500">{formatDate(asset.created_at)}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="bg-forge-dark rounded-xl border border-gray-800 overflow-hidden">
|
||
<table className="w-full">
|
||
<thead className="bg-forge-gray">
|
||
<tr>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase">Name</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase">Type</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase">Source</th>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-400 uppercase">Date</th>
|
||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-400 uppercase">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-800">
|
||
{assets.map((asset) => {
|
||
const Icon = FILE_TYPE_ICONS[asset.file_type as keyof typeof FILE_TYPE_ICONS] || FileText;
|
||
const colorClass = FILE_TYPE_COLORS[asset.file_type as keyof typeof FILE_TYPE_COLORS] || 'text-gray-400';
|
||
|
||
return (
|
||
<tr key={asset.id} className="hover:bg-forge-gray/50">
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-3">
|
||
<button onClick={() => toggleSelection(asset)} className="text-gray-500 hover:text-white">
|
||
{selectedAssetsMap.has(asset.id) ? <CheckSquare className="w-4 h-4 text-forge-yellow" /> : <Square className="w-4 h-4" />}
|
||
</button>
|
||
<Icon className={clsx('w-5 h-5', colorClass)} />
|
||
<span className="text-white">{asset.filename}</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-400 capitalize">{asset.file_type}</td>
|
||
<td className="px-4 py-3 text-gray-400">
|
||
{asset.source_module?.replace('_', ' ') || 'Upload'}
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-400">{formatDate(asset.created_at)}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<button
|
||
onClick={() => setSelectedAsset(asset)}
|
||
className="p-1 text-gray-400 hover:text-white"
|
||
>
|
||
<Eye className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={(e: React.MouseEvent) => handleDownload(asset, e)}
|
||
className="p-1 text-gray-400 hover:text-forge-yellow"
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={(e: React.MouseEvent) => handleDelete(asset, e)}
|
||
className="p-1 text-gray-400 hover:text-red-400"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-center gap-4">
|
||
<button
|
||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||
disabled={page === 1}
|
||
className="px-4 py-2 bg-forge-dark border border-gray-700 rounded-lg text-gray-400 hover:text-white disabled:opacity-50"
|
||
>
|
||
Previous
|
||
</button>
|
||
<span className="text-gray-400">
|
||
Page {page} of {totalPages}
|
||
</span>
|
||
<button
|
||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||
disabled={page === totalPages}
|
||
className="px-4 py-2 bg-forge-dark border border-gray-700 rounded-lg text-gray-400 hover:text-white disabled:opacity-50"
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Preview Modal */}
|
||
{selectedAsset && (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||
onClick={() => setSelectedAsset(null)}
|
||
>
|
||
<div
|
||
className="bg-forge-dark rounded-xl border border-gray-800 max-w-4xl max-h-[90vh] overflow-auto"
|
||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||
>
|
||
{/* Preview Content */}
|
||
<div className="p-4">
|
||
{selectedAsset.file_type === 'image' && (
|
||
<img
|
||
src={`/api/v1/assets/${selectedAsset.id}/download`}
|
||
alt={selectedAsset.filename}
|
||
className="max-w-full max-h-[60vh] mx-auto rounded-lg"
|
||
/>
|
||
)}
|
||
{selectedAsset.file_type === 'video' && (
|
||
<video
|
||
src={`/api/v1/assets/${selectedAsset.id}/download`}
|
||
controls
|
||
autoPlay
|
||
className="max-w-full max-h-[60vh] mx-auto rounded-lg"
|
||
/>
|
||
)}
|
||
{selectedAsset.file_type === 'audio' && (
|
||
<div className="p-8 flex flex-col items-center">
|
||
<Mic className="w-16 h-16 text-green-400 mb-4" />
|
||
<audio
|
||
src={`/api/v1/assets/${selectedAsset.id}/download`}
|
||
controls
|
||
autoPlay
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Info & Actions */}
|
||
<div className="p-4 border-t border-gray-800">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-white font-medium">{selectedAsset.filename}</h3>
|
||
<p className="text-sm text-gray-500">
|
||
{selectedAsset.file_type} • {selectedAsset.width && `${selectedAsset.width}x${selectedAsset.height}`} • {formatDate(selectedAsset.created_at)}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => handleDownload(selectedAsset)}
|
||
className="btn-primary flex items-center gap-2"
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
Download
|
||
</button>
|
||
<button
|
||
onClick={() => setSelectedAsset(null)}
|
||
className="px-4 py-2 bg-forge-gray text-gray-300 rounded-lg hover:text-white"
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|