asdf98 commited on
Commit
b287e40
·
verified ·
1 Parent(s): 87679d6

feat: wire theme selection in Settings Appearance tab with live preview cards

Browse files
Files changed (1) hide show
  1. src/components/SettingsPanel.tsx +53 -72
src/components/SettingsPanel.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import type React from 'react';
2
- import { X, Settings, Keyboard, Monitor, Shield, HardDrive, Info, RefreshCw, KeyRound, Lock, Unlock, Trash2, Plus, Navigation, Grid3X3, Database, Palette, Clock, MousePointer2, FolderOpen, FileDown, Image as ImageIcon, LayoutGrid } from 'lucide-react';
3
- import { useAppStore } from '../store';
4
  import { useState, useEffect } from 'react';
5
  import { invoke } from '@tauri-apps/api/core';
6
  import { unlockVault, lockVault, isVaultUnlocked, saveCredential, listCredentials, deleteCredential, type CredentialSummary } from '../credentialsVault';
@@ -8,11 +8,16 @@ import { unlockVault, lockVault, isVaultUnlocked, saveCredential, listCredential
8
  type Tab = 'general' | 'appearance' | 'shortcuts' | 'privacy' | 'vault' | 'storage' | 'about';
9
  interface ShieldReport { blocked_requests: number; blocked_cosmetic: number; https_upgrades: number; engine_rules: number; }
10
  interface StorageInfo { data_path: string; total_size_bytes: number; file_count: number; project_count: number; library_count: number; library_size_bytes: number; projects_size_bytes: number; }
11
-
12
  function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return (bytes / Math.pow(k, i)).toFixed(i > 0 ? 1 : 0) + ' ' + sizes[i]; }
13
 
 
 
 
 
 
 
