feat: refine library UI/UX, import flow, filtering, and performance
Browse files- 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 => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 (!
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 59 |
addToCanvas(item, true);
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 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-[
|
| 105 |
-
{isDragOver && <div className="absolute inset-0 z-50 bg-[
|
| 106 |
|
| 107 |
-
<div className="flex items-center justify-between px-
|
| 108 |
-
<div className="flex items-center gap-2 text-[
|
| 109 |
-
<div className="flex items-center gap-1"><button onClick={loadLibrary} className="text-[
|
| 110 |
</div>
|
| 111 |
|
| 112 |
-
<div className="px-
|
| 113 |
-
<div className="relative"><Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
</div>
|
| 116 |
|
| 117 |
-
<div className="flex-1 overflow-y-auto
|
| 118 |
-
<div className="flex justify-between items-center mb-
|
| 119 |
-
<div className="grid grid-cols-3 gap-2
|
| 120 |
-
<
|
| 121 |
<input ref={fileInputRef} type="file" accept={FILE_ACCEPT} multiple className="hidden" onChange={handleFileUpload} />
|
| 122 |
-
{filtered.map(img => <
|
| 123 |
-
{filtered.length === 0 && !isLoading && <div className="col-span-3 text-center text-[
|
| 124 |
-
{isLoading && <div className="col-span-3 text-center text-[
|
| 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 = () => {
|
| 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-[
|
| 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>; }
|