asdf98 commited on
Commit
7977502
·
verified ·
1 Parent(s): d89e122

feat: refine library UI/UX, import flow, filtering, and performance

Browse files
Files changed (1) hide show
  1. src/components/LibraryPanel.tsx +111 -53
src/components/LibraryPanel.tsx CHANGED
@@ -1,69 +1,96 @@
1
- import { useState, useEffect, useCallback, useRef } from 'react';
2
- import { X, Search, Folder, Grid, Plus, Tag, Trash2, RefreshCw, Upload, Edit3, Copy, ExternalLink, ChevronLeft } from 'lucide-react';
3
  import { useAppStore } from '../store';
4
  import { invoke } from '@tauri-apps/api/core';
5
  import { listen } from '@tauri-apps/api/event';
6
 
7
  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; }
8
- const ACCEPTED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp']);
9
- const FILE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/bmp';
 
 
10
  function toast(msg: string) { window.dispatchEvent(new CustomEvent('lumaref:toast', { detail: msg })); }
 
 
 
 
 
11
 
12
  export const LibraryPanel = () => {
13
  const { isLibraryOpen, setIsLibraryOpen, setImages, pan, zoom } = useAppStore();
14
  const [search, setSearch] = useState('');
 
15
  const [activeTag, setActiveTag] = useState<string | null>(null);
16
  const [libraryItems, setLibraryItems] = useState<LibItem[]>([]);
17
  const [isLoading, setIsLoading] = useState(false);
18
  const [allTags, setAllTags] = useState<string[]>([]);
19
  const [isDragOver, setIsDragOver] = useState(false);
20
  const [editingItem, setEditingItem] = useState<LibItem | null>(null);
 
 
21
  const fileInputRef = useRef<HTMLInputElement>(null);
 
22
 
23
- const rebuildTags = (items: LibItem[]) => {
24
  const tags = new Set<string>();
25
  items.forEach(item => (item.tags || []).forEach(t => tags.add(t)));
26
- setAllTags(Array.from(tags).sort());
27
- };
28
 
29
  const loadLibrary = useCallback(() => {
 
30
  setIsLoading(true);
31
  invoke<LibItem[]>('library_items')
32
- .then(items => { setLibraryItems(items); rebuildTags(items); })
 
 
 
 
 
33
  .catch(err => toast(`✗ Library load failed: ${err}`))
34
- .finally(() => setIsLoading(false));
35
- }, []);
36
 
37
  useEffect(() => { if (isLibraryOpen) loadLibrary(); }, [isLibraryOpen, loadLibrary]);
38
- useEffect(() => { const unlisten = listen<any>('board://image_added', loadLibrary); return () => { unlisten.then(fn => fn()); }; }, [loadLibrary]);
39
- useEffect(() => { const handler = () => loadLibrary(); window.addEventListener('lumaref:library-refresh', handler); return () => window.removeEventListener('lumaref:library-refresh', handler); }, [loadLibrary]);
40
 
41
- const addToCanvas = (item: LibItem, quiet = false) => {
42
  const naturalW = Math.max(1, item.width || 300);
43
  const naturalH = Math.max(1, item.height || naturalW);
44
- const w = Math.min(500, naturalW);
45
  const h = w * (naturalH / naturalW);
46
- 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 }]);
47
  if (!quiet) toast('Added to canvas');
48
- };
49
 
50
- const importFileAsDataUrl = (file: File) => {
51
- if (!ACCEPTED_IMAGE_TYPES.has(file.type)) { toast(`✗ Unsupported image type: ${file.name}`); return; }
 
 
 
52
  const reader = new FileReader();
53
  reader.onload = async (ev) => {
54
  const dataUrl = ev.target?.result as string;
55
- if (!dataUrl) return;
56
  try {
 
57
  const item = await invoke<LibItem>('library_import_data_url', { dataUrl, title: file.name.replace(/\.[^/.]+$/, '') });
58
- const duplicate = libraryItems.some(i => i.hash === item.hash);
59
  addToCanvas(item, true);
60
- loadLibrary();
61
- toast(duplicate ? 'Already in library added to canvas' : `Imported ${file.name}`);
62
- } catch (err) { toast(`✗ Import failed: ${err}`); console.error('Import failed:', err); }
 
 
 
 
 
 
63
  };
64
- reader.onerror = () => toast(`✗ Could not read ${file.name}`);
65
  reader.readAsDataURL(file);
66
- };
67
 
