File size: 15,777 Bytes
6f44ee8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d78fc5
 
 
 
 
 
6f44ee8
 
5d78fc5
6f44ee8
 
 
5d78fc5
 
 
 
 
 
 
 
6f44ee8
5d78fc5
 
 
 
 
 
 
 
 
6f44ee8
5d78fc5
 
 
 
6f44ee8
5d78fc5
6f44ee8
5d78fc5
6f44ee8
 
5d78fc5
 
 
6f44ee8
 
 
 
 
 
 
 
 
5d78fc5
 
 
 
 
 
6f44ee8
 
 
5d78fc5
 
 
6f44ee8
 
5d78fc5
6f44ee8
 
5d78fc5
6f44ee8
 
 
5d78fc5
 
6f44ee8
 
 
5d78fc5
 
6f44ee8
 
 
5d78fc5
6f44ee8
5d78fc5
6f44ee8
5d78fc5
6f44ee8
 
 
 
 
 
 
 
 
5d78fc5
6f44ee8
5d78fc5
 
 
 
6f44ee8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import { useState, useEffect, useCallback, useRef } from 'react';
import { X, Search, Folder, Grid, Plus, Tag, Trash2, RefreshCw, Upload, Edit3, Copy, ExternalLink, ChevronLeft } from 'lucide-react';
import { useAppStore } from '../store';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

interface LibItem { id: string; url: string; source_url: string; title: string; data_url: string; hash: string; width: number; height: number; colors: string[]; tags: string[]; created_at: number; }