14
  export const SettingsPanel = () => {
15
- const { isSettingsOpen, setIsSettingsOpen, isAlwaysOnTop, setIsAlwaysOnTop, bgOpacity, setBgOpacity, showMinimap, setShowMinimap, showGrid, setShowGrid, images, textNotes, annotations, palettes, zoom, pan, boardTitle } = useAppStore();
16
  const [activeTab, setActiveTab] = useState<Tab>('general');
17
  const [shieldReport, setShieldReport] = useState<ShieldReport>({ blocked_requests: 0, blocked_cosmetic: 0, https_upgrades: 0, engine_rules: 0 });
18
  const [vaultUnlocked, setVaultUnlocked] = useState(isVaultUnlocked());
@@ -30,90 +35,66 @@ export const SettingsPanel = () => {
30
  useEffect(() => { if (isSettingsOpen && activeTab === 'storage') loadStorageInfo(); }, [isSettingsOpen, activeTab]);
31
  const reloadCreds = () => listCredentials().then(setCreds).catch(e => setVaultMsg(String(e)));
32
  const loadStorageInfo = () => { setStorageLoading(true); invoke<StorageInfo>('storage_info').then(setStorageInfo).catch(() => {}).finally(() => setStorageLoading(false)); };
33
-
34
- const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
35
- { id: 'general', label: 'General', icon: <Settings size={16} /> },
36
- { id: 'appearance', label: 'Appearance', icon: <Monitor size={16} /> },
37
- { id: 'shortcuts', label: 'Shortcuts', icon: <Keyboard size={16} /> },
38
- { id: 'privacy', label: 'Privacy', icon: <Shield size={16} /> },
39
- { id: 'vault', label: 'Password Vault', icon: <KeyRound size={16} /> },
40
- { id: 'storage', label: 'Storage', icon: <HardDrive size={16} /> },
41
- { id: 'about', label: 'About', icon: <Info size={16} /> },
42
- ];
43
-
44
  const unlock = async () => { try { await unlockVault(master); setMaster(''); setVaultUnlocked(true); setVaultMsg('Vault unlocked'); await reloadCreds(); } catch (e) { setVaultMsg(`Unlock failed: ${String(e)}`); } };
45
  const saveCred = async () => { try { await saveCredential({ origin, username, password }); setOrigin(''); setUsername(''); setPassword(''); setVaultMsg('Credential saved'); await reloadCreds(); } catch (e) { setVaultMsg(`Save failed: ${String(e)}`); } };
46
-
47
  const handleClearLibrary = async () => { if (!confirm('Clear all images from the asset library? This cannot be undone.')) return; try { await invoke('storage_clear_library'); setStorageMsg('Library cleared'); loadStorageInfo(); } catch (e) { setStorageMsg(`Failed: ${e}`); } };
48
- const handleClearProjects = async () => { if (!confirm('Delete ALL saved projects? This cannot be undone. The current board will remain in memory but will not be recoverable after closing.')) return; try { await invoke('storage_clear_projects'); setStorageMsg('Projects cleared'); loadStorageInfo(); } catch (e) { setStorageMsg(`Failed: ${e}`); } };
49
  const handleRevealFolder = () => invoke('storage_reveal_folder').catch(e => setStorageMsg(`Failed: ${e}`));
50
- const handleExportRefs = async () => {
51
- const state = JSON.stringify({ textNotes: [], images, annotations: [], palettes: [], zoom, pan, title: boardTitle });
52
- try {
53
- const bytes = await invoke<number[]>('refs_export', { stateJson: state });
54
- const blob = new Blob([new Uint8Array(bytes)], { type: 'application/octet-stream' });
55
- const url = URL.createObjectURL(blob);
56
- const a = document.createElement('a'); a.href = url; a.download = `${boardTitle.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() || 'board'}.refs`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
57
- setStorageMsg('Board exported as .refs file');
58
- } catch (e) { setStorageMsg(`Export failed: ${e}`); }
59
- };
60
 
61
  return (
62
- <div className={`absolute right-4 top-4 bottom-4 w-[min(720px,calc(100vw-32px))] z-[80] bg-[#1C1C1E]/98 shadow-2xl border border-[#3A3A3E] rounded-2xl flex flex-col transform transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] overflow-hidden ${isSettingsOpen ? 'translate-x-0' : 'translate-x-[calc(100%+32px)]'}`}>
63
- <div className="h-14 border-b border-white/5 flex items-center justify-between px-5 bg-black/20 shrink-0"><h2 className="text-[#E0E0E0] font-medium flex items-center gap-2"><Settings size={16} className="text-[#808080]" /> Settings</h2><button onClick={() => setIsSettingsOpen(false)} className="text-[#808080] hover:text-[#E0E0E0] p-1 rounded-md hover:bg-white/5"><X size={18} /></button></div>
64
  <div className="flex flex-1 min-h-0 overflow-hidden">
65
- <nav className="w-[190px] bg-black/10 border-r border-white/5 p-3 flex flex-col gap-1 shrink-0 overflow-y-auto custom-scrollbar">{tabs.map(tab => <button key={tab.id} onClick={() => setActiveTab(tab.id)} className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13px] text-left transition-colors ${activeTab === tab.id ? 'bg-[#0A84FF] text-white font-medium shadow-lg shadow-[#0A84FF]/10' : 'text-[#808080] hover:text-[#E0E0E0] hover:bg-white/5'}`}>{tab.icon}{tab.label}</button>)}</nav>
66
- <main className="flex-1 min-w-0 overflow-y-auto custom-scrollbar"><div className="p-6 pb-10 text-[#E0E0E0] text-sm max-w-[520px] mx-auto">
67
- {activeTab === 'general' && <Page title="General" subtitle="Core behavior for the canvas, window, and workspace."><SettingsGroup title="Startup & Window"><SettingRow label="Open last board on startup" description="Load the most recent project automatically when Refstudio starts." active={true} onToggle={() => {}} disabled /><SettingRow label="Always on Top" description="Keep Refstudio above other apps while drawing in Photoshop, Krita, Blender, or ZBrush." active={isAlwaysOnTop} onToggle={() => setIsAlwaysOnTop(!isAlwaysOnTop)} />{isAlwaysOnTop && <RangeRow label="Overlay opacity" description="Adjust transparency while Refstudio is pinned above another app." value={bgOpacity} min={10} max={100} suffix="%" onChange={setBgOpacity} />}</SettingsGroup><SettingsGroup title="Canvas"><SettingRow icon={<Grid3X3 size={16} />} label="Grid" description="Show the subtle dotted canvas grid. This is intentionally kept in Settings, not the floating toolbar." active={showGrid} onToggle={() => setShowGrid(!showGrid)} /><SettingRow icon={<Navigation size={16} />} label="Navigator / Minimap" description="Show a compact board overview in the bottom-right corner. Off by default to keep the canvas clean." active={showMinimap} onToggle={() => setShowMinimap(!showMinimap)} /></SettingsGroup></Page>}
68
- {activeTab === 'appearance' && <Page title="Appearance" subtitle="Visual defaults for artist-friendly, low-noise reference work."><SettingsGroup title="Theme"><div className="p-4 grid grid-cols-2 gap-3"><ThemeCard active name="Dark Canvas" colors={['#1C1C1E', '#2A2A2E', '#3A3A3E', '#0A84FF']} /><ThemeCard name="Warm Studio" colors={['#221F1B', '#342D26', '#6B5A45', '#D4A373']} disabled /></div></SettingsGroup><SettingsGroup title="Canvas Display"><InfoRow icon={<Palette size={16} />} label="Canvas background" value="#1C1C1E" description="Neutral dark background from the Refstudio SRS visual language." /><InfoRow icon={<MousePointer2 size={16} />} label="Interface behavior" value="Auto-hide chrome" description="The toolbar remains hidden until you move toward it, keeping the board dominant." /></SettingsGroup></Page>}
69
- {activeTab === 'shortcuts' && <Page title="Keyboard Shortcuts" subtitle="Hotkey-first controls for one-hand artist workflow."><SettingsGroup title="Panels"><ShortcutRow combo="B" label="Toggle Browser Panel" /><ShortcutRow combo="L" label="Toggle Library Panel" /><ShortcutRow combo="Esc" label="Close panels / clear selection" /><ShortcutRow combo="Ctrl + ," label="Open Settings" /></SettingsGroup><SettingsGroup title="Canvas"><ShortcutRow combo="Space + Drag" label="Pan canvas" /><ShortcutRow combo="Scroll" label="Zoom to cursor" /><ShortcutRow combo="Ctrl + 0" label="Fit all images" /><ShortcutRow combo="Ctrl + 1" label="100% zoom" /><ShortcutRow combo="A" label="Toggle annotation mode" /><ShortcutRow combo="D" label="Desaturate selection" /><ShortcutRow combo="Shift + D" label="Desaturate all" /><ShortcutRow combo="Delete" label="Delete selection" /></SettingsGroup><SettingsGroup title="Editing"><ShortcutRow combo="Ctrl + Z" label="Undo" /><ShortcutRow combo="Ctrl + Shift + Z" label="Redo" /><ShortcutRow combo="Ctrl + A" label="Select all images" /><ShortcutRow combo="Ctrl + G" label="Group selection" /><ShortcutRow combo="Ctrl + Shift + G" label="Ungroup selection" /></SettingsGroup></Page>}
70
- {activeTab === 'privacy' && <Page title="Privacy" subtitle="Local-first browsing and capture controls."><SettingsGroup title="Muse Shield"><div className="p-4 grid grid-cols-2 gap-3"><StatBox value={shieldReport.engine_rules.toLocaleString()} label="Rules" /><StatBox value={shieldReport.blocked_requests.toLocaleString()} label="Requests blocked" /><StatBox value={shieldReport.blocked_cosmetic.toLocaleString()} label="Elements hidden" /><StatBox value={shieldReport.https_upgrades.toLocaleString()} label="HTTPS upgrades" /></div><div className="px-4 pb-4"><button onClick={() => invoke('shield_update_lists').catch(() => {})} className="w-full py-2.5 bg-white/5 hover:bg-white/10 rounded-xl border border-white/10 text-sm flex items-center justify-center gap-2"><RefreshCw size={14} /> Update filters</button></div></SettingsGroup><SettingsGroup title="Local Data"><InfoRow icon={<Shield size={16} />} label="Privacy model" value="Local-first" description="Boards, library metadata, and browsing history stay on your machine." /><ActionRow label="App navigation history" description="Clear Refstudio's app-managed URL history." actionLabel="Clear History" danger onClick={() => invoke('history_clear').catch(() => {})} /></SettingsGroup></Page>}
71
- {activeTab === 'vault' && <Page title="Password Vault" subtitle="Stronghold-backed local credential storage."><SettingsGroup title="Vault Status"><div className="p-4 space-y-3"><p className="text-[#909090] text-xs leading-relaxed">Passwords are stored using the Tauri Stronghold frontend plugin. The vault unlocks only with your master password.</p>{!vaultUnlocked ? <div className="flex gap-2"><input type="password" value={master} onChange={e => setMaster(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') unlock(); }} placeholder="Master password" className="flex-1 bg-black/30 border border-white/10 rounded-xl px-3 py-2 outline-none focus:border-[#0A84FF]" /><button onClick={unlock} className="px-4 py-2 bg-[#0A84FF] rounded-xl text-white flex items-center gap-2"><Unlock size={14} /> Unlock</button></div> : <button onClick={() => { lockVault(); setVaultUnlocked(false); setCreds([]); }} className="px-4 py-2 bg-white/5 border border-white/10 rounded-xl text-white flex items-center gap-2"><Lock size={14} /> Lock vault</button>}{vaultMsg && <div className="text-xs text-[#FFD60A]">{vaultMsg}</div>}</div></SettingsGroup>{vaultUnlocked && <SettingsGroup title="Credentials"><div className="p-4 space-y-2"><input value={origin} onChange={e => setOrigin(e.target.value)} placeholder="Site / origin (example.com)" className="w-full bg-black/30 border border-white/10 rounded-xl px-3 py-2 outline-none focus:border-[#0A84FF]" /><input value={username} onChange={e => setUsername(e.target.value)} placeholder="Username / email" className="w-full bg-black/30 border border-white/10 rounded-xl px-3 py-2 outline-none focus:border-[#0A84FF]" /><input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" className="w-full bg-black/30 border border-white/10 rounded-xl px-3 py-2 outline-none focus:border-[#0A84FF]" /><button onClick={saveCred} className="w-full px-3 py-2.5 bg-[#0A84FF] rounded-xl text-white flex items-center justify-center gap-2"><Plus size={14} /> Save credential</button></div><div className="px-4 pb-4 space-y-2">{creds.map(c => <div key={c.id} className="flex items-center justify-between bg-black/20 border border-white/5 rounded-xl p-3"><div><div className="text-white text-sm">{c.username}</div><div className="text-[#808080] text-xs">{c.origin}</div></div><button onClick={() => deleteCredential(c.id).then(reloadCreds)} className="text-red-300 hover:bg-red-500/10 rounded-lg p-2"><Trash2 size={14} /></button></div>)}</div></SettingsGroup>}</Page>}
72
 
73
- {activeTab === 'storage' && <Page title="Storage" subtitle="Manage local data, export boards, and inspect disk usage.">
74
- <SettingsGroup title="Usage">
75
- {storageLoading ? <div className="px-4 py-6 text-center text-[#808080]"><RefreshCw size={16} className="animate-spin mx-auto mb-2" />Loading...</div> : storageInfo ? <div className="p-4 grid grid-cols-2 gap-3">
76
- <StatBox value={formatBytes(storageInfo.total_size_bytes)} label="Total storage" />
77
- <StatBox value={String(storageInfo.file_count)} label="Files on disk" />
78
- <StatBox value={String(storageInfo.project_count)} label="Saved projects" />
79
- <StatBox value={String(storageInfo.library_count)} label="Library images" />
80
- </div> : <div className="px-4 py-4 text-[#808080] text-xs">Could not load storage info.</div>}
81
- {storageInfo && <div className="px-4 pb-4 space-y-2">
82
- <InfoRow icon={<Database size={16} />} label="Projects data" value={formatBytes(storageInfo.projects_size_bytes)} description={`${storageInfo.project_count} board files saved locally.`} />
83
- <InfoRow icon={<ImageIcon size={16} />} label="Asset library" value={formatBytes(storageInfo.library_size_bytes)} description={`${storageInfo.library_count} images with embedded data URLs and extracted palettes.`} />
84
- <InfoRow icon={<Clock size={16} />} label="Auto-save interval" value="800ms" description="Project state is debounced then written to disk after edits." />
85
- </div>}
86
- </SettingsGroup>
87
- <SettingsGroup title="Data Location">
88
- <div className="px-4 py-3 flex items-center justify-between gap-4">
89
- <div><div className="text-[14px] font-medium text-white">App data folder</div><div className="text-[11px] text-[#8A8A8C] mt-1 font-mono break-all leading-5">{storageInfo?.data_path || '...'}</div></div>
90
- <button onClick={handleRevealFolder} className="shrink-0 px-3 py-2 rounded-xl text-[12px] font-semibold border bg-white/5 text-white border-white/10 hover:bg-white/10 flex items-center gap-1.5"><FolderOpen size={13} /> Open</button>
91
  </div>
92
  </SettingsGroup>
93
- <SettingsGroup title="Export">
94
- <ActionRow label="Export current board as .refs" description="Creates a portable .refs archive with all images embedded. Can be shared or backed up." actionLabel="Export .refs" onClick={handleExportRefs} />
95
- </SettingsGroup>
96
- <SettingsGroup title="Danger Zone">
97
- <ActionRow label="Clear asset library" description="Remove all imported images from the library. Does not affect boards already open." actionLabel="Clear Library" danger onClick={handleClearLibrary} />
98
- <ActionRow label="Delete all projects" description="Remove every saved board file from disk. The current board stays in memory until you close the app." actionLabel="Delete Projects" danger onClick={handleClearProjects} />
99
  </SettingsGroup>
100
- {storageMsg && <div className="mt-3 text-xs text-[#FFD60A] bg-[#FFD60A]/10 border border-[#FFD60A]/20 rounded-xl px-3 py-2">{storageMsg}</div>}
101
  </Page>}
102
 
103
- {activeTab === 'about' && <Page title="About" subtitle="Refstudio alpha build information."><div className="flex flex-col items-center text-center mt-8 gap-3"><div className="w-20 h-20 bg-[#2A2A2E] rounded-2xl flex items-center justify-center"><Monitor size={32} className="text-[#0A84FF]" /></div><h1 className="text-2xl font-semibold">Refstudio</h1><p className="text-[#808080]">1.0.0-alpha</p><p className="max-w-sm text-[#909090] text-sm leading-relaxed">A local-first artist reference board with embedded browser capture, asset library, annotations, and privacy-respecting storage.</p></div></Page>}
 
 
 
 
 
 
 
 
104
  </div></main>
105
  </div>
106
  </div>
107
  );
108
  };
109
 
110
- function Page({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { return <div className="space-y-6"><div><h1 className="text-2xl font-semibold text-white tracking-tight">{title}</h1><p className="text-sm text-[#8A8A8C] mt-1 leading-relaxed">{subtitle}</p></div>{children}</div>; }
111
- function SettingsGroup({ title, children }: { title: string; children: React.ReactNode }) { return <section><h3 className="text-[13px] font-semibold uppercase tracking-[0.12em] text-[#808080] mb-3">{title}</h3><div className="rounded-2xl border border-white/8 bg-black/18 overflow-hidden divide-y divide-white/6">{children}</div></section>; }
112
- function SettingRow({ label, description, active, onToggle, disabled, icon }: { label: string; description: string; active: boolean; onToggle: () => void; disabled?: boolean; icon?: React.ReactNode }) { return <button type="button" disabled={disabled} onClick={onToggle} className="w-full min-h-[76px] px-4 py-3 flex items-center justify-between gap-5 text-left hover:bg-white/[0.035] disabled:opacity-60 disabled:cursor-default transition-colors"><div className="flex items-start gap-3 min-w-0 flex-1">{icon && <div className="mt-0.5 text-[#0A84FF] shrink-0">{icon}</div>}<div className="min-w-0"><div className="text-[14px] font-medium text-white leading-5">{label}</div><div className="text-[12px] text-[#8A8A8C] leading-4 mt-1 max-w-[370px]">{description}</div></div></div><Switch active={active} /></button>; }
113
- function Switch({ active }: { active: boolean }) { return <div className={`relative shrink-0 w-[52px] h-[30px] rounded-full transition-colors ${active ? 'bg-[#0A84FF]' : 'bg-[#3A3A3E]'}`}><div className={`absolute top-[3px] w-6 h-6 rounded-full bg-white shadow-md transition-all ${active ? 'left-[25px]' : 'left-[3px]'}`} /></div>; }
114
- function RangeRow({ label, description, value, min, max, suffix, onChange }: { label: string; description: string; value: number; min: number; max: number; suffix: string; onChange: (v: number) => void }) { return <div className="px-4 py-3 flex items-center justify-between gap-4 hover:bg-white/[0.035]"><div><div className="text-[14px] font-medium text-white">{label}</div><div className="text-[12px] text-[#8A8A8C] mt-1 max-w-[340px]">{description}</div></div><div className="flex items-center gap-3 shrink-0"><span className="text-xs text-[#A0A0A0] w-10 text-right">{value}{suffix}</span><input type="range" min={min} max={max} value={value} onChange={e => onChange(Number(e.target.value))} className="w-32 accent-[#0A84FF]" /></div></div>; }
115
- function InfoRow({ icon, label, value, description }: { icon: React.ReactNode; label: string; value: string; description: string }) { return <div className="px-4 py-3 flex items-start justify-between gap-4"><div className="flex items-start gap-3 min-w-0"><div className="text-[#0A84FF] mt-0.5 shrink-0">{icon}</div><div><div className="text-[14px] font-medium text-white">{label}</div><div className="text-[12px] text-[#8A8A8C] mt-1 max-w-[360px] leading-4">{description}</div></div></div><div className="text-[12px] text-[#C0C0C0] shrink-0 bg-white/5 border border-white/8 rounded-lg px-2 py-1">{value}</div></div>; }
116
- function ActionRow({ label, description, actionLabel, onClick, danger }: { label: string; description: string; actionLabel: string; onClick: () => void; danger?: boolean }) { return <div className="px-4 py-3 flex items-center justify-between gap-4"><div><div className="text-[14px] font-medium text-white">{label}</div><div className="text-[12px] text-[#8A8A8C] mt-1 max-w-[360px]">{description}</div></div><button onClick={onClick} className={`shrink-0 px-3 py-2 rounded-xl text-[12px] font-semibold border ${danger ? 'bg-red-500/10 text-red-300 border-red-500/20 hover:bg-red-500/20' : 'bg-white/5 text-white border-white/10 hover:bg-white/10'}`}>{actionLabel}</button></div>; }
117
- function ShortcutRow({ combo, label }: { combo: string; label: string }) { return <div className="px-4 py-2.5 flex items-center justify-between gap-4"><span className="text-[13px] text-[#E0E0E0]">{label}</span><kbd className="bg-black/30 border border-white/10 px-2.5 py-1 rounded-lg text-[11px] text-[#C0C0C0] font-mono">{combo}</kbd></div>; }
118
- function ThemeCard({ name, colors, active, disabled }: { name: string; colors: string[]; active?: boolean; disabled?: boolean }) { return <div className={`p-3 rounded-xl border ${active ? 'border-[#0A84FF] bg-[#0A84FF]/10' : 'border-white/10 bg-black/20'} ${disabled ? 'opacity-40' : ''}`}><div className="flex gap-1.5 mb-3">{colors.map(c => <div key={c} className="w-7 h-7 rounded-full border border-black/20" style={{ backgroundColor: c }} />)}</div><div className="text-sm text-white font-medium">{name}</div>{disabled && <div className="text-[10px] text-[#808080] mt-1">Coming later</div>}</div>; }
119
- function StatBox({ value, label }: { value: string; label: string }) { return <div className="bg-black/20 rounded-xl p-3 text-center border border-white/5"><div className="text-lg font-bold text-[#0A84FF]">{value}</div><div className="text-[10px] text-[#808080] mt-1">{label}</div></div>; }
 
1
  import type React from 'react';
2
+ import { X, Settings, Keyboard, Monitor, Shield, HardDrive, Info, RefreshCw, KeyRound, Lock, Unlock, Trash2, Plus, Navigation, Grid3X3, Database, Palette, Clock, MousePointer2, FolderOpen, FileDown, Image as ImageIcon, LayoutGrid, FileArchive, Check } from 'lucide-react';
3
+ import { useAppStore, type ThemeId } from '../store';
4
  import { useState, useEffect } from 'react';
5
  import { invoke } from '@tauri-apps/api/core';
6
  import { unlockVault, lockVault, isVaultUnlocked, saveCredential, listCredentials, deleteCredential, type CredentialSummary } from '../credentialsVault';
 
8
  type Tab = 'general' | 'appearance' | 'shortcuts' | 'privacy' | 'vault' | 'storage' | 'about';
9
  interface ShieldReport { blocked_requests: number; blocked_cosmetic: number; https_upgrades: number; engine_rules: number; }
10
  interface StorageInfo { data_path: string; total_size_bytes: number; file_count: number; project_count: number; library_count: number; library_size_bytes: number; projects_size_bytes: number; }
 
11
  function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return (bytes / Math.pow(k, i)).toFixed(i > 0 ? 1 : 0) + ' ' + sizes[i]; }
12
 
13
+ const THEMES: { id: ThemeId; name: string; description: string; colors: string[] }[] = [
14
+ { id: 'dark-canvas', name: 'Dark Canvas', description: 'Neutral dark background optimized for image reference work. Default.', colors: ['#1C1C1E', '#2A2A2E', '#3A3A3E', '#0A84FF'] },
15
+ { id: 'warm-studio', name: 'Warm Studio', description: 'Warm earthy tones inspired by traditional art studios and parchment.', colors: ['#221F1B', '#2E2A24', '#46403A', '#D4A373'] },
16
+ { id: 'midnight', name: 'Midnight', description: 'Deep blue-black for late night sessions with reduced eye strain.', colors: ['#0F1319', '#171D28', '#253040', '#6C89E8'] },
17
+ ];
18
+
19
  export const SettingsPanel = () => {
20
+ const { isSettingsOpen, setIsSettingsOpen, isAlwaysOnTop, setIsAlwaysOnTop, bgOpacity, setBgOpacity, showMinimap, setShowMinimap, showGrid, setShowGrid, theme, setTheme, images, textNotes, annotations, palettes, zoom, pan, boardTitle } = useAppStore();
21
  const [activeTab, setActiveTab] = useState<Tab>('general');
22
  const [shieldReport, setShieldReport] = useState<ShieldReport>({ blocked_requests: 0, blocked_cosmetic: 0, https_upgrades: 0, engine_rules: 0 });
23
  const [vaultUnlocked, setVaultUnlocked] = useState(isVaultUnlocked());
 
35
  useEffect(() => { if (isSettingsOpen && activeTab === 'storage') loadStorageInfo(); }, [isSettingsOpen, activeTab]);
36
  const reloadCreds = () => listCredentials().then(setCreds).catch(e => setVaultMsg(String(e)));
37
  const loadStorageInfo = () => { setStorageLoading(true); invoke<StorageInfo>('storage_info').then(setStorageInfo).catch(() => {}).finally(() => setStorageLoading(false)); };
 
 
 
 
 
 
 
 
 
 
 
38
  const unlock = async () => { try { await unlockVault(master); setMaster(''); setVaultUnlocked(true); setVaultMsg('Vault unlocked'); await reloadCreds(); } catch (e) { setVaultMsg(`Unlock failed: ${String(e)}`); } };
39
  const saveCred = async () => { try { await saveCredential({ origin, username, password }); setOrigin(''); setUsername(''); setPassword(''); setVaultMsg('Credential saved'); await reloadCreds(); } catch (e) { setVaultMsg(`Save failed: ${String(e)}`); } };
 
40
  const handleClearLibrary = async () => { if (!confirm('Clear all images from the asset library? This cannot be undone.')) return; try { await invoke('storage_clear_library'); setStorageMsg('Library cleared'); loadStorageInfo(); } catch (e) { setStorageMsg(`Failed: ${e}`); } };
41
+ const handleClearProjects = async () => { if (!confirm('Delete ALL saved projects? This cannot be undone.')) return; try { await invoke('storage_clear_projects'); setStorageMsg('Projects cleared'); loadStorageInfo(); } catch (e) { setStorageMsg(`Failed: ${e}`); } };
42
  const handleRevealFolder = () => invoke('storage_reveal_folder').catch(e => setStorageMsg(`Failed: ${e}`));
43
+ const handleExportRefs = async () => { const state = JSON.stringify({ textNotes: [], images, annotations: [], palettes: [], zoom, pan, title: boardTitle }); try { const bytes = await invoke<number[]>('refs_export', { stateJson: state }); const blob = new Blob([new Uint8Array(bytes)], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${boardTitle.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() || 'board'}.refs`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); setStorageMsg('Board exported as .refs'); } catch (e) { setStorageMsg(`Export failed: ${e}`); } };
44
+
45
+ const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [{ id: 'general', label: 'General', icon: <Settings size={16} /> },{ id: 'appearance', label: 'Appearance', icon: <Monitor size={16} /> },{ id: 'shortcuts', label: 'Shortcuts', icon: <Keyboard size={16} /> },{ id: 'privacy', label: 'Privacy', icon: <Shield size={16} /> },{ id: 'vault', label: 'Password Vault', icon: <KeyRound size={16} /> },{ id: 'storage', label: 'Storage', icon: <HardDrive size={16} /> },{ id: 'about', label: 'About', icon: <Info size={16} /> }];
 
 
 
 
 
 
 
