forge/frontend/components/RecentAssets.tsx

263 lines
16 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { assetsApi } from '@/lib/api';
import { Clock, FileImage, FileVideo, FileAudio, FileText, ChevronRight, GripVertical, ChevronLeft, ChevronLast, ChevronFirst } from 'lucide-react';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
const RECENT_LIMIT = 40;
export default function RecentAssets() {
const router = useRouter();
const [assets, setAssets] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isCollapsed, setIsCollapsed] = useState(false);
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null);
const fetchRecentAssets = async () => {
try {
const response = await assetsApi.list({ limit: RECENT_LIMIT, sort: 'created_at', order: 'desc' });
setAssets(response.data);
} catch (err) {
console.error("Failed to fetch recent assets", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchRecentAssets();
const interval = setInterval(fetchRecentAssets, 30000);
return () => clearInterval(interval);
}, []);
const renderPreview = (asset: any) => {
if (asset.mime_type.startsWith('image/')) {
return (
<div className="w-16 h-16 rounded overflow-hidden bg-black/20">
<img src={`/api/v1/assets/${asset.id}/download`} alt="preview" className="w-full h-full object-cover" />
</div>
);
}
if (asset.mime_type.startsWith('video/')) {
return (
<div className="w-16 h-16 rounded overflow-hidden relative bg-black">
<video src={`/api/v1/assets/${asset.id}/download`} className="w-full h-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<FileVideo className="w-6 h-6 text-white" />
</div>
</div>
);
}
if (asset.mime_type.startsWith('audio/')) {
return <div className="w-16 h-16 flex items-center justify-center bg-green-500/20 rounded text-green-400"><FileAudio className="w-8 h-8" /></div>;
}
return <div className="w-16 h-16 flex items-center justify-center bg-gray-500/20 rounded text-gray-400"><FileText className="w-8 h-8" /></div>;
};
const handleClick = (asset: any) => {
if (asset.mime_type.startsWith('image/')) {
router.push(`/image/upscale?assetId=${asset.id}`);
} else if (asset.mime_type.startsWith('video/')) {
router.push(`/video/upscale?assetId=${asset.id}`);
} else if (asset.mime_type.startsWith('audio/')) {
router.push(`/audio/voice-to-text?assetId=${asset.id}`);
} else {
router.push(`/files?assetId=${asset.id}`);
}
};
const handleAction = (e: React.MouseEvent, asset: any, action: string) => {
e.stopPropagation();
setSelectedAssetId(null);
switch (action) {
case 'upscale_image':
router.push(`/image/upscale?assetId=${asset.id}`);
break;
case 'img_to_video':
router.push(`/video/generate?assetId=${asset.id}`);
break;
case 'remove_bg':
router.push(`/image/remove-bg?assetId=${asset.id}`);
break;
case 'upscale_video':
router.push(`/video/upscale?assetId=${asset.id}`);
break;
case 'subtitles':
router.push(`/video/subtitles?assetId=${asset.id}`);
break;
case 'transcribe':
router.push(`/audio/voice-to-text?assetId=${asset.id}`);
break;
case 'alt_text':
router.push(`/text/alt-text?assetId=${asset.id}`);
break;
default:
router.push(`/files?assetId=${asset.id}`);
}
};
const toggleMenu = (e: React.MouseEvent, assetId: string) => {
e.stopPropagation();
if (selectedAssetId === assetId) {
setSelectedAssetId(null);
} else {
setSelectedAssetId(assetId);
}
};
const handleDragEnd = (result: any) => {
if (!result.destination) return;
const items = Array.from(assets);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setAssets(items);
};
if (loading && assets.length === 0) {
return (
<div className={`bg-forge-dark border-l border-gray-800 flex flex-col transition-all duration-300 ${isCollapsed ? 'w-16 items-center py-4' : 'w-80 p-4'}`}>
<div className="animate-pulse bg-gray-800/50 w-8 h-8 rounded" />
</div>
);
}
return (
<div className={`bg-forge-dark border-l border-gray-800 flex flex-col flex-shrink-0 transition-all duration-300 ${isCollapsed ? 'w-14' : 'w-80'}`}>
<div className={`p-4 border-b border-gray-800 flex items-center ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
{!isCollapsed && (
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Clock className="w-4 h-4 text-forge-yellow" />
Recent
</h3>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="text-gray-400 hover:text-white transition-colors"
>
{isCollapsed ? <ChevronFirst className="w-5 h-5" /> : <ChevronLast className="w-5 h-5" />}
</button>
</div>
{!isCollapsed && (
<div className="flex-1 overflow-y-auto p-4">
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="recent-assets">
{(provided: any) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="space-y-3"
>
{assets.map((asset, index) => (
<Draggable key={asset.id} draggableId={asset.id} index={index}>
{(provided: any, snapshot: any) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
draggable={true}
onDragStart={(e) => {
// Standard HTML5 drag start
// We pass a JSON string with asset details
const dragData = JSON.stringify({
id: asset.id,
url: `/api/v1/assets/${asset.id}/download`,
filename: asset.original_filename,
mime_type: asset.mime_type,
type: 'forge-asset'
});
e.dataTransfer.setData('application/json', dragData);
e.dataTransfer.setData('text/plain', dragData); // Fallback
// If dragging an image, try to set the drag image
if (asset.mime_type.startsWith('image/')) {
// Optional: Set custom drag image if needed, but browser default is usually okay for img tags
// But here we are dragging a div.
// e.dataTransfer.setDragImage(e.currentTarget.querySelector('img'), 0, 0);
}
}}
className={`group relative bg-forge-gray/30 hover:bg-forge-gray rounded-xl p-3 border border-transparent hover:border-gray-700 transition-all cursor-pointer ${snapshot.isDragging ? 'shadow-xl ring-2 ring-forge-yellow border-forge-yellow z-50' : ''
}`}
>
<div className="flex items-center gap-3">
<div className="text-gray-500 group-hover:text-gray-300">
<GripVertical className="w-4 h-4" />
</div>
<div className="flex-shrink-0">
{renderPreview(asset)}
</div>
<div className="flex-1 min-w-0">
<p className="text-[11px] text-gray-200 break-words font-medium leading-tight">{asset.original_filename}</p>
<p className="text-xs text-gray-500 truncate">
{(asset.file_size_bytes / 1024).toFixed(1)} KB
</p>
</div>
<button
onClick={(e) => toggleMenu(e, asset.id)}
className="p-1 hover:bg-gray-700 rounded transition-colors self-center"
>
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${selectedAssetId === asset.id ? 'rotate-90 text-forge-yellow' : ''}`} />
</button>
</div>
{/* Context Menu */}
{selectedAssetId === asset.id && (
<div className="absolute top-full right-0 mt-1 w-56 bg-forge-dark border border-gray-700 rounded-lg shadow-xl z-50 py-1">
{asset.mime_type.startsWith('image/') && (
<>
<button onClick={(e) => handleAction(e, asset, 'upscale_image')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Upscale Image</button>
<button onClick={(e) => handleAction(e, asset, 'img_to_video')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Generate Video</button>
<button onClick={(e) => handleAction(e, asset, 'remove_bg')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Remove Background</button>
<button onClick={(e) => handleAction(e, asset, 'alt_text')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Generate Alt Text</button>
</>
)}
{asset.mime_type.startsWith('video/') && (
<>
<button onClick={(e) => handleAction(e, asset, 'upscale_video')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Upscale Video</button>
<button onClick={(e) => handleAction(e, asset, 'subtitles')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Generate Subtitles</button>
</>
)}
{asset.mime_type.startsWith('audio/') && (
<button onClick={(e) => handleAction(e, asset, 'transcribe')} className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-forge-gray hover:text-white transition-colors">Transcribe Audio</button>
)}
</div>
)}
</div>
)}
</Draggable>
))}
{provided.placeholder}
{assets.length === 0 && (
<p className="text-center text-gray-500 text-sm py-4">
No recent activity
</p>
)}
</div>
)}
</Droppable>
</DragDropContext>
</div>
)}
{isCollapsed && (
<div className="flex-1 flex flex-col items-center pt-4 gap-4">
{assets.slice(0, 5).map(asset => (
<div key={asset.id} className="w-10 h-10 rounded-lg overflow-hidden opacity-50 hover:opacity-100 transition-opacity cursor-pointer" title={asset.original_filename} onClick={() => setIsCollapsed(false)}>
{asset.mime_type.startsWith('image/') ? (
<img src={`/api/v1/assets/${asset.id}/download`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gray-800 flex items-center justify-center">
<Clock className="w-4 h-4 text-gray-500" />
</div>
)}
</div>
))}
</div>
)}
</div>
);
}