asdf98 commited on
Commit
6f44ee8
Β·
verified Β·
1 Parent(s): bf0bbb0

feat: LibraryPanel - OS drag-drop import, library-to-canvas drag, inline tag/metadata editor with proper UX

Browse files
Files changed (1) hide show
  1. src/components/LibraryPanel.tsx +326 -190
src/components/LibraryPanel.tsx CHANGED
@@ -1,190 +1,326 @@
1
- import { useState, useEffect, useCallback, useRef } from 'react';
2
- import { X, Search, Folder, Grid, Plus, Tag, Trash2, RefreshCw, Upload } 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
- export const LibraryPanel = () => {
8
- const { isLibraryOpen, setIsLibraryOpen, setImages, pan, zoom } = useAppStore();
9
- const [search, setSearch] = useState('');
10
- const [activeTag, setActiveTag] = useState<string | null>(null);
11
- const [libraryItems, setLibraryItems] = useState<any[]>([]);
12
- const [isLoading, setIsLoading] = useState(false);
13
- const [allTags, setAllTags] = useState<string[]>([]);
14
- const fileInputRef = useRef<HTMLInputElement>(null);
15
-
16
- const loadLibrary = useCallback(() => {
17
- setIsLoading(true);
18
- invoke<any[]>('library_items').then(items => {
19
- setLibraryItems(items);
20
- const tags = new Set<string>();
21
- items.forEach(item => (item.tags || []).forEach((t: string) => tags.add(t)));
22
- setAllTags(Array.from(tags).sort());
23
- setIsLoading(false);
24
- }).catch(() => setIsLoading(false));
25
- }, []);
26
-
27
- useEffect(() => { if (isLibraryOpen) loadLibrary(); }, [isLibraryOpen, loadLibrary]);
28
-
29
- // Listen for new images added from browser hover overlay
30
- useEffect(() => {
31
- const unlisten = listen<any>('board://image_added', () => { loadLibrary(); });
32
- return () => { unlisten.then(fn => fn()); };
33
- }, [loadLibrary]);
34
-
35
- const handleAddToCanvas = (item: any) => {
36
- const w = Math.min(500, item.width || 300);
37
- const h = item.height ? w * (item.height / item.width) : w;
38
- setImages(prev => [...prev, {
39
- id: crypto.randomUUID(),
40
- url: item.data_url || item.url,
41
- x: (-pan.x + window.innerWidth / 3) / zoom,
42
- y: (-pan.y + window.innerHeight / 3) / zoom,
43
- width: w, height: h, aspectRatio: w / h,
44
- }]);
45
- };
46
-
47
- const handleDelete = (id: string) => {
48
- invoke('library_remove_item', { id }).then(() => {
49
- setLibraryItems(prev => prev.filter(i => i.id !== id));
50
- }).catch(() => {});
51
- };
52
-
53
- const handleAddTag = (id: string) => {
54
- const tag = prompt('Enter tag name:');
55
- if (tag && tag.trim()) {
56
- invoke('library_add_tag', { id, tag: tag.trim() }).then(() => loadLibrary()).catch(() => {});
57
- }
58
- };
59
-
60
- // Upload local files to library
61
- const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
62
- const files = e.target.files;
63
- if (!files) return;
64
- Array.from(files).forEach(file => {
65
- if (!file.type.startsWith('image/')) return;
66
- const reader = new FileReader();
67
- reader.onload = (ev) => {
68
- const dataUrl = ev.target?.result as string;
69
- if (!dataUrl) return;
70
- const img = new Image();
71
- img.onload = () => {
72
- // Add to canvas directly
73
- const w = Math.min(500, img.width);
74
- const h = w * (img.height / img.width);
75
- setImages(prev => [...prev, {
76
- id: crypto.randomUUID(),
77
- url: dataUrl,
78
- x: (-pan.x + window.innerWidth / 3 + Math.random() * 100) / zoom,
79
- y: (-pan.y + window.innerHeight / 3 + Math.random() * 100) / zoom,
80
- width: w, height: h, aspectRatio: w / h,
81
- }]);
82
- };
83
- img.src = dataUrl;
84
- };
85
- reader.readAsDataURL(file);
86
- });
87
- if (e.target) e.target.value = '';
88
- };
89
-
90
- const filtered = libraryItems.filter(img => {
91
- if (activeTag && !(img.tags || []).includes(activeTag)) return false;
92
- if (search) {
93
- const q = search.toLowerCase();
94
- const matchesTag = (img.tags || []).some((t: string) => t.toLowerCase().includes(q));
95
- const matchesTitle = (img.title || '').toLowerCase().includes(q);
96
- const matchesUrl = (img.source_url || img.url || '').toLowerCase().includes(q);
97
- if (!matchesTag && !matchesTitle && !matchesUrl) return false;
98
- }
99
- return true;
100
- });
101
-
102
- return (
103
- <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'}`}>
104
- {/* Header */}
105
- <div className="flex items-center justify-between p-4 border-b border-[#3A3A3E]">
106
- <div className="flex items-center gap-2 text-[#E0E0E0] text-[14px] font-medium">
107
- <Folder size={16} className="text-[#0A84FF]" /> Asset Library
108
- <span className="text-[11px] text-[#808080] ml-1">({libraryItems.length})</span>
109
- </div>
110
- <div className="flex items-center gap-1">
111
- <button onClick={loadLibrary} className="text-[#A0A0A0] hover:text-[#E0E0E0] p-1.5 rounded-md hover:bg-white/5" title="Refresh">
112
- <RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
113
- </button>
114
- <button onClick={() => setIsLibraryOpen(false)} className="text-[#A0A0A0] hover:text-[#E0E0E0] p-1.5 rounded-md hover:bg-white/5">
115
- <X size={16} />
116
- </button>
117
- </div>
118
- </div>
119
-
120
- {/* Search + Tags */}
121
- <div className="px-4 py-3 bg-[#2A2A2E] flex flex-col gap-3">
122
- <div className="relative">
123
- <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#808080]" />
124
- <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]" />
125
- </div>
126
- {allTags.length > 0 && (
127
- <div className="flex items-center gap-1.5 overflow-x-auto pb-1 hide-scrollbar">
128
- <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>
129
- {allTags.map(tag => (
130
- <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]'}`}>
131
- <Tag size={10} />{tag}
132
- </button>
133
- ))}
134
- </div>
135
- )}
136
- </div>
137
-
138
- {/* Grid */}
139
- <div className="flex-1 overflow-y-auto bg-[#1C1C1E] p-4 custom-scrollbar">
140
- <div className="flex justify-between items-center mb-4">
141
- <span className="text-[11px] font-medium text-[#808080] uppercase tracking-widest">Images ({filtered.length})</span>
142
- <Grid size={14} className="text-[#808080]" />
143
- </div>
144
- <div className="grid grid-cols-3 gap-3">
145
- {/* Upload File Button β€” matching ui2 prototype */}
146
- <div
147
- onClick={() => fileInputRef.current?.click()}
148
- className="aspect-square bg-white/5 hover:bg-white/10 border border-dashed border-white/20 hover:border-white/30 rounded-xl cursor-pointer flex flex-col items-center justify-center text-[#A0A0A0] hover:text-[#E0E0E0] transition-all group shadow-sm"
149
- >
150
- <div className="w-8 h-8 rounded-full bg-black/20 flex items-center justify-center mb-2 group-hover:scale-110 transition-transform">
151
- <Plus size={16} />
152
- </div>
153
- <span className="text-[11px] font-medium">Upload File</span>
154
- </div>
155
- <input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleFileUpload} />
156
-
157
- {filtered.map((img, i) => (
158
- <div key={img.id || i} className="aspect-square bg-[#2A2A2E] rounded-xl cursor-pointer group relative overflow-hidden ring-1 ring-[#3A3A3E] hover:ring-[#0A84FF] transition-all" onClick={() => handleAddToCanvas(img)}>
159
- <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} />
160
- <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">
161
- <div className="flex justify-end gap-1 pointer-events-auto">
162
- <button onClick={(e) => { e.stopPropagation(); handleAddTag(img.id); }} 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="Add Tag"><Tag size={10} /></button>
163
- <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>
164
- </div>
165
- <div className="pointer-events-auto">
166
- <span className="text-[10px] text-white font-medium truncate block">{img.title || 'Reference'}</span>
167
- {img.tags?.length > 0 && <div className="flex gap-1 mt-0.5 overflow-hidden">{img.tags.slice(0, 2).map((t: string) => <span key={t} className="text-[9px] bg-white/20 text-white/90 px-1 rounded">{t}</span>)}</div>}
168
- <div className="text-[9px] text-white/50 mt-0.5">{img.width}Γ—{img.height} β€’ Click to add</div>
169
- </div>
170
- </div>
171
- {img.colors?.length > 0 && (
172
- <div className="absolute bottom-0 left-0 right-0 h-1 flex opacity-0 group-hover:opacity-100 transition-opacity">
173
- {img.colors.slice(0, 6).map((c: string, ci: number) => <div key={ci} className="flex-1" style={{ backgroundColor: c }} />)}
174
- </div>
175
- )}
176
- </div>
177
- ))}
178
- {filtered.length === 0 && !isLoading && (
179
- <div className="col-span-3 text-center text-[#808080] py-16 text-sm flex flex-col items-center gap-3">
180
- <Folder size={32} className="opacity-20" />
181
- <p>Library is empty</p>
182
- <p className="text-xs opacity-60">Use the Browser panel to capture images, or click Upload File above.</p>
183
- </div>
184
- )}
185
- {isLoading && <div className="col-span-3 text-center text-[#808080] py-12"><RefreshCw size={20} className="animate-spin mx-auto mb-2" />Loading...</div>}
186
- </div>
187
- </div>
188
- </div>
189
- );
190
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import { getCurrentWindow } from '@tauri-apps/api/window';
7
+
8
+ 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; }
9
+
10
+ export const LibraryPanel = () => {
11
+ const { isLibraryOpen, setIsLibraryOpen, setImages, pan, zoom } = useAppStore();
12
+ const [search, setSearch] = useState('');
13
+ const [activeTag, setActiveTag] = useState<string | null>(null);
14
+ const [libraryItems, setLibraryItems] = useState<LibItem[]>([]);
15
+ const [isLoading, setIsLoading] = useState(false);
16
+ const [allTags, setAllTags] = useState<string[]>([]);
17
+ const [isDragOver, setIsDragOver] = useState(false);
18
+ const [editingItem, setEditingItem] = useState<LibItem | null>(null);
19
+ const fileInputRef = useRef<HTMLInputElement>(null);
20
+
21
+ const loadLibrary = useCallback(() => {
22
+ setIsLoading(true);
23
+ invoke<LibItem[]>('library_items').then(items => {
24
+ setLibraryItems(items);
25
+ const tags = new Set<string>();
26
+ items.forEach(item => (item.tags || []).forEach(t => tags.add(t)));
27
+ setAllTags(Array.from(tags).sort());
28
+ setIsLoading(false);
29
+ }).catch(() => setIsLoading(false));
30
+ }, []);
31
+
32
+ useEffect(() => { if (isLibraryOpen) loadLibrary(); }, [isLibraryOpen, loadLibrary]);
33
+ useEffect(() => { const unlisten = listen<any>('board://image_added', () => loadLibrary()); return () => { unlisten.then(fn => fn()); }; }, [loadLibrary]);
34
+
35
+ // ─── OS-level file drop via Tauri drag-drop event (works on Windows/Mac/Linux) ───
36
+ useEffect(() => {
37
+ const unlisten = getCurrentWindow().onDragDropEvent(async (event) => {
38
+ if (event.payload.type === 'over') { setIsDragOver(true); }
39
+ else if (event.payload.type === 'leave' || event.payload.type === 'cancel') { setIsDragOver(false); }
40
+ else if (event.payload.type === 'drop') {
41
+ setIsDragOver(false);
42
+ const paths = event.payload.paths;
43
+ if (!paths || paths.length === 0) return;
44
+ const imageExts = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.avif', '.tiff'];
45
+ for (const filePath of paths) {
46
+ const lower = filePath.toLowerCase();
47
+ if (!imageExts.some(ext => lower.endsWith(ext))) continue;
48
+ try {
49
+ const item = await invoke<LibItem>('library_import_local', { path: filePath });
50
+ // Add to canvas immediately
51
+ const w = Math.min(500, item.width || 300);
52
+ const h = item.height ? w * (item.height / item.width) : w;
53
+ setImages(prev => [...prev, {
54
+ id: crypto.randomUUID(),
55
+ url: item.data_url,
56
+ x: (-pan.x + window.innerWidth / 3 + Math.random() * 120) / zoom,
57
+ y: (-pan.y + window.innerHeight / 3 + Math.random() * 120) / zoom,
58
+ width: w, height: h, aspectRatio: w / h,
59
+ }]);
60
+ } catch (err) { console.error(`Failed to import ${filePath}:`, err); }
61
+ }
62
+ loadLibrary();
63
+ }
64
+ });
65
+ return () => { unlisten.then(fn => fn()); };
66
+ }, [pan, zoom, loadLibrary, setImages]);
67
+
68
+ // ─── HTML5 drag-drop fallback (browser-based file drop) ───
69
+ const handleBrowserDrop = useCallback(async (e: React.DragEvent) => {
70
+ e.preventDefault(); e.stopPropagation(); setIsDragOver(false);
71
+ const files = Array.from(e.dataTransfer.files);
72
+ for (const file of files) {
73
+ if (!file.type.startsWith('image/')) continue;
74
+ const reader = new FileReader();
75
+ reader.onload = async (ev) => {
76
+ const dataUrl = ev.target?.result as string;
77
+ if (!dataUrl) return;
78
+ try {
79
+ const item = await invoke<LibItem>('library_import_data_url', { dataUrl, title: file.name.replace(/\.[^/.]+$/, '') });
80
+ const w = Math.min(500, item.width || 300);
81
+ const h = item.height ? w * (item.height / item.width) : w;
82
+ setImages(prev => [...prev, { id: crypto.randomUUID(), url: item.data_url, x: (-pan.x + window.innerWidth / 3 + Math.random() * 100) / zoom, y: (-pan.y + window.innerHeight / 3 + Math.random() * 100) / zoom, width: w, height: h, aspectRatio: w / h }]);
83
+ } catch (err) { console.error('Import failed:', err); }
84
+ };
85
+ reader.readAsDataURL(file);
86
+ }
87
+ loadLibrary();
88
+ }, [pan, zoom, setImages, loadLibrary]);
89
+
90
+ // ─── File input upload ───
91
+ const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
92
+ const files = e.target.files; if (!files) return;
93
+ Array.from(files).forEach(file => {
94
+ if (!file.type.startsWith('image/')) return;
95
+ const reader = new FileReader();
96
+ reader.onload = async (ev) => {
97
+ const dataUrl = ev.target?.result as string; if (!dataUrl) return;
98
+ try {
99
+ const item = await invoke<LibItem>('library_import_data_url', { dataUrl, title: file.name.replace(/\.[^/.]+$/, '') });
100
+ const w = Math.min(500, item.width || 300);
101
+ const h = item.height ? w * (item.height / item.width) : w;
102
+ setImages(prev => [...prev, { id: crypto.randomUUID(), url: item.data_url, x: (-pan.x + window.innerWidth / 3 + Math.random() * 100) / zoom, y: (-pan.y + window.innerHeight / 3 + Math.random() * 100) / zoom, width: w, height: h, aspectRatio: w / h }]);
103
+ loadLibrary();
104
+ } catch (err) { console.error('Upload failed:', err); }
105
+ };
106
+ reader.readAsDataURL(file);
107
+ });
108
+ if (e.target) e.target.value = '';
109
+ };
110
+
111
+ // ─── Add to canvas ───
112
+ const handleAddToCanvas = (item: LibItem) => {
113
+ const w = Math.min(500, item.width || 300);
114
+ const h = item.height ? w * (item.height / item.width) : w;
115
+ setImages(prev => [...prev, { id: crypto.randomUUID(), url: item.data_url || item.url, x: (-pan.x + window.innerWidth / 3) / zoom, y: (-pan.y + window.innerHeight / 3) / zoom, width: w, height: h, aspectRatio: w / h }]);
116
+ };
117
+
118
+ // ─── Library-to-canvas drag ───
119
+ const handleDragStart = (e: React.DragEvent, item: LibItem) => {
120
+ const payload = JSON.stringify({ id: item.id, data_url: item.data_url, width: item.width, height: item.height, title: item.title });
121
+ e.dataTransfer.setData('application/x-muse-library-item', payload);
122
+ e.dataTransfer.setData('text/plain', payload);
123
+ e.dataTransfer.effectAllowed = 'copy';
124
+ };
125
+
126
+ const handleDelete = (id: string) => { invoke('library_remove_item', { id }).then(() => { setLibraryItems(prev => prev.filter(i => i.id !== id)); if (editingItem?.id === id) setEditingItem(null); }); };
127
+
128
+ const filtered = libraryItems.filter(img => {
129
+ if (activeTag && !(img.tags || []).includes(activeTag)) return false;
130
+ if (search) { 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); }
131
+ return true;
132
+ });
133
+
134
+ // ─── Detail / Metadata Editor ───
135
+ if (editingItem) {
136
+ return <MetadataEditor item={editingItem} onClose={() => setEditingItem(null)} onUpdate={(updated) => { setEditingItem(updated); setLibraryItems(prev => prev.map(i => i.id === updated.id ? updated : i)); const tags = new Set<string>(); libraryItems.forEach(item => (item.id === updated.id ? updated : item).tags.forEach(t => tags.add(t))); setAllTags(Array.from(tags).sort()); }} onDelete={() => { handleDelete(editingItem.id); setEditingItem(null); }} onAddToCanvas={() => handleAddToCanvas(editingItem)} isOpen={isLibraryOpen} onClosePanel={() => setIsLibraryOpen(false)} />;
137
+ }
138
+
139
+ return (
140
+ <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'}`}
141
+ onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; setIsDragOver(true); }}
142
+ onDragLeave={() => setIsDragOver(false)}
143
+ onDrop={handleBrowserDrop}
144
+ >
145
+ {/* Drop overlay */}
146
+ {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>}
147
+
148
+ {/* Header */}
149
+ <div className="flex items-center justify-between p-4 border-b border-[#3A3A3E]">
150
+ <div className="flex items-center gap-2 text-[#E0E0E0] text-[14px] font-medium">
151
+ <Folder size={16} className="text-[#0A84FF]" /> Asset Library
152
+ <span className="text-[11px] text-[#808080] ml-1">({libraryItems.length})</span>
153
+ </div>
154
+ <div className="flex items-center gap-1">
155
+ <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>
156
+ <button onClick={() => setIsLibraryOpen(false)} className="text-[#A0A0A0] hover:text-[#E0E0E0] p-1.5 rounded-md hover:bg-white/5"><X size={16} /></button>
157
+ </div>
158
+ </div>
159
+
160
+ {/* Search + Tags */}
161
+ <div className="px-4 py-3 bg-[#2A2A2E] flex flex-col gap-3">
162
+ <div className="relative">
163
+ <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#808080]" />
164
+ <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]" />
165
+ </div>
166
+ {allTags.length > 0 && (
167
+ <div className="flex items-center gap-1.5 overflow-x-auto pb-1 hide-scrollbar">
168
+ <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>
169
+ {allTags.map(tag => (
170
+ <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>
171
+ ))}
172
+ </div>
173
+ )}
174
+ </div>
175
+
176
+ {/* Grid */}
177
+ <div className="flex-1 overflow-y-auto bg-[#1C1C1E] p-4 custom-scrollbar">
178
+ <div className="flex justify-between items-center mb-4">
179
+ <span className="text-[11px] font-medium text-[#808080] uppercase tracking-widest">Images ({filtered.length})</span>
180
+ <Grid size={14} className="text-[#808080]" />
181
+ </div>
182
+ <div className="grid grid-cols-3 gap-3">
183
+ {/* Upload / Drop zone tile */}
184
+ <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">
185
+ <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>
186
+ <span className="text-[11px] font-medium">Import Files</span>
187
+ <span className="text-[9px] text-[#606060] mt-0.5">or drag & drop</span>
188
+ </div>
189
+ <input ref={fileInputRef} type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/avif" multiple className="hidden" onChange={handleFileUpload} />
190
+
191
+ {filtered.map((img) => (
192
+ <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"
193
+ draggable onDragStart={(e) => handleDragStart(e, img)} onClick={() => handleAddToCanvas(img)}
194
+ >
195
+ <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} />
196
+ <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">
197
+ <div className="flex justify-end gap-1 pointer-events-auto">
198
+ <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>
199
+ <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>
200
+ </div>
201
+ <div className="pointer-events-auto">
202
+ <span className="text-[10px] text-white font-medium truncate block">{img.title || 'Reference'}</span>
203
+ {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>}
204
+ <div className="text-[9px] text-white/50 mt-0.5">{img.width}Γ—{img.height}</div>
205
+ </div>
206
+ </div>
207
+ {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>}
208
+ </div>
209
+ ))}
210
+ {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>}
211
+ {isLoading && <div className="col-span-3 text-center text-[#808080] py-12"><RefreshCw size={20} className="animate-spin mx-auto mb-2" />Loading...</div>}
212
+ </div>
213
+ </div>
214
+ </div>
215
+ );
216
+ };
217
+
218
+ // ─── Metadata Editor Sub-panel ───────────────────────────────────────────────
219
+ 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 }) {
220
+ const [title, setTitle] = useState(item.title);
221
+ const [newTag, setNewTag] = useState('');
222
+ const [saving, setSaving] = useState(false);
223
+
224
+ useEffect(() => { setTitle(item.title); }, [item.id]);
225
+
226
+ const saveTitle = () => {
227
+ if (title.trim() === item.title) return;
228
+ setSaving(true);
229
+ invoke<LibItem>('library_update_metadata', { id: item.id, title: title.trim() || null, tags: null }).then(updated => { onUpdate(updated); setSaving(false); }).catch(() => setSaving(false));
230
+ };
231
+
232
+ const addTag = () => {
233
+ if (!newTag.trim()) return;
234
+ invoke<LibItem>('library_add_tag', { id: item.id, tag: newTag.trim() }).then(updated => { onUpdate(updated); setNewTag(''); }).catch(() => {});
235
+ };
236
+
237
+ const removeTag = (tag: string) => {
238
+ invoke<LibItem>('library_remove_tag', { id: item.id, tag }).then(updated => { onUpdate(updated); }).catch(() => {});
239
+ };
240
+
241
+ const copyColor = (hex: string) => { navigator.clipboard.writeText(hex); };
242
+
243
+ return (
244
+ <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'}`}>
245
+ {/* Header */}
246
+ <div className="flex items-center justify-between p-4 border-b border-[#3A3A3E]">
247
+ <button onClick={onClose} className="flex items-center gap-2 text-[#0A84FF] text-[13px] font-medium hover:text-[#5AAFFF]"><ChevronLeft size={16} /> Back to Library</button>
248
+ <button onClick={onClosePanel} className="text-[#A0A0A0] hover:text-[#E0E0E0] p-1.5 rounded-md hover:bg-white/5"><X size={16} /></button>
249
+ </div>
250
+
251
+ {/* Preview */}
252
+ <div className="h-[200px] bg-[#0D0D0F] flex items-center justify-center border-b border-[#3A3A3E] shrink-0">
253
+ <img src={item.data_url || item.url} className="max-w-full max-h-full object-contain" />
254
+ </div>
255
+
256
+ {/* Metadata form */}
257
+ <div className="flex-1 overflow-y-auto p-5 flex flex-col gap-5">
258
+ {/* Title */}
259
+ <div>
260
+ <label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Title</label>
261
+ <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" />
262
+ </div>
263
+
264
+ {/* Dimensions */}
265
+ <div>
266
+ <label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Dimensions</label>
267
+ <div className="text-[13px] text-[#C0C0C0]">{item.width} Γ— {item.height} px</div>
268
+ </div>
269
+
270
+ {/* Tags */}
271
+ <div>
272
+ <label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Tags</label>
273
+ <div className="flex flex-wrap gap-2 mb-2">
274
+ {(item.tags || []).map(tag => (
275
+ <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">
276
+ <Tag size={10} className="text-[#0A84FF]" />{tag}
277
+ <button onClick={() => removeTag(tag)} className="ml-0.5 text-[#808080] hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"><X size={10} /></button>
278
+ </span>
279
+ ))}
280
+ </div>
281
+ <div className="flex gap-2">
282
+ <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 placeholder:text-[#606060]" />
283
+ <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>
284
+ </div>
285
+ </div>
286
+
287
+ {/* Colors */}
288
+ {item.colors?.length > 0 && (
289
+ <div>
290
+ <label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Extracted Palette</label>
291
+ <div className="flex gap-2">
292
+ {item.colors.map((c, i) => (
293
+ <button key={i} onClick={() => copyColor(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} β€” click to copy`}>
294
+ <Copy size={10} className="absolute top-1 right-1 text-white opacity-0 group-hover:opacity-80" />
295
+ </button>
296
+ ))}
297
+ </div>
298
+ <p className="text-[9px] text-[#606060] mt-1">Click swatch to copy HEX</p>
299
+ </div>
300
+ )}
301
+
302
+ {/* Source URL */}
303
+ {item.source_url && (
304
+ <div>
305
+ <label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Source</label>
306
+ <div className="flex items-center gap-2 text-[12px] text-[#0A84FF] truncate">
307
+ <ExternalLink size={12} /><span className="truncate">{item.source_url}</span>
308
+ </div>
309
+ </div>
310
+ )}
311
+
312
+ {/* Date */}
313
+ <div>
314
+ <label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Added</label>
315
+ <div className="text-[12px] text-[#A0A0A0]">{new Date(item.created_at * 1000).toLocaleString()}</div>
316
+ </div>
317
+ </div>
318
+
319
+ {/* Actions */}
320
+ <div className="p-4 border-t border-[#3A3A3E] flex gap-2">
321
+ <button onClick={onAddToCanvas} className="flex-1 py-2.5 rounded-xl bg-[#0A84FF] text-white text-[13px] font-semibold hover:bg-[#0A84FF]/90 transition-colors">Add to Canvas</button>
322
+ <button onClick={onDelete} className="px-4 py-2.5 rounded-xl bg-red-500/10 text-red-400 text-[13px] font-semibold hover:bg-red-500/20 transition-colors"><Trash2 size={14} /></button>
323
+ </div>
324
+ </div>
325
+ );
326
+ }