68
  const handleBrowserDrop = (e: React.DragEvent) => {
69
  e.preventDefault(); e.stopPropagation(); setIsDragOver(false);
@@ -78,61 +105,92 @@ export const LibraryPanel = () => {
78
  };
79
 
80
  const handleDragStart = (e: React.DragEvent, item: LibItem) => {
81
- const payload = JSON.stringify({ id: item.id, data_url: item.data_url, width: item.width, height: item.height, title: item.title, source_url: item.source_url || item.url });
82
  e.dataTransfer.setData('application/x-lumaref-library-item', payload);
83
  e.dataTransfer.setData('text/plain', payload);
84
  e.dataTransfer.effectAllowed = 'copy';
85
  };
86
 
87
- 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); toast('Removed from library'); }).catch(err => toast(`✗ Delete failed: ${err}`));
 
 
 
 
 
 
 
88
 
89
  const updateItem = (updated: LibItem) => {
90
  const next = libraryItems.map(i => i.id === updated.id ? updated : i);
91
  setLibraryItems(next); rebuildTags(next); setEditingItem(updated);
92
  };
93
 
94
- const filtered = libraryItems.filter(img => {
95
- if (activeTag && !(img.tags || []).includes(activeTag)) return false;
96
- if (!search) return true;
97
- const q = search.toLowerCase();
98
- return (img.tags || []).some(t => t.toLowerCase().includes(q)) || (img.title || '').toLowerCase().includes(q) || (img.source_url || img.url || '').toLowerCase().includes(q);
99
- });
 
 
 
 
 
100
 
101
  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)} />;
102
 
103
  return (
104
- <div className={`absolute left-0 top-0 h-full w-[42%] min-w-[360px] max-w-[480px] 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}>
105
- {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={28} />Drop images to import</div></div>}
106
 
107
- <div className="flex items-center justify-between px-4 py-3 border-b border-[#3A3A3E]">
108
- <div className="flex items-center gap-2 text-[#E0E0E0] text-[13px] font-medium"><Folder size={15} className="text-[#0A84FF]" /> Library <span className="text-[11px] text-[#808080] ml-1">({libraryItems.length})</span></div>
109
- <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>
110
  </div>
111
 
112
- <div className="px-4 py-3 bg-[#2A2A2E] flex flex-col gap-2">
113
- <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 library..." 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>
114
- {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-2.5 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-2.5 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>}
 
 
 
 
115
  </div>
116
 
117
- <div className="flex-1 overflow-y-auto bg-[#1C1C1E] p-3 custom-scrollbar">
118
- <div className="flex justify-between items-center mb-3"><span className="text-[10px] font-medium text-[#808080] uppercase tracking-widest">Images ({filtered.length})</span><Grid size={13} className="text-[#808080]" /></div>
119
- <div className="grid grid-cols-3 gap-2.5">
120
- <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-lg cursor-pointer flex flex-col items-center justify-center text-[#A0A0A0] hover:text-[#E0E0E0] transition-all group shadow-sm"><div className="w-7 h-7 rounded-full bg-black/20 flex items-center justify-center mb-1.5 group-hover:scale-110 transition-transform"><Plus size={15} /></div><span className="text-[10px] font-medium">Import</span></div>
121
  <input ref={fileInputRef} type="file" accept={FILE_ACCEPT} multiple className="hidden" onChange={handleFileUpload} />
122
- {filtered.map(img => <div key={img.id} className="aspect-square bg-[#2A2A2E] rounded-lg 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>)}
123
- {filtered.length === 0 && !isLoading && <div className="col-span-3 text-center text-[#808080] py-14 text-sm flex flex-col items-center gap-2"><Folder size={28} className="opacity-20" /><p>Library is empty</p></div>}
124
- {isLoading && <div className="col-span-3 text-center text-[#808080] py-12"><RefreshCw size={18} className="animate-spin mx-auto mb-2" />Loading...</div>}
125
  </div>
126
  </div>
127
  </div>
128
  );
129
  };
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  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 }) {
132
- const [title, setTitle] = useState(item.title); const [newTag, setNewTag] = useState('');
133
- useEffect(() => { setTitle(item.title); }, [item.id]);
134
- const saveTitle = () => { if (title.trim() === item.title) return; invoke<LibItem>('library_update_metadata', { id: item.id, title: title.trim() || null, tags: null }).then(item => { onUpdate(item); toast('Updated'); }).catch(err => toast(`✗ Update failed: ${err}`)); };
135
- const addTag = () => { if (!newTag.trim()) return; invoke<LibItem>('library_add_tag', { id: item.id, tag: newTag.trim() }).then(updated => { onUpdate(updated); setNewTag(''); }).catch(err => toast(`✗ Tag failed: ${err}`)); };
136
  const removeTag = (tag: string) => invoke<LibItem>('library_remove_tag', { id: item.id, tag }).then(onUpdate).catch(err => toast(`✗ Tag failed: ${err}`));
137
- return <div className={`absolute left-0 top-0 h-full w-[42%] min-w-[360px] max-w-[480px] 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 px-4 py-3 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-[180px] 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-4 flex flex-col gap-4"><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).then(() => toast(`Copied ${c}`)).catch(() => {})} className="w-9 h-9 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><button onClick={() => navigator.clipboard.writeText(item.source_url).then(() => toast('Source copied')).catch(() => {})} className="flex items-center gap-2 text-[12px] text-[#0A84FF] truncate hover:underline text-left"><ExternalLink size={12} /><span className="truncate">{item.source_url}</span></button></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>;
138
  }
 
 
1
+ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
2
+ import { X, Search, Folder, Grid, Plus, Tag, Trash2, RefreshCw, Upload, Edit3, Copy, ExternalLink, ChevronLeft, Image as ImageIcon, CheckCircle2, AlertCircle, Database } from 'lucide-react';
3
  import { useAppStore } from '../store';
4
  import { invoke } from '@tauri-apps/api/core';
5
  import { listen } from '@tauri-apps/api/event';
6
 
7
  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; }