46
 
47
  return (
48
+ <div className={`absolute right-4 top-4 bottom-4 w-[min(720px,calc(100vw-32px))] z-[80] bg-[var(--panel-bg)]/98 shadow-2xl border border-[var(--panel-border)] rounded-2xl flex flex-col transform transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] overflow-hidden ${isSettingsOpen ? 'translate-x-0' : 'translate-x-[calc(100%+32px)]'}`}>
49
+ <div className="h-14 border-b border-white/5 flex items-center justify-between px-5 bg-black/20 shrink-0"><h2 className="text-[var(--ui-primary)] font-medium flex items-center gap-2"><Settings size={16} className="text-[var(--ui-secondary)]" /> Settings</h2><button onClick={() => setIsSettingsOpen(false)} className="text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] p-1 rounded-md hover:bg-white/5"><X size={18} /></button></div>
50
  <div className="flex flex-1 min-h-0 overflow-hidden">
51
+ <nav className="w-[190px] bg-black/10 border-r border-white/5 p-3 flex flex-col gap-1 shrink-0 overflow-y-auto custom-scrollbar">{tabs.map(tab => <button key={tab.id} onClick={() => setActiveTab(tab.id)} className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13px] text-left transition-colors ${activeTab === tab.id ? 'bg-[var(--accent)] text-white font-medium shadow-lg' : 'text-[var(--ui-secondary)] hover:text-[var(--ui-primary)] hover:bg-white/5'}`}>{tab.icon}{tab.label}</button>)}</nav>
52
+ <main className="flex-1 min-w-0 overflow-y-auto custom-scrollbar"><div className="p-6 pb-10 text-[var(--ui-primary)] text-sm max-w-[520px] mx-auto">
 
 
 
 
 
53
 
54
+ {activeTab === 'general' && <Page title="General" subtitle="Core behavior for the canvas, window, and workspace."><SettingsGroup title="Startup & Window"><SettingRow label="Open last board on startup" description="Load the most recent project automatically when Refstudio starts." active={true} onToggle={() => {}} disabled /><SettingRow label="Always on Top" description="Keep Refstudio above other apps while drawing." active={isAlwaysOnTop} onToggle={() => setIsAlwaysOnTop(!isAlwaysOnTop)} />{isAlwaysOnTop && <RangeRow label="Overlay opacity" description="Adjust transparency while pinned above another app." value={bgOpacity} min={10} max={100} suffix="%" onChange={setBgOpacity} />}</SettingsGroup><SettingsGroup title="Canvas"><SettingRow icon={<Grid3X3 size={16} />} label="Grid" description="Show the subtle dotted canvas grid." active={showGrid} onToggle={() => setShowGrid(!showGrid)} /><SettingRow icon={<Navigation size={16} />} label="Navigator / Minimap" description="Compact board overview in the bottom-right corner. Off by default." active={showMinimap} onToggle={() => setShowMinimap(!showMinimap)} /></SettingsGroup></Page>}
55
+
56
+ {activeTab === 'appearance' && <Page title="Appearance" subtitle="Choose a workspace theme that reduces eye strain during long reference sessions.">
57
+ <SettingsGroup title="Theme">
58
+ <div className="p-4 grid grid-cols-1 gap-3">
59
+ {THEMES.map(t => (
60
+ <button key={t.id} onClick={() => setTheme(t.id)} className={`p-4 rounded-xl border text-left transition-all ${theme === t.id ? 'border-[var(--accent)] bg-[var(--accent)]/8 shadow-lg shadow-[var(--accent)]/5' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/10'}`}>
61
+ <div className="flex items-center justify-between mb-3">
62
+ <div className="flex gap-2">{t.colors.map(c => <div key={c} className="w-8 h-8 rounded-lg border border-black/30 shadow-inner" style={{ backgroundColor: c }} />)}</div>
63
+ {theme === t.id && <div className="w-6 h-6 rounded-full bg-[var(--accent)] flex items-center justify-center"><Check size={14} className="text-white" /></div>}
64
+ </div>
65
+ <div className="text-[14px] font-semibold text-[var(--ui-primary)]">{t.name}</div>
66
+ <div className="text-[12px] text-[var(--ui-secondary)] mt-1 leading-4">{t.description}</div>
67
+ </button>
68
+ ))}
 
 
 
