feat: SettingsPanel storage tab - add .refs export/import with toast, data size stats, storage mode info
Browse files- src/components/SettingsPanel.tsx +146 -57
src/components/SettingsPanel.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { X, Settings, Keyboard, Monitor, Shield, HardDrive, Info, RefreshCw, Grid3X3,
|
| 2 |
import { useAppStore, type ThemeId } from '../store';
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import { invoke } from '@tauri-apps/api/core';
|
|
@@ -13,32 +13,115 @@ const THEMES: { id: ThemeId; name: string; description: string; colors: string[]
|
|
| 13 |
];
|
| 14 |
|
| 15 |
export const SettingsPanel = () => {
|
| 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 [
|
|
|
|
| 20 |
|
| 21 |
useEffect(() => { if (isSettingsOpen) invoke<ShieldReport>('shield_get_report').then(setShieldReport).catch(() => {}); }, [isSettingsOpen]);
|
|
|
|
| 22 |
|
| 23 |
-
//
|
| 24 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
try {
|
| 26 |
-
const state = { title: boardTitle, images, textNotes, annotations, palettes, zoom, pan };
|
| 27 |
-
const
|
| 28 |
-
const blob = new Blob([
|
| 29 |
const url = URL.createObjectURL(blob);
|
|
|
|
| 30 |
const a = document.createElement('a');
|
| 31 |
-
a.href = url;
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
a.click();
|
| 35 |
-
document.body.removeChild(a);
|
| 36 |
-
URL.revokeObjectURL(url);
|
| 37 |
-
setExportMsg('✓ Board exported');
|
| 38 |
-
setTimeout(() => setExportMsg(''), 3000);
|
| 39 |
} catch (e) {
|
| 40 |
-
|
| 41 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
};
|
| 43 |
|
| 44 |
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
|
@@ -65,29 +148,13 @@ export const SettingsPanel = () => {
|
|
| 65 |
|
| 66 |
{activeTab === 'general' && <>
|
| 67 |
<Header title="General" subtitle="Core behavior for the canvas, window, and workspace." />
|
| 68 |
-
<Section title="Window">
|
| 69 |
-
|
| 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' && <>
|
|
@@ -99,32 +166,50 @@ export const SettingsPanel = () => {
|
|
| 99 |
|
| 100 |
{activeTab === 'privacy' && <>
|
| 101 |
<Header title="Privacy" subtitle="Local-first browsing and capture." />
|
| 102 |
-
<Section title="Muse Shield">
|
| 103 |
-
|
| 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
|
| 116 |
-
|
|
|
|
| 117 |
<div className="p-4 space-y-3">
|
| 118 |
-
<p className="text-[12px] text-[#808080]">
|
| 119 |
-
<button onClick={
|
| 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 |
-
|
| 124 |
-
|
| 125 |
-
<
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
</Section>
|
| 129 |
</>}
|
| 130 |
|
|
@@ -134,11 +219,15 @@ export const SettingsPanel = () => {
|
|
| 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 |
};
|
|
@@ -147,6 +236,6 @@ function Header({ title, subtitle }: { title: string; subtitle: string }) { retu
|
|
| 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>; }
|
|
|
|
| 1 |
+
import { X, Settings, Keyboard, Monitor, Shield, HardDrive, Info, RefreshCw, Grid3X3, Navigation, FileDown, FileUp, FileArchive, Check, Trash2 } from 'lucide-react';
|
| 2 |
import { useAppStore, type ThemeId } from '../store';
|
| 3 |
import { useState, useEffect } from 'react';
|
| 4 |
import { invoke } from '@tauri-apps/api/core';
|
|
|
|
| 13 |
];
|
| 14 |
|
| 15 |
export const SettingsPanel = () => {
|
| 16 |
+
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();
|
| 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 [toastMsg, setToastMsg] = useState('');
|
| 20 |
+
const [isExporting, setIsExporting] = useState(false);
|
| 21 |
|
| 22 |
useEffect(() => { if (isSettingsOpen) invoke<ShieldReport>('shield_get_report').then(setShieldReport).catch(() => {}); }, [isSettingsOpen]);
|
| 23 |
+
useEffect(() => { if (toastMsg) { const t = setTimeout(() => setToastMsg(''), 4000); return () => clearTimeout(t); } }, [toastMsg]);
|
| 24 |
|
| 25 |
+
// Estimate data size (images as base64 take ~1.37x original size)
|
| 26 |
+
const dataSize = (() => {
|
| 27 |
+
let bytes = 0;
|
| 28 |
+
images.forEach(img => { bytes += (img.url?.length || 0); });
|
| 29 |
+
textNotes.forEach(n => { bytes += (n.text?.length || 0) * 2; });
|
| 30 |
+
annotations.forEach(a => { bytes += a.points.length * 16; });
|
| 31 |
+
if (bytes > 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
| 32 |
+
if (bytes > 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
| 33 |
+
return `${bytes} bytes`;
|
| 34 |
+
})();
|
| 35 |
+
|
| 36 |
+
// Export as .refs (portable ZIP with all assets embedded)
|
| 37 |
+
const handleExportRefs = async () => {
|
| 38 |
+
if (isExporting) return;
|
| 39 |
+
setIsExporting(true);
|
| 40 |
try {
|
| 41 |
+
const state = JSON.stringify({ title: boardTitle, images, textNotes, annotations, palettes, zoom, pan });
|
| 42 |
+
const bytes = await invoke<number[]>('refs_export', { stateJson: state });
|
| 43 |
+
const blob = new Blob([new Uint8Array(bytes)], { type: 'application/octet-stream' });
|
| 44 |
const url = URL.createObjectURL(blob);
|
| 45 |
+
const filename = `${(boardTitle || 'board').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, '-').toLowerCase() || 'board'}.refs`;
|
| 46 |
const a = document.createElement('a');
|
| 47 |
+
a.href = url; a.download = filename;
|
| 48 |
+
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
|
| 49 |
+
setToastMsg(`✓ Exported "${filename}" — portable board with all images embedded`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
} catch (e) {
|
| 51 |
+
setToastMsg(`✗ Export failed: ${e}`);
|
| 52 |
}
|
| 53 |
+
setIsExporting(false);
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
// Export as JSON (lightweight, URLs only)
|
| 57 |
+
const handleExportJson = () => {
|
| 58 |
+
try {
|
| 59 |
+
const state = { title: boardTitle, images, textNotes, annotations, palettes, zoom, pan };
|
| 60 |
+
const json = JSON.stringify(state, null, 2);
|
| 61 |
+
const blob = new Blob([json], { type: 'application/json' });
|
| 62 |
+
const url = URL.createObjectURL(blob);
|
| 63 |
+
const filename = `${(boardTitle || 'board').replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, '-').toLowerCase() || 'board'}.json`;
|
| 64 |
+
const a = document.createElement('a'); a.href = url; a.download = filename;
|
| 65 |
+
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
|
| 66 |
+
setToastMsg(`✓ Exported "${filename}" — lightweight JSON (images as URLs/base64)`);
|
| 67 |
+
} catch (e) { setToastMsg(`✗ Export failed: ${e}`); }
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
// Import .refs file
|
| 71 |
+
const handleImportRefs = () => {
|
| 72 |
+
const input = document.createElement('input');
|
| 73 |
+
input.type = 'file'; input.accept = '.refs';
|
| 74 |
+
input.onchange = async (e) => {
|
| 75 |
+
const file = (e.target as HTMLInputElement).files?.[0];
|
| 76 |
+
if (!file) return;
|
| 77 |
+
try {
|
| 78 |
+
const buffer = await file.arrayBuffer();
|
| 79 |
+
const bytes = Array.from(new Uint8Array(buffer));
|
| 80 |
+
const stateJson = await invoke<string>('refs_import', { data: bytes });
|
| 81 |
+
const state = JSON.parse(stateJson);
|
| 82 |
+
if (state.images) setImages(state.images);
|
| 83 |
+
if (state.textNotes) setTextNotes(state.textNotes);
|
| 84 |
+
if (state.annotations) setAnnotations(state.annotations);
|
| 85 |
+
if (state.palettes) setPalettes(state.palettes);
|
| 86 |
+
if (state.zoom) setZoom(state.zoom);
|
| 87 |
+
if (state.pan) setPan(state.pan);
|
| 88 |
+
if (state.title) setBoardTitle(state.title);
|
| 89 |
+
setCurrentScreen('board');
|
| 90 |
+
setToastMsg(`✓ Imported "${file.name}" — ${state.images?.length || 0} images loaded`);
|
| 91 |
+
} catch (e) { setToastMsg(`✗ Import failed: ${e}`); }
|
| 92 |
+
};
|
| 93 |
+
input.click();
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
// Import JSON
|
| 97 |
+
const handleImportJson = () => {
|
| 98 |
+
const input = document.createElement('input');
|
| 99 |
+
input.type = 'file'; input.accept = '.json';
|
| 100 |
+
input.onchange = async (e) => {
|
| 101 |
+
const file = (e.target as HTMLInputElement).files?.[0];
|
| 102 |
+
if (!file) return;
|
| 103 |
+
try {
|
| 104 |
+
const text = await file.text();
|
| 105 |
+
const state = JSON.parse(text);
|
| 106 |
+
if (state.images) setImages(state.images);
|
| 107 |
+
if (state.textNotes) setTextNotes(state.textNotes);
|
| 108 |
+
if (state.annotations) setAnnotations(state.annotations);
|
| 109 |
+
if (state.palettes) setPalettes(state.palettes);
|
| 110 |
+
if (state.zoom) setZoom(state.zoom);
|
| 111 |
+
if (state.pan) setPan(state.pan);
|
| 112 |
+
if (state.title) setBoardTitle(state.title);
|
| 113 |
+
setCurrentScreen('board');
|
| 114 |
+
setToastMsg(`✓ Imported "${file.name}"`);
|
| 115 |
+
} catch (e) { setToastMsg(`✗ Import failed: ${e}`); }
|
| 116 |
+
};
|
| 117 |
+
input.click();
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
// Clear board
|
| 121 |
+
const handleClearBoard = () => {
|
| 122 |
+
if (!confirm('Clear all images, notes, and annotations from the current board? This cannot be undone.')) return;
|
| 123 |
+
setImages([]); setTextNotes([]); setAnnotations([]); setPalettes([]);
|
| 124 |
+
setToastMsg('Board cleared');
|
| 125 |
};
|
| 126 |
|
| 127 |
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
|
|
|
| 148 |
|
| 149 |
{activeTab === 'general' && <>
|
| 150 |
<Header title="General" subtitle="Core behavior for the canvas, window, and workspace." />
|
| 151 |
+
<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>
|
| 152 |
+
<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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
</>}
|
| 154 |
|
| 155 |
{activeTab === 'appearance' && <>
|
| 156 |
<Header title="Appearance" subtitle="Choose a workspace theme for long sessions." />
|
| 157 |
+
<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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
</>}
|
| 159 |
|
| 160 |
{activeTab === 'shortcuts' && <>
|
|
|
|
| 166 |
|
| 167 |
{activeTab === 'privacy' && <>
|
| 168 |
<Header title="Privacy" subtitle="Local-first browsing and capture." />
|
| 169 |
+
<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>
|
| 170 |
+
<Section title="Data"><InfoRow label="Privacy model" value="Local-first" description="Everything stays on your machine. No telemetry." /></Section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
</>}
|
| 172 |
|
| 173 |
{activeTab === 'storage' && <>
|
| 174 |
+
<Header title="Storage & Export" subtitle="Export, import, and manage board data." />
|
| 175 |
+
|
| 176 |
+
<Section title="Export as .refs (Portable Archive)">
|
| 177 |
<div className="p-4 space-y-3">
|
| 178 |
+
<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>
|
| 179 |
+
<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>
|
|
|
|
| 180 |
</div>
|
| 181 |
</Section>
|
| 182 |
+
|
| 183 |
+
<Section title="Export as JSON (Lightweight)">
|
| 184 |
+
<div className="p-4 space-y-3">
|
| 185 |
+
<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>
|
| 186 |
+
<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>
|
| 187 |
+
</div>
|
| 188 |
+
</Section>
|
| 189 |
+
|
| 190 |
+
<Section title="Import">
|
| 191 |
+
<div className="p-4 space-y-3">
|
| 192 |
+
<p className="text-[12px] text-[#808080]">Load a previously exported board file. Replaces current board content.</p>
|
| 193 |
+
<div className="flex gap-2">
|
| 194 |
+
<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>
|
| 195 |
+
<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>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
</Section>
|
| 199 |
+
|
| 200 |
+
<Section title="Board Statistics">
|
| 201 |
+
<InfoRow label="Images" value={String(images.length)} description="Reference images on canvas" />
|
| 202 |
+
<InfoRow label="Annotations" value={String(annotations.length)} description="Freehand drawing strokes" />
|
| 203 |
+
<InfoRow label="Text Notes" value={String(textNotes.length)} description="Rich text note nodes" />
|
| 204 |
+
<InfoRow label="Estimated Size" value={dataSize} description="Approximate in-memory data footprint" />
|
| 205 |
+
<InfoRow label="Storage Mode" value="Embedded" description="Images stored as base64 inside project — fully portable, no broken links" />
|
| 206 |
+
<InfoRow label="Auto-save" value="Every 800ms" description="Debounced write after each canvas change" />
|
| 207 |
+
</Section>
|
| 208 |
+
|
| 209 |
+
<Section title="Danger Zone">
|
| 210 |
+
<div className="p-4 space-y-3">
|
| 211 |
+
<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>
|
| 212 |
+
</div>
|
| 213 |
</Section>
|
| 214 |
</>}
|
| 215 |
|
|
|
|
| 219 |
<h1 className="text-2xl font-semibold">Refstudio</h1>
|
| 220 |
<p className="text-[#808080]">1.0.0-alpha</p>
|
| 221 |
<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>
|
| 222 |
+
<div className="text-[11px] text-[#606060] mt-4">Built with Tauri v2 + Rust + React<br/>{shieldReport.engine_rules.toLocaleString()} adblock filter rules loaded</div>
|
| 223 |
</div>
|
| 224 |
</>}
|
| 225 |
</div>
|
| 226 |
</main>
|
| 227 |
</div>
|
| 228 |
+
|
| 229 |
+
{/* Toast notification */}
|
| 230 |
+
{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>}
|
| 231 |
</div>
|
| 232 |
);
|
| 233 |
};
|
|
|
|
| 236 |
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>; }
|
| 237 |
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>; }
|
| 238 |
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>; }
|
| 239 |
+
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>; }
|
| 240 |
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>; }
|
| 241 |
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>; }
|