forge/frontend/app/files/page.tsx

724 lines
28 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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