export const LibraryPanel = () => {
  const { isLibraryOpen, setIsLibraryOpen, setImages, pan, zoom } = useAppStore();
  const [search, setSearch] = useState('');
  const [activeTag, setActiveTag] = useState<string | null>(null);
  const [libraryItems, setLibraryItems] = useState<LibItem[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [allTags, setAllTags] = useState<string[]>([]);
  const [isDragOver, setIsDragOver] = useState(false);
  const [editingItem, setEditingItem] = useState<LibItem | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const rebuildTags = (items: LibItem[]) => {
    const tags = new Set<string>();
    items.forEach(item => (item.tags || []).forEach(t => tags.add(t)));
    setAllTags(Array.from(tags).sort());
  };

  const loadLibrary = useCallback(() => {
    setIsLoading(true);
    invoke<LibItem[]>('library_items').then(items => { setLibraryItems(items); rebuildTags(items); setIsLoading(false); }).catch(() => setIsLoading(false));
  }, []);

  useEffect(() => { if (isLibraryOpen) loadLibrary(); }, [isLibraryOpen, loadLibrary]);
  useEffect(() => { const unlisten = listen<any>('board://image_added', loadLibrary); return () => { unlisten.then(fn => fn()); }; }, [loadLibrary]);
  useEffect(() => { const handler = () => loadLibrary(); window.addEventListener('muse:library-refresh', handler); return () => window.removeEventListener('muse:library-refresh', handler); }, [loadLibrary]);

  const addToCanvas = (item: LibItem) => {
    const w = Math.min(500, item.width || 300);
    const h = item.height ? w * (item.height / item.width) : w;
    setImages(prev => [...prev, { id: crypto.randomUUID(), url: item.data_url || item.url, sourceUrl: item.source_url || item.url, x: (-pan.x + window.innerWidth / 3) / zoom, y: (-pan.y + window.innerHeight / 3) / zoom, width: w, height: h, aspectRatio: w / h }]);
  };

  const importFileAsDataUrl = (file: File) => {
    if (!file.type.startsWith('image/')) return;
    const reader = new FileReader();
    reader.onload = async (ev) => {
      const dataUrl = ev.target?.result as string;
      if (!dataUrl) return;
      try {
        const item = await invoke<LibItem>('library_import_data_url', { dataUrl, title: file.name.replace(/\.[^/.]+$/, '') });
        addToCanvas(item);
        loadLibrary();
      } catch (err) { console.error('Import failed:', err); }
    };
    reader.readAsDataURL(file);
  };

  const handleBrowserDrop = (e: React.DragEvent) => {
    e.preventDefault(); e.stopPropagation(); setIsDragOver(false);
    Array.from(e.dataTransfer.files).forEach(importFileAsDataUrl);
  };

  const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) Array.from(e.target.files).forEach(importFileAsDataUrl);
    e.target.value = '';
  };

  const handleDragStart = (e: React.DragEvent, item: LibItem) => {
    const payload = JSON.stringify({ id: item.id, data_url: item.data_url, width: item.width, height: item.height, title: item.title });
    e.dataTransfer.setData('application/x-muse-library-item', payload);
    e.dataTransfer.setData('text/plain', payload);
    e.dataTransfer.effectAllowed = 'copy';
  };

  const handleDelete = (id: string) => invoke('library_remove_item', { id }).then(() => { const next = libraryItems.filter(i => i.id !== id); setLibraryItems(next); rebuildTags(next); if (editingItem?.id === id) setEditingItem(null); });

  const updateItem = (updated: LibItem) => {
    const next = libraryItems.map(i => i.id === updated.id ? updated : i);
    setLibraryItems(next); rebuildTags(next); setEditingItem(updated);
  };

  const filtered = libraryItems.filter(img => {
    if (activeTag && !(img.tags || []).includes(activeTag)) return false;
    if (!search) return true;
    const q = search.toLowerCase();
    return (img.tags || []).some(t => t.toLowerCase().includes(q)) || (img.title || '').toLowerCase().includes(q) || (img.source_url || img.url || '').toLowerCase().includes(q);
  });

  if (editingItem) return <MetadataEditor item={editingItem} onClose={() => setEditingItem(null)} onUpdate={updateItem} onDelete={() => { handleDelete(editingItem.id); setEditingItem(null); }} onAddToCanvas={() => addToCanvas(editingItem)} isOpen={isLibraryOpen} onClosePanel={() => setIsLibraryOpen(false)} />;

  return (
    <div className={`absolute left-0 top-0 h-full w-[45%] max-w-[500px] bg-[#1C1C1E] shadow-2xl flex flex-col z-[60] transform transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] ${isLibraryOpen ? 'translate-x-0' : '-translate-x-full'}`} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; setIsDragOver(true); }} onDragLeave={() => setIsDragOver(false)} onDrop={handleBrowserDrop}>
      {isDragOver && <div className="absolute inset-0 z-50 bg-[#0A84FF]/10 border-2 border-dashed border-[#0A84FF] rounded-xl m-3 flex items-center justify-center pointer-events-none"><div className="text-[#0A84FF] font-semibold text-sm flex flex-col items-center gap-2"><Upload size={32} />Drop images to import</div></div>}

      <div className="flex items-center justify-between p-4 border-b border-[#3A3A3E]">
        <div className="flex items-center gap-2 text-[#E0E0E0] text-[14px] font-medium"><Folder size={16} className="text-[#0A84FF]" /> Asset Library <span className="text-[11px] text-[#808080] ml-1">({libraryItems.length})</span></div>
        <div className="flex items-center gap-1"><button onClick={loadLibrary} className="text-[#A0A0A0] hover:text-[#E0E0E0] p-1.5 rounded-md hover:bg-white/5" title="Refresh"><RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} /></button><button onClick={() => setIsLibraryOpen(false)} className="text-[#A0A0A0] hover:text-[#E0E0E0] p-1.5 rounded-md hover:bg-white/5"><X size={16} /></button></div>
      </div>

      <div className="px-4 py-3 bg-[#2A2A2E] flex flex-col gap-3">
        <div className="relative"><Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#808080]" /><input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Search by name, tag, or URL..." className="w-full bg-[#1C1C1E] text-[#E0E0E0] pl-9 pr-3 py-2 text-[13px] rounded-lg border border-[#3A3A3E] focus:border-[#0A84FF] outline-none placeholder:text-[#606060]" /></div>
        {allTags.length > 0 && <div className="flex items-center gap-1.5 overflow-x-auto pb-1 hide-scrollbar"><button onClick={() => setActiveTag(null)} className={`whitespace-nowrap px-3 py-1 rounded-full text-[11px] font-medium transition-colors ${!activeTag ? 'bg-[#0A84FF] text-white' : 'bg-[#3A3A3E] text-[#C0C0C0] hover:bg-[#4A4A4E]'}`}>All</button>{allTags.map(tag => <button key={tag} onClick={() => setActiveTag(activeTag === tag ? null : tag)} className={`whitespace-nowrap px-3 py-1 rounded-full text-[11px] font-medium flex items-center gap-1 transition-colors ${activeTag === tag ? 'bg-[#0A84FF] text-white' : 'bg-[#3A3A3E] text-[#C0C0C0] hover:bg-[#4A4A4E]'}`}><Tag size={10} />{tag}</button>)}</div>}
      </div>

      <div className="flex-1 overflow-y-auto bg-[#1C1C1E] p-4 custom-scrollbar">
        <div className="flex justify-between items-center mb-4"><span className="text-[11px] font-medium text-[#808080] uppercase tracking-widest">Images ({filtered.length})</span><Grid size={14} className="text-[#808080]" /></div>
        <div className="grid grid-cols-3 gap-3">
          <div onClick={() => fileInputRef.current?.click()} className="aspect-square bg-white/5 hover:bg-white/10 border border-dashed border-white/20 hover:border-[#0A84FF]/50 rounded-xl cursor-pointer flex flex-col items-center justify-center text-[#A0A0A0] hover:text-[#E0E0E0] transition-all group shadow-sm"><div className="w-8 h-8 rounded-full bg-black/20 flex items-center justify-center mb-2 group-hover:scale-110 transition-transform"><Plus size={16} /></div><span className="text-[11px] font-medium">Import Files</span><span className="text-[9px] text-[#606060] mt-0.5">or drag here</span></div>
          <input ref={fileInputRef} type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/avif" multiple className="hidden" onChange={handleFileUpload} />
          {filtered.map(img => <div key={img.id} className="aspect-square bg-[#2A2A2E] rounded-xl cursor-pointer group relative overflow-hidden ring-1 ring-[#3A3A3E] hover:ring-[#0A84FF] transition-all" draggable onDragStart={(e) => handleDragStart(e, img)} onClick={() => addToCanvas(img)}><img src={img.data_url || img.url} className="w-full h-full object-cover opacity-90 group-hover:opacity-100 group-hover:scale-105 transition-all duration-500" draggable={false} /><div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-between p-2 pointer-events-none"><div className="flex justify-end gap-1 pointer-events-auto"><button onClick={(e) => { e.stopPropagation(); setEditingItem(img); }} className="w-5 h-5 rounded bg-black/50 flex items-center justify-center text-white/80 hover:text-white hover:bg-black/70" title="Edit metadata"><Edit3 size={10} /></button><button onClick={(e) => { e.stopPropagation(); handleDelete(img.id); }} className="w-5 h-5 rounded bg-black/50 flex items-center justify-center text-red-400 hover:text-red-300 hover:bg-red-500/20" title="Delete"><Trash2 size={10} /></button></div><div className="pointer-events-auto"><span className="text-[10px] text-white font-medium truncate block">{img.title || 'Reference'}</span>{img.tags?.length > 0 && <div className="flex gap-1 mt-0.5 overflow-hidden">{img.tags.slice(0, 3).map(t => <span key={t} className="text-[9px] bg-white/20 text-white/90 px-1 rounded">{t}</span>)}</div>}<div className="text-[9px] text-white/50 mt-0.5">{img.width}×{img.height}</div></div></div>{img.colors?.length > 0 && <div className="absolute bottom-0 left-0 right-0 h-1 flex opacity-0 group-hover:opacity-100 transition-opacity">{img.colors.slice(0, 6).map((c, ci) => <div key={ci} className="flex-1" style={{ backgroundColor: c }} />)}</div>}</div>)}
          {filtered.length === 0 && !isLoading && <div className="col-span-3 text-center text-[#808080] py-16 text-sm flex flex-col items-center gap-3"><Folder size={32} className="opacity-20" /><p>Library is empty</p><p className="text-xs opacity-60">Drag files here, use Import, or capture from browser.</p></div>}
          {isLoading && <div className="col-span-3 text-center text-[#808080] py-12"><RefreshCw size={20} className="animate-spin mx-auto mb-2" />Loading...</div>}
        </div>
      </div>
    </div>
  );
};

