fix: SettingsPanel uses only existing commands, export works as JSON download, remove non-existent imports"
Browse files- src/components/SettingsPanel.tsx +120 -68
src/components/SettingsPanel.tsx
CHANGED
|
@@ -1,18 +1,14 @@
|
|
| 1 |
-
import
|
| 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';
|
| 7 |
|
| 8 |
-
type Tab = 'general' | 'appearance' | 'shortcuts' | 'privacy' | '
|
| 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
|
| 16 |
{ id: 'midnight', name: 'Midnight', description: 'Deep blue-black for late night sessions with reduced eye strain.', colors: ['#0F1319', '#171D28', '#253040', '#6C89E8'] },
|
| 17 |
];
|
| 18 |
|
|
@@ -20,81 +16,137 @@ 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 [
|
| 24 |
-
const [master, setMaster] = useState('');
|
| 25 |
-
const [creds, setCreds] = useState<CredentialSummary[]>([]);
|
| 26 |
-
const [origin, setOrigin] = useState('');
|
| 27 |
-
const [username, setUsername] = useState('');
|
| 28 |
-
const [password, setPassword] = useState('');
|
| 29 |
-
const [vaultMsg, setVaultMsg] = useState('');
|
| 30 |
-
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null);
|
| 31 |
-
const [storageLoading, setStorageLoading] = useState(false);
|
| 32 |
-
const [storageMsg, setStorageMsg] = useState('');
|
| 33 |
|
| 34 |
useEffect(() => { if (isSettingsOpen) invoke<ShieldReport>('shield_get_report').then(setShieldReport).catch(() => {}); }, [isSettingsOpen]);
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
return (
|
| 48 |
-
<div className={`absolute right-4 top-4 bottom-4 w-[min(
|
| 49 |
-
<div className="h-14 border-b border-white/5 flex items-center justify-between px-5 bg-black/20 shrink-0">
|
|
|
|
|
|
|
|
|
|
| 50 |
<div className="flex flex-1 min-h-0 overflow-hidden">
|
| 51 |
-
<nav className="w-[
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
<
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
<
|
| 62 |
-
<div className="flex gap-2">{t.colors.map(c => <div key={c} className="w-8 h-8 rounded-lg border border-black/30
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
<
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
</div>
|
| 88 |
</div>
|
| 89 |
);
|
| 90 |
};
|
| 91 |
|
| 92 |
-
function
|
| 93 |
-
function
|
| 94 |
-
function
|
| 95 |
-
function
|
| 96 |
-
function
|
| 97 |
-
function
|
| 98 |
-
function
|
| 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>; }
|
|
|
|
| 1 |
+
import { X, Settings, Keyboard, Monitor, Shield, HardDrive, Info, RefreshCw, Grid3X3, Palette, Navigation, MousePointer2, FolderOpen, FileDown, Check } from 'lucide-react';
|
|
|
|
| 2 |
import { useAppStore, type ThemeId } from '../store';
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import { invoke } from '@tauri-apps/api/core';
|
|
|
|
| 5 |
|
| 6 |
+
type Tab = 'general' | 'appearance' | 'shortcuts' | 'privacy' | 'storage' | 'about';
|
| 7 |
interface ShieldReport { blocked_requests: number; blocked_cosmetic: number; https_upgrades: number; engine_rules: number; }
|
|
|
|
|
|
|
| 8 |
|
| 9 |
const THEMES: { id: ThemeId; name: string; description: string; colors: string[] }[] = [
|
| 10 |
{ id: 'dark-canvas', name: 'Dark Canvas', description: 'Neutral dark background optimized for image reference work. Default.', colors: ['#1C1C1E', '#2A2A2E', '#3A3A3E', '#0A84FF'] },
|
| 11 |
+
{ id: 'warm-studio', name: 'Warm Studio', description: 'Warm earthy tones inspired by traditional art studios.', colors: ['#221F1B', '#2E2A24', '#46403A', '#D4A373'] },
|
| 12 |
{ id: 'midnight', name: 'Midnight', description: 'Deep blue-black for late night sessions with reduced eye strain.', colors: ['#0F1319', '#171D28', '#253040', '#6C89E8'] },
|
| 13 |
];
|
| 14 |
|
|
|
|
| 16 |
const { isSettingsOpen, setIsSettingsOpen, isAlwaysOnTop, setIsAlwaysOnTop, bgOpacity, setBgOpacity, showMinimap, setShowMinimap, showGrid, setShowGrid, theme, setTheme, images, textNotes, annotations, palettes, zoom, pan, boardTitle } = useAppStore();
|
| 17 |
const [activeTab, setActiveTab] = useState<Tab>('general');
|
| 18 |
const [shieldReport, setShieldReport] = useState<ShieldReport>({ blocked_requests: 0, blocked_cosmetic: 0, https_upgrades: 0, engine_rules: 0 });
|
| 19 |
+
const [exportMsg, setExportMsg] = useState('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
useEffect(() => { if (isSettingsOpen) invoke<ShieldReport>('shield_get_report').then(setShieldReport).catch(() => {}); }, [isSettingsOpen]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
// Export board as JSON file download (works without any Rust command)
|
| 24 |
+
const handleExportBoard = () => {
|
| 25 |
+
try {
|
| 26 |
+
const state = { title: boardTitle, images, textNotes, annotations, palettes, zoom, pan };
|
| 27 |
+
const json = JSON.stringify(state, null, 2);
|
| 28 |
+
const blob = new Blob([json], { type: 'application/json' });
|
| 29 |
+
const url = URL.createObjectURL(blob);
|
| 30 |
+
const a = document.createElement('a');
|
| 31 |
+
a.href = url;
|
| 32 |
+
a.download = `${(boardTitle || 'board').replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}.refstudio.json`;
|
| 33 |
+
document.body.appendChild(a);
|
| 34 |
+
a.click();
|
| 35 |
+
document.body.removeChild(a);
|
| 36 |
+
URL.revokeObjectURL(url);
|
| 37 |
+
setExportMsg('✓ Board exported');
|
| 38 |
+
setTimeout(() => setExportMsg(''), 3000);
|
| 39 |
+
} catch (e) {
|
| 40 |
+
setExportMsg(`Export failed: ${e}`);
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
| 45 |
+
{ id: 'general', label: 'General', icon: <Settings size={16} /> },
|
| 46 |
+
{ id: 'appearance', label: 'Appearance', icon: <Monitor size={16} /> },
|
| 47 |
+
{ id: 'shortcuts', label: 'Shortcuts', icon: <Keyboard size={16} /> },
|
| 48 |
+
{ id: 'privacy', label: 'Privacy', icon: <Shield size={16} /> },
|
| 49 |
+
{ id: 'storage', label: 'Storage & Export', icon: <HardDrive size={16} /> },
|
| 50 |
+
{ id: 'about', label: 'About', icon: <Info size={16} /> },
|
| 51 |
+
];
|
| 52 |
|
| 53 |
return (
|
| 54 |
+
<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)]'}`}>
|
| 55 |
+
<div className="h-14 border-b border-white/5 flex items-center justify-between px-5 bg-black/20 shrink-0">
|
| 56 |
+
<h2 className="text-white font-medium flex items-center gap-2"><Settings size={16} className="text-[#808080]" /> Settings</h2>
|
| 57 |
+
<button onClick={() => setIsSettingsOpen(false)} className="text-[#808080] hover:text-white p-1 rounded-md hover:bg-white/5"><X size={18} /></button>
|
| 58 |
+
</div>
|
| 59 |
<div className="flex flex-1 min-h-0 overflow-hidden">
|
| 60 |
+
<nav className="w-[180px] bg-black/10 border-r border-white/5 p-3 flex flex-col gap-1 shrink-0 overflow-y-auto">
|
| 61 |
+
{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>)}
|
| 62 |
+
</nav>
|
| 63 |
+
<main className="flex-1 min-w-0 overflow-y-auto custom-scrollbar">
|
| 64 |
+
<div className="p-6 pb-10 text-white text-sm max-w-[480px] mx-auto space-y-6">
|
| 65 |
|
| 66 |
+
{activeTab === 'general' && <>
|
| 67 |
+
<Header title="General" subtitle="Core behavior for the canvas, window, and workspace." />
|
| 68 |
+
<Section title="Window">
|
| 69 |
+
<ToggleRow label="Always on Top" description="Keep above other apps while drawing." active={isAlwaysOnTop} onToggle={() => setIsAlwaysOnTop(!isAlwaysOnTop)} />
|
| 70 |
+
{isAlwaysOnTop && <RangeRow label="Overlay opacity" value={bgOpacity} min={10} max={100} suffix="%" onChange={setBgOpacity} />}
|
| 71 |
+
</Section>
|
| 72 |
+
<Section title="Canvas">
|
| 73 |
+
<ToggleRow icon={<Grid3X3 size={16} />} label="Grid" description="Show dotted canvas grid." active={showGrid} onToggle={() => setShowGrid(!showGrid)} />
|
| 74 |
+
<ToggleRow icon={<Navigation size={16} />} label="Minimap" description="Compact board overview in bottom-right." active={showMinimap} onToggle={() => setShowMinimap(!showMinimap)} />
|
| 75 |
+
</Section>
|
| 76 |
+
</>}
|
| 77 |
|
| 78 |
+
{activeTab === 'appearance' && <>
|
| 79 |
+
<Header title="Appearance" subtitle="Choose a workspace theme for long sessions." />
|
| 80 |
+
<Section title="Theme">
|
| 81 |
+
<div className="p-4 grid gap-3">
|
| 82 |
+
{THEMES.map(t => (
|
| 83 |
+
<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'}`}>
|
| 84 |
+
<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>
|
| 85 |
+
<div className="text-[14px] font-semibold">{t.name}</div>
|
| 86 |
+
<div className="text-[12px] text-[#808080] mt-1">{t.description}</div>
|
| 87 |
+
</button>
|
| 88 |
+
))}
|
| 89 |
+
</div>
|
| 90 |
+
</Section>
|
| 91 |
+
</>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
+
{activeTab === 'shortcuts' && <>
|
| 94 |
+
<Header title="Keyboard Shortcuts" subtitle="Hotkey-first controls for one-hand artist workflow." />
|
| 95 |
+
<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>
|
| 96 |
+
<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>
|
| 97 |
+
<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>
|
| 98 |
+
</>}
|
| 99 |
|
| 100 |
+
{activeTab === 'privacy' && <>
|
| 101 |
+
<Header title="Privacy" subtitle="Local-first browsing and capture." />
|
| 102 |
+
<Section title="Muse Shield">
|
| 103 |
+
<div className="p-4 grid grid-cols-2 gap-3">
|
| 104 |
+
<Stat value={shieldReport.engine_rules.toLocaleString()} label="Filter rules" />
|
| 105 |
+
<Stat value={shieldReport.blocked_requests.toLocaleString()} label="Requests blocked" />
|
| 106 |
+
<Stat value={shieldReport.blocked_cosmetic.toLocaleString()} label="Elements hidden" />
|
| 107 |
+
<Stat value={shieldReport.https_upgrades.toLocaleString()} label="HTTPS upgrades" />
|
| 108 |
+
</div>
|
| 109 |
+
<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>
|
| 110 |
+
</Section>
|
| 111 |
+
<Section title="Data"><InfoRow label="Privacy model" value="Local-first" description="Boards, library, and history stay on your machine. Nothing leaves your device." /></Section>
|
| 112 |
+
</>}
|
| 113 |
|
| 114 |
+
{activeTab === 'storage' && <>
|
| 115 |
+
<Header title="Storage & Export" subtitle="Export boards and manage local data." />
|
| 116 |
+
<Section title="Export Current Board">
|
| 117 |
+
<div className="p-4 space-y-3">
|
| 118 |
+
<p className="text-[12px] text-[#808080]">Download the current board as a JSON file. Contains all image URLs, positions, annotations, and settings. Images stored as external URLs (not embedded).</p>
|
| 119 |
+
<button onClick={handleExportBoard} 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 (.json)</button>
|
| 120 |
+
{exportMsg && <div className="text-xs text-[#FFD60A] bg-[#FFD60A]/10 border border-[#FFD60A]/20 rounded-lg px-3 py-2">{exportMsg}</div>}
|
| 121 |
+
</div>
|
| 122 |
+
</Section>
|
| 123 |
+
<Section title="Info">
|
| 124 |
+
<InfoRow label="Images on board" value={String(images.length)} description="Reference images currently on canvas." />
|
| 125 |
+
<InfoRow label="Annotations" value={String(annotations.length)} description="Freehand drawing strokes." />
|
| 126 |
+
<InfoRow label="Text notes" value={String(textNotes.length)} description="Rich text note nodes." />
|
| 127 |
+
<InfoRow label="Auto-save" value="800ms" description="Debounced write after every edit." />
|
| 128 |
+
</Section>
|
| 129 |
+
</>}
|
| 130 |
|
| 131 |
+
{activeTab === 'about' && <>
|
| 132 |
+
<div className="flex flex-col items-center text-center mt-8 gap-3">
|
| 133 |
+
<div className="w-20 h-20 bg-[#2A2A2E] rounded-2xl flex items-center justify-center"><Monitor size={32} className="text-[#0A84FF]" /></div>
|
| 134 |
+
<h1 className="text-2xl font-semibold">Refstudio</h1>
|
| 135 |
+
<p className="text-[#808080]">1.0.0-alpha</p>
|
| 136 |
+
<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>
|
| 137 |
+
</div>
|
| 138 |
+
</>}
|
| 139 |
+
</div>
|
| 140 |
+
</main>
|
| 141 |
</div>
|
| 142 |
</div>
|
| 143 |
);
|
| 144 |
};
|
| 145 |
|
| 146 |
+
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>; }
|
| 147 |
+
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>; }
|
| 148 |
+
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>; }
|
| 149 |
+
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>; }
|
| 150 |
+
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">{value}</div></div>; }
|
| 151 |
+
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>; }
|
| 152 |
+
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>; }
|
|
|
|
|
|