69
  </div>
70
  </SettingsGroup>
71
+ <SettingsGroup title="Canvas Display">
72
+ <InfoRow icon={<Palette size={16} />} label="Canvas background" value="var(--canvas-bg)" description="Adapts to the selected theme. Neutral dark recedes so references are figure against ground." />
73
+ <InfoRow icon={<MousePointer2 size={16} />} label="Interface chrome" value="Auto-hide" description="The toolbar remains hidden until you move toward it, keeping the board dominant per SRS §6." />
 
 
 
74
  </SettingsGroup>
 
75
  </Page>}
76
 
77
+ {activeTab === 'shortcuts' && <Page title="Keyboard Shortcuts" subtitle="Hotkey-first controls for one-hand artist workflow."><SettingsGroup title="Panels"><ShortcutRow combo="B" label="Toggle Browser Panel" /><ShortcutRow combo="L" label="Toggle Library Panel" /><ShortcutRow combo="Esc" label="Close panels / clear selection" /><ShortcutRow combo="Ctrl + ," label="Open Settings" /></SettingsGroup><SettingsGroup title="Canvas"><ShortcutRow combo="Space + Drag" label="Pan canvas" /><ShortcutRow combo="Scroll" label="Zoom to cursor" /><ShortcutRow combo="Ctrl + 0" label="Fit all images" /><ShortcutRow combo="Ctrl + 1" label="100% zoom" /><ShortcutRow combo="A" label="Toggle annotation mode" /><ShortcutRow combo="D" label="Desaturate selection" /><ShortcutRow combo="Shift + D" label="Desaturate all" /><ShortcutRow combo="Delete" label="Delete selection" /></SettingsGroup><SettingsGroup title="Editing"><ShortcutRow combo="Ctrl + Z" label="Undo" /><ShortcutRow combo="Ctrl + Shift + Z" label="Redo" /><ShortcutRow combo="Ctrl + A" label="Select all images" /><ShortcutRow combo="Ctrl + G" label="Group selection" /><ShortcutRow combo="Ctrl + Shift + G" label="Ungroup selection" /></SettingsGroup></Page>}
78
+
79
+ {activeTab === 'privacy' && <Page title="Privacy" subtitle="Local-first browsing and capture controls."><SettingsGroup title="Muse Shield"><div className="p-4 grid grid-cols-2 gap-3"><StatBox value={shieldReport.engine_rules.toLocaleString()} label="Rules" /><StatBox value={shieldReport.blocked_requests.toLocaleString()} label="Requests blocked" /><StatBox value={shieldReport.blocked_cosmetic.toLocaleString()} label="Elements hidden" /><StatBox value={shieldReport.https_upgrades.toLocaleString()} label="HTTPS upgrades" /></div><div className="px-4 pb-4"><button onClick={() => invoke('shield_update_lists').catch(() => {})} className="w-full py-2.5 bg-white/5 hover:bg-white/10 rounded-xl border border-white/10 text-sm flex items-center justify-center gap-2"><RefreshCw size={14} /> Update filters</button></div></SettingsGroup><SettingsGroup title="Local Data"><InfoRow icon={<Shield size={16} />} label="Privacy model" value="Local-first" description="Boards, library, and history stay on your machine." /><ActionRow label="App navigation history" description="Clear Refstudio's app-managed URL history." actionLabel="Clear History" danger onClick={() => invoke('history_clear').catch(() => {})} /></SettingsGroup></Page>}
80
+
81
+ {activeTab === 'vault' && <Page title="Password Vault" subtitle="Stronghold-backed local credential storage."><SettingsGroup title="Vault Status"><div className="p-4 space-y-3"><p className="text-[var(--ui-secondary)] text-xs leading-relaxed">Passwords are stored using Tauri Stronghold. Unlocks only with your master password.</p>{!vaultUnlocked ? <div className="flex gap-2"><input type="password" value={master} onChange={e => setMaster(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') unlock(); }} placeholder="Master password" className="flex-1 bg-black/30 border border-white/10 rounded-xl px-3 py-2 outline-none focus:border-[var(--accent)]" /><button onClick={unlock} className="px-4 py-2 bg-[var(--accent)] rounded-xl text-white flex items-center gap-2"><Unlock size={14} /> Unlock</button></div> : <button onClick={() => { lockVault(); setVaultUnlocked(false); setCreds([]); }} className="px-4 py-2 bg-white/5 border border-white/10 rounded-xl text-white flex items-center gap-2"><Lock size={14} /> Lock vault</button>}{vaultMsg && <div className="text-xs text-[#FFD60A]">{vaultMsg}</div>}</div></SettingsGroup>{vaultUnlocked && <SettingsGroup title="Credentials"><div className="p-4 space-y-2"><input value={origin} onChange={e => setOrigin(e.target.value)} placeholder="Site (example.com)" className="w-full bg-black/30 border border-white/10 rounded-xl px-3 py-2 outline-none focus:border-[var(--accent)]" /><input value={username} onChange={e => setUsername(e.target.value)} placeholder="Username" className="w-full bg-black/30 border border-white/10 rounded-xl px-3 py-2 outline-none focus:border-[var(--accent)]" /><input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" className="w-full bg-black/30 border border-white/10 rounded-xl px-3 py-2 outline-none focus:border-[var(--accent)]" /><button onClick={saveCred} className="w-full px-3 py-2.5 bg-[var(--accent)] rounded-xl text-white flex items-center justify-center gap-2"><Plus size={14} /> Save</button></div><div className="px-4 pb-4 space-y-2">{creds.map(c => <div key={c.id} className="flex items-center justify-between bg-black/20 border border-white/5 rounded-xl p-3"><div><div className="text-white text-sm">{c.username}</div><div className="text-[var(--ui-secondary)] text-xs">{c.origin}</div></div><button onClick={() => deleteCredential(c.id).then(reloadCreds)} className="text-red-300 hover:bg-red-500/10 rounded-lg p-2"><Trash2 size={14} /></button></div>)}</div></SettingsGroup>}</Page>}
82
+
83
+ {activeTab === 'storage' && <Page title="Storage" subtitle="Manage local data, export boards, and inspect disk usage."><SettingsGroup title="Usage">{storageLoading ? <div className="px-4 py-6 text-center text-[var(--ui-secondary)]"><RefreshCw size={16} className="animate-spin mx-auto mb-2" />Loading...</div> : storageInfo ? <div className="p-4 grid grid-cols-2 gap-3"><StatBox value={formatBytes(storageInfo.total_size_bytes)} label="Total storage" /><StatBox value={String(storageInfo.file_count)} label="Files on disk" /><StatBox value={String(storageInfo.project_count)} label="Saved projects" /><StatBox value={String(storageInfo.library_count)} label="Library images" /></div> : null}{storageInfo && <div className="px-4 pb-4 space-y-1"><InfoRow icon={<Database size={16} />} label="Projects" value={formatBytes(storageInfo.projects_size_bytes)} description={`${storageInfo.project_count} board files.`} /><InfoRow icon={<ImageIcon size={16} />} label="Library" value={formatBytes(storageInfo.library_size_bytes)} description={`${storageInfo.library_count} images with palettes.`} /><InfoRow icon={<Clock size={16} />} label="Auto-save" value="800ms" description="Debounced write after edits." /></div>}</SettingsGroup><SettingsGroup title="Data Location"><div className="px-4 py-3 flex items-center justify-between gap-4"><div><div className="text-[14px] font-medium text-white">App data folder</div><div className="text-[11px] text-[var(--ui-secondary)] mt-1 font-mono break-all">{storageInfo?.data_path || '...'}</div></div><button onClick={handleRevealFolder} className="shrink-0 px-3 py-2 rounded-xl text-[12px] font-semibold border bg-white/5 text-white border-white/10 hover:bg-white/10 flex items-center gap-1.5"><FolderOpen size={13} /> Open</button></div></SettingsGroup><SettingsGroup title="Export"><ActionRow label="Export as .refs archive" description="Portable file with all images embedded." actionLabel="Export .refs" onClick={handleExportRefs} /></SettingsGroup><SettingsGroup title="Danger Zone"><ActionRow label="Clear asset library" description="Remove all imported images." actionLabel="Clear" danger onClick={handleClearLibrary} /><ActionRow label="Delete all projects" description="Remove every saved board." actionLabel="Delete" danger onClick={handleClearProjects} /></SettingsGroup>{storageMsg && <div className="mt-3 text-xs text-[#FFD60A] bg-[#FFD60A]/10 border border-[#FFD60A]/20 rounded-xl px-3 py-2">{storageMsg}</div>}</Page>}
84
+
85
+ {activeTab === 'about' && <Page title="About" subtitle="Refstudio alpha build."><div className="flex flex-col items-center text-center mt-8 gap-3"><div className="w-20 h-20 bg-[var(--panel-surface)] rounded-2xl flex items-center justify-center"><Monitor size={32} className="text-[var(--accent)]" /></div><h1 className="text-2xl font-semibold">Refstudio</h1><p className="text-[var(--ui-secondary)]">1.0.0-alpha</p><p className="max-w-sm text-[var(--ui-secondary)] text-sm leading-relaxed">A local-first artist reference board with embedded browser capture, asset library, annotations, and privacy-respecting storage.</p></div></Page>}
86
  </div></main>
87
  </div>
88
  </div>
89
  );
90
  };
91
 
92
+ function Page({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { return <div className="space-y-6"><div><h1 className="text-2xl font-semibold text-[var(--ui-primary)] tracking-tight">{title}</h1><p className="text-sm text-[var(--ui-secondary)] mt-1 leading-relaxed">{subtitle}</p></div>{children}</div>; }
93
+ function SettingsGroup({ title, children }: { title: string; children: React.ReactNode }) { return <section><h3 className="text-[13px] font-semibold uppercase tracking-[0.12em] text-[var(--ui-secondary)] mb-3">{title}</h3><div className="rounded-2xl border border-white/8 bg-black/18 overflow-hidden divide-y divide-white/6">{children}</div></section>; }
94
+ function SettingRow({ label, description, active, onToggle, disabled, icon }: { label: string; description: string; active: boolean; onToggle: () => void; disabled?: boolean; icon?: React.ReactNode }) { return <button type="button" disabled={disabled} onClick={onToggle} className="w-full min-h-[76px] px-4 py-3 flex items-center justify-between gap-5 text-left hover:bg-white/[0.035] disabled:opacity-60 disabled:cursor-default transition-colors"><div className="flex items-start gap-3 min-w-0 flex-1">{icon && <div className="mt-0.5 text-[var(--accent)] shrink-0">{icon}</div>}<div className="min-w-0"><div className="text-[14px] font-medium text-[var(--ui-primary)] leading-5">{label}</div><div className="text-[12px] text-[var(--ui-secondary)] leading-4 mt-1 max-w-[370px]">{description}</div></div></div><Switch active={active} /></button>; }
95
+ function Switch({ active }: { active: boolean }) { return <div className={`relative shrink-0 w-[52px] h-[30px] rounded-full transition-colors ${active ? 'bg-[var(--accent)]' : 'bg-[#3A3A3E]'}`}><div className={`absolute top-[3px] w-6 h-6 rounded-full bg-white shadow-md transition-all ${active ? 'left-[25px]' : 'left-[3px]'}`} /></div>; }
96
+ function RangeRow({ label, description, value, min, max, suffix, onChange }: { label: string; description: string; value: number; min: number; max: number; suffix: string; onChange: (v: number) => void }) { return <div className="px-4 py-3 flex items-center justify-between gap-4 hover:bg-white/[0.035]"><div><div className="text-[14px] font-medium text-[var(--ui-primary)]">{label}</div><div className="text-[12px] text-[var(--ui-secondary)] mt-1 max-w-[340px]">{description}</div></div><div className="flex items-center gap-3 shrink-0"><span className="text-xs text-[var(--ui-secondary)] w-10 text-right">{value}{suffix}</span><input type="range" min={min} max={max} value={value} onChange={e => onChange(Number(e.target.value))} className="w-32 accent-[var(--accent)]" /></div></div>; }
97
+ function InfoRow({ icon, label, value, description }: { icon: React.ReactNode; label: string; value: string; description: string }) { return <div className="px-4 py-3 flex items-start justify-between gap-4"><div className="flex items-start gap-3 min-w-0"><div className="text-[var(--accent)] mt-0.5 shrink-0">{icon}</div><div><div className="text-[14px] font-medium text-[var(--ui-primary)]">{label}</div><div className="text-[12px] text-[var(--ui-secondary)] mt-1 max-w-[360px] leading-4">{description}</div></div></div><div className="text-[12px] text-[var(--ui-secondary)] shrink-0 bg-white/5 border border-white/8 rounded-lg px-2 py-1">{value}</div></div>; }
98
+ function ActionRow({ label, description, actionLabel, onClick, danger }: { label: string; description: string; actionLabel: string; onClick: () => void; danger?: boolean }) { return <div className="px-4 py-3 flex items-center justify-between gap-4"><div><div className="text-[14px] font-medium text-[var(--ui-primary)]">{label}</div><div className="text-[12px] text-[var(--ui-secondary)] mt-1 max-w-[360px]">{description}</div></div><button onClick={onClick} className={`shrink-0 px-3 py-2 rounded-xl text-[12px] font-semibold border ${danger ? 'bg-red-500/10 text-red-300 border-red-500/20 hover:bg-red-500/20' : 'bg-white/5 text-white border-white/10 hover:bg-white/10'}`}>{actionLabel}</button></div>; }
99
+ function ShortcutRow({ combo, label }: { combo: string; label: string }) { return <div className="px-4 py-2.5 flex items-center justify-between gap-4"><span className="text-[13px] text-[var(--ui-primary)]">{label}</span><kbd className="bg-black/30 border border-white/10 px-2.5 py-1 rounded-lg text-[11px] text-[var(--ui-secondary)] font-mono">{combo}</kbd></div>; }
100
+ function StatBox({ value, label }: { value: string; label: string }) { return <div className="bg-black/20 rounded-xl p-3 text-center border border-white/5"><div className="text-lg font-bold text-[var(--accent)]">{value}</div><div className="text-[10px] text-[var(--ui-secondary)] mt-1">{label}</div></div>; }