| import { useState, useEffect, useCallback, useRef } from 'react'; |
| import { X, Search, Folder, Grid, Plus, Tag, Trash2, RefreshCw, Upload, Edit3, Copy, ExternalLink, ChevronLeft } from 'lucide-react'; |
| import { useAppStore } from '../store'; |
| import { invoke } from '@tauri-apps/api/core'; |
| import { listen } from '@tauri-apps/api/event'; |
|
|
| interface LibItem { id: string; url: string; source_url: string; title: string; data_url: string; hash: string; width: number; height: number; colors: string[]; tags: string[]; created_at: number; } |
|
|
| export const LibraryPanel = () => { |
| const { isLibraryOpen, setIsLibraryOpen, setImages, pan, zoom } = useAppStore(); |
| const [search, setSearch] = useState(''); |
| const [activeTag, setActiveTag] = useState<string | null>(null); |
| const [libraryItems, setLibraryItems] = useState<LibItem[]>([]); |
| const [isLoading, setIsLoading] = useState(false); |
| const [allTags, setAllTags] = useState<string[]>([]); |
| const [isDragOver, setIsDragOver] = useState(false); |
| const [editingItem, setEditingItem] = useState<LibItem | null>(null); |
| const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
| const rebuildTags = (items: LibItem[]) => { |
| const tags = new Set<string>(); |
| items.forEach(item => (item.tags || []).forEach(t => tags.add(t))); |
| setAllTags(Array.from(tags).sort()); |
| }; |
|
|
| const loadLibrary = useCallback(() => { |
| setIsLoading(true); |
| invoke<LibItem[]>('library_items').then(items => { setLibraryItems(items); rebuildTags(items); setIsLoading(false); }).catch(() => setIsLoading(false)); |
| }, []); |
|
|
| useEffect(() => { if (isLibraryOpen) loadLibrary(); }, [isLibraryOpen, loadLibrary]); |
| useEffect(() => { const unlisten = listen<any>('board://image_added', loadLibrary); return () => { unlisten.then(fn => fn()); }; }, [loadLibrary]); |
| useEffect(() => { const handler = () => loadLibrary(); window.addEventListener('muse:library-refresh', handler); return () => window.removeEventListener('muse:library-refresh', handler); }, [loadLibrary]); |
|
|
| const addToCanvas = (item: LibItem) => { |
| const w = Math.min(500, item.width || 300); |
| const h = item.height ? w * (item.height / item.width) : w; |
| setImages(prev => [...prev, { id: crypto.randomUUID(), url: item.data_url || item.url, sourceUrl: item.source_url || item.url, x: (-pan.x + window.innerWidth / 3) / zoom, y: (-pan.y + window.innerHeight / 3) / zoom, width: w, height: h, aspectRatio: w / h }]); |
| }; |
|
|
| const importFileAsDataUrl = (file: File) => { |
| if (!file.type.startsWith('image/')) return; |
| const reader = new FileReader(); |
| reader.onload = async (ev) => { |
| const dataUrl = ev.target?.result as string; |
| if (!dataUrl) return; |
| try { |
| const item = await invoke<LibItem>('library_import_data_url', { dataUrl, title: file.name.replace(/\.[^/.]+$/, '') }); |
| addToCanvas(item); |
| loadLibrary(); |
| } catch (err) { console.error('Import failed:', err); } |
| }; |
| reader.readAsDataURL(file); |
| }; |
|
|
| const handleBrowserDrop = (e: React.DragEvent) => { |
| e.preventDefault(); e.stopPropagation(); setIsDragOver(false); |
| Array.from(e.dataTransfer.files).forEach(importFileAsDataUrl); |
| }; |
|
|
| const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { |
| if (e.target.files) Array.from(e.target.files).forEach(importFileAsDataUrl); |
| e.target.value = ''; |
| }; |
|
|
| const handleDragStart = (e: React.DragEvent, item: LibItem) => { |
| const payload = JSON.stringify({ id: item.id, data_url: item.data_url, width: item.width, height: item.height, title: item.title }); |
| e.dataTransfer.setData('application/x-muse-library-item', payload); |
| e.dataTransfer.setData('text/plain', payload); |
| e.dataTransfer.effectAllowed = 'copy'; |
| }; |
|
|
| const handleDelete = (id: string) => invoke('library_remove_item', { id }).then(() => { const next = libraryItems.filter(i => i.id !== id); setLibraryItems(next); rebuildTags(next); if (editingItem?.id === id) setEditingItem(null); }); |
|
|
| const updateItem = (updated: LibItem) => { |
| const next = libraryItems.map(i => i.id === updated.id ? updated : i); |
| setLibraryItems(next); rebuildTags(next); setEditingItem(updated); |
| }; |
|
|
| const filtered = libraryItems.filter(img => { |
| if (activeTag && !(img.tags || []).includes(activeTag)) return false; |
| if (!search) return true; |
| const q = search.toLowerCase(); |
| return (img.tags || []).some(t => t.toLowerCase().includes(q)) || (img.title || '').toLowerCase().includes(q) || (img.source_url || img.url || '').toLowerCase().includes(q); |
| }); |
|
|
| if (editingItem) return <MetadataEditor item={editingItem} onClose={() => setEditingItem(null)} onUpdate={updateItem} onDelete={() => { handleDelete(editingItem.id); setEditingItem(null); }} onAddToCanvas={() => addToCanvas(editingItem)} isOpen={isLibraryOpen} onClosePanel={() => setIsLibraryOpen(false)} />; |
|
|
| return ( |
| <div className={`absolute left-0 top-0 h-full w-[45%] max-w-[500px] bg-[#1C1C1E] shadow-2xl flex flex-col z-[60] transform transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] ${isLibraryOpen ? 'translate-x-0' : '-translate-x-full'}`} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; setIsDragOver(true); }} onDragLeave={() => setIsDragOver(false)} onDrop={handleBrowserDrop}> |
| {isDragOver && <div className="absolute inset-0 z-50 bg-[#0A84FF]/10 border-2 border-dashed border-[#0A84FF] rounded-xl m-3 flex items-center justify-center pointer-events-none"><div className="text-[#0A84FF] font-semibold text-sm flex flex-col items-center gap-2"><Upload size={32} />Drop images to import</div></div>} |
| |
| <div className="flex items-center justify-between p-4 border-b border-[#3A3A3E]"> |
| <div className="flex items-center gap-2 text-[#E0E0E0] text-[14px] font-medium"><Folder size={16} className="text-[#0A84FF]" /> Asset Library <span className="text-[11px] text-[#808080] ml-1">({libraryItems.length})</span></div> |
| <div className="flex items-center gap-1"><button onClick={loadLibrary} className="text-[#A0A0A0] hover:text-[#E0E0E0] p-1.5 rounded-md hover:bg-white/5" title="Refresh"><RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} /></button><button onClick={() => setIsLibraryOpen(false)} className="text-[#A0A0A0] hover:text-[#E0E0E0] p-1.5 rounded-md hover:bg-white/5"><X size={16} /></button></div> |
| </div> |
| |
| <div className="px-4 py-3 bg-[#2A2A2E] flex flex-col gap-3"> |
| <div className="relative"><Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#808080]" /><input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Search by name, tag, or URL..." className="w-full bg-[#1C1C1E] text-[#E0E0E0] pl-9 pr-3 py-2 text-[13px] rounded-lg border border-[#3A3A3E] focus:border-[#0A84FF] outline-none placeholder:text-[#606060]" /></div> |
| {allTags.length > 0 && <div className="flex items-center gap-1.5 overflow-x-auto pb-1 hide-scrollbar"><button onClick={() => setActiveTag(null)} className={`whitespace-nowrap px-3 py-1 rounded-full text-[11px] font-medium transition-colors ${!activeTag ? 'bg-[#0A84FF] text-white' : 'bg-[#3A3A3E] text-[#C0C0C0] hover:bg-[#4A4A4E]'}`}>All</button>{allTags.map(tag => <button key={tag} onClick={() => setActiveTag(activeTag === tag ? null : tag)} className={`whitespace-nowrap px-3 py-1 rounded-full text-[11px] font-medium flex items-center gap-1 transition-colors ${activeTag === tag ? 'bg-[#0A84FF] text-white' : 'bg-[#3A3A3E] text-[#C0C0C0] hover:bg-[#4A4A4E]'}`}><Tag size={10} />{tag}</button>)}</div>} |
| </div> |
| |
| <div className="flex-1 overflow-y-auto bg-[#1C1C1E] p-4 custom-scrollbar"> |
| <div className="flex justify-between items-center mb-4"><span className="text-[11px] font-medium text-[#808080] uppercase tracking-widest">Images ({filtered.length})</span><Grid size={14} className="text-[#808080]" /></div> |
| <div className="grid grid-cols-3 gap-3"> |
| <div onClick={() => fileInputRef.current?.click()} className="aspect-square bg-white/5 hover:bg-white/10 border border-dashed border-white/20 hover:border-[#0A84FF]/50 rounded-xl cursor-pointer flex flex-col items-center justify-center text-[#A0A0A0] hover:text-[#E0E0E0] transition-all group shadow-sm"><div className="w-8 h-8 rounded-full bg-black/20 flex items-center justify-center mb-2 group-hover:scale-110 transition-transform"><Plus size={16} /></div><span className="text-[11px] font-medium">Import Files</span><span className="text-[9px] text-[#606060] mt-0.5">or drag here</span></div> |
| <input ref={fileInputRef} type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/avif" multiple className="hidden" onChange={handleFileUpload} /> |
| {filtered.map(img => <div key={img.id} className="aspect-square bg-[#2A2A2E] rounded-xl cursor-pointer group relative overflow-hidden ring-1 ring-[#3A3A3E] hover:ring-[#0A84FF] transition-all" draggable onDragStart={(e) => handleDragStart(e, img)} onClick={() => addToCanvas(img)}><img src={img.data_url || img.url} className="w-full h-full object-cover opacity-90 group-hover:opacity-100 group-hover:scale-105 transition-all duration-500" draggable={false} /><div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-between p-2 pointer-events-none"><div className="flex justify-end gap-1 pointer-events-auto"><button onClick={(e) => { e.stopPropagation(); setEditingItem(img); }} className="w-5 h-5 rounded bg-black/50 flex items-center justify-center text-white/80 hover:text-white hover:bg-black/70" title="Edit metadata"><Edit3 size={10} /></button><button onClick={(e) => { e.stopPropagation(); handleDelete(img.id); }} className="w-5 h-5 rounded bg-black/50 flex items-center justify-center text-red-400 hover:text-red-300 hover:bg-red-500/20" title="Delete"><Trash2 size={10} /></button></div><div className="pointer-events-auto"><span className="text-[10px] text-white font-medium truncate block">{img.title || 'Reference'}</span>{img.tags?.length > 0 && <div className="flex gap-1 mt-0.5 overflow-hidden">{img.tags.slice(0, 3).map(t => <span key={t} className="text-[9px] bg-white/20 text-white/90 px-1 rounded">{t}</span>)}</div>}<div className="text-[9px] text-white/50 mt-0.5">{img.width}×{img.height}</div></div></div>{img.colors?.length > 0 && <div className="absolute bottom-0 left-0 right-0 h-1 flex opacity-0 group-hover:opacity-100 transition-opacity">{img.colors.slice(0, 6).map((c, ci) => <div key={ci} className="flex-1" style={{ backgroundColor: c }} />)}</div>}</div>)} |
| {filtered.length === 0 && !isLoading && <div className="col-span-3 text-center text-[#808080] py-16 text-sm flex flex-col items-center gap-3"><Folder size={32} className="opacity-20" /><p>Library is empty</p><p className="text-xs opacity-60">Drag files here, use Import, or capture from browser.</p></div>} |
| {isLoading && <div className="col-span-3 text-center text-[#808080] py-12"><RefreshCw size={20} className="animate-spin mx-auto mb-2" />Loading...</div>} |
| </div> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| function MetadataEditor({ item, onClose, onUpdate, onDelete, onAddToCanvas, isOpen, onClosePanel }: { item: LibItem; onClose: () => void; onUpdate: (item: LibItem) => void; onDelete: () => void; onAddToCanvas: () => void; isOpen: boolean; onClosePanel: () => void }) { |
| const [title, setTitle] = useState(item.title); const [newTag, setNewTag] = useState(''); |
| useEffect(() => { setTitle(item.title); }, [item.id]); |
| const saveTitle = () => { if (title.trim() === item.title) return; invoke<LibItem>('library_update_metadata', { id: item.id, title: title.trim() || null, tags: null }).then(onUpdate).catch(() => {}); }; |
| const addTag = () => { if (!newTag.trim()) return; invoke<LibItem>('library_add_tag', { id: item.id, tag: newTag.trim() }).then(updated => { onUpdate(updated); setNewTag(''); }).catch(() => {}); }; |
| const removeTag = (tag: string) => invoke<LibItem>('library_remove_tag', { id: item.id, tag }).then(onUpdate).catch(() => {}); |
| return <div className={`absolute left-0 top-0 h-full w-[45%] max-w-[500px] bg-[#1C1C1E] shadow-2xl flex flex-col z-[60] transform transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] ${isOpen ? 'translate-x-0' : '-translate-x-full'}`}><div className="flex items-center justify-between p-4 border-b border-[#3A3A3E]"><button onClick={onClose} className="flex items-center gap-2 text-[#0A84FF] text-[13px] font-medium"><ChevronLeft size={16} /> Back</button><button onClick={onClosePanel} className="text-[#A0A0A0] p-1.5"><X size={16} /></button></div><div className="h-[200px] bg-[#0D0D0F] flex items-center justify-center border-b border-[#3A3A3E]"><img src={item.data_url || item.url} className="max-w-full max-h-full object-contain" /></div><div className="flex-1 overflow-y-auto p-5 flex flex-col gap-5"><div><label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Title</label><input value={title} onChange={e => setTitle(e.target.value)} onBlur={saveTitle} onKeyDown={e => { if (e.key === 'Enter') saveTitle(); }} className="w-full bg-[#2A2A2E] text-[#E0E0E0] px-3 py-2 text-[13px] rounded-lg border border-[#3A3A3E] focus:border-[#0A84FF] outline-none" /></div><div><label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Dimensions</label><div className="text-[13px] text-[#C0C0C0]">{item.width} × {item.height} px</div></div><div><label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Tags</label><div className="flex flex-wrap gap-2 mb-2">{(item.tags || []).map(tag => <span key={tag} className="flex items-center gap-1 bg-[#3A3A3E] text-[#E0E0E0] px-2.5 py-1 rounded-full text-[11px] font-medium group"><Tag size={10} className="text-[#0A84FF]" />{tag}<button onClick={() => removeTag(tag)} className="ml-0.5 text-[#808080] hover:text-red-400"><X size={10} /></button></span>)}</div><div className="flex gap-2"><input value={newTag} onChange={e => setNewTag(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addTag(); }} placeholder="Add tag..." className="flex-1 bg-[#2A2A2E] text-[#E0E0E0] px-3 py-1.5 text-[12px] rounded-lg border border-[#3A3A3E] focus:border-[#0A84FF] outline-none" /><button onClick={addTag} disabled={!newTag.trim()} className="px-3 py-1.5 rounded-lg bg-[#0A84FF]/20 text-[#0A84FF] text-[12px] font-medium disabled:opacity-30">Add</button></div></div>{item.colors?.length > 0 && <div><label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Palette</label><div className="flex gap-2">{item.colors.map((c, i) => <button key={i} onClick={() => navigator.clipboard.writeText(c)} className="w-10 h-10 rounded-lg shadow-md hover:scale-110 transition-transform border border-white/10 relative group" style={{ backgroundColor: c }} title={c}><Copy size={10} className="absolute top-1 right-1 text-white opacity-0 group-hover:opacity-80" /></button>)}</div></div>}{item.source_url && <div><label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Source</label><div className="flex items-center gap-2 text-[12px] text-[#0A84FF] truncate"><ExternalLink size={12} /><span className="truncate">{item.source_url}</span></div></div>}</div><div className="p-4 border-t border-[#3A3A3E] flex gap-2"><button onClick={onAddToCanvas} className="flex-1 py-2.5 rounded-xl bg-[#0A84FF] text-white text-[13px] font-semibold">Add to Canvas</button><button onClick={onDelete} className="px-4 py-2.5 rounded-xl bg-red-500/10 text-red-400 text-[13px] font-semibold"><Trash2 size={14} /></button></div></div>; |
| } |
|
|