8
+ const ACCEPTED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/x-icon', 'image/vnd.microsoft.icon', 'image/tiff']);
9
+ const FILE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon,image/vnd.microsoft.icon,image/tiff,.png,.jpg,.jpeg,.webp,.gif,.bmp,.ico,.tif,.tiff';
10
+ const IMAGE_EXT_RE = /\.(png|jpe?g|webp|gif|bmp|ico|tiff?)$/i;
11
+ const MAX_FRONTEND_FILE_SIZE = 50 * 1024 * 1024;
12
  function toast(msg: string) { window.dispatchEvent(new CustomEvent('lumaref:toast', { detail: msg })); }
13
+ function prettyBytes(n: number) { if (!Number.isFinite(n) || n <= 0) return '—'; if (n > 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; if (n > 1024) return `${Math.round(n / 1024)} KB`; return `${n} B`; }
14
+ function formatDate(ts: number) { try { return new Date(ts * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } catch { return ''; } }
15
+ function inferKind(item: LibItem) { const src = (item.data_url || item.url || '').toLowerCase(); if (src.startsWith('data:image/gif') || item.url.toLowerCase().endsWith('.gif')) return 'GIF'; if (src.includes('image/webp')) return 'WEBP'; if (src.includes('image/png')) return 'PNG'; if (src.includes('image/jpeg') || src.includes('image/jpg')) return 'JPG'; if (src.includes('image/bmp')) return 'BMP'; if (src.includes('image/x-icon')) return 'ICO'; if (src.includes('image/tiff')) return 'TIFF'; return 'IMG'; }
16
+ function canImportFile(file: File) { return ACCEPTED_IMAGE_TYPES.has(file.type) || IMAGE_EXT_RE.test(file.name); }
17
+ function useDebounced<T>(value: T, delay = 120) { const [v, setV] = useState(value); useEffect(() => { const t = window.setTimeout(() => setV(value), delay); return () => window.clearTimeout(t); }, [value, delay]); return v; }
18
 
19
  export const LibraryPanel = () => {
20
  const { isLibraryOpen, setIsLibraryOpen, setImages, pan, zoom } = useAppStore();
21
  const [search, setSearch] = useState('');
22
+ const debouncedSearch = useDebounced(search);
23
  const [activeTag, setActiveTag] = useState<string | null>(null);
24
  const [libraryItems, setLibraryItems] = useState<LibItem[]>([]);
25
  const [isLoading, setIsLoading] = useState(false);
26
  const [allTags, setAllTags] = useState<string[]>([]);
27
  const [isDragOver, setIsDragOver] = useState(false);
28
  const [editingItem, setEditingItem] = useState<LibItem | null>(null);
29
+ const [importingCount, setImportingCount] = useState(0);
30
+ const [lastImportError, setLastImportError] = useState<string | null>(null);
31
  const fileInputRef = useRef<HTMLInputElement>(null);
32
+ const loadSeqRef = useRef(0);
33
 
34
+ const rebuildTags = useCallback((items: LibItem[]) => {
35
  const tags = new Set<string>();
36
  items.forEach(item => (item.tags || []).forEach(t => tags.add(t)));
37
+ setAllTags(Array.from(tags).sort((a, b) => a.localeCompare(b)));
38
+ }, []);
39
 
40
  const loadLibrary = useCallback(() => {
41
+ const seq = ++loadSeqRef.current;
42
  setIsLoading(true);
43
  invoke<LibItem[]>('library_items')
44
+ .then(items => {
45
+ if (seq !== loadSeqRef.current) return;
46
+ const sorted = [...items].sort((a, b) => b.created_at - a.created_at);
47
+ setLibraryItems(sorted);
48
+ rebuildTags(sorted);
49
+ })
50
  .catch(err => toast(`✗ Library load failed: ${err}`))
51
+ .finally(() => { if (seq === loadSeqRef.current) setIsLoading(false); });
52
+ }, [rebuildTags]);
53
 
54
  useEffect(() => { if (isLibraryOpen) loadLibrary(); }, [isLibraryOpen, loadLibrary]);
55
+ useEffect(() => { const unlisten = listen<any>('board://image_added', () => { if (isLibraryOpen) loadLibrary(); }); return () => { unlisten.then(fn => fn()); }; }, [loadLibrary, isLibraryOpen]);
56
+ useEffect(() => { const handler = () => { if (isLibraryOpen) loadLibrary(); }; window.addEventListener('lumaref:library-refresh', handler); return () => window.removeEventListener('lumaref:library-refresh', handler); }, [loadLibrary, isLibraryOpen]);
57
 
58
+ const addToCanvas = useCallback((item: LibItem, quiet = false) => {
59
  const naturalW = Math.max(1, item.width || 300);
60
  const naturalH = Math.max(1, item.height || naturalW);
61
+ const w = Math.min(520, naturalW);
62
  const h = w * (naturalH / naturalW);
63
+ 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: Math.round(w), height: Math.round(h), aspectRatio: w / h }]);
64
  if (!quiet) toast('Added to canvas');
65
+ }, [pan.x, pan.y, zoom, setImages]);
66
 
