forge/frontend/components/AssetPickerModal.tsx

201 lines
9.4 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { assetsApi } from '@/lib/api';
import { X, Search, Check, FileImage, FileVideo, FileAudio, FileText, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface Asset {
id: string;
original_filename: string;
mime_type: string;
file_size_bytes: number;
created_at: string;
}
interface AssetPickerModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (assets: Asset[]) => void;
allowedTypes?: string[]; // e.g. ['image/', 'video/'] prefixes
maxSelect?: number;
title?: string;
}
export default function AssetPickerModal({
isOpen,
onClose,
onConfirm,
allowedTypes = [],
maxSelect,
title = "Select from Library"
}: AssetPickerModalProps) {
const [assets, setAssets] = useState<Asset[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
if (isOpen) {
loadAssets();
setSelected(new Set()); // Reset selection on open
}
}, [isOpen]);
const loadAssets = async () => {
setLoading(true);
try {
// Fetch reasonably large number of recent assets
// In a real app, implement pagination or infinite scroll
const response = await assetsApi.list({ limit: 100, sort: 'created_at', order: 'desc' });
setAssets(response.data);
} catch (error) {
console.error('Failed to load assets', error);
toast.error('Failed to load library');
} finally {
setLoading(false);
}
};
const toggleSelection = (asset: Asset) => {
const newSelected = new Set(selected);
if (newSelected.has(asset.id)) {
newSelected.delete(asset.id);
} else {
if (maxSelect && newSelected.size >= maxSelect) {
toast.error(`Maximum ${maxSelect} items allowed`);
return;
}
newSelected.add(asset.id);
}
setSelected(newSelected);
};
const handleConfirm = () => {
const selectedAssets = assets.filter(a => selected.has(a.id));
onConfirm(selectedAssets);
onClose();
};
const filteredAssets = assets.filter(asset => {
// Type filter
if (allowedTypes.length > 0) {
if (!allowedTypes.some(type => asset.mime_type.startsWith(type))) return false;
}
// Search filter
if (search) {
return asset.original_filename.toLowerCase().includes(search.toLowerCase());
}
return true;
});
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-forge-dark border border-gray-800 rounded-2xl w-full max-w-4xl flex flex-col max-h-[85vh] shadow-2xl">
{/* Header */}
<div className="p-4 border-b border-gray-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white">{title}</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-800 rounded-lg text-gray-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Toolbar */}
<div className="p-4 border-b border-gray-800 flex gap-4">
<div className="relative flex-1">
<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) => setSearch(e.target.value)}
className="w-full bg-black/40 border border-gray-700 rounded-lg pl-9 pr-4 py-2 text-sm text-white focus:ring-1 focus:ring-forge-yellow focus:border-forge-yellow outline-none"
/>
</div>
<div className="text-sm text-gray-400 flex items-center">
{selected.size} selected
</div>
</div>
{/* content */}
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
{loading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-forge-yellow" />
</div>
) : filteredAssets.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
<FileImage className="w-12 h-12 mb-4 opacity-50" />
<p>No matching files found</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filteredAssets.map(asset => {
const isSelected = selected.has(asset.id);
return (
<div
key={asset.id}
onClick={() => toggleSelection(asset)}
className={`group relative aspect-square rounded-xl overflow-hidden cursor-pointer border-2 transition-all ${isSelected ? 'border-forge-yellow ring-2 ring-forge-yellow/20' : 'border-transparent hover:border-gray-600'
}`}
>
{/* Thumbnail */}
<div className="absolute inset-0 bg-gray-900">
{asset.mime_type.startsWith('image/') ? (
<img
src={`/api/v1/assets/${asset.id}/download`}
alt={asset.original_filename}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-600">
{asset.mime_type.startsWith('video/') ? <FileVideo className="w-8 h-8" /> :
asset.mime_type.startsWith('audio/') ? <FileAudio className="w-8 h-8" /> :
<FileText className="w-8 h-8" />}
</div>
)}
</div>
{/* Overlay */}
<div className={`absolute inset-0 bg-black/40 transition-opacity ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
<div className="absolute top-2 right-2">
<div className={`w-6 h-6 rounded-full flex items-center justify-center border ${isSelected ? 'bg-forge-yellow border-forge-yellow text-black' : 'bg-black/50 border-white/50 text-transparent'
}`}>
<Check className="w-4 h-4" />
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/90 to-transparent">
<p className="text-xs text-white truncate">{asset.original_filename}</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-800 flex justify-end gap-3 bg-gray-900/50 rounded-b-2xl">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={selected.size === 0}
className="btn-primary px-6 py-2 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
Select {selected.size > 0 ? `(${selected.size})` : ''}
</button>
</div>
</div>
</div>
);
}