353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import api, { assetsApi } from '@/lib/api';
|
|
import { clsx } from 'clsx';
|
|
import { toast } from 'react-hot-toast';
|
|
import {
|
|
X,
|
|
Search,
|
|
Image as ImageIcon,
|
|
Video,
|
|
Mic,
|
|
FileText,
|
|
Check,
|
|
Loader2,
|
|
FolderOpen,
|
|
Upload,
|
|
} from 'lucide-react';
|
|
import FileUpload from './FileUpload';
|
|
|
|
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;
|
|
}
|
|
|
|
interface AssetLibraryProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSelect: (asset: Asset) => void;
|
|
fileTypes?: string[]; // ['image', 'video', 'audio']
|
|
title?: string;
|
|
multiple?: boolean;
|
|
}
|
|
|
|
const FILE_TYPE_ICONS = {
|
|
image: ImageIcon,
|
|
video: Video,
|
|
audio: Mic,
|
|
document: FileText,
|
|
};
|
|
|
|
export default function AssetLibrary({
|
|
isOpen,
|
|
onClose,
|
|
onSelect,
|
|
fileTypes = ['image', 'video', 'audio'],
|
|
title = 'Select from My Files',
|
|
multiple = false,
|
|
}: AssetLibraryProps) {
|
|
const [assets, setAssets] = useState<Asset[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [selectedType, setSelectedType] = useState<string | null>(null);
|
|
const [page, setPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [selectedAssets, setSelectedAssets] = useState<Set<string>>(new Set());
|
|
const [showUpload, setShowUpload] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
loadAssets();
|
|
}
|
|
}, [isOpen, search, selectedType, page]);
|
|
|
|
const loadAssets = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const types = selectedType ? selectedType : fileTypes.join(',');
|
|
const response = await api.get('/assets/library', {
|
|
params: {
|
|
file_types: types,
|
|
search: search || undefined,
|
|
page,
|
|
limit: 20,
|
|
},
|
|
});
|
|
setAssets(response.data.items);
|
|
setTotalPages(response.data.pages);
|
|
} catch (error) {
|
|
console.error('Failed to load assets:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSelect = (asset: Asset) => {
|
|
if (multiple) {
|
|
const newSelected = new Set(selectedAssets);
|
|
if (newSelected.has(asset.id)) {
|
|
newSelected.delete(asset.id);
|
|
} else {
|
|
newSelected.add(asset.id);
|
|
}
|
|
setSelectedAssets(newSelected);
|
|
} else {
|
|
onSelect(asset);
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const handleConfirmMultiple = () => {
|
|
const selected = assets.filter(a => selectedAssets.has(a.id));
|
|
selected.forEach(asset => onSelect(asset));
|
|
onClose();
|
|
};
|
|
|
|
const handleUpload = async (file: File) => {
|
|
setUploading(true);
|
|
try {
|
|
const response = await assetsApi.upload(file);
|
|
toast.success('File uploaded!');
|
|
loadAssets(); // Reload to show new file
|
|
// Auto-select the new file
|
|
if (!multiple) {
|
|
onSelect(response.data);
|
|
setShowUpload(false);
|
|
onClose();
|
|
}
|
|
} catch (error) {
|
|
toast.error('Failed to upload file');
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
|
<div className="bg-forge-dark border border-gray-800 rounded-xl w-full max-w-4xl max-h-[80vh] flex flex-col">
|
|
{/* Header */}
|
|
<div className="p-4 border-b border-gray-800 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<FolderOpen className="w-5 h-5 text-forge-yellow" />
|
|
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setShowUpload(!showUpload)}
|
|
className="px-3 py-2 bg-forge-yellow/10 text-forge-yellow rounded-lg hover:bg-forge-yellow/20 transition-colors flex items-center gap-2 text-sm"
|
|
>
|
|
<Upload className="w-4 h-4" />
|
|
Upload New
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 text-gray-400 hover:text-white transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="p-4 border-b border-gray-800 flex items-center gap-4">
|
|
{/* Search */}
|
|
<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);
|
|
setPage(1);
|
|
}}
|
|
className="w-full pl-10 pr-4 py-2 bg-forge-gray 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-gray text-gray-400 hover:text-white'
|
|
)}
|
|
>
|
|
All
|
|
</button>
|
|
{fileTypes.map((type) => {
|
|
const Icon = FILE_TYPE_ICONS[type as keyof typeof FILE_TYPE_ICONS] || FileText;
|
|
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-gray text-gray-400 hover:text-white'
|
|
)}
|
|
>
|
|
<Icon className="w-4 h-4" />
|
|
{type.charAt(0).toUpperCase() + type.slice(1)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Upload Section */}
|
|
{showUpload && (
|
|
<div className="p-4 border-b border-gray-800">
|
|
<FileUpload
|
|
onUpload={handleUpload}
|
|
accept={
|
|
fileTypes.includes('image')
|
|
? { 'image/*': ['.png', '.jpg', '.jpeg', '.webp', '.gif'] }
|
|
: fileTypes.includes('video')
|
|
? { 'video/*': ['.mp4', '.webm', '.mov'] }
|
|
: { 'audio/*': ['.mp3', '.wav', '.ogg'] }
|
|
}
|
|
label="Drop file here or click to upload"
|
|
/>
|
|
{uploading && (
|
|
<div className="mt-2 flex items-center gap-2 text-forge-yellow text-sm">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Uploading...
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Asset Grid */}
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-48">
|
|
<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-48 text-gray-500">
|
|
<FolderOpen className="w-12 h-12 mb-3" />
|
|
<p>No files found</p>
|
|
<p className="text-sm">Upload some files or generate content to see them here</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3">
|
|
{assets.map((asset) => {
|
|
const isSelected = selectedAssets.has(asset.id);
|
|
const Icon = FILE_TYPE_ICONS[asset.file_type as keyof typeof FILE_TYPE_ICONS] || FileText;
|
|
|
|
// Determine preview URL
|
|
let previewUrl = null;
|
|
if (asset.thumbnail_url) {
|
|
previewUrl = `${process.env.NEXT_PUBLIC_API_URL}${asset.thumbnail_url}`;
|
|
} else if (asset.mime_type?.startsWith('image/')) {
|
|
// Fallback to direct download for images without thumbnails
|
|
previewUrl = `/api/v1/assets/${asset.id}/download`;
|
|
}
|
|
|
|
return (
|
|
<button
|
|
key={asset.id}
|
|
onClick={() => handleSelect(asset)}
|
|
className={clsx(
|
|
'relative aspect-square rounded-lg overflow-hidden border-2 transition-all hover:scale-105',
|
|
isSelected
|
|
? 'border-forge-yellow'
|
|
: 'border-transparent hover:border-gray-600'
|
|
)}
|
|
>
|
|
{/* Thumbnail or Icon */}
|
|
{previewUrl ? (
|
|
<img
|
|
src={previewUrl}
|
|
alt={asset.filename}
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full bg-forge-gray flex items-center justify-center">
|
|
<Icon className="w-8 h-8 text-gray-500" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Selection indicator */}
|
|
{multiple && isSelected && (
|
|
<div className="absolute top-2 right-2 w-6 h-6 bg-forge-yellow rounded-full flex items-center justify-center">
|
|
<Check className="w-4 h-4 text-black" />
|
|
</div>
|
|
)}
|
|
|
|
{/* File type badge */}
|
|
<div className="absolute bottom-0 left-0 right-0 bg-black/70 px-2 py-1">
|
|
<p className="text-xs text-gray-300 truncate">{asset.filename}</p>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="p-4 border-t border-gray-800 flex items-center justify-center gap-2">
|
|
<button
|
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
className="px-3 py-1 bg-forge-gray rounded text-sm text-gray-400 hover:text-white disabled:opacity-50"
|
|
>
|
|
Previous
|
|
</button>
|
|
<span className="text-sm text-gray-400">
|
|
Page {page} of {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
|
disabled={page === totalPages}
|
|
className="px-3 py-1 bg-forge-gray rounded text-sm text-gray-400 hover:text-white disabled:opacity-50"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer for multiple selection */}
|
|
{multiple && selectedAssets.size > 0 && (
|
|
<div className="p-4 border-t border-gray-800 flex items-center justify-between">
|
|
<span className="text-sm text-gray-400">
|
|
{selectedAssets.size} file(s) selected
|
|
</span>
|
|
<button
|
|
onClick={handleConfirmMultiple}
|
|
className="px-4 py-2 bg-forge-yellow text-black font-medium rounded-lg hover:bg-forge-yellow/90"
|
|
>
|
|
Use Selected
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|