201 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|