67
+ const importFileAsDataUrl = useCallback((file: File) => {
68
+ if (!canImportFile(file)) { const msg = `Unsupported image type: ${file.name}`; setLastImportError(msg); toast(`✗ ${msg}`); return; }
69
+ if (file.size > MAX_FRONTEND_FILE_SIZE) { const msg = `${file.name} is too large (${prettyBytes(file.size)}), max 50 MB`; setLastImportError(msg); toast(`✗ ${msg}`); return; }
70
+ setImportingCount(c => c + 1);
71
+ setLastImportError(null);
72
  const reader = new FileReader();
73
  reader.onload = async (ev) => {
74
  const dataUrl = ev.target?.result as string;
75
+ if (!dataUrl) { setImportingCount(c => Math.max(0, c - 1)); return; }
76
  try {
77
+ const before = new Set(libraryItems.map(i => i.hash));
78
  const item = await invoke<LibItem>('library_import_data_url', { dataUrl, title: file.name.replace(/\.[^/.]+$/, '') });
79
+ const duplicate = before.has(item.hash);
80
  addToCanvas(item, true);
81
+ setLibraryItems(prev => {
82
+ const without = prev.filter(i => i.id !== item.id && i.hash !== item.hash);
83
+ const next = duplicate ? [item, ...without] : [item, ...without];
84
+ rebuildTags(next);
85
+ return next;
86
+ });
87
+ toast(duplicate ? `Already in library — added ${file.name} to canvas` : `Imported ${file.name}`);
88
+ } catch (err) { const msg = `Import failed: ${err}`; setLastImportError(msg); toast(`✗ ${msg}`); console.error('Import failed:', err); }
89
+ finally { setImportingCount(c => Math.max(0, c - 1)); }
90
  };
91
+ reader.onerror = () => { setLastImportError(`Could not read ${file.name}`); toast(`✗ Could not read ${file.name}`); setImportingCount(c => Math.max(0, c - 1)); };
92
  reader.readAsDataURL(file);
93
+ }, [libraryItems, addToCanvas, rebuildTags]);
94
 
