| import { X, Settings, Keyboard, Monitor, Shield, HardDrive, Info, RefreshCw, Grid3X3, Navigation, FileDown, FileUp, FileArchive, Check, Trash2 } from 'lucide-react'; |
| import { useAppStore, type ThemeId } from '../store'; |
| import { useState, useEffect } from 'react'; |
| import { invoke } from '@tauri-apps/api/core'; |
|
|
| type Tab = 'general' | 'appearance' | 'shortcuts' | 'privacy' | 'storage' | 'about'; |
| interface ShieldReport { blocked_requests: number; blocked_cosmetic: number; https_upgrades: number; engine_rules: number; } |
|
|
| const THEMES: { id: ThemeId; name: string; description: string; colors: string[] }[] = [ |
| { id: 'dark-canvas', name: 'Dark Canvas', description: 'Neutral dark background optimized for image reference work. Default.', colors: ['#1C1C1E', '#2A2A2E', '#3A3A3E', '#0A84FF'] }, |
| { id: 'warm-studio', name: 'Warm Studio', description: 'Warm earthy tones inspired by traditional art studios.', colors: ['#221F1B', '#2E2A24', '#46403A', '#D4A373'] }, |
| { id: 'midnight', name: 'Midnight', description: 'Deep blue-black for late night sessions with reduced eye strain.', colors: ['#0F1319', '#171D28', '#253040', '#6C89E8'] }, |
| ]; |
|
|
| export const SettingsPanel = () => { |
| const { isSettingsOpen, setIsSettingsOpen, isAlwaysOnTop, setIsAlwaysOnTop, bgOpacity, setBgOpacity, showMinimap, setShowMinimap, showGrid, setShowGrid, theme, setTheme, images, textNotes, annotations, palettes, zoom, pan, boardTitle, setImages, setTextNotes, setAnnotations, setPalettes, setZoom, setPan, setBoardTitle, setCurrentScreen } = useAppStore(); |
| const [activeTab, setActiveTab] = useState<Tab>('general'); |
| const [shieldReport, setShieldReport] = useState<ShieldReport>({ blocked_requests: 0, blocked_cosmetic: 0, https_upgrades: 0, engine_rules: 0 }); |
| const [toastMsg, setToastMsg] = useState(''); |
| const [isExporting, setIsExporting] = useState(false); |
|
|
| useEffect(() => { if (isSettingsOpen) invoke<ShieldReport>('shield_get_report').then(setShieldReport).catch(() => {}); }, [isSettingsOpen]); |
| useEffect(() => { if (toastMsg) { const t = setTimeout(() => setToastMsg(''), 4000); return () => clearTimeout(t); } }, [toastMsg]); |
|
|
| |
| const dataSize = (() => { |
| let bytes = 0; |
| images.forEach(img => { bytes += (img.url?.length || 0); }); |
| textNotes.forEach(n => { bytes += (n.text?.length || 0) * 2; }); |
| annotations.forEach(a => { bytes += a.points.length * 16; }); |
| if (bytes > 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; |
| if (bytes > 1024) return `${(bytes / 1024).toFixed(0)} KB`; |
| return `${bytes} bytes`; |
| })(); |
|
|
| |
| const handleExportRefs = async () => { |
| if (isExporting) return; |
| setIsExporting(true); |
| try { |
| const state = JSON.stringify({ title: boardTitle, images, textNotes, annotations, palettes, zoom, pan }); |
| 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 filename = `${(boardTitle || 'board').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, '-').toLowerCase() || 'board'}.refs`; |
| const a = document.createElement('a'); |
| a.href = url; a.download = filename; |
| document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); |
| setToastMsg(`✓ Exported "${filename}" — portable board with all images embedded`); |
| } catch (e) { |
| setToastMsg(`✗ Export failed: ${e}`); |
| } |
| setIsExporting(false); |
| }; |
|
|
| |
| const handleExportJson = () => { |
| try { |
| const state = { title: boardTitle, images, textNotes, annotations, palettes, zoom, pan }; |
| const json = JSON.stringify(state, null, 2); |
| const blob = new Blob([json], { type: 'application/json' }); |
| const url = URL.createObjectURL(blob); |
| const filename = `${(boardTitle || 'board').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, '-').toLowerCase() || 'board'}.json`; |
| const a = document.createElement('a'); a.href = url; a.download = filename; |
| document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); |
| setToastMsg(`✓ Exported "${filename}" — lightweight JSON (images as URLs/base64)`); |
| } catch (e) { setToastMsg(`✗ Export failed: ${e}`); } |
| }; |
|
|
| |
| const handleImportRefs = () => { |
| const input = document.createElement('input'); |
| input.type = 'file'; input.accept = '.refs'; |
| input.onchange = async (e) => { |
| const file = (e.target as HTMLInputElement).files?.[0]; |
| if (!file) return; |
| try { |
| const buffer = await file.arrayBuffer(); |
| const bytes = Array.from(new Uint8Array(buffer)); |
| const stateJson = await invoke<string>('refs_import', { data: bytes }); |
| const state = JSON.parse(stateJson); |
| if (state.images) setImages(state.images); |
| if (state.textNotes) setTextNotes(state.textNotes); |
| if (state.annotations) setAnnotations(state.annotations); |
| if (state.palettes) setPalettes(state.palettes); |
| if (state.zoom) setZoom(state.zoom); |
| if (state.pan) setPan(state.pan); |
| if (state.title) setBoardTitle(state.title); |
| setCurrentScreen('board'); |
| setToastMsg(`✓ Imported "${file.name}" — ${state.images?.length || 0} images loaded`); |
| } catch (e) { setToastMsg(`✗ Import failed: ${e}`); } |
| }; |
| input.click(); |
| }; |
|
|
| |
| const handleImportJson = () => { |
| const input = document.createElement('input'); |
| input.type = 'file'; input.accept = '.json'; |
| input.onchange = async (e) => { |
| const file = (e.target as HTMLInputElement).files?.[0]; |
| if (!file) return; |
| try { |
| const text = await file.text(); |
| const state = JSON.parse(text); |
| if (state.images) setImages(state.images); |
| if (state.textNotes) setTextNotes(state.textNotes); |
| if (state.annotations) setAnnotations(state.annotations); |
| if (state.palettes) setPalettes(state.palettes); |
| if (state.zoom) setZoom(state.zoom); |
| if (state.pan) setPan(state.pan); |
| if (state.title) setBoardTitle(state.title); |
| setCurrentScreen('board'); |
| setToastMsg(`✓ Imported "${file.name}"`); |
| } catch (e) { setToastMsg(`✗ Import failed: ${e}`); } |
| }; |
| input.click(); |
| }; |
|
|
| |
| const handleClearBoard = () => { |
| if (!confirm('Clear all images, notes, and annotations from the current board? This cannot be undone.')) return; |
| setImages([]); setTextNotes([]); setAnnotations([]); setPalettes([]); |
| setToastMsg('Board cleared'); |
| }; |
|
|
| 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: 'storage', label: 'Storage & Export', icon: <HardDrive size={16} /> }, |
| { id: 'about', label: 'About', icon: <Info size={16} /> }, |
| ]; |
|
|
| return ( |
| <div className={`absolute right-4 top-4 bottom-4 w-[min(680px,calc(100vw-32px))] z-[80] bg-[#1C1C1E]/[0.98] backdrop-blur-xl shadow-2xl border border-white/10 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)]'}`}> |
| <div className="h-14 border-b border-white/5 flex items-center justify-between px-5 bg-black/20 shrink-0"> |
| <h2 className="text-white font-medium flex items-center gap-2"><Settings size={16} className="text-[#808080]" /> Settings</h2> |
| <button onClick={() => setIsSettingsOpen(false)} className="text-[#808080] hover:text-white p-1 rounded-md hover:bg-white/5"><X size={18} /></button> |
| </div> |
| <div className="flex flex-1 min-h-0 overflow-hidden"> |
| <nav className="w-[180px] bg-black/10 border-r border-white/5 p-3 flex flex-col gap-1 shrink-0 overflow-y-auto"> |
| {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' : 'text-[#808080] hover:text-white hover:bg-white/5'}`}>{tab.icon}{tab.label}</button>)} |
| </nav> |
| <main className="flex-1 min-w-0 overflow-y-auto custom-scrollbar"> |
| <div className="p-6 pb-10 text-white text-sm max-w-[480px] mx-auto space-y-6"> |
| |
| {activeTab === 'general' && <> |
| <Header title="General" subtitle="Core behavior for the canvas, window, and workspace." /> |
| <Section title="Window"><ToggleRow label="Always on Top" description="Keep above other apps while drawing." active={isAlwaysOnTop} onToggle={() => setIsAlwaysOnTop(!isAlwaysOnTop)} />{isAlwaysOnTop && <RangeRow label="Overlay opacity" value={bgOpacity} min={10} max={100} suffix="%" onChange={setBgOpacity} />}</Section> |
| <Section title="Canvas"><ToggleRow icon={<Grid3X3 size={16} />} label="Grid" description="Show dotted canvas grid." active={showGrid} onToggle={() => setShowGrid(!showGrid)} /><ToggleRow icon={<Navigation size={16} />} label="Minimap" description="Compact board overview in bottom-right." active={showMinimap} onToggle={() => setShowMinimap(!showMinimap)} /></Section> |
| </>} |
| |
| {activeTab === 'appearance' && <> |
| <Header title="Appearance" subtitle="Choose a workspace theme for long sessions." /> |
| <Section title="Theme"><div className="p-4 grid gap-3">{THEMES.map(t => <button key={t.id} onClick={() => setTheme(t.id)} className={`p-4 rounded-xl border text-left transition-all ${theme === t.id ? 'border-[#0A84FF] bg-[#0A84FF]/8' : 'border-white/10 bg-black/20 hover:border-white/20'}`}><div className="flex items-center justify-between mb-3"><div className="flex gap-2">{t.colors.map(c => <div key={c} className="w-8 h-8 rounded-lg border border-black/30" style={{ backgroundColor: c }} />)}</div>{theme === t.id && <div className="w-6 h-6 rounded-full bg-[#0A84FF] flex items-center justify-center"><Check size={14} /></div>}</div><div className="text-[14px] font-semibold">{t.name}</div><div className="text-[12px] text-[#808080] mt-1">{t.description}</div></button>)}</div></Section> |
| </>} |
| |
| {activeTab === 'shortcuts' && <> |
| <Header title="Keyboard Shortcuts" subtitle="Hotkey-first controls for one-hand artist workflow." /> |
| <Section title="Panels"><SK combo="B" label="Toggle Browser" /><SK combo="L" label="Toggle Library" /><SK combo="Esc" label="Close panels / clear selection" /><SK combo="Ctrl + ," label="Open Settings" /></Section> |
| <Section title="Canvas"><SK combo="Space + Drag" label="Pan canvas" /><SK combo="Scroll" label="Zoom to cursor" /><SK combo="Ctrl + 0" label="Fit all images" /><SK combo="Ctrl + 1" label="100% zoom" /><SK combo="A" label="Toggle annotation mode" /><SK combo="D" label="Desaturate selection" /><SK combo="Shift + D" label="Desaturate all" /><SK combo="H" label="Flip horizontal" /><SK combo="F" label="Focus mode" /><SK combo="V" label="Value mirror" /><SK combo="Z (hold)" label="Zoom lens" /><SK combo="Delete" label="Delete selection" /></Section> |
| <Section title="Editing"><SK combo="Ctrl + Z" label="Undo" /><SK combo="Ctrl + Shift + Z" label="Redo" /><SK combo="Ctrl + A" label="Select all" /><SK combo="Ctrl + D" label="Duplicate" /><SK combo="Ctrl + G" label="Group" /><SK combo="Ctrl + Shift + G" label="Ungroup" /></Section> |
| </>} |
| |
| {activeTab === 'privacy' && <> |
| <Header title="Privacy" subtitle="Local-first browsing and capture." /> |
| <Section title="Muse Shield"><div className="p-4 grid grid-cols-2 gap-3"><Stat value={shieldReport.engine_rules.toLocaleString()} label="Filter rules" /><Stat value={shieldReport.blocked_requests.toLocaleString()} label="Requests blocked" /><Stat value={shieldReport.blocked_cosmetic.toLocaleString()} label="Elements hidden" /><Stat 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></Section> |
| <Section title="Data"><InfoRow label="Privacy model" value="Local-first" description="Everything stays on your machine. No telemetry." /></Section> |
| </>} |
|
|
| {activeTab === 'storage' && <> |
| <Header title="Storage & Export" subtitle="Export, import, and manage board data." /> |
| |
| <Section title="Export as .refs (Portable Archive)"> |
| <div className="p-4 space-y-3"> |
| <p className="text-[12px] text-[#808080] leading-relaxed">Creates a standalone <code className="text-white/70">.refs</code> ZIP archive containing all images, metadata, annotations, and layout. Fully portable — can be opened on any machine without network access.</p> |
| <button onClick={handleExportRefs} disabled={isExporting} className={`w-full py-2.5 bg-[#0A84FF]/10 hover:bg-[#0A84FF]/20 border border-[#0A84FF]/30 rounded-xl text-sm flex items-center justify-center gap-2 text-[#0A84FF] font-medium ${isExporting ? 'opacity-50' : ''}`}><FileArchive size={14} />{isExporting ? 'Exporting...' : 'Export Board as .refs'}</button> |
| </div> |
| </Section> |
| |
| <Section title="Export as JSON (Lightweight)"> |
| <div className="p-4 space-y-3"> |
| <p className="text-[12px] text-[#808080] leading-relaxed">Exports board as a JSON file. Images are stored as base64 data URLs or external URLs depending on how they were captured. Smaller file, requires embedded data to be preserved.</p> |
| <button onClick={handleExportJson} 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"><FileDown size={14} /> Export Board as .json</button> |
| </div> |
| </Section> |
| |
| <Section title="Import"> |
| <div className="p-4 space-y-3"> |
| <p className="text-[12px] text-[#808080]">Load a previously exported board file. Replaces current board content.</p> |
| <div className="flex gap-2"> |
| <button onClick={handleImportRefs} className="flex-1 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"><FileUp size={14} /> Import .refs</button> |
| <button onClick={handleImportJson} className="flex-1 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"><FileUp size={14} /> Import .json</button> |
| </div> |
| </div> |
| </Section> |
| |
| <Section title="Board Statistics"> |
| <InfoRow label="Images" value={String(images.length)} description="Reference images on canvas" /> |
| <InfoRow label="Annotations" value={String(annotations.length)} description="Freehand drawing strokes" /> |
| <InfoRow label="Text Notes" value={String(textNotes.length)} description="Rich text note nodes" /> |
| <InfoRow label="Estimated Size" value={dataSize} description="Approximate in-memory data footprint" /> |
| <InfoRow label="Storage Mode" value="Embedded" description="Images stored as base64 inside project — fully portable, no broken links" /> |
| <InfoRow label="Auto-save" value="Every 800ms" description="Debounced write after each canvas change" /> |
| </Section> |
| |
| <Section title="Danger Zone"> |
| <div className="p-4 space-y-3"> |
| <button onClick={handleClearBoard} className="w-full py-2.5 bg-[#FF453A]/10 hover:bg-[#FF453A]/20 border border-[#FF453A]/30 rounded-xl text-sm flex items-center justify-center gap-2 text-[#FF453A]"><Trash2 size={14} /> Clear Current Board</button> |
| </div> |
| </Section> |
| </>} |
|
|
| {activeTab === 'about' && <> |
| <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-[#808080] text-sm leading-relaxed">A local-first artist reference board with embedded browser capture, asset library, annotations, and privacy-respecting storage.</p> |
| <div className="text-[11px] text-[#606060] mt-4">Built with Tauri v2 + Rust + React<br/>{shieldReport.engine_rules.toLocaleString()} adblock filter rules loaded</div> |
| </div> |
| </>} |
| </div> |
| </main> |
| </div> |
|
|
| {} |
| {toastMsg && <div className="absolute bottom-4 left-4 right-4 z-50 pointer-events-none flex justify-center"><div className="bg-[#2A2A2E] border border-white/10 text-white text-[12px] font-medium px-4 py-2.5 rounded-full shadow-2xl backdrop-blur pointer-events-auto max-w-[400px] truncate">{toastMsg}</div></div>} |
| </div> |
| ); |
| }; |
|
|
| function Header({ title, subtitle }: { title: string; subtitle: string }) { return <div><h1 className="text-2xl font-semibold tracking-tight">{title}</h1><p className="text-sm text-[#808080] mt-1">{subtitle}</p></div>; } |
| function Section({ title, children }: { title: string; children: React.ReactNode }) { return <section><h3 className="text-[12px] font-semibold uppercase tracking-wider text-[#808080] mb-3">{title}</h3><div className="rounded-2xl border border-white/8 bg-black/20 overflow-hidden divide-y divide-white/5">{children}</div></section>; } |
| function ToggleRow({ label, description, active, onToggle, icon }: { label: string; description: string; active: boolean; onToggle: () => void; icon?: React.ReactNode }) { return <button type="button" onClick={onToggle} className="w-full px-4 py-3 flex items-center justify-between gap-4 text-left hover:bg-white/[0.03] 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><div className="text-[14px] font-medium">{label}</div><div className="text-[12px] text-[#808080] mt-0.5">{description}</div></div></div><div className={`relative shrink-0 w-[44px] h-[24px] rounded-full transition-colors ${active ? 'bg-[#0A84FF]' : 'bg-[#3A3A3E]'}`}><div className={`absolute top-[2px] w-5 h-5 rounded-full bg-white shadow-md transition-all ${active ? 'left-[22px]' : 'left-[2px]'}`} /></div></button>; } |
| function RangeRow({ label, value, min, max, suffix, onChange }: { label: 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"><span className="text-[13px]">{label}</span><div className="flex items-center gap-3"><span className="text-xs text-[#808080] w-8 text-right">{value}{suffix}</span><input type="range" min={min} max={max} value={value} onChange={e => onChange(Number(e.target.value))} className="w-28 accent-[#0A84FF]" /></div></div>; } |
| function InfoRow({ label, value, description }: { label: string; value: string; description: string }) { return <div className="px-4 py-3 flex items-center justify-between gap-4"><div><div className="text-[13px] font-medium">{label}</div><div className="text-[11px] text-[#808080] mt-0.5">{description}</div></div><div className="text-[11px] text-[#808080] bg-white/5 border border-white/8 rounded-lg px-2 py-1 shrink-0">{value}</div></div>; } |
| function SK({ combo, label }: { combo: string; label: string }) { return <div className="px-4 py-2 flex items-center justify-between gap-4"><span className="text-[13px]">{label}</span><kbd className="bg-black/30 border border-white/10 px-2 py-0.5 rounded-lg text-[11px] text-[#808080] font-mono">{combo}</kbd></div>; } |
| function Stat({ 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>; } |
|
|