function MetadataEditor({ item, onClose, onUpdate, onDelete, onAddToCanvas, isOpen, onClosePanel }: { item: LibItem; onClose: () => void; onUpdate: (item: LibItem) => void; onDelete: () => void; onAddToCanvas: () => void; isOpen: boolean; onClosePanel: () => void }) {
  const [title, setTitle] = useState(item.title); const [newTag, setNewTag] = useState('');
  useEffect(() => { setTitle(item.title); }, [item.id]);
  const saveTitle = () => { if (title.trim() === item.title) return; invoke<LibItem>('library_update_metadata', { id: item.id, title: title.trim() || null, tags: null }).then(onUpdate).catch(() => {}); };
  const addTag = () => { if (!newTag.trim()) return; invoke<LibItem>('library_add_tag', { id: item.id, tag: newTag.trim() }).then(updated => { onUpdate(updated); setNewTag(''); }).catch(() => {}); };
  const removeTag = (tag: string) => invoke<LibItem>('library_remove_tag', { id: item.id, tag }).then(onUpdate).catch(() => {});
  return <div className={`absolute left-0 top-0 h-full w-[45%] max-w-[500px] bg-[#1C1C1E] shadow-2xl flex flex-col z-[60] transform transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] ${isOpen ? 'translate-x-0' : '-translate-x-full'}`}><div className="flex items-center justify-between p-4 border-b border-[#3A3A3E]"><button onClick={onClose} className="flex items-center gap-2 text-[#0A84FF] text-[13px] font-medium"><ChevronLeft size={16} /> Back</button><button onClick={onClosePanel} className="text-[#A0A0A0] p-1.5"><X size={16} /></button></div><div className="h-[200px] bg-[#0D0D0F] flex items-center justify-center border-b border-[#3A3A3E]"><img src={item.data_url || item.url} className="max-w-full max-h-full object-contain" /></div><div className="flex-1 overflow-y-auto p-5 flex flex-col gap-5"><div><label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Title</label><input value={title} onChange={e => setTitle(e.target.value)} onBlur={saveTitle} onKeyDown={e => { if (e.key === 'Enter') saveTitle(); }} className="w-full bg-[#2A2A2E] text-[#E0E0E0] px-3 py-2 text-[13px] rounded-lg border border-[#3A3A3E] focus:border-[#0A84FF] outline-none" /></div><div><label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Dimensions</label><div className="text-[13px] text-[#C0C0C0]">{item.width} × {item.height} px</div></div><div><label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Tags</label><div className="flex flex-wrap gap-2 mb-2">{(item.tags || []).map(tag => <span key={tag} className="flex items-center gap-1 bg-[#3A3A3E] text-[#E0E0E0] px-2.5 py-1 rounded-full text-[11px] font-medium group"><Tag size={10} className="text-[#0A84FF]" />{tag}<button onClick={() => removeTag(tag)} className="ml-0.5 text-[#808080] hover:text-red-400"><X size={10} /></button></span>)}</div><div className="flex gap-2"><input value={newTag} onChange={e => setNewTag(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addTag(); }} placeholder="Add tag..." className="flex-1 bg-[#2A2A2E] text-[#E0E0E0] px-3 py-1.5 text-[12px] rounded-lg border border-[#3A3A3E] focus:border-[#0A84FF] outline-none" /><button onClick={addTag} disabled={!newTag.trim()} className="px-3 py-1.5 rounded-lg bg-[#0A84FF]/20 text-[#0A84FF] text-[12px] font-medium disabled:opacity-30">Add</button></div></div>{item.colors?.length > 0 && <div><label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Palette</label><div className="flex gap-2">{item.colors.map((c, i) => <button key={i} onClick={() => navigator.clipboard.writeText(c)} className="w-10 h-10 rounded-lg shadow-md hover:scale-110 transition-transform border border-white/10 relative group" style={{ backgroundColor: c }} title={c}><Copy size={10} className="absolute top-1 right-1 text-white opacity-0 group-hover:opacity-80" /></button>)}</div></div>}{item.source_url && <div><label className="text-[10px] font-bold text-[#808080] uppercase tracking-widest mb-1.5 block">Source</label><div className="flex items-center gap-2 text-[12px] text-[#0A84FF] truncate"><ExternalLink size={12} /><span className="truncate">{item.source_url}</span></div></div>}</div><div className="p-4 border-t border-[#3A3A3E] flex gap-2"><button onClick={onAddToCanvas} className="flex-1 py-2.5 rounded-xl bg-[#0A84FF] text-white text-[13px] font-semibold">Add to Canvas</button><button onClick={onDelete} className="px-4 py-2.5 rounded-xl bg-red-500/10 text-red-400 text-[13px] font-semibold"><Trash2 size={14} /></button></div></div>;
}