95
  const handleBrowserDrop = (e: React.DragEvent) => {
96
  e.preventDefault(); e.stopPropagation(); setIsDragOver(false);
 
105
  };
106
 
107
  const handleDragStart = (e: React.DragEvent, item: LibItem) => {
108
+ const payload = JSON.stringify({ id: item.id, data_url: item.data_url, dataUrl: item.data_url, width: item.width, height: item.height, title: item.title, source_url: item.source_url || item.url, sourceUrl: item.source_url || item.url });
109
  e.dataTransfer.setData('application/x-lumaref-library-item', payload);
110
  e.dataTransfer.setData('text/plain', payload);
111
  e.dataTransfer.effectAllowed = 'copy';
112
  };
113
 
114
+ const handleDelete = useCallback((id: string) => {
115
+ invoke('library_remove_item', { id }).then(() => {
116
+ const next = libraryItems.filter(i => i.id !== id);
117
+ setLibraryItems(next); rebuildTags(next);
118
+ if (editingItem?.id === id) setEditingItem(null);
119
+ toast('Removed from library');
120
+ }).catch(err => toast(`✗ Delete failed: ${err}`));
121
+ }, [libraryItems, editingItem, rebuildTags]);
122
 
123
  const updateItem = (updated: LibItem) => {
124
  const next = libraryItems.map(i => i.id === updated.id ? updated : i);
125
  setLibraryItems(next); rebuildTags(next); setEditingItem(updated);
126
  };
127
 
128
+ const filtered = useMemo(() => {
129
+ const q = debouncedSearch.trim().toLowerCase();
130
+ return libraryItems.filter(img => {
131
+ if (activeTag && !(img.tags || []).includes(activeTag)) return false;
132
+ if (!q) return true;
133
+ return (img.tags || []).some(t => t.toLowerCase().includes(q)) || (img.title || '').toLowerCase().includes(q) || (img.source_url || img.url || '').toLowerCase().includes(q) || img.hash.toLowerCase().startsWith(q);
134
+ });
135
+ }, [libraryItems, activeTag, debouncedSearch]);
136
+
137
+ const totalBytes = useMemo(() => libraryItems.reduce((sum, item) => sum + (item.data_url?.length || 0) * 0.75, 0), [libraryItems]);
138
+ const hasFilters = Boolean(activeTag || search.trim());
139
 
140
  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)} />;
141
 
142
  return (
143
+ <div className={`absolute left-0 top-0 h-full w-[40%] min-w-[360px] max-w-[500px] bg-[var(--panel-bg)] shadow-2xl flex flex-col z-[60] transform transition-transform duration-300 ease-out border-r border-[var(--panel-border)] ${isLibraryOpen ? 'translate-x-0' : '-translate-x-full'}`} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; setIsDragOver(true); }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragOver(false); }} onDrop={handleBrowserDrop}>
144
+ {isDragOver && <div className="absolute inset-0 z-50 bg-[var(--accent)]/10 border-2 border-dashed border-[var(--accent)] rounded-xl m-3 flex items-center justify-center pointer-events-none"><div className="bg-[var(--panel-surface)] border border-[var(--accent)] rounded-xl px-5 py-4 text-[var(--accent)] font-semibold text-sm flex flex-col items-center gap-2 shadow-2xl"><Upload size={28} />Drop images to import<div className="text-[10px] font-normal text-[var(--ui-secondary)]">PNG · JPG · WEBP · GIF · BMP · ICO · TIFF</div></div></div>}
145
 
146
+ <div className="h-12 flex items-center justify-between px-3 border-b border-[var(--panel-border)] shrink-0">
147
+ <div className="flex items-center gap-2 text-[var(--ui-primary)] text-[13px] font-semibold"><Folder size={15} className="text-[var(--accent)]" /> Library <span className="text-[11px] text-[var(--ui-secondary)] font-medium">{libraryItems.length}</span></div>
148
+ <div className="flex items-center gap-1"><button onClick={loadLibrary} className="text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] 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-[var(--ui-secondary)] hover:text-[var(--ui-primary)] p-1.5 rounded-md hover:bg-white/5"><X size={16} /></button></div>
149
  </div>
150
 
151
+ <div className="px-3 py-2 bg-[var(--panel-surface)] border-b border-[var(--panel-border)] shrink-0 space-y-2">
152
+ <div className="relative"><Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--ui-secondary)]" /><input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Search title, tag, source, hash..." className="w-full bg-black/25 text-[var(--ui-primary)] pl-9 pr-8 py-2 text-[13px] rounded-lg border border-[var(--panel-border)] focus:border-[var(--accent)] outline-none placeholder:text-[var(--ui-secondary)]" />{search && <button onClick={() => setSearch('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--ui-secondary)] hover:text-[var(--ui-primary)]"><X size={13}/></button>}</div>
153
+ <div className="flex items-center justify-between gap-2 text-[10px] text-[var(--ui-secondary)]">
154
+ <div className="flex items-center gap-2"><Database size={11}/> {prettyBytes(totalBytes)} approx</div>
155
+ {importingCount > 0 ? <span className="flex items-center gap-1 text-[var(--accent)]"><RefreshCw size={11} className="animate-spin"/> Importing {importingCount}</span> : lastImportError ? <span className="flex items-center gap-1 text-[#FF453A] truncate max-w-[220px]"><AlertCircle size={11}/>{lastImportError}</span> : <span className="flex items-center gap-1"><CheckCircle2 size={11}/> Ready</span>}
156
+ </div>
157
+ {allTags.length > 0 && <div className="flex items-center gap-1.5 overflow-x-auto pb-0.5 hide-scrollbar"><button onClick={() => setActiveTag(null)} className={`whitespace-nowrap px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${!activeTag ? 'bg-[var(--accent)] text-white' : 'bg-white/5 text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] hover:bg-white/10'}`}>All</button>{allTags.map(tag => <button key={tag} onClick={() => setActiveTag(activeTag === tag ? null : tag)} className={`whitespace-nowrap px-2.5 py-1 rounded-full text-[11px] font-medium flex items-center gap-1 transition-colors ${activeTag === tag ? 'bg-[var(--accent)] text-white' : 'bg-white/5 text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] hover:bg-white/10'}`}><Tag size={10} />{tag}</button>)}</div>}
158
  </div>
159
 
160
+ <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
161
+ <div className="flex justify-between items-center mb-2"><span className="text-[10px] font-semibold text-[var(--ui-secondary)] uppercase tracking-widest">{hasFilters ? `Results (${filtered.length})` : `Images (${filtered.length})`}</span><Grid size={13} className="text-[var(--ui-secondary)]" /></div>
162
+ <div className="grid grid-cols-3 gap-2">
163
+ <button onClick={() => fileInputRef.current?.click()} className="aspect-square bg-white/5 hover:bg-white/10 border border-dashed border-white/20 hover:border-[var(--accent)]/60 rounded-lg cursor-pointer flex flex-col items-center justify-center text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] transition-all group shadow-sm"><div className="w-7 h-7 rounded-full bg-black/20 flex items-center justify-center mb-1.5 group-hover:scale-110 transition-transform"><Plus size={15} /></div><span className="text-[10px] font-medium">Import</span></button>
164
  <input ref={fileInputRef} type="file" accept={FILE_ACCEPT} multiple className="hidden" onChange={handleFileUpload} />
165
+ {filtered.map(img => <LibraryTile key={img.id} item={img} onAdd={() => addToCanvas(img)} onDragStart={handleDragStart} onEdit={() => setEditingItem(img)} onDelete={() => handleDelete(img.id)} />)}
166
+ {filtered.length === 0 && !isLoading && <div className="col-span-3 text-center text-[var(--ui-secondary)] py-14 text-sm flex flex-col items-center gap-2"><Folder size={28} className="opacity-20" /><p>{hasFilters ? 'No matching images' : 'Library is empty'}</p>{hasFilters && <button onClick={() => { setSearch(''); setActiveTag(null); }} className="text-[var(--accent)] text-xs hover:underline">Clear filters</button>}</div>}
167
+ {isLoading && <div className="col-span-3 text-center text-[var(--ui-secondary)] py-12"><RefreshCw size={18} className="animate-spin mx-auto mb-2" />Loading...</div>}
168
  </div>
169
  </div>
170
  </div>
171
  );
172
  };
173
 
174
+ function LibraryTile({ item, onAdd, onDragStart, onEdit, onDelete }: { item: LibItem; onAdd: () => void; onDragStart: (e: React.DragEvent, item: LibItem) => void; onEdit: () => void; onDelete: () => void }) {
175
+ const [failed, setFailed] = useState(false);
176
+ const kind = inferKind(item);
177
+ return <div className="aspect-square bg-[var(--panel-surface)] rounded-lg cursor-pointer group relative overflow-hidden ring-1 ring-[var(--panel-border)] hover:ring-[var(--accent)] transition-all" draggable onDragStart={(e) => onDragStart(e, item)} onClick={onAdd} title={`${item.title || 'Reference'} · ${item.width}×${item.height}`}>
178
+ {failed ? <div className="w-full h-full flex items-center justify-center text-[var(--ui-secondary)]"><ImageIcon size={24}/></div> : <img src={item.data_url || item.url} className="w-full h-full object-cover opacity-90 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300" draggable={false} loading="lazy" onError={() => setFailed(true)} />}
179
+ <div className="absolute top-1 left-1 px-1.5 py-0.5 rounded bg-black/55 text-white/80 text-[8px] font-semibold tracking-wide pointer-events-none">{kind}</div>
180
+ <div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-between p-2 pointer-events-none">
181
+ <div className="flex justify-end gap-1 pointer-events-auto"><button onClick={(e) => { e.stopPropagation(); onEdit(); }} className="w-6 h-6 rounded bg-black/55 flex items-center justify-center text-white/80 hover:text-white hover:bg-black/75" title="Edit metadata"><Edit3 size={11} /></button><button onClick={(e) => { e.stopPropagation(); if (confirm('Remove this image from the library?')) onDelete(); }} className="w-6 h-6 rounded bg-black/55 flex items-center justify-center text-red-300 hover:text-white hover:bg-red-500/80" title="Delete"><Trash2 size={11} /></button></div>
182
+ <div className="pointer-events-auto min-w-0"><span className="text-[10px] text-white font-medium truncate block">{item.title || 'Reference'}</span>{item.tags?.length > 0 && <div className="flex gap-1 mt-0.5 overflow-hidden">{item.tags.slice(0, 2).map(t => <span key={t} className="text-[8px] bg-white/20 text-white/90 px-1 rounded truncate max-w-[48px]">{t}</span>)}</div>}<div className="text-[9px] text-white/55 mt-0.5">{item.width}×{item.height} · {formatDate(item.created_at)}</div></div>
183
+ </div>
184
+ {item.colors?.length > 0 && <div className="absolute bottom-0 left-0 right-0 h-1 flex opacity-70 group-hover:opacity-100 transition-opacity">{item.colors.slice(0, 6).map((c, ci) => <div key={ci} className="flex-1" style={{ backgroundColor: c }} />)}</div>}
185
+ </div>;
186
+ }
187
+
188
  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 }) {
189
+ const [title, setTitle] = useState(item.title); const [newTag, setNewTag] = useState(''); const [saving, setSaving] = useState(false);
190
+ useEffect(() => { setTitle(item.title); }, [item.id, item.title]);
191
+ const saveTitle = () => { if (title.trim() === item.title) return; setSaving(true); invoke<LibItem>('library_update_metadata', { id: item.id, title: title.trim() || null, tags: null }).then(item => { onUpdate(item); toast('Updated'); }).catch(err => toast(`✗ Update failed: ${err}`)).finally(() => setSaving(false)); };
192
+ const addTag = () => { const t = newTag.trim().toLowerCase(); if (!t) return; invoke<LibItem>('library_add_tag', { id: item.id, tag: t }).then(updated => { onUpdate(updated); setNewTag(''); }).catch(err => toast(`✗ Tag failed: ${err}`)); };
193
  const removeTag = (tag: string) => invoke<LibItem>('library_remove_tag', { id: item.id, tag }).then(onUpdate).catch(err => toast(`✗ Tag failed: ${err}`));
194
+ return <div className={`absolute left-0 top-0 h-full w-[40%] min-w-[360px] max-w-[500px] bg-[var(--panel-bg)] shadow-2xl flex flex-col z-[60] transform transition-transform duration-300 ease-out border-r border-[var(--panel-border)] ${isOpen ? 'translate-x-0' : '-translate-x-full'}`}><div className="h-12 flex items-center justify-between px-3 border-b border-[var(--panel-border)] shrink-0"><button onClick={onClose} className="flex items-center gap-2 text-[var(--accent)] text-[13px] font-medium hover:underline"><ChevronLeft size={16} /> Back</button><div className="flex items-center gap-1 text-[10px] text-[var(--ui-secondary)]">{saving && 'Saving...'}<button onClick={onClosePanel} className="text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] p-1.5 rounded-md hover:bg-white/5"><X size={16} /></button></div></div><div className="h-[190px] bg-black/25 flex items-center justify-center border-b border-[var(--panel-border)]"><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-4 flex flex-col gap-4 custom-scrollbar"><div><label className="text-[10px] font-bold text-[var(--ui-secondary)] 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-[var(--panel-surface)] text-[var(--ui-primary)] px-3 py-2 text-[13px] rounded-lg border border-[var(--panel-border)] focus:border-[var(--accent)] outline-none" /></div><div className="grid grid-cols-2 gap-2"><InfoBox label="Dimensions" value={`${item.width} × ${item.height}`} /><InfoBox label="Format" value={inferKind(item)} /><InfoBox label="Added" value={formatDate(item.created_at) || '—'} /><InfoBox label="Hash" value={item.hash.slice(0, 10)} /></div><div><label className="text-[10px] font-bold text-[var(--ui-secondary)] 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-white/8 text-[var(--ui-primary)] px-2.5 py-1 rounded-full text-[11px] font-medium group"><Tag size={10} className="text-[var(--accent)]" />{tag}<button onClick={() => removeTag(tag)} className="ml-0.5 text-[var(--ui-secondary)] hover:text-[#FF453A]"><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-[var(--panel-surface)] text-[var(--ui-primary)] px-3 py-1.5 text-[12px] rounded-lg border border-[var(--panel-border)] focus:border-[var(--accent)] outline-none" /><button onClick={addTag} disabled={!newTag.trim()} className="px-3 py-1.5 rounded-lg bg-[var(--accent)]/15 text-[var(--accent)] text-[12px] font-medium disabled:opacity-30">Add</button></div></div>{item.colors?.length > 0 && <div><label className="text-[10px] font-bold text-[var(--ui-secondary)] uppercase tracking-widest mb-1.5 block">Palette</label><div className="flex flex-wrap gap-2">{item.colors.map((c, i) => <button key={i} onClick={() => navigator.clipboard.writeText(c).then(() => toast(`Copied ${c}`)).catch(() => {})} className="w-9 h-9 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-[var(--ui-secondary)] uppercase tracking-widest mb-1.5 block">Source</label><button onClick={() => navigator.clipboard.writeText(item.source_url).then(() => toast('Source copied')).catch(() => {})} className="flex items-center gap-2 text-[12px] text-[var(--accent)] truncate hover:underline text-left"><ExternalLink size={12} /><span className="truncate">{item.source_url}</span></button></div>}</div><div className="p-3 border-t border-[var(--panel-border)] flex gap-2"><button onClick={onAddToCanvas} className="flex-1 py-2.5 rounded-xl bg-[var(--accent)] text-white text-[13px] font-semibold">Add to Canvas</button><button onClick={() => { if (confirm('Remove this image from the library?')) onDelete(); }} className="px-4 py-2.5 rounded-xl bg-[#FF453A]/10 text-[#FF453A] text-[13px] font-semibold hover:bg-[#FF453A]/20"><Trash2 size={14} /></button></div></div>;
195
  }
196
+ function InfoBox({ label, value }: { label: string; value: string }) { return <div className="rounded-lg bg-[var(--panel-surface)] border border-[var(--panel-border)] p-2 min-w-0"><div className="text-[9px] uppercase tracking-wider text-[var(--ui-secondary)] mb-0.5">{label}</div><div className="text-[12px] text-[var(--ui-primary)] truncate font-medium">{value}</div></div>; }