🎨 Complete Coinbase design system rebuild: white canvas, single blue accent, pill CTAs, editorial spacing, hairline borders, dark hero bands, asset rows, mono numbers, 96px section rhythm
Browse files- src/renderer/App.tsx +33 -104
- src/renderer/components/Sidebar.tsx +40 -101
- src/renderer/components/TopBar.tsx +26 -79
- src/renderer/components/ui/index.tsx +74 -203
- src/renderer/index.css +140 -203
- src/renderer/index.html +7 -6
- src/renderer/pages/Dashboard.tsx +87 -178
- src/renderer/pages/HistoryPage.tsx +34 -65
- src/renderer/pages/LockScreen.tsx +35 -89
- src/renderer/pages/OnboardingScreen.tsx +78 -124
- src/renderer/pages/SecurityPage.tsx +66 -125
- src/renderer/pages/SendPage.tsx +73 -104
- src/renderer/pages/SettingsPage.tsx +55 -72
- src/renderer/pages/VoicePage.tsx +92 -178
- tailwind.config.js +70 -71
src/renderer/App.tsx
CHANGED
|
@@ -18,148 +18,77 @@ type AppState = 'loading' | 'onboarding' | 'locked' | 'unlocked';
|
|
| 18 |
function AppContent() {
|
| 19 |
const [appState, setAppState] = useState<AppState>('loading');
|
| 20 |
const [currentPage, setCurrentPage] = useState<Page>('dashboard');
|
| 21 |
-
const [prevPage, setPrevPage] = useState<Page>('dashboard');
|
| 22 |
const [publicKey, setPublicKey] = useState<string | null>(null);
|
| 23 |
const [balance, setBalance] = useState({ sol: 0, usdt: 0 });
|
| 24 |
const [aiStatus, setAiStatus] = useState<any>(null);
|
| 25 |
const { addToast } = useToast();
|
| 26 |
|
| 27 |
-
useEffect(() => {
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
if (!window.solvox) return;
|
| 31 |
-
return window.solvox.on.locked(() => { setAppState('locked'); });
|
| 32 |
-
}, []);
|
| 33 |
-
|
| 34 |
-
const checkWalletState = async () => {
|
| 35 |
try {
|
| 36 |
if (!window.solvox) { setAppState('unlocked'); return; }
|
| 37 |
const exists = await window.solvox.wallet.exists();
|
| 38 |
if (!exists) { setAppState('onboarding'); return; }
|
| 39 |
const unlocked = await window.solvox.wallet.isUnlocked();
|
| 40 |
setAppState(unlocked ? 'unlocked' : 'locked');
|
| 41 |
-
if (unlocked) {
|
| 42 |
-
setPublicKey(await window.solvox.wallet.getPublicKey());
|
| 43 |
-
refreshBalance();
|
| 44 |
-
}
|
| 45 |
} catch { setAppState('onboarding'); }
|
| 46 |
};
|
| 47 |
|
| 48 |
const refreshBalance = async () => {
|
| 49 |
-
try {
|
| 50 |
-
if (!window.solvox) return;
|
| 51 |
-
const r = await window.solvox.wallet.getBalance();
|
| 52 |
-
if (r.success) setBalance({ sol: r.sol || 0, usdt: r.usdt || 0 });
|
| 53 |
-
} catch {}
|
| 54 |
-
};
|
| 55 |
-
|
| 56 |
-
const handleUnlock = async (pk: string) => {
|
| 57 |
-
setPublicKey(pk);
|
| 58 |
-
setAppState('unlocked');
|
| 59 |
-
refreshBalance();
|
| 60 |
-
addToast({ type: 'success', title: 'Wallet Unlocked', message: 'Welcome back to SolVox' });
|
| 61 |
-
if (window.solvox) {
|
| 62 |
-
window.solvox.ai.initialize().then(r => {
|
| 63 |
-
if (r.success) {
|
| 64 |
-
window.solvox.ai.getStatus().then(setAiStatus);
|
| 65 |
-
addToast({ type: 'info', title: 'AI Engine Ready', message: '6 QVAC modules loaded locally' });
|
| 66 |
-
}
|
| 67 |
-
});
|
| 68 |
-
}
|
| 69 |
};
|
| 70 |
|
| 71 |
-
const
|
| 72 |
-
setPublicKey(pk);
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
addToast({ type: 'success', title: 'Wallet Created!', message: 'Your keys are encrypted and stored locally' });
|
| 76 |
};
|
| 77 |
|
| 78 |
-
const handleLock = async () => {
|
| 79 |
-
if (window.solvox) await window.solvox.wallet.lock();
|
| 80 |
-
setAppState('locked');
|
| 81 |
-
};
|
| 82 |
-
|
| 83 |
-
const navigate = (page: Page) => {
|
| 84 |
-
setPrevPage(currentPage);
|
| 85 |
-
setCurrentPage(page);
|
| 86 |
-
};
|
| 87 |
|
| 88 |
-
if (appState === 'loading') return <
|
| 89 |
-
if (appState === 'onboarding') return <OnboardingScreen onComplete={
|
| 90 |
if (appState === 'locked') return <LockScreen onUnlock={handleUnlock} />;
|
| 91 |
|
| 92 |
-
const
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
case 'security': return <SecurityPage {...props} />;
|
| 100 |
-
case 'settings': return <SettingsPage {...props} onLock={handleLock} />;
|
| 101 |
-
default: return <Dashboard balance={balance} publicKey={publicKey} onRefresh={refreshBalance} onNavigate={navigate} />;
|
| 102 |
-
}
|
| 103 |
};
|
| 104 |
|
| 105 |
return (
|
| 106 |
-
<div className="flex h-screen
|
| 107 |
-
<Sidebar currentPage={currentPage} onNavigate={
|
| 108 |
<div className="flex-1 flex flex-col overflow-hidden">
|
| 109 |
<TopBar publicKey={publicKey} balance={balance} aiStatus={aiStatus} onLock={handleLock} />
|
| 110 |
-
<main className="flex-1 overflow-y-auto
|
| 111 |
-
<div className="page-enter">{
|
| 112 |
</main>
|
| 113 |
</div>
|
| 114 |
</div>
|
| 115 |
);
|
| 116 |
}
|
| 117 |
|
| 118 |
-
export default function App() {
|
| 119 |
-
return (
|
| 120 |
-
<ToastProvider>
|
| 121 |
-
<AppContent />
|
| 122 |
-
</ToastProvider>
|
| 123 |
-
);
|
| 124 |
-
}
|
| 125 |
|
| 126 |
-
function
|
| 127 |
return (
|
| 128 |
-
<div className="h-screen flex flex-col items-center justify-center
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
{Array.from({ length: 12 }, (_, i) => (
|
| 132 |
-
<div key={i} className="particle" style={{
|
| 133 |
-
width: 2 + Math.random() * 3, height: 2 + Math.random() * 3,
|
| 134 |
-
left: `${10 + Math.random() * 80}%`, top: `${10 + Math.random() * 80}%`,
|
| 135 |
-
background: i % 2 === 0 ? '#9945FF' : '#14F195',
|
| 136 |
-
'--dur': `${5 + Math.random() * 5}s`, '--opacity': 0.1 + Math.random() * 0.15,
|
| 137 |
-
'--x1': `${-20 + Math.random() * 40}px`, '--y1': `${-30 + Math.random() * 60}px`,
|
| 138 |
-
'--x2': `${-20 + Math.random() * 40}px`, '--y2': `${-40 + Math.random() * 80}px`,
|
| 139 |
-
'--x3': `${-20 + Math.random() * 40}px`, '--y3': `${-20 + Math.random() * 40}px`,
|
| 140 |
-
} as any} />
|
| 141 |
-
))}
|
| 142 |
</div>
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
<div className="
|
| 146 |
-
<div className="
|
| 147 |
-
<span className="text-white text-2xl font-black">SV</span>
|
| 148 |
-
</div>
|
| 149 |
-
<div className="text-4xl font-extrabold gradient-text mb-2">SolVox</div>
|
| 150 |
-
<div className="text-sol-muted text-sm mb-8">Initializing local AI engine...</div>
|
| 151 |
-
|
| 152 |
-
{/* Orbit rings */}
|
| 153 |
-
<div className="relative w-16 h-16">
|
| 154 |
-
<div className="orbit-ring absolute inset-0" />
|
| 155 |
-
<div className="orbit-ring orbit-ring-2 absolute inset-2" />
|
| 156 |
-
<div className="absolute inset-0 flex items-center justify-center">
|
| 157 |
-
<div className="w-2 h-2 rounded-full bg-sol-purple animate-pulse" />
|
| 158 |
-
</div>
|
| 159 |
-
</div>
|
| 160 |
-
|
| 161 |
-
<div className="mt-6 text-[11px] text-sol-muted/60">Powered by QVAC SDK • 100% Local AI</div>
|
| 162 |
</div>
|
|
|
|
| 163 |
</div>
|
| 164 |
);
|
| 165 |
}
|
|
|
|
| 18 |
function AppContent() {
|
| 19 |
const [appState, setAppState] = useState<AppState>('loading');
|
| 20 |
const [currentPage, setCurrentPage] = useState<Page>('dashboard');
|
|
|
|
| 21 |
const [publicKey, setPublicKey] = useState<string | null>(null);
|
| 22 |
const [balance, setBalance] = useState({ sol: 0, usdt: 0 });
|
| 23 |
const [aiStatus, setAiStatus] = useState<any>(null);
|
| 24 |
const { addToast } = useToast();
|
| 25 |
|
| 26 |
+
useEffect(() => { init(); }, []);
|
| 27 |
+
useEffect(() => { if (window.solvox) return window.solvox.on.locked(() => setAppState('locked')); }, []);
|
| 28 |
|
| 29 |
+
const init = async () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
try {
|
| 31 |
if (!window.solvox) { setAppState('unlocked'); return; }
|
| 32 |
const exists = await window.solvox.wallet.exists();
|
| 33 |
if (!exists) { setAppState('onboarding'); return; }
|
| 34 |
const unlocked = await window.solvox.wallet.isUnlocked();
|
| 35 |
setAppState(unlocked ? 'unlocked' : 'locked');
|
| 36 |
+
if (unlocked) { setPublicKey(await window.solvox.wallet.getPublicKey()); refreshBalance(); }
|
|
|
|
|
|
|
|
|
|
| 37 |
} catch { setAppState('onboarding'); }
|
| 38 |
};
|
| 39 |
|
| 40 |
const refreshBalance = async () => {
|
| 41 |
+
try { if (!window.solvox) return; const r = await window.solvox.wallet.getBalance(); if (r.success) setBalance({ sol: r.sol || 0, usdt: r.usdt || 0 }); } catch {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
};
|
| 43 |
|
| 44 |
+
const handleUnlock = (pk: string) => {
|
| 45 |
+
setPublicKey(pk); setAppState('unlocked'); refreshBalance();
|
| 46 |
+
addToast({ type: 'success', title: 'Wallet unlocked' });
|
| 47 |
+
if (window.solvox) window.solvox.ai.initialize().then(r => { if (r.success) window.solvox.ai.getStatus().then(setAiStatus); });
|
|
|
|
| 48 |
};
|
| 49 |
|
| 50 |
+
const handleLock = async () => { if (window.solvox) await window.solvox.wallet.lock(); setAppState('locked'); };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
+
if (appState === 'loading') return <Loading />;
|
| 53 |
+
if (appState === 'onboarding') return <OnboardingScreen onComplete={pk => { setPublicKey(pk); setAppState('unlocked'); refreshBalance(); }} />;
|
| 54 |
if (appState === 'locked') return <LockScreen onUnlock={handleUnlock} />;
|
| 55 |
|
| 56 |
+
const pages: Record<Page, JSX.Element> = {
|
| 57 |
+
dashboard: <Dashboard balance={balance} publicKey={publicKey} onRefresh={refreshBalance} onNavigate={setCurrentPage} />,
|
| 58 |
+
send: <SendPage balance={balance} onSent={refreshBalance} />,
|
| 59 |
+
history: <HistoryPage />,
|
| 60 |
+
voice: <VoicePage aiStatus={aiStatus} />,
|
| 61 |
+
security: <SecurityPage />,
|
| 62 |
+
settings: <SettingsPage onLock={handleLock} />,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
};
|
| 64 |
|
| 65 |
return (
|
| 66 |
+
<div className="flex h-screen bg-canvas">
|
| 67 |
+
<Sidebar currentPage={currentPage} onNavigate={setCurrentPage} />
|
| 68 |
<div className="flex-1 flex flex-col overflow-hidden">
|
| 69 |
<TopBar publicKey={publicKey} balance={balance} aiStatus={aiStatus} onLock={handleLock} />
|
| 70 |
+
<main className="flex-1 overflow-y-auto">
|
| 71 |
+
<div className="page-enter" key={currentPage}>{pages[currentPage]}</div>
|
| 72 |
</main>
|
| 73 |
</div>
|
| 74 |
</div>
|
| 75 |
);
|
| 76 |
}
|
| 77 |
|
| 78 |
+
export default function App() { return <ToastProvider><AppContent /></ToastProvider>; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
+
function Loading() {
|
| 81 |
return (
|
| 82 |
+
<div className="h-screen flex flex-col items-center justify-center bg-canvas">
|
| 83 |
+
<div className="w-12 h-12 rounded-full bg-primary flex items-center justify-center mb-6">
|
| 84 |
+
<span className="text-on-primary font-bold text-title-md">SV</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
</div>
|
| 86 |
+
<div className="display-text text-display-sm text-ink mb-2">SolVox</div>
|
| 87 |
+
<div className="text-body-md text-body">Initializing local AI engine…</div>
|
| 88 |
+
<div className="mt-8 w-48 h-1 bg-surface-strong rounded-pill overflow-hidden">
|
| 89 |
+
<div className="h-full bg-primary rounded-pill" style={{ width: '60%', animation: 'shimmer 1.5s infinite', background: 'linear-gradient(90deg, #0052ff 0%, #003ecc 50%, #0052ff 100%)', backgroundSize: '200% 100%' }} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
</div>
|
| 91 |
+
<div className="text-caption text-muted mt-4">Powered by QVAC SDK · 100% Local AI</div>
|
| 92 |
</div>
|
| 93 |
);
|
| 94 |
}
|
src/renderer/components/Sidebar.tsx
CHANGED
|
@@ -1,118 +1,57 @@
|
|
| 1 |
-
import React
|
| 2 |
|
| 3 |
-
interface SidebarProps {
|
| 4 |
-
currentPage: string;
|
| 5 |
-
onNavigate: (page: any) => void;
|
| 6 |
-
collapsed?: boolean;
|
| 7 |
-
}
|
| 8 |
|
| 9 |
-
const
|
| 10 |
-
{ id: 'dashboard', label: '
|
| 11 |
-
{ id: 'voice', label: 'Voice AI'
|
| 12 |
-
{ id: 'send', label: 'Send'
|
| 13 |
-
{ id: 'history', label: '
|
| 14 |
-
{ id: 'security', label: 'Security'
|
| 15 |
-
{ id: 'settings', label: 'Settings'
|
| 16 |
];
|
| 17 |
|
| 18 |
export default function Sidebar({ currentPage, onNavigate }: SidebarProps) {
|
| 19 |
-
const [hovered, setHovered] = useState<string | null>(null);
|
| 20 |
-
|
| 21 |
return (
|
| 22 |
-
<aside className="w-[
|
| 23 |
-
{/*
|
| 24 |
-
<div className="px-5 pt-
|
| 25 |
-
<div className="flex items-center gap-2
|
| 26 |
-
<div className="w-
|
| 27 |
-
<span className="text-
|
| 28 |
-
</div>
|
| 29 |
-
<div>
|
| 30 |
-
<div className="text-base font-extrabold tracking-tight gradient-text-static">SolVox</div>
|
| 31 |
-
<div className="text-[10px] text-sol-muted font-medium -mt-0.5">Voice AI Wallet</div>
|
| 32 |
</div>
|
|
|
|
| 33 |
</div>
|
| 34 |
</div>
|
| 35 |
|
| 36 |
-
<div className="
|
| 37 |
|
| 38 |
-
{/*
|
| 39 |
<nav className="flex-1 px-3 py-4 space-y-0.5">
|
| 40 |
-
{
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
isActive
|
| 51 |
-
? 'bg-sol-purple/12 text-sol-text'
|
| 52 |
-
: 'text-sol-muted hover:text-sol-text hover:bg-white/[0.03]'
|
| 53 |
-
}`}
|
| 54 |
-
>
|
| 55 |
-
{/* Active indicator bar */}
|
| 56 |
-
{isActive && <div className="nav-active-bar" />}
|
| 57 |
-
|
| 58 |
-
{/* Icon */}
|
| 59 |
-
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-sm font-bold transition-all duration-300 ${
|
| 60 |
-
isActive
|
| 61 |
-
? 'bg-sol-purple/20 text-sol-purple'
|
| 62 |
-
: 'bg-transparent text-sol-muted group-hover:bg-sol-purple/8 group-hover:text-sol-purple'
|
| 63 |
-
}`}>
|
| 64 |
-
{item.icon}
|
| 65 |
-
</div>
|
| 66 |
-
|
| 67 |
-
{/* Label */}
|
| 68 |
-
<span className={`text-[13px] font-medium transition-colors ${
|
| 69 |
-
isActive ? 'text-sol-text' : ''
|
| 70 |
-
}`}>
|
| 71 |
-
{item.label}
|
| 72 |
-
</span>
|
| 73 |
-
|
| 74 |
-
{/* Hover glow */}
|
| 75 |
-
{(isActive || isHovered) && (
|
| 76 |
-
<div className="absolute inset-0 rounded-xl bg-sol-purple/[0.04] pointer-events-none" />
|
| 77 |
-
)}
|
| 78 |
-
</button>
|
| 79 |
-
);
|
| 80 |
-
})}
|
| 81 |
</nav>
|
| 82 |
|
| 83 |
-
{/* QVAC
|
| 84 |
-
<div className="px-
|
| 85 |
-
<div className="
|
| 86 |
-
|
| 87 |
-
<div className="
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
<div className="text-[11px] font-bold text-tether-green">QVAC Engine</div>
|
| 96 |
-
<div className="text-[9px] text-sol-muted">6 AI modules active</div>
|
| 97 |
-
</div>
|
| 98 |
-
</div>
|
| 99 |
-
|
| 100 |
-
{/* Module dots */}
|
| 101 |
-
<div className="flex items-center gap-1">
|
| 102 |
-
{['LLM', 'STT', 'TTS', 'EMB', 'NMT', 'OCR'].map((m, i) => (
|
| 103 |
-
<div key={m} className="flex flex-col items-center">
|
| 104 |
-
<div className="w-1.5 h-1.5 rounded-full bg-sol-green animate-pulse" style={{ animationDelay: `${i * 200}ms` }} />
|
| 105 |
-
<span className="text-[7px] text-sol-muted mt-0.5">{m}</span>
|
| 106 |
-
</div>
|
| 107 |
-
))}
|
| 108 |
-
</div>
|
| 109 |
-
|
| 110 |
-
<div className="mt-2.5 pt-2 border-t border-sol-border/30">
|
| 111 |
-
<div className="flex items-center gap-1.5">
|
| 112 |
-
<div className="w-1.5 h-1.5 rounded-full bg-sol-green" />
|
| 113 |
-
<span className="text-[9px] text-sol-muted">100% local • Vulkan GPU</span>
|
| 114 |
-
</div>
|
| 115 |
-
</div>
|
| 116 |
</div>
|
| 117 |
</div>
|
| 118 |
</div>
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
|
| 3 |
+
interface SidebarProps { currentPage: string; onNavigate: (page: any) => void; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
const nav = [
|
| 6 |
+
{ id: 'dashboard', label: 'Home' },
|
| 7 |
+
{ id: 'voice', label: 'Voice AI' },
|
| 8 |
+
{ id: 'send', label: 'Send' },
|
| 9 |
+
{ id: 'history', label: 'Transactions' },
|
| 10 |
+
{ id: 'security', label: 'Security' },
|
| 11 |
+
{ id: 'settings', label: 'Settings' },
|
| 12 |
];
|
| 13 |
|
| 14 |
export default function Sidebar({ currentPage, onNavigate }: SidebarProps) {
|
|
|
|
|
|
|
| 15 |
return (
|
| 16 |
+
<aside className="w-[200px] flex flex-col bg-canvas border-r border-hairline">
|
| 17 |
+
{/* Wordmark */}
|
| 18 |
+
<div className="px-5 pt-6 pb-5">
|
| 19 |
+
<div className="flex items-center gap-2">
|
| 20 |
+
<div className="w-7 h-7 rounded-full bg-primary flex items-center justify-center">
|
| 21 |
+
<span className="text-on-primary text-caption-strong font-bold">SV</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
</div>
|
| 23 |
+
<span className="text-title-md text-ink">SolVox</span>
|
| 24 |
</div>
|
| 25 |
</div>
|
| 26 |
|
| 27 |
+
<div className="hairline-soft mx-5" />
|
| 28 |
|
| 29 |
+
{/* Nav Links */}
|
| 30 |
<nav className="flex-1 px-3 py-4 space-y-0.5">
|
| 31 |
+
{nav.map(item => (
|
| 32 |
+
<button key={item.id} onClick={() => onNavigate(item.id)}
|
| 33 |
+
className={`w-full text-left px-3 py-2.5 rounded-lg text-nav-link transition-colors ${
|
| 34 |
+
currentPage === item.id
|
| 35 |
+
? 'text-primary bg-primary/[0.06] font-semibold'
|
| 36 |
+
: 'text-body hover:text-ink hover:bg-surface-soft'
|
| 37 |
+
}`}>
|
| 38 |
+
{item.label}
|
| 39 |
+
</button>
|
| 40 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
</nav>
|
| 42 |
|
| 43 |
+
{/* QVAC Status */}
|
| 44 |
+
<div className="px-4 pb-4">
|
| 45 |
+
<div className="bg-surface-soft rounded-xl p-3">
|
| 46 |
+
<div className="text-caption-strong text-muted uppercase tracking-wider mb-1.5">QVAC Engine</div>
|
| 47 |
+
<div className="flex items-center gap-1.5 flex-wrap">
|
| 48 |
+
{['LLM', 'STT', 'TTS', 'EMB', 'NMT', 'OCR'].map(m => (
|
| 49 |
+
<span key={m} className="badge-pill text-[10px] py-0">{m}</span>
|
| 50 |
+
))}
|
| 51 |
+
</div>
|
| 52 |
+
<div className="flex items-center gap-1.5 mt-2">
|
| 53 |
+
<div className="w-1.5 h-1.5 rounded-full bg-semantic-up" />
|
| 54 |
+
<span className="text-caption text-muted">100% local</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
</div>
|
| 56 |
</div>
|
| 57 |
</div>
|
src/renderer/components/TopBar.tsx
CHANGED
|
@@ -1,98 +1,45 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
-
import {
|
| 3 |
|
| 4 |
-
interface TopBarProps {
|
| 5 |
-
publicKey: string | null;
|
| 6 |
-
balance: { sol: number; usdt: number };
|
| 7 |
-
aiStatus: any;
|
| 8 |
-
onLock: () => void;
|
| 9 |
-
}
|
| 10 |
|
| 11 |
export default function TopBar({ publicKey, balance, aiStatus, onLock }: TopBarProps) {
|
| 12 |
const [copied, setCopied] = useState(false);
|
| 13 |
-
const
|
| 14 |
-
|
| 15 |
-
const
|
| 16 |
-
if (publicKey) {
|
| 17 |
-
navigator.clipboard.writeText(publicKey);
|
| 18 |
-
setCopied(true);
|
| 19 |
-
setTimeout(() => setCopied(false), 2000);
|
| 20 |
-
}
|
| 21 |
-
};
|
| 22 |
-
|
| 23 |
-
const activeModules = aiStatus
|
| 24 |
-
? [aiStatus.llm, aiStatus.transcription, aiStatus.tts, aiStatus.embed, aiStatus.translation, aiStatus.ocr].filter(Boolean).length
|
| 25 |
-
: 0;
|
| 26 |
|
| 27 |
return (
|
| 28 |
-
<header className="h-
|
| 29 |
-
{/* Left
|
| 30 |
<div className="flex items-center gap-3">
|
| 31 |
-
<button
|
| 32 |
-
|
| 33 |
-
className="
|
| 34 |
-
|
| 35 |
-
>
|
| 36 |
-
<div className="w-1.5 h-1.5 rounded-full bg-sol-green" />
|
| 37 |
-
<span className="text-xs font-mono text-sol-muted group-hover:text-sol-text transition-colors">{shortAddress}</span>
|
| 38 |
-
<span className="text-[10px] text-sol-muted">{copied ? '✓' : '⊕'}</span>
|
| 39 |
</button>
|
| 40 |
-
|
| 41 |
-
<div className="badge badge-warn">
|
| 42 |
-
<div className="w-1 h-1 rounded-full bg-warning" />
|
| 43 |
-
Devnet
|
| 44 |
-
</div>
|
| 45 |
</div>
|
| 46 |
|
| 47 |
-
{/* Center
|
| 48 |
-
<div className="flex items-center gap-
|
| 49 |
-
<div className="
|
| 50 |
-
<div className="
|
| 51 |
-
|
| 52 |
-
</div>
|
| 53 |
-
<div>
|
| 54 |
-
<div className="text-[10px] text-sol-muted leading-none">SOL</div>
|
| 55 |
-
<div className="text-sm font-bold text-sol-text leading-tight">
|
| 56 |
-
<AnimatedNumber value={balance.sol} decimals={4} />
|
| 57 |
-
</div>
|
| 58 |
-
</div>
|
| 59 |
</div>
|
| 60 |
-
|
| 61 |
-
<div className="
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-tether-green to-tether-dark flex items-center justify-center">
|
| 65 |
-
<span className="text-white text-[10px] font-bold">₮</span>
|
| 66 |
-
</div>
|
| 67 |
-
<div>
|
| 68 |
-
<div className="text-[10px] text-sol-muted leading-none">USDT</div>
|
| 69 |
-
<div className="text-sm font-bold text-sol-text leading-tight">
|
| 70 |
-
<AnimatedNumber value={balance.usdt} decimals={2} />
|
| 71 |
-
</div>
|
| 72 |
-
</div>
|
| 73 |
</div>
|
| 74 |
</div>
|
| 75 |
|
| 76 |
-
{/* Right
|
| 77 |
<div className="flex items-center gap-3">
|
| 78 |
-
{
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
{activeModules > 0 ? `${activeModules}/6 AI` : 'AI Loading'}
|
| 83 |
-
</span>
|
| 84 |
-
</div>
|
| 85 |
-
|
| 86 |
-
{/* Lock Button */}
|
| 87 |
-
<button
|
| 88 |
-
onClick={onLock}
|
| 89 |
-
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-sol-border/40 text-sol-muted hover:text-danger hover:border-danger/30 hover:bg-danger/5 transition-all"
|
| 90 |
-
>
|
| 91 |
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
| 92 |
-
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
| 93 |
-
</svg>
|
| 94 |
-
<span className="text-[11px] font-medium">Lock</span>
|
| 95 |
-
</button>
|
| 96 |
</div>
|
| 97 |
</header>
|
| 98 |
);
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
+
import { Num } from './ui/index';
|
| 3 |
|
| 4 |
+
interface TopBarProps { publicKey: string | null; balance: { sol: number; usdt: number }; aiStatus: any; onLock: () => void; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export default function TopBar({ publicKey, balance, aiStatus, onLock }: TopBarProps) {
|
| 7 |
const [copied, setCopied] = useState(false);
|
| 8 |
+
const short = publicKey ? `${publicKey.slice(0, 4)}…${publicKey.slice(-4)}` : '—';
|
| 9 |
+
const copy = () => { if (publicKey) { navigator.clipboard.writeText(publicKey); setCopied(true); setTimeout(() => setCopied(false), 1500); } };
|
| 10 |
+
const mods = aiStatus ? [aiStatus.llm, aiStatus.transcription, aiStatus.tts, aiStatus.embed, aiStatus.translation, aiStatus.ocr].filter(Boolean).length : 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
return (
|
| 13 |
+
<header className="h-[56px] border-b border-hairline flex items-center justify-between px-5 bg-canvas">
|
| 14 |
+
{/* Left */}
|
| 15 |
<div className="flex items-center gap-3">
|
| 16 |
+
<button onClick={copy} className="flex items-center gap-2 px-3 py-1.5 rounded-pill bg-surface-strong text-body-sm text-muted hover:text-ink transition-colors">
|
| 17 |
+
<span className="w-1.5 h-1.5 rounded-full bg-semantic-up" />
|
| 18 |
+
<span className="font-mono text-caption">{short}</span>
|
| 19 |
+
<span className="text-caption text-muted-soft">{copied ? '✓' : '⊕'}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
</button>
|
| 21 |
+
<span className="badge-pill text-[10px]">DEVNET</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
</div>
|
| 23 |
|
| 24 |
+
{/* Center — Balances */}
|
| 25 |
+
<div className="flex items-center gap-6">
|
| 26 |
+
<div className="text-center">
|
| 27 |
+
<div className="text-caption text-muted">SOL</div>
|
| 28 |
+
<div className="text-title-sm text-ink"><Num value={balance.sol} decimals={4} /></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
</div>
|
| 30 |
+
<div className="w-px h-6 bg-hairline" />
|
| 31 |
+
<div className="text-center">
|
| 32 |
+
<div className="text-caption text-muted">USDT</div>
|
| 33 |
+
<div className="text-title-sm text-ink"><Num value={balance.usdt} decimals={2} /></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
</div>
|
| 35 |
</div>
|
| 36 |
|
| 37 |
+
{/* Right */}
|
| 38 |
<div className="flex items-center gap-3">
|
| 39 |
+
<span className={`badge-pill text-[10px] ${mods > 0 ? 'badge-pill-blue' : ''}`}>
|
| 40 |
+
{mods > 0 ? `${mods}/6 AI` : 'Loading…'}
|
| 41 |
+
</span>
|
| 42 |
+
<button onClick={onLock} className="btn-secondary text-body-sm py-2 px-3">Lock</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
</div>
|
| 44 |
</header>
|
| 45 |
);
|
src/renderer/components/ui/index.tsx
CHANGED
|
@@ -1,43 +1,29 @@
|
|
| 1 |
import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
|
| 2 |
|
| 3 |
-
/* ══════════════════════════════════════════════════════
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
type ToastType = 'success' | 'error' | 'warning' | 'info';
|
| 8 |
-
interface Toast { id: string; type: ToastType; title: string; message?: string; duration?: number; }
|
| 9 |
-
interface ToastCtx { addToast: (t: Omit<Toast, 'id'>) => void; }
|
| 10 |
-
|
| 11 |
-
const ToastContext = createContext<ToastCtx>({ addToast: () => {} });
|
| 12 |
export const useToast = () => useContext(ToastContext);
|
| 13 |
|
| 14 |
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
| 15 |
const [toasts, setToasts] = useState<Toast[]>([]);
|
| 16 |
-
|
| 17 |
const addToast = useCallback((t: Omit<Toast, 'id'>) => {
|
| 18 |
-
const id = Date.now().toString(36)
|
| 19 |
-
setToasts(
|
| 20 |
-
setTimeout(() => setToasts(
|
| 21 |
}, []);
|
| 22 |
-
|
| 23 |
return (
|
| 24 |
<ToastContext.Provider value={{ addToast }}>
|
| 25 |
{children}
|
| 26 |
-
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2
|
| 27 |
{toasts.map(t => (
|
| 28 |
-
<div key={t.id} className="
|
| 29 |
-
<div className=
|
| 30 |
-
t.type === 'success' ? '
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
}`}>
|
| 35 |
-
<span className="text-lg mt-0.5">
|
| 36 |
-
{t.type === 'success' ? '✅' : t.type === 'error' ? '❌' : t.type === 'warning' ? '⚠️' : 'ℹ️'}
|
| 37 |
-
</span>
|
| 38 |
-
<div className="flex-1 min-w-0">
|
| 39 |
-
<div className="text-sm font-semibold text-sol-text">{t.title}</div>
|
| 40 |
-
{t.message && <div className="text-xs text-sol-muted mt-0.5 truncate">{t.message}</div>}
|
| 41 |
</div>
|
| 42 |
</div>
|
| 43 |
</div>
|
|
@@ -47,187 +33,69 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
| 47 |
);
|
| 48 |
}
|
| 49 |
|
| 50 |
-
/* ════════════════════════════════════
|
| 51 |
-
|
| 52 |
-
═══════════════════════════════════════════════════════════════════════ */
|
| 53 |
-
|
| 54 |
-
interface ModalProps {
|
| 55 |
-
open: boolean;
|
| 56 |
-
onClose: () => void;
|
| 57 |
-
title?: string;
|
| 58 |
-
children: React.ReactNode;
|
| 59 |
-
size?: 'sm' | 'md' | 'lg';
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
export function Modal({ open, onClose, title, children, size = 'md' }: ModalProps) {
|
| 63 |
-
if (!open) return null;
|
| 64 |
-
const widths = { sm: 'max-w-sm', md: 'max-w-lg', lg: 'max-w-2xl' };
|
| 65 |
-
return (
|
| 66 |
-
<div className="fixed inset-0 z-40 flex items-center justify-center fade-in" onClick={onClose}>
|
| 67 |
-
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
| 68 |
-
<div
|
| 69 |
-
className={`relative ${widths[size]} w-full mx-4 glass-strong rounded-3xl p-6 shadow-card scale-in`}
|
| 70 |
-
onClick={e => e.stopPropagation()}
|
| 71 |
-
>
|
| 72 |
-
{title && (
|
| 73 |
-
<div className="flex items-center justify-between mb-5">
|
| 74 |
-
<h3 className="text-lg font-bold text-sol-text">{title}</h3>
|
| 75 |
-
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-sol-muted hover:text-sol-text hover:bg-sol-border/30 transition-all">✕</button>
|
| 76 |
-
</div>
|
| 77 |
-
)}
|
| 78 |
-
{children}
|
| 79 |
-
</div>
|
| 80 |
-
</div>
|
| 81 |
-
);
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
/* ═══════════════════════════════════════════════════════════════════════
|
| 85 |
-
SHIMMER SKELETON
|
| 86 |
-
═══════════════════════════════════════════════════════════════════════ */
|
| 87 |
-
|
| 88 |
-
export function Shimmer({ className = '', height = 'h-4' }: { className?: string; height?: string }) {
|
| 89 |
-
return <div className={`shimmer rounded-lg ${height} ${className}`} />;
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
export function ShimmerCard() {
|
| 93 |
-
return (
|
| 94 |
-
<div className="glass rounded-2xl p-6 space-y-3">
|
| 95 |
-
<Shimmer height="h-3" className="w-24" />
|
| 96 |
-
<Shimmer height="h-8" className="w-40" />
|
| 97 |
-
<Shimmer height="h-3" className="w-32" />
|
| 98 |
-
</div>
|
| 99 |
-
);
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
/* ═══════════════════════════════════════════════════════════════════════
|
| 103 |
-
ANIMATED COUNTER (for balance display)
|
| 104 |
-
═══════════════════════════════════════════════════════════════════════ */
|
| 105 |
-
|
| 106 |
-
export function AnimatedNumber({ value, decimals = 2, prefix = '', suffix = '', className = '' }: {
|
| 107 |
-
value: number; decimals?: number; prefix?: string; suffix?: string; className?: string;
|
| 108 |
-
}) {
|
| 109 |
const [display, setDisplay] = useState(value);
|
| 110 |
-
|
| 111 |
useEffect(() => {
|
| 112 |
const diff = value - display;
|
| 113 |
-
if (Math.abs(diff) < 0.
|
| 114 |
-
const steps = 20;
|
| 115 |
let step = 0;
|
| 116 |
const interval = setInterval(() => {
|
| 117 |
step++;
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
if (step >= steps) { setDisplay(value); clearInterval(interval); }
|
| 122 |
-
}, 25);
|
| 123 |
return () => clearInterval(interval);
|
| 124 |
}, [value]);
|
| 125 |
-
|
| 126 |
-
return (
|
| 127 |
-
<span className={`ticker ${className}`}>
|
| 128 |
-
{prefix}{display.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}{suffix}
|
| 129 |
-
</span>
|
| 130 |
-
);
|
| 131 |
}
|
| 132 |
|
| 133 |
-
/* ═════════════════════════════════════════════════════════
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
export function CircularProgress({ value, size = 80, strokeWidth = 6, color = '#9945FF', children }: {
|
| 138 |
-
value: number; size?: number; strokeWidth?: number; color?: string; children?: React.ReactNode;
|
| 139 |
}) {
|
| 140 |
-
const r = (size - strokeWidth) / 2;
|
| 141 |
-
const circ = 2 * Math.PI * r;
|
| 142 |
-
const offset = circ - (value / 100) * circ;
|
| 143 |
-
return (
|
| 144 |
-
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
| 145 |
-
<svg width={size} height={size} className="-rotate-90">
|
| 146 |
-
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke="rgba(45,45,94,0.4)" strokeWidth={strokeWidth} />
|
| 147 |
-
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke={color} strokeWidth={strokeWidth}
|
| 148 |
-
strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round"
|
| 149 |
-
className="transition-all duration-1000 ease-out" />
|
| 150 |
-
</svg>
|
| 151 |
-
<div className="absolute inset-0 flex items-center justify-center">{children}</div>
|
| 152 |
-
</div>
|
| 153 |
-
);
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
-
/* ═══════════════════════════════════════════════════════════════════════
|
| 157 |
-
PARTICLES BACKGROUND (for lock/onboarding screens)
|
| 158 |
-
═══════════════════════════════════════════════════════════════════════ */
|
| 159 |
-
|
| 160 |
-
export function ParticlesBackground() {
|
| 161 |
-
const particles = Array.from({ length: 20 }, (_, i) => ({
|
| 162 |
-
id: i,
|
| 163 |
-
size: 2 + Math.random() * 4,
|
| 164 |
-
x: Math.random() * 100,
|
| 165 |
-
y: Math.random() * 100,
|
| 166 |
-
dur: 4 + Math.random() * 8,
|
| 167 |
-
opacity: 0.05 + Math.random() * 0.15,
|
| 168 |
-
color: i % 3 === 0 ? '#9945FF' : i % 3 === 1 ? '#14F195' : '#26A17B',
|
| 169 |
-
x1: -30 + Math.random() * 60, y1: -40 + Math.random() * 80,
|
| 170 |
-
x2: -30 + Math.random() * 60, y2: -60 + Math.random() * 120,
|
| 171 |
-
x3: -30 + Math.random() * 60, y3: -30 + Math.random() * 60,
|
| 172 |
-
}));
|
| 173 |
-
|
| 174 |
-
return (
|
| 175 |
-
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
| 176 |
-
{particles.map(p => (
|
| 177 |
-
<div key={p.id} className="particle" style={{
|
| 178 |
-
width: p.size, height: p.size,
|
| 179 |
-
left: `${p.x}%`, top: `${p.y}%`,
|
| 180 |
-
background: p.color,
|
| 181 |
-
'--dur': `${p.dur}s`, '--opacity': p.opacity,
|
| 182 |
-
'--x1': `${p.x1}px`, '--y1': `${p.y1}px`,
|
| 183 |
-
'--x2': `${p.x2}px`, '--y2': `${p.y2}px`,
|
| 184 |
-
'--x3': `${p.x3}px`, '--y3': `${p.y3}px`,
|
| 185 |
-
} as any} />
|
| 186 |
-
))}
|
| 187 |
-
</div>
|
| 188 |
-
);
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
/* ═══════════════════════════════════════════════════════════════════════
|
| 192 |
-
ORBIT LOADER (for AI loading states)
|
| 193 |
-
═══════════════════════════════════════════════════════════════════════ */
|
| 194 |
-
|
| 195 |
-
export function OrbitLoader({ size = 64 }: { size?: number }) {
|
| 196 |
return (
|
| 197 |
-
<div className="
|
| 198 |
-
<div className="
|
| 199 |
-
<div className="
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
</div>
|
| 204 |
</div>
|
| 205 |
);
|
| 206 |
}
|
| 207 |
|
| 208 |
-
/* ════════════════════════════════════════════
|
| 209 |
-
|
| 210 |
-
|
|
|
|
| 211 |
|
|
|
|
| 212 |
export function StepIndicator({ steps, current }: { steps: string[]; current: number }) {
|
| 213 |
return (
|
| 214 |
-
<div className="flex items-center justify-center gap-
|
| 215 |
{steps.map((label, i) => (
|
| 216 |
<React.Fragment key={i}>
|
| 217 |
-
<div className="flex flex-col items-center gap-1">
|
| 218 |
-
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-
|
| 219 |
-
i < current ? 'bg-
|
| 220 |
-
i === current ? 'bg-
|
| 221 |
-
'bg-
|
| 222 |
}`}>
|
| 223 |
{i < current ? '✓' : i + 1}
|
| 224 |
</div>
|
| 225 |
-
<span className={`text-
|
| 226 |
</div>
|
| 227 |
{i < steps.length - 1 && (
|
| 228 |
-
<div className={`w-
|
| 229 |
-
i < current ? 'bg-sol-green' : 'bg-sol-border'
|
| 230 |
-
}`} />
|
| 231 |
)}
|
| 232 |
</React.Fragment>
|
| 233 |
))}
|
|
@@ -235,28 +103,31 @@ export function StepIndicator({ steps, current }: { steps: string[]; current: nu
|
|
| 235 |
);
|
| 236 |
}
|
| 237 |
|
| 238 |
-
/* ═════════════════
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
export function IconButton({ icon, label, onClick, active, color = 'purple', size = 'md' }: {
|
| 243 |
-
icon: string; label: string; onClick?: () => void; active?: boolean; color?: 'purple' | 'green' | 'tether' | 'danger' | 'warning';
|
| 244 |
-
size?: 'sm' | 'md' | 'lg';
|
| 245 |
-
}) {
|
| 246 |
-
const sizes = { sm: 'w-10 h-10 text-lg', md: 'w-14 h-14 text-xl', lg: 'w-20 h-20 text-3xl' };
|
| 247 |
-
const colors = {
|
| 248 |
-
purple: active ? 'bg-sol-purple/20 text-sol-purple border-sol-purple/30' : 'bg-sol-card text-sol-muted border-sol-border hover:border-sol-purple/30 hover:text-sol-purple',
|
| 249 |
-
green: active ? 'bg-sol-green/20 text-sol-green border-sol-green/30' : 'bg-sol-card text-sol-muted border-sol-border hover:border-sol-green/30 hover:text-sol-green',
|
| 250 |
-
tether: active ? 'bg-tether-green/20 text-tether-green border-tether-green/30' : 'bg-sol-card text-sol-muted border-sol-border hover:border-tether-green/30 hover:text-tether-green',
|
| 251 |
-
danger: 'bg-sol-card text-sol-muted border-sol-border hover:border-danger/30 hover:text-danger',
|
| 252 |
-
warning: 'bg-sol-card text-sol-muted border-sol-border hover:border-warning/30 hover:text-warning',
|
| 253 |
-
};
|
| 254 |
return (
|
| 255 |
-
<
|
| 256 |
-
<div className=
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
</div>
|
| 259 |
-
|
| 260 |
-
</button>
|
| 261 |
);
|
| 262 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
|
| 2 |
|
| 3 |
+
/* ═══ TOAST SYSTEM ═══════════════════════════════════════════════════ */
|
| 4 |
+
type ToastType = 'success' | 'error' | 'info';
|
| 5 |
+
interface Toast { id: string; type: ToastType; title: string; message?: string; }
|
| 6 |
+
const ToastContext = createContext<{ addToast: (t: Omit<Toast, 'id'>) => void }>({ addToast: () => {} });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
export const useToast = () => useContext(ToastContext);
|
| 8 |
|
| 9 |
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
| 10 |
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
|
|
| 11 |
const addToast = useCallback((t: Omit<Toast, 'id'>) => {
|
| 12 |
+
const id = Date.now().toString(36);
|
| 13 |
+
setToasts(p => [...p, { ...t, id }]);
|
| 14 |
+
setTimeout(() => setToasts(p => p.filter(x => x.id !== id)), 4000);
|
| 15 |
}, []);
|
|
|
|
| 16 |
return (
|
| 17 |
<ToastContext.Provider value={{ addToast }}>
|
| 18 |
{children}
|
| 19 |
+
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
|
| 20 |
{toasts.map(t => (
|
| 21 |
+
<div key={t.id} className="bg-canvas rounded-xl p-4 shadow-soft border border-hairline min-w-[300px] page-enter cursor-pointer" onClick={() => setToasts(p => p.filter(x => x.id !== t.id))}>
|
| 22 |
+
<div className="flex items-center gap-3">
|
| 23 |
+
<div className={`w-2 h-2 rounded-full ${t.type === 'success' ? 'bg-semantic-up' : t.type === 'error' ? 'bg-semantic-down' : 'bg-primary'}`} />
|
| 24 |
+
<div>
|
| 25 |
+
<div className="text-title-sm text-ink">{t.title}</div>
|
| 26 |
+
{t.message && <div className="text-body-sm text-body mt-0.5">{t.message}</div>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</div>
|
| 28 |
</div>
|
| 29 |
</div>
|
|
|
|
| 33 |
);
|
| 34 |
}
|
| 35 |
|
| 36 |
+
/* ═══ ANIMATED NUMBER (CoinbaseMono) ═════════════════════════════════ */
|
| 37 |
+
export function Num({ value, decimals = 2, prefix = '', suffix = '' }: { value: number; decimals?: number; prefix?: string; suffix?: string }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
const [display, setDisplay] = useState(value);
|
|
|
|
| 39 |
useEffect(() => {
|
| 40 |
const diff = value - display;
|
| 41 |
+
if (Math.abs(diff) < 0.0001) { setDisplay(value); return; }
|
|
|
|
| 42 |
let step = 0;
|
| 43 |
const interval = setInterval(() => {
|
| 44 |
step++;
|
| 45 |
+
setDisplay(display + diff * (1 - Math.pow(1 - step / 16, 3)));
|
| 46 |
+
if (step >= 16) { setDisplay(value); clearInterval(interval); }
|
| 47 |
+
}, 30);
|
|
|
|
|
|
|
| 48 |
return () => clearInterval(interval);
|
| 49 |
}, [value]);
|
| 50 |
+
return <span className="number-mono">{prefix}{display.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}{suffix}</span>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
|
| 53 |
+
/* ═══ ASSET ROW ══════════════════════════════════════════════════════ */
|
| 54 |
+
export function AssetRow({ icon, name, ticker, price, change, onClick }: {
|
| 55 |
+
icon: string; name: string; ticker: string; price: number; change?: number; onClick?: () => void;
|
|
|
|
|
|
|
|
|
|
| 56 |
}) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
return (
|
| 58 |
+
<div className="asset-row px-4 cursor-pointer" onClick={onClick}>
|
| 59 |
+
<div className="asset-icon mr-3 text-sm font-bold text-ink">{icon}</div>
|
| 60 |
+
<div className="flex-1">
|
| 61 |
+
<div className="text-title-sm text-ink">{name}</div>
|
| 62 |
+
<div className="text-caption text-muted">{ticker}</div>
|
| 63 |
+
</div>
|
| 64 |
+
<div className="text-right">
|
| 65 |
+
<div className="number-mono text-number-display text-ink">${price.toLocaleString(undefined, { minimumFractionDigits: 2 })}</div>
|
| 66 |
+
{change !== undefined && (
|
| 67 |
+
<div className={`number-mono text-caption ${change >= 0 ? 'text-semantic-up' : 'text-semantic-down'}`}>
|
| 68 |
+
{change >= 0 ? '+' : ''}{change.toFixed(2)}%
|
| 69 |
+
</div>
|
| 70 |
+
)}
|
| 71 |
</div>
|
| 72 |
</div>
|
| 73 |
);
|
| 74 |
}
|
| 75 |
|
| 76 |
+
/* ═══ PRODUCT UI CARD (Dark) ═════════════════════════════════════════ */
|
| 77 |
+
export function ProductCard({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
| 78 |
+
return <div className={`card-dark ${className}`}>{children}</div>;
|
| 79 |
+
}
|
| 80 |
|
| 81 |
+
/* ═══ STEP INDICATOR ═════════════════════════════════════════════════ */
|
| 82 |
export function StepIndicator({ steps, current }: { steps: string[]; current: number }) {
|
| 83 |
return (
|
| 84 |
+
<div className="flex items-center justify-center gap-3 mb-12">
|
| 85 |
{steps.map((label, i) => (
|
| 86 |
<React.Fragment key={i}>
|
| 87 |
+
<div className="flex flex-col items-center gap-1.5">
|
| 88 |
+
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-caption-strong transition-colors ${
|
| 89 |
+
i < current ? 'bg-primary text-on-primary' :
|
| 90 |
+
i === current ? 'bg-primary text-on-primary' :
|
| 91 |
+
'bg-surface-strong text-muted'
|
| 92 |
}`}>
|
| 93 |
{i < current ? '✓' : i + 1}
|
| 94 |
</div>
|
| 95 |
+
<span className={`text-caption ${i <= current ? 'text-ink' : 'text-muted'}`}>{label}</span>
|
| 96 |
</div>
|
| 97 |
{i < steps.length - 1 && (
|
| 98 |
+
<div className={`w-10 h-0.5 -mt-5 rounded-full ${i < current ? 'bg-primary' : 'bg-surface-strong'}`} />
|
|
|
|
|
|
|
| 99 |
)}
|
| 100 |
</React.Fragment>
|
| 101 |
))}
|
|
|
|
| 103 |
);
|
| 104 |
}
|
| 105 |
|
| 106 |
+
/* ═══ PIPELINE TRACE (shows which QVAC module did what) ══════════════ */
|
| 107 |
+
export function PipelineTrace({ steps }: { steps: Array<{ module: string; operation: string; input: string; output: string; durationMs: number }> }) {
|
| 108 |
+
if (!steps || steps.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
return (
|
| 110 |
+
<div className="bg-surface-soft rounded-xl p-4 mt-3">
|
| 111 |
+
<div className="text-caption-strong text-muted uppercase tracking-wider mb-2">QVAC Pipeline Trace</div>
|
| 112 |
+
<div className="space-y-1.5">
|
| 113 |
+
{steps.map((s, i) => (
|
| 114 |
+
<div key={i} className="flex items-start gap-2 text-caption">
|
| 115 |
+
<div className="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 shrink-0" />
|
| 116 |
+
<div className="flex-1 min-w-0">
|
| 117 |
+
<span className="font-mono text-primary text-caption-strong">{s.module}</span>
|
| 118 |
+
<span className="text-muted mx-1">→</span>
|
| 119 |
+
<span className="text-body">{s.operation}</span>
|
| 120 |
+
<span className="text-muted-soft ml-1 number-mono">{s.durationMs}ms</span>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
))}
|
| 124 |
</div>
|
| 125 |
+
</div>
|
|
|
|
| 126 |
);
|
| 127 |
}
|
| 128 |
+
|
| 129 |
+
/* ═══ RISK BADGE ═════════════════════════════════════════════════════ */
|
| 130 |
+
export function RiskBadge({ level, score }: { level: string; score: number }) {
|
| 131 |
+
const colors: Record<string, string> = { safe: 'badge-pill-green', caution: 'badge-pill', warning: 'badge-pill-red', danger: 'badge-pill-red' };
|
| 132 |
+
return <span className={`badge-pill ${colors[level] || 'badge-pill'}`}>{level.toUpperCase()} · {score}</span>;
|
| 133 |
+
}
|
src/renderer/index.css
CHANGED
|
@@ -2,256 +2,193 @@
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
-
/* ─── Reset
|
| 6 |
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 7 |
|
| 8 |
body {
|
| 9 |
-
font-family: 'Inter', system-ui,
|
| 10 |
-
background: #
|
| 11 |
-
color: #
|
| 12 |
overflow: hidden;
|
| 13 |
height: 100vh;
|
| 14 |
-webkit-app-region: drag;
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
button, input, select, textarea, a, label { -webkit-app-region: no-drag; }
|
| 17 |
|
| 18 |
/* ─── Scrollbar ──────────────────────────────────────────────────────── */
|
| 19 |
-
::-webkit-scrollbar { width:
|
| 20 |
::-webkit-scrollbar-track { background: transparent; }
|
| 21 |
-
::-webkit-scrollbar-thumb { background: #
|
| 22 |
-
::-webkit-scrollbar-thumb:hover { background: #
|
| 23 |
-
|
| 24 |
-
/* ─── Gradient Mesh Background ───────────────────────────────────────── */
|
| 25 |
-
.mesh-bg {
|
| 26 |
-
background:
|
| 27 |
-
radial-gradient(ellipse at 20% 50%, rgba(153, 69, 255, 0.08) 0%, transparent 50%),
|
| 28 |
-
radial-gradient(ellipse at 80% 20%, rgba(20, 241, 149, 0.05) 0%, transparent 50%),
|
| 29 |
-
radial-gradient(ellipse at 50% 80%, rgba(38, 161, 123, 0.06) 0%, transparent 50%),
|
| 30 |
-
#0A0A1E;
|
| 31 |
-
}
|
| 32 |
|
| 33 |
-
/* ───
|
| 34 |
-
.
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
-
|
| 38 |
-
border: 1px solid rgba(153, 69, 255, 0.08);
|
| 39 |
-
}
|
| 40 |
-
.glass-strong {
|
| 41 |
-
background: rgba(26, 26, 62, 0.85);
|
| 42 |
-
backdrop-filter: blur(32px) saturate(1.6);
|
| 43 |
-
-webkit-backdrop-filter: blur(32px) saturate(1.6);
|
| 44 |
-
border: 1px solid rgba(153, 69, 255, 0.12);
|
| 45 |
-
}
|
| 46 |
-
.glass-hover { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
|
| 47 |
-
.glass-hover:hover {
|
| 48 |
-
background: rgba(30, 30, 68, 0.8);
|
| 49 |
-
border-color: rgba(153, 69, 255, 0.2);
|
| 50 |
-
transform: translateY(-2px);
|
| 51 |
-
box-shadow: 0 8px 32px rgba(153, 69, 255, 0.1);
|
| 52 |
}
|
| 53 |
|
| 54 |
-
/* ───
|
| 55 |
-
.
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
| 60 |
-
background-clip: text;
|
| 61 |
-
}
|
| 62 |
-
.gradient-text-static {
|
| 63 |
-
background: linear-gradient(135deg, #9945FF, #14F195);
|
| 64 |
-
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
| 65 |
-
background-clip: text;
|
| 66 |
-
}
|
| 67 |
-
@keyframes gradient-shift {
|
| 68 |
-
0%,100% { background-position: 0% 50%; }
|
| 69 |
-
50% { background-position: 100% 50%; }
|
| 70 |
}
|
| 71 |
|
| 72 |
-
/*
|
| 73 |
-
.glow-purple { box-shadow: 0 0 24px rgba(153, 69, 255, 0.25), 0 0 48px rgba(153, 69, 255, 0.08); }
|
| 74 |
-
.glow-green { box-shadow: 0 0 24px rgba(20, 241, 149, 0.25), 0 0 48px rgba(20, 241, 149, 0.08); }
|
| 75 |
-
.glow-tether { box-shadow: 0 0 24px rgba(38, 161, 123, 0.25), 0 0 48px rgba(38, 161, 123, 0.08); }
|
| 76 |
-
.glow-danger { box-shadow: 0 0 24px rgba(255, 68, 102, 0.3); }
|
| 77 |
-
|
| 78 |
-
/* ─── Page Transitions ───────────────────────────────────────────────── */
|
| 79 |
-
.page-enter { animation: page-in 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
|
| 80 |
-
@keyframes page-in {
|
| 81 |
-
from { opacity: 0; transform: translateY(16px) scale(0.98); }
|
| 82 |
-
to { opacity: 1; transform: translateY(0) scale(1); }
|
| 83 |
-
}
|
| 84 |
-
.slide-enter { animation: slide-up 0.35s cubic-bezier(0.4, 0, 0.2, 1); }
|
| 85 |
-
@keyframes slide-up {
|
| 86 |
-
from { transform: translateY(24px); opacity: 0; }
|
| 87 |
-
to { transform: translateY(0); opacity: 1; }
|
| 88 |
-
}
|
| 89 |
-
.fade-in { animation: fadein 0.3s ease-out; }
|
| 90 |
-
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
|
| 91 |
-
.scale-in { animation: scalein 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
| 92 |
-
@keyframes scalein {
|
| 93 |
-
from { opacity: 0; transform: scale(0.9); }
|
| 94 |
-
to { opacity: 1; transform: scale(1); }
|
| 95 |
-
}
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
background:
|
| 104 |
-
|
| 105 |
-
animation: shimmer 1.5s infinite;
|
| 106 |
}
|
| 107 |
-
|
| 108 |
|
| 109 |
-
|
| 110 |
-
@
|
| 111 |
-
0%, 100% { opacity: 1; transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 68, 102, 0.4); }
|
| 112 |
-
50% { opacity: 0.85; transform: scale(1.08); box-shadow: 0 0 0 16px rgba(255, 68, 102, 0); }
|
| 113 |
}
|
| 114 |
-
.
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
border-top-color: #9945FF;
|
| 120 |
-
border-radius: 50%;
|
| 121 |
-
animation: orbit 2s linear infinite;
|
| 122 |
}
|
| 123 |
-
.
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
/* ─── Floating Particles ─────────────────────────────────────────────── */
|
| 128 |
-
.particle {
|
| 129 |
-
position: absolute;
|
| 130 |
-
border-radius: 50%;
|
| 131 |
-
animation: float var(--dur, 6s) ease-in-out infinite;
|
| 132 |
-
opacity: var(--opacity, 0.15);
|
| 133 |
}
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
| 139 |
}
|
|
|
|
| 140 |
|
| 141 |
-
/*
|
| 142 |
-
.num-scroll { display: inline-block; transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); }
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
background: linear-gradient(135deg, #9945FF, #7B3FE4);
|
| 148 |
-
}
|
| 149 |
-
.btn-primary::before {
|
| 150 |
-
content: '';
|
| 151 |
-
@apply absolute inset-0 rounded-xl opacity-0 transition-opacity duration-300;
|
| 152 |
-
background: linear-gradient(135deg, #B06CFF, #9945FF);
|
| 153 |
}
|
| 154 |
-
.
|
| 155 |
-
|
| 156 |
-
.btn-primary:active { transform: translateY(0); }
|
| 157 |
-
.btn-primary > * { @apply relative z-10; }
|
| 158 |
-
|
| 159 |
-
.btn-ghost {
|
| 160 |
-
@apply rounded-xl font-medium transition-all duration-300 border border-sol-border text-sol-muted;
|
| 161 |
}
|
| 162 |
-
.
|
| 163 |
-
@apply
|
| 164 |
-
background:
|
|
|
|
| 165 |
}
|
| 166 |
|
| 167 |
-
/*
|
|
|
|
| 168 |
.input-field {
|
| 169 |
-
@apply w-full px-4 py-3
|
|
|
|
|
|
|
| 170 |
}
|
| 171 |
.input-field:focus {
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
| 174 |
}
|
|
|
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
.badge-tether { @apply bg-tether-green/15 text-tether-green; }
|
| 181 |
-
.badge-danger { @apply bg-danger/15 text-danger; }
|
| 182 |
-
.badge-warn { @apply bg-warning/15 text-warning; }
|
| 183 |
-
.badge-muted { @apply bg-sol-muted/10 text-sol-muted; }
|
| 184 |
-
|
| 185 |
-
/* ─── Toast / Notification ───────────────────────────────────────────── */
|
| 186 |
-
.toast-enter { animation: toast-slide 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
| 187 |
-
@keyframes toast-slide {
|
| 188 |
-
from { opacity: 0; transform: translateX(100%) scale(0.9); }
|
| 189 |
-
to { opacity: 1; transform: translateX(0) scale(1); }
|
| 190 |
-
}
|
| 191 |
-
.toast-exit { animation: toast-out 0.25s ease-in forwards; }
|
| 192 |
-
@keyframes toast-out {
|
| 193 |
-
to { opacity: 0; transform: translateX(100%) scale(0.9); }
|
| 194 |
}
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
-
|
| 197 |
-
.
|
| 198 |
-
|
|
|
|
| 199 |
}
|
| 200 |
-
.
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
}
|
| 204 |
|
| 205 |
-
/*
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
background:
|
|
|
|
|
|
|
| 209 |
}
|
| 210 |
|
| 211 |
-
/*
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
| 215 |
}
|
|
|
|
|
|
|
| 216 |
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
/* ─── Circular Voice Button Glow ─────────────────────────────────────── */
|
| 222 |
-
.voice-btn-idle {
|
| 223 |
-
box-shadow:
|
| 224 |
-
0 0 0 0 rgba(153, 69, 255, 0.4),
|
| 225 |
-
0 0 20px rgba(153, 69, 255, 0.2),
|
| 226 |
-
inset 0 0 20px rgba(153, 69, 255, 0.1);
|
| 227 |
-
animation: voice-idle 3s ease-in-out infinite;
|
| 228 |
}
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
}
|
| 233 |
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
}
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
| 243 |
}
|
|
|
|
|
|
|
|
|
|
| 244 |
|
| 245 |
-
|
| 246 |
-
.
|
| 247 |
-
.
|
| 248 |
-
.stagger > *:nth-child(2) { animation-delay: 60ms; }
|
| 249 |
-
.stagger > *:nth-child(3) { animation-delay: 120ms; }
|
| 250 |
-
.stagger > *:nth-child(4) { animation-delay: 180ms; }
|
| 251 |
-
.stagger > *:nth-child(5) { animation-delay: 240ms; }
|
| 252 |
-
.stagger > *:nth-child(6) { animation-delay: 300ms; }
|
| 253 |
-
.stagger > *:nth-child(7) { animation-delay: 360ms; }
|
| 254 |
-
.stagger > *:nth-child(8) { animation-delay: 420ms; }
|
| 255 |
-
|
| 256 |
-
/* ─── Number Ticker ──────────────────────────────────────────────────── */
|
| 257 |
-
.ticker { font-variant-numeric: tabular-nums; transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); }
|
|
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
+
/* ─── Reset ──────────────────────────────────────────────────────────── */
|
| 6 |
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 7 |
|
| 8 |
body {
|
| 9 |
+
font-family: 'Inter', -apple-system, system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
| 10 |
+
background: #ffffff;
|
| 11 |
+
color: #0a0b0d;
|
| 12 |
overflow: hidden;
|
| 13 |
height: 100vh;
|
| 14 |
-webkit-app-region: drag;
|
| 15 |
+
-webkit-font-smoothing: antialiased;
|
| 16 |
+
-moz-osx-font-smoothing: grayscale;
|
| 17 |
}
|
| 18 |
button, input, select, textarea, a, label { -webkit-app-region: no-drag; }
|
| 19 |
|
| 20 |
/* ─── Scrollbar ──────────────────────────────────────────────────────── */
|
| 21 |
+
::-webkit-scrollbar { width: 6px; }
|
| 22 |
::-webkit-scrollbar-track { background: transparent; }
|
| 23 |
+
::-webkit-scrollbar-thumb { background: #dee1e6; border-radius: 100px; }
|
| 24 |
+
::-webkit-scrollbar-thumb:hover { background: #7c828a; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
/* ─── Display Type (Inter as CoinbaseDisplay substitute) ─────────────── */
|
| 27 |
+
.display-text {
|
| 28 |
+
font-family: 'Inter', sans-serif;
|
| 29 |
+
font-weight: 400;
|
| 30 |
+
letter-spacing: -0.02em;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
+
/* ─── Mono Numbers ───────────────────────────────────────────────────── */
|
| 34 |
+
.number-mono {
|
| 35 |
+
font-family: 'JetBrains Mono', 'Geist Mono', monospace;
|
| 36 |
+
font-weight: 500;
|
| 37 |
+
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
+
/* ═══ COINBASE BUTTON SYSTEM ═════════════════════════════════════════ */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
+
.btn-primary {
|
| 43 |
+
@apply inline-flex items-center justify-center px-5 py-3 text-button font-semibold text-on-primary rounded-pill transition-colors;
|
| 44 |
+
background-color: #0052ff;
|
| 45 |
+
}
|
| 46 |
+
.btn-primary:hover { background-color: #003ecc; }
|
| 47 |
+
.btn-primary:active { background-color: #003ecc; }
|
| 48 |
+
.btn-primary:disabled { background-color: #a8b8cc; cursor: not-allowed; }
|
| 49 |
|
| 50 |
+
.btn-primary-lg {
|
| 51 |
+
@apply inline-flex items-center justify-center px-8 py-4 text-button font-semibold text-on-primary rounded-pill transition-colors;
|
| 52 |
+
background-color: #0052ff;
|
| 53 |
+
height: 56px;
|
|
|
|
| 54 |
}
|
| 55 |
+
.btn-primary-lg:hover { background-color: #003ecc; }
|
| 56 |
|
| 57 |
+
.btn-secondary {
|
| 58 |
+
@apply inline-flex items-center justify-center px-5 py-3 text-button font-semibold text-ink bg-surface-strong rounded-pill transition-colors;
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
+
.btn-secondary:hover { background-color: #dee1e6; }
|
| 61 |
+
|
| 62 |
+
.btn-secondary-dark {
|
| 63 |
+
@apply inline-flex items-center justify-center px-5 py-3 text-button font-semibold text-on-dark rounded-pill transition-colors;
|
| 64 |
+
background-color: #16181c;
|
|
|
|
|
|
|
|
|
|
| 65 |
}
|
| 66 |
+
.btn-secondary-dark:hover { background-color: #1e2025; }
|
| 67 |
+
|
| 68 |
+
.btn-outline-dark {
|
| 69 |
+
@apply inline-flex items-center justify-center px-5 py-3 text-button font-semibold text-on-dark rounded-pill transition-colors border border-white/30 bg-transparent;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
}
|
| 71 |
+
.btn-outline-dark:hover { border-color: #ffffff; }
|
| 72 |
+
|
| 73 |
+
.btn-text {
|
| 74 |
+
@apply inline-flex items-center justify-center px-2 py-1 font-semibold rounded-pill transition-colors bg-transparent;
|
| 75 |
+
color: #0052ff;
|
| 76 |
+
font-size: 16px;
|
| 77 |
}
|
| 78 |
+
.btn-text:hover { background-color: rgba(0, 82, 255, 0.06); }
|
| 79 |
|
| 80 |
+
/* ═══ COINBASE CARDS ═════════════════════════════════════════════════ */
|
|
|
|
| 81 |
|
| 82 |
+
.card {
|
| 83 |
+
@apply bg-canvas rounded-xl p-xl;
|
| 84 |
+
border: 1px solid #dee1e6;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
+
.card-flat {
|
| 87 |
+
@apply bg-canvas rounded-xl p-xl;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
+
.card-dark {
|
| 90 |
+
@apply rounded-xl p-xl;
|
| 91 |
+
background: #16181c;
|
| 92 |
+
color: #ffffff;
|
| 93 |
}
|
| 94 |
|
| 95 |
+
/* ═══ COINBASE FORMS ═════════════════════════════════════════════════ */
|
| 96 |
+
|
| 97 |
.input-field {
|
| 98 |
+
@apply w-full px-4 py-3.5 text-body-md bg-canvas text-ink rounded-md transition-all;
|
| 99 |
+
border: 1px solid #dee1e6;
|
| 100 |
+
height: 48px;
|
| 101 |
}
|
| 102 |
.input-field:focus {
|
| 103 |
+
outline: none;
|
| 104 |
+
border-color: #0052ff;
|
| 105 |
+
border-width: 2px;
|
| 106 |
+
padding-left: 15px; /* compensate for 2px border */
|
| 107 |
}
|
| 108 |
+
.input-field::placeholder { color: #7c828a; }
|
| 109 |
|
| 110 |
+
.search-pill {
|
| 111 |
+
@apply w-full px-5 py-3 text-body-sm bg-surface-strong text-ink rounded-pill transition-all;
|
| 112 |
+
height: 44px;
|
| 113 |
+
border: none;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
}
|
| 115 |
+
.search-pill:focus { outline: none; box-shadow: 0 0 0 2px #0052ff; }
|
| 116 |
+
|
| 117 |
+
/* ═══ BADGE PILLS ════════════════════════════════════════════════════ */
|
| 118 |
|
| 119 |
+
.badge-pill {
|
| 120 |
+
@apply inline-flex items-center px-2.5 py-0.5 rounded-pill text-caption-strong uppercase tracking-wider;
|
| 121 |
+
background: #eef0f3;
|
| 122 |
+
color: #0a0b0d;
|
| 123 |
}
|
| 124 |
+
.badge-pill-blue {
|
| 125 |
+
background: rgba(0, 82, 255, 0.08);
|
| 126 |
+
color: #0052ff;
|
| 127 |
+
}
|
| 128 |
+
.badge-pill-green {
|
| 129 |
+
background: rgba(5, 177, 105, 0.08);
|
| 130 |
+
color: #05b169;
|
| 131 |
+
}
|
| 132 |
+
.badge-pill-red {
|
| 133 |
+
background: rgba(207, 32, 47, 0.08);
|
| 134 |
+
color: #cf202f;
|
| 135 |
}
|
| 136 |
|
| 137 |
+
/* ═══ DARK HERO BAND ═════════════════════════════════════════════════ */
|
| 138 |
+
|
| 139 |
+
.hero-band-dark {
|
| 140 |
+
background: #0a0b0d;
|
| 141 |
+
color: #ffffff;
|
| 142 |
+
padding: 96px 0;
|
| 143 |
}
|
| 144 |
|
| 145 |
+
/* ═══ ASSET ROW ══════════════════════════════════════════════════════ */
|
| 146 |
+
|
| 147 |
+
.asset-row {
|
| 148 |
+
@apply flex items-center py-4 transition-colors;
|
| 149 |
+
border-bottom: 1px solid #eef0f3;
|
| 150 |
}
|
| 151 |
+
.asset-row:hover { background: #f7f7f7; }
|
| 152 |
+
.asset-row:last-child { border-bottom: none; }
|
| 153 |
|
| 154 |
+
.asset-icon {
|
| 155 |
+
@apply flex items-center justify-center rounded-full;
|
| 156 |
+
width: 32px; height: 32px;
|
| 157 |
+
background: #eef0f3;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
}
|
| 159 |
+
|
| 160 |
+
/* ═══ HAIRLINE DIVIDER ═══════════════════════════════════════════════ */
|
| 161 |
+
|
| 162 |
+
.hairline { @apply w-full; height: 1px; background: #dee1e6; }
|
| 163 |
+
.hairline-soft { @apply w-full; height: 1px; background: #eef0f3; }
|
| 164 |
+
|
| 165 |
+
/* ═══ PAGE TRANSITIONS ═══════════════════════════════════════════════ */
|
| 166 |
+
|
| 167 |
+
.page-enter { animation: page-fade 0.25s ease-out; }
|
| 168 |
+
@keyframes page-fade {
|
| 169 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 170 |
+
to { opacity: 1; transform: translateY(0); }
|
| 171 |
}
|
| 172 |
|
| 173 |
+
/* ═══ TOGGLE SWITCH ══════════════════════════════════════════════════ */
|
| 174 |
+
|
| 175 |
+
.toggle {
|
| 176 |
+
@apply relative inline-flex items-center cursor-pointer;
|
| 177 |
+
width: 44px; height: 24px;
|
| 178 |
+
}
|
| 179 |
+
.toggle-track {
|
| 180 |
+
@apply w-full h-full rounded-full transition-colors;
|
| 181 |
+
background: #dee1e6;
|
| 182 |
}
|
| 183 |
+
.toggle-track.active { background: #0052ff; }
|
| 184 |
+
.toggle-thumb {
|
| 185 |
+
@apply absolute w-5 h-5 rounded-full bg-white shadow-soft transition-all;
|
| 186 |
+
top: 2px; left: 2px;
|
| 187 |
}
|
| 188 |
+
.toggle-thumb.active { left: 22px; }
|
| 189 |
+
|
| 190 |
+
/* ═══ SECTION RHYTHM ═════════════════════════════════════════════════ */
|
| 191 |
|
| 192 |
+
.section-light { @apply bg-canvas; padding: 96px 0; }
|
| 193 |
+
.section-soft { @apply bg-surface-soft; padding: 96px 0; }
|
| 194 |
+
.section-dark { background: #0a0b0d; color: #ffffff; padding: 96px 0; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/renderer/index.html
CHANGED
|
@@ -1,15 +1,16 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
-
<html lang="en"
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<meta http-equiv="Content-Security-Policy"
|
| 7 |
-
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.mainnet-beta.solana.com https://api.devnet.solana.com; object-src 'none'; base-uri 'self';">
|
| 8 |
-
<title>SolVox
|
| 9 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
-
<link href="https://fonts.
|
|
|
|
| 11 |
</head>
|
| 12 |
-
<body
|
| 13 |
<div id="root"></div>
|
| 14 |
<script type="module" src="./main.tsx"></script>
|
| 15 |
</body>
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<meta http-equiv="Content-Security-Policy"
|
| 7 |
+
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.mainnet-beta.solana.com https://api.devnet.solana.com; font-src 'self' https://fonts.gstatic.com; object-src 'none'; base-uri 'self';">
|
| 8 |
+
<title>SolVox</title>
|
| 9 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 12 |
</head>
|
| 13 |
+
<body>
|
| 14 |
<div id="root"></div>
|
| 15 |
<script type="module" src="./main.tsx"></script>
|
| 16 |
</body>
|
src/renderer/pages/Dashboard.tsx
CHANGED
|
@@ -1,209 +1,118 @@
|
|
| 1 |
import React, { useEffect, useState } from 'react';
|
| 2 |
-
import {
|
| 3 |
|
| 4 |
-
interface
|
| 5 |
-
balance: { sol: number; usdt: number };
|
| 6 |
-
publicKey: string | null;
|
| 7 |
-
onRefresh: () => void;
|
| 8 |
-
onNavigate: (page: any) => void;
|
| 9 |
-
}
|
| 10 |
|
| 11 |
-
export default function Dashboard({ balance, publicKey, onRefresh, onNavigate }:
|
| 12 |
-
const [
|
| 13 |
const [aiStatus, setAiStatus] = useState<any>(null);
|
| 14 |
-
const [loading, setLoading] = useState(true);
|
| 15 |
-
|
| 16 |
-
useEffect(() => { loadData(); }, []);
|
| 17 |
|
| 18 |
-
|
| 19 |
-
if (!window.solvox)
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
setAiStatus(s);
|
| 24 |
-
} catch {}
|
| 25 |
-
setLoading(false);
|
| 26 |
-
};
|
| 27 |
|
| 28 |
-
const
|
| 29 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
return (
|
| 32 |
-
<div className="
|
| 33 |
-
{/* ── Hero
|
| 34 |
-
<
|
| 35 |
-
<div className="absolute -right-16 -top-16 w-48 h-48 rounded-full bg-sol-purple/8 blur-3xl" />
|
| 36 |
-
<div className="absolute -left-8 -bottom-8 w-32 h-32 rounded-full bg-sol-green/6 blur-2xl" />
|
| 37 |
<div className="relative z-10">
|
| 38 |
-
<div className="
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
<div className="flex items-center gap-3 mt-2">
|
| 45 |
-
<div className="badge badge-green">
|
| 46 |
-
<div className="w-1 h-1 rounded-full bg-sol-green" />
|
| 47 |
-
Devnet
|
| 48 |
-
</div>
|
| 49 |
-
<button onClick={onRefresh} className="text-xs text-sol-muted hover:text-sol-purple transition-colors flex items-center gap-1">
|
| 50 |
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
|
| 51 |
-
Refresh
|
| 52 |
-
</button>
|
| 53 |
-
</div>
|
| 54 |
-
</div>
|
| 55 |
-
<CircularProgress value={activeModules / 6 * 100} size={72} strokeWidth={5} color="#14F195">
|
| 56 |
-
<div className="text-center">
|
| 57 |
-
<div className="text-sm font-bold text-sol-green">{activeModules}</div>
|
| 58 |
-
<div className="text-[8px] text-sol-muted">/6 AI</div>
|
| 59 |
-
</div>
|
| 60 |
-
</CircularProgress>
|
| 61 |
</div>
|
| 62 |
</div>
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-sol-purple to-sol-purple-light flex items-center justify-center shadow-glow-sm">
|
| 70 |
-
<span className="text-white font-bold text-sm">◎</span>
|
| 71 |
-
</div>
|
| 72 |
-
<div>
|
| 73 |
-
<div className="text-xs text-sol-muted font-medium">Solana</div>
|
| 74 |
-
<div className="text-lg font-bold"><AnimatedNumber value={balance.sol} decimals={4} /></div>
|
| 75 |
-
</div>
|
| 76 |
</div>
|
| 77 |
-
<div className="
|
| 78 |
-
<
|
| 79 |
-
<div className="
|
| 80 |
</div>
|
| 81 |
</div>
|
|
|
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
<div className="text-xs text-sol-muted font-medium">Tether USDT</div>
|
| 90 |
-
<div className="text-lg font-bold"><AnimatedNumber value={balance.usdt} decimals={2} /></div>
|
| 91 |
-
</div>
|
| 92 |
-
</div>
|
| 93 |
-
<div className="flex items-center justify-between">
|
| 94 |
-
<span className="text-xs text-sol-muted">≈ <AnimatedNumber value={balance.usdt} decimals={2} prefix="$" /></span>
|
| 95 |
-
<div className="progress-bar w-20"><div className="progress-fill" style={{ width: `${Math.min(100, balance.usdt / 10)}%`, background: 'linear-gradient(90deg, #26A17B, #14F195)' }} /></div>
|
| 96 |
-
</div>
|
| 97 |
</div>
|
| 98 |
-
</
|
| 99 |
-
|
| 100 |
-
{/* ── Quick Actions ─────────────────────────────────────────────── */}
|
| 101 |
-
<div className="glass rounded-2xl p-5">
|
| 102 |
-
<div className="text-xs text-sol-muted font-semibold uppercase tracking-wider mb-4">Quick Actions</div>
|
| 103 |
-
<div className="flex items-center justify-around">
|
| 104 |
-
<IconButton icon="↗" label="Send" color="purple" onClick={() => onNavigate('send')} />
|
| 105 |
-
<IconButton icon="↙" label="Receive" color="green" />
|
| 106 |
-
<IconButton icon="◎" label="Voice AI" color="tether" onClick={() => onNavigate('voice')} />
|
| 107 |
-
<IconButton icon="⊞" label="Scan QR" color="warning" />
|
| 108 |
-
<IconButton icon="☰" label="History" color="purple" onClick={() => onNavigate('history')} />
|
| 109 |
-
</div>
|
| 110 |
-
</div>
|
| 111 |
|
| 112 |
-
{/* ── AI
|
| 113 |
-
<div className="grid grid-cols-5 gap-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
<div className="
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
</div>
|
| 123 |
-
<div className="space-y-2.5 stagger">
|
| 124 |
-
{[
|
| 125 |
-
{ name: 'LLM — Llama 3.2 3B', active: aiStatus?.llm, pkg: 'llm-llamacpp', color: '#9945FF' },
|
| 126 |
-
{ name: 'Speech-to-Text — Whisper', active: aiStatus?.transcription, pkg: 'transcription-whispercpp', color: '#14F195' },
|
| 127 |
-
{ name: 'Text-to-Speech — Piper', active: aiStatus?.tts, pkg: 'tts-onnx', color: '#26A17B' },
|
| 128 |
-
{ name: 'Embeddings — Nomic', active: aiStatus?.embed, pkg: 'embed-llamacpp', color: '#B06CFF' },
|
| 129 |
-
{ name: 'Translation — MarianMT', active: aiStatus?.translation, pkg: 'translation-nmtcpp', color: '#FFB830' },
|
| 130 |
-
{ name: 'OCR — PaddleOCR', active: aiStatus?.ocr, pkg: 'ocr-onnx', color: '#3B82F6' },
|
| 131 |
-
].map(m => (
|
| 132 |
-
<div key={m.name} className="flex items-center gap-3 py-1.5">
|
| 133 |
-
<div className="w-1 h-8 rounded-full" style={{ background: m.active ? m.color : 'rgba(45,45,94,0.5)' }} />
|
| 134 |
-
<div className="flex-1 min-w-0">
|
| 135 |
-
<div className="text-xs font-semibold text-sol-text truncate">{m.name}</div>
|
| 136 |
-
<div className="text-[10px] text-sol-muted font-mono">@qvac/{m.pkg}</div>
|
| 137 |
-
</div>
|
| 138 |
-
<div className={`badge ${m.active ? 'badge-green' : 'badge-muted'}`}>
|
| 139 |
-
{m.active ? '● Active' : '○ Loading'}
|
| 140 |
</div>
|
|
|
|
|
|
|
|
|
|
| 141 |
</div>
|
| 142 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
</div>
|
| 144 |
-
|
| 145 |
-
<div className="flex items-center gap-2 text-[10px] text-sol-muted">
|
| 146 |
-
<span className="w-1.5 h-1.5 rounded-full bg-tether-green" />
|
| 147 |
-
All inference runs 100% locally via QVAC Fabric (Vulkan GPU)
|
| 148 |
-
</div>
|
| 149 |
-
</div>
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
<div className="
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
) : recentTxs.length === 0 ? (
|
| 159 |
-
<div className="flex flex-col items-center justify-center py-8 text-center">
|
| 160 |
-
<div className="w-14 h-14 rounded-2xl bg-sol-card flex items-center justify-center mb-3">
|
| 161 |
-
<span className="text-2xl opacity-40">📭</span>
|
| 162 |
</div>
|
| 163 |
-
|
| 164 |
-
<div className="
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
<div className="space-y-2">
|
| 168 |
-
{recentTxs.map((tx, i) => (
|
| 169 |
-
<div key={i} className="flex items-center gap-3 p-2.5 rounded-xl bg-sol-dark/40 hover:bg-sol-dark/60 transition-colors">
|
| 170 |
-
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-xs ${
|
| 171 |
-
tx.status === 'success' ? 'bg-sol-green/10 text-sol-green' : 'bg-danger/10 text-danger'
|
| 172 |
-
}`}>
|
| 173 |
-
{tx.status === 'success' ? '✓' : '✗'}
|
| 174 |
-
</div>
|
| 175 |
-
<div className="flex-1 min-w-0">
|
| 176 |
-
<div className="text-xs font-mono text-sol-muted truncate">{tx.signature?.slice(0, 16)}...</div>
|
| 177 |
-
<div className="text-[10px] text-sol-muted/60">{tx.timestamp ? new Date(tx.timestamp).toLocaleDateString() : 'Pending'}</div>
|
| 178 |
-
</div>
|
| 179 |
-
<div className={`badge text-[10px] ${tx.status === 'success' ? 'badge-green' : 'badge-danger'}`}>
|
| 180 |
-
{tx.status}
|
| 181 |
-
</div>
|
| 182 |
</div>
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
</div>
|
| 188 |
|
| 189 |
-
{/* ── Wallet Address ──────────────────────────────────────────
|
| 190 |
-
<
|
| 191 |
-
<div className="flex items-center justify-between">
|
| 192 |
-
<div
|
| 193 |
-
<div className="
|
| 194 |
-
|
| 195 |
-
</div>
|
| 196 |
-
<div>
|
| 197 |
-
<div className="text-[10px] text-sol-muted uppercase tracking-wider font-semibold">Wallet Address</div>
|
| 198 |
-
<div className="text-xs font-mono text-sol-muted/80">{publicKey || '—'}</div>
|
| 199 |
-
</div>
|
| 200 |
</div>
|
| 201 |
-
<button onClick={() => publicKey && navigator.clipboard.writeText(publicKey)}
|
| 202 |
-
className="btn-ghost px-3 py-1.5 text-xs">
|
| 203 |
-
Copy
|
| 204 |
-
</button>
|
| 205 |
</div>
|
| 206 |
-
</
|
| 207 |
</div>
|
| 208 |
);
|
| 209 |
}
|
|
|
|
| 1 |
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import { Num, AssetRow, PipelineTrace } from '../components/ui/index';
|
| 3 |
|
| 4 |
+
interface Props { balance: { sol: number; usdt: number }; publicKey: string | null; onRefresh: () => void; onNavigate: (p: any) => void; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
+
export default function Dashboard({ balance, publicKey, onRefresh, onNavigate }: Props) {
|
| 7 |
+
const [txs, setTxs] = useState<any[]>([]);
|
| 8 |
const [aiStatus, setAiStatus] = useState<any>(null);
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
if (!window.solvox) return;
|
| 12 |
+
Promise.all([window.solvox.wallet.getHistory(5), window.solvox.ai.getStatus()])
|
| 13 |
+
.then(([h, s]) => { if (h.success) setTxs(h.history || []); setAiStatus(s); }).catch(() => {});
|
| 14 |
+
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
const total = balance.sol * 170 + balance.usdt;
|
| 17 |
+
const mods = aiStatus ? [
|
| 18 |
+
{ k: 'llm', n: 'LLM', p: '@qvac/llm-llamacpp' }, { k: 'transcription', n: 'Speech-to-Text', p: '@qvac/transcription-whispercpp' },
|
| 19 |
+
{ k: 'tts', n: 'Text-to-Speech', p: '@qvac/tts-onnx' }, { k: 'embed', n: 'Embeddings', p: '@qvac/embed-llamacpp' },
|
| 20 |
+
{ k: 'translation', n: 'Translation', p: '@qvac/translation-nmtcpp' }, { k: 'ocr', n: 'OCR', p: '@qvac/ocr-onnx' },
|
| 21 |
+
] : [];
|
| 22 |
|
| 23 |
return (
|
| 24 |
+
<div className="max-w-content mx-auto px-8 py-section">
|
| 25 |
+
{/* ── Hero Band ──────────────────────────────────────────────── */}
|
| 26 |
+
<section className="bg-surface-dark rounded-xl p-xl mb-12 relative overflow-hidden">
|
|
|
|
|
|
|
| 27 |
<div className="relative z-10">
|
| 28 |
+
<div className="text-on-dark-soft text-body-sm mb-1">Total portfolio value</div>
|
| 29 |
+
<div className="text-on-dark display-text text-display-lg mb-2"><Num value={total} decimals={2} prefix="$" /></div>
|
| 30 |
+
<div className="flex items-center gap-3 mt-4">
|
| 31 |
+
<button onClick={() => onNavigate('send')} className="btn-primary">Send</button>
|
| 32 |
+
<button onClick={() => onNavigate('voice')} className="btn-secondary-dark">Voice AI</button>
|
| 33 |
+
<button onClick={onRefresh} className="btn-outline-dark">Refresh</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
</div>
|
| 35 |
</div>
|
| 36 |
+
{/* Floating product-UI card — Coinbase signature */}
|
| 37 |
+
<div className="absolute right-8 top-6 w-[260px]">
|
| 38 |
+
<div className="card-dark p-4 mb-2 shadow-soft">
|
| 39 |
+
<div className="text-on-dark-soft text-caption mb-1">SOL Balance</div>
|
| 40 |
+
<div className="text-on-dark number-mono text-number-display"><Num value={balance.sol} decimals={4} /></div>
|
| 41 |
+
<div className="text-on-dark-soft text-caption mt-1">≈ $<Num value={balance.sol * 170} decimals={2} /></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
</div>
|
| 43 |
+
<div className="card-dark p-4 shadow-soft ml-4">
|
| 44 |
+
<div className="text-on-dark-soft text-caption mb-1">USDT Balance</div>
|
| 45 |
+
<div className="text-on-dark number-mono text-number-display"><Num value={balance.usdt} decimals={2} /></div>
|
| 46 |
</div>
|
| 47 |
</div>
|
| 48 |
+
</section>
|
| 49 |
|
| 50 |
+
{/* ── Assets ─────────────────────────────────────────────────── */}
|
| 51 |
+
<section className="mb-12">
|
| 52 |
+
<div className="text-title-lg display-text text-ink mb-6">Assets</div>
|
| 53 |
+
<div className="card">
|
| 54 |
+
<AssetRow icon="◎" name="Solana" ticker="SOL" price={balance.sol * 170} change={2.34} />
|
| 55 |
+
<AssetRow icon="₮" name="Tether" ticker="USDT" price={balance.usdt} change={0.01} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
</div>
|
| 57 |
+
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
+
{/* ── AI Engine + Transactions ──────────────────────────────── */}
|
| 60 |
+
<div className="grid grid-cols-5 gap-6">
|
| 61 |
+
<section className="col-span-3">
|
| 62 |
+
<div className="text-title-lg display-text text-ink mb-6">QVAC AI Engine</div>
|
| 63 |
+
<div className="card">
|
| 64 |
+
{mods.map(m => (
|
| 65 |
+
<div key={m.k} className="flex items-center justify-between py-3 border-b border-hairline-soft last:border-0">
|
| 66 |
+
<div>
|
| 67 |
+
<div className="text-title-sm text-ink">{m.n}</div>
|
| 68 |
+
<div className="text-caption text-muted font-mono">{m.p}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
</div>
|
| 70 |
+
<span className={`badge-pill text-[10px] ${(aiStatus as any)?.[m.k] ? 'badge-pill-green' : ''}`}>
|
| 71 |
+
{(aiStatus as any)?.[m.k] ? 'ACTIVE' : 'LOADING'}
|
| 72 |
+
</span>
|
| 73 |
</div>
|
| 74 |
))}
|
| 75 |
+
<div className="pt-3 mt-1 text-caption text-muted flex items-center gap-1.5">
|
| 76 |
+
<div className="w-1.5 h-1.5 rounded-full bg-semantic-up" />
|
| 77 |
+
All inference runs 100% locally via QVAC Fabric (Vulkan GPU)
|
| 78 |
+
</div>
|
| 79 |
</div>
|
| 80 |
+
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
+
<section className="col-span-2">
|
| 83 |
+
<div className="text-title-lg display-text text-ink mb-6">Recent activity</div>
|
| 84 |
+
<div className="card">
|
| 85 |
+
{txs.length === 0 ? (
|
| 86 |
+
<div className="text-center py-8">
|
| 87 |
+
<div className="text-body text-muted">No transactions yet</div>
|
| 88 |
+
<div className="text-caption text-muted-soft mt-1">Try "Send 1 SOL to…"</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
</div>
|
| 90 |
+
) : txs.map((tx, i) => (
|
| 91 |
+
<div key={i} className="flex items-center gap-3 py-3 border-b border-hairline-soft last:border-0">
|
| 92 |
+
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-caption-strong ${tx.status === 'success' ? 'bg-semantic-up/10 text-semantic-up' : 'bg-semantic-down/10 text-semantic-down'}`}>
|
| 93 |
+
{tx.status === 'success' ? '✓' : '✗'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
</div>
|
| 95 |
+
<div className="flex-1 min-w-0">
|
| 96 |
+
<div className="text-caption font-mono text-muted truncate">{tx.signature?.slice(0, 16)}…</div>
|
| 97 |
+
<div className="text-caption text-muted-soft">{tx.timestamp ? new Date(tx.timestamp).toLocaleDateString() : '—'}</div>
|
| 98 |
+
</div>
|
| 99 |
+
<span className={`badge-pill text-[10px] ${tx.status === 'success' ? 'badge-pill-green' : 'badge-pill-red'}`}>{tx.status}</span>
|
| 100 |
+
</div>
|
| 101 |
+
))}
|
| 102 |
+
</div>
|
| 103 |
+
</section>
|
| 104 |
</div>
|
| 105 |
|
| 106 |
+
{/* ── Wallet Address ────────────────────────────────────────── */}
|
| 107 |
+
<section className="mt-12">
|
| 108 |
+
<div className="bg-surface-soft rounded-xl p-5 flex items-center justify-between">
|
| 109 |
+
<div>
|
| 110 |
+
<div className="text-caption-strong text-muted uppercase tracking-wider">Wallet address</div>
|
| 111 |
+
<div className="text-body-sm font-mono text-muted mt-1">{publicKey || '—'}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
</div>
|
| 113 |
+
<button onClick={() => publicKey && navigator.clipboard.writeText(publicKey)} className="btn-secondary text-body-sm py-2 px-4">Copy</button>
|
|
|
|
|
|
|
|
|
|
| 114 |
</div>
|
| 115 |
+
</section>
|
| 116 |
</div>
|
| 117 |
);
|
| 118 |
}
|
src/renderer/pages/HistoryPage.tsx
CHANGED
|
@@ -4,97 +4,66 @@ export default function HistoryPage() {
|
|
| 4 |
const [txs, setTxs] = useState<any[]>([]);
|
| 5 |
const [loading, setLoading] = useState(true);
|
| 6 |
const [query, setQuery] = useState('');
|
| 7 |
-
const [
|
| 8 |
const [searching, setSearching] = useState(false);
|
| 9 |
|
| 10 |
useEffect(() => { load(); }, []);
|
| 11 |
-
|
| 12 |
-
const
|
| 13 |
-
setLoading(true);
|
| 14 |
-
try { if (window.solvox) { const r = await window.solvox.wallet.getHistory(20); if (r.success) setTxs(r.history || []); } } catch {}
|
| 15 |
-
setLoading(false);
|
| 16 |
-
};
|
| 17 |
-
|
| 18 |
-
const search = async () => {
|
| 19 |
-
if (!query.trim()) return;
|
| 20 |
-
setSearching(true);
|
| 21 |
-
try { if (window.solvox) { const r = await window.solvox.rag.search(query); if (r.success) setRagResults(r.results || []); } } catch {}
|
| 22 |
-
setSearching(false);
|
| 23 |
-
};
|
| 24 |
|
| 25 |
return (
|
| 26 |
-
<div className="
|
| 27 |
-
<div className="flex items-center justify-between">
|
| 28 |
-
<h2 className="text-
|
| 29 |
-
<button onClick={load} className="btn-
|
| 30 |
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
|
| 31 |
-
Refresh
|
| 32 |
-
</button>
|
| 33 |
</div>
|
| 34 |
|
| 35 |
{/* AI Search */}
|
| 36 |
-
<div className="
|
| 37 |
<div className="flex gap-2">
|
| 38 |
-
<
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-sol-muted" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
| 42 |
-
</div>
|
| 43 |
-
<button onClick={search} disabled={searching} className="btn-primary px-4 py-2 text-white text-xs disabled:opacity-50">
|
| 44 |
-
<span>{searching ? '...' : 'Search'}</span>
|
| 45 |
-
</button>
|
| 46 |
</div>
|
| 47 |
-
<div className="flex items-center gap-1.5
|
| 48 |
-
<div className="w-1 h-1 rounded-full bg-
|
| 49 |
-
|
| 50 |
</div>
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
<div className="text-
|
| 58 |
-
<div className="mt-1 flex items-center gap-2">
|
| 59 |
-
<div className="progress-bar w-16"><div className="progress-fill" style={{ width: `${r.score * 100}%` }} /></div>
|
| 60 |
-
<span className="text-[10px] text-sol-muted">{(r.score * 100).toFixed(0)}% match</span>
|
| 61 |
-
</div>
|
| 62 |
</div>
|
| 63 |
))}
|
| 64 |
</div>
|
| 65 |
)}
|
| 66 |
</div>
|
| 67 |
|
| 68 |
-
{/*
|
| 69 |
-
<div className="
|
| 70 |
{loading ? (
|
| 71 |
-
<div className="p-
|
| 72 |
-
{[1,2,3,4].map(i => <div key={i} className="shimmer rounded-lg h-14" />)}
|
| 73 |
-
</div>
|
| 74 |
) : txs.length === 0 ? (
|
| 75 |
-
<div className="
|
| 76 |
-
<div className="
|
| 77 |
-
|
| 78 |
-
</div>
|
| 79 |
-
<div className="text-sm font-semibold mb-1">No Transactions Yet</div>
|
| 80 |
-
<div className="text-xs text-sol-muted max-w-xs">Send or receive tokens and your history will appear here.</div>
|
| 81 |
</div>
|
| 82 |
) : (
|
| 83 |
-
<div
|
| 84 |
{txs.map((tx, i) => (
|
| 85 |
-
<div key={i} className="
|
| 86 |
-
<div className={`w-
|
| 87 |
-
tx.status === 'success' ? 'bg-sol-green/10 text-sol-green' : 'bg-danger/10 text-danger'
|
| 88 |
-
}`}>
|
| 89 |
{tx.status === 'success' ? '✓' : '✗'}
|
| 90 |
</div>
|
| 91 |
<div className="flex-1 min-w-0">
|
| 92 |
-
<div className="text-
|
| 93 |
-
<div className="text-
|
| 94 |
</div>
|
| 95 |
-
<
|
| 96 |
-
<a href={`https://solscan.io/tx/${tx.signature}`} target="_blank" rel="noopener noreferrer"
|
| 97 |
-
className="text-sol-purple text-[10px] hover:underline">View ↗</a>
|
| 98 |
</div>
|
| 99 |
))}
|
| 100 |
</div>
|
|
|
|
| 4 |
const [txs, setTxs] = useState<any[]>([]);
|
| 5 |
const [loading, setLoading] = useState(true);
|
| 6 |
const [query, setQuery] = useState('');
|
| 7 |
+
const [rag, setRag] = useState<any[]>([]);
|
| 8 |
const [searching, setSearching] = useState(false);
|
| 9 |
|
| 10 |
useEffect(() => { load(); }, []);
|
| 11 |
+
const load = async () => { setLoading(true); try { if (window.solvox) { const r = await window.solvox.wallet.getHistory(20); if (r.success) setTxs(r.history || []); } } catch {} setLoading(false); };
|
| 12 |
+
const search = async () => { if (!query.trim()) return; setSearching(true); try { if (window.solvox) { const r = await window.solvox.rag.search(query, 'transaction'); if (r.success) setRag(r.results || []); } } catch {} setSearching(false); };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
return (
|
| 15 |
+
<div className="max-w-content mx-auto px-8 py-section">
|
| 16 |
+
<div className="flex items-center justify-between mb-6">
|
| 17 |
+
<h2 className="display-text text-title-lg text-ink">Transactions</h2>
|
| 18 |
+
<button onClick={load} className="btn-secondary text-body-sm py-2 px-3">Refresh</button>
|
|
|
|
|
|
|
|
|
|
| 19 |
</div>
|
| 20 |
|
| 21 |
{/* AI Search */}
|
| 22 |
+
<div className="bg-surface-soft rounded-xl p-4 mb-8">
|
| 23 |
<div className="flex gap-2">
|
| 24 |
+
<input value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && search()}
|
| 25 |
+
placeholder='Search with AI — "last payment to Alice"' className="search-pill flex-1 text-body-sm" />
|
| 26 |
+
<button onClick={search} disabled={searching} className="btn-primary text-body-sm py-2 px-4 disabled:opacity-50">{searching ? '…' : 'Search'}</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</div>
|
| 28 |
+
<div className="text-caption text-muted mt-2 flex items-center gap-1.5">
|
| 29 |
+
<div className="w-1 h-1 rounded-full bg-primary" />
|
| 30 |
+
Semantic search via @qvac/embed-llamacpp — 100% local
|
| 31 |
</div>
|
| 32 |
+
{rag.length > 0 && (
|
| 33 |
+
<div className="mt-3 space-y-2">
|
| 34 |
+
<div className="text-caption-strong text-primary uppercase tracking-wider">AI Results</div>
|
| 35 |
+
{rag.map((r, i) => (
|
| 36 |
+
<div key={i} className="bg-canvas rounded-lg p-3 border border-hairline">
|
| 37 |
+
<div className="text-body-sm text-ink">{r.text}</div>
|
| 38 |
+
<div className="text-caption text-muted mt-1 number-mono">{(r.score * 100).toFixed(0)}% match</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
</div>
|
| 40 |
))}
|
| 41 |
</div>
|
| 42 |
)}
|
| 43 |
</div>
|
| 44 |
|
| 45 |
+
{/* Table */}
|
| 46 |
+
<div className="card" style={{ padding: 0 }}>
|
| 47 |
{loading ? (
|
| 48 |
+
<div className="p-12 text-center text-body text-muted">Loading…</div>
|
|
|
|
|
|
|
| 49 |
) : txs.length === 0 ? (
|
| 50 |
+
<div className="p-12 text-center">
|
| 51 |
+
<div className="text-title-md text-ink mb-1">No transactions yet</div>
|
| 52 |
+
<div className="text-body-sm text-muted">Send or receive tokens to see history.</div>
|
|
|
|
|
|
|
|
|
|
| 53 |
</div>
|
| 54 |
) : (
|
| 55 |
+
<div>
|
| 56 |
{txs.map((tx, i) => (
|
| 57 |
+
<div key={i} className="asset-row px-5">
|
| 58 |
+
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-caption-strong mr-3 ${tx.status === 'success' ? 'bg-semantic-up/10 text-semantic-up' : 'bg-semantic-down/10 text-semantic-down'}`}>
|
|
|
|
|
|
|
| 59 |
{tx.status === 'success' ? '✓' : '✗'}
|
| 60 |
</div>
|
| 61 |
<div className="flex-1 min-w-0">
|
| 62 |
+
<div className="text-body-sm font-mono text-ink truncate">{tx.signature?.slice(0, 20)}…</div>
|
| 63 |
+
<div className="text-caption text-muted">{tx.timestamp ? new Date(tx.timestamp).toLocaleString() : 'Pending'}</div>
|
| 64 |
</div>
|
| 65 |
+
<span className={`badge-pill text-[10px] mr-3 ${tx.status === 'success' ? 'badge-pill-green' : 'badge-pill-red'}`}>{tx.status}</span>
|
| 66 |
+
<a href={`https://solscan.io/tx/${tx.signature}`} target="_blank" rel="noopener noreferrer" className="btn-text text-body-sm py-0 px-1">View →</a>
|
|
|
|
| 67 |
</div>
|
| 68 |
))}
|
| 69 |
</div>
|
src/renderer/pages/LockScreen.tsx
CHANGED
|
@@ -1,132 +1,78 @@
|
|
| 1 |
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
-
import { ParticlesBackground } from '../components/ui/index';
|
| 3 |
|
| 4 |
-
interface
|
| 5 |
|
| 6 |
-
export default function LockScreen({ onUnlock }:
|
| 7 |
const [pin, setPin] = useState('');
|
| 8 |
const [error, setError] = useState('');
|
| 9 |
const [loading, setLoading] = useState(false);
|
| 10 |
-
const [
|
| 11 |
-
const
|
| 12 |
-
const inputRef = useRef<HTMLInputElement>(null);
|
| 13 |
|
| 14 |
-
useEffect(() => {
|
| 15 |
|
| 16 |
-
const
|
| 17 |
-
if (!window.solvox) return;
|
| 18 |
-
const available = await window.solvox.auth.biometricAvailable();
|
| 19 |
-
setBiometricAvailable(available);
|
| 20 |
-
if (available) handleBiometric();
|
| 21 |
-
};
|
| 22 |
-
|
| 23 |
-
const handleBiometric = async () => {
|
| 24 |
-
if (!window.solvox) return;
|
| 25 |
-
setLoading(true);
|
| 26 |
const r = await window.solvox.auth.biometric('Unlock SolVox');
|
| 27 |
if (r.success) { const pk = await window.solvox.wallet.getPublicKey(); if (pk) onUnlock(pk); }
|
| 28 |
-
else
|
| 29 |
setLoading(false);
|
| 30 |
};
|
| 31 |
|
| 32 |
-
const
|
| 33 |
e.preventDefault();
|
| 34 |
-
if (pin.length < 6)
|
| 35 |
setLoading(true); setError('');
|
| 36 |
try {
|
| 37 |
if (window.solvox) {
|
| 38 |
const r = await window.solvox.auth.unlock(pin);
|
| 39 |
if (r.success) { const pk = await window.solvox.wallet.getPublicKey(); if (pk) onUnlock(pk); }
|
| 40 |
-
else
|
| 41 |
-
} else
|
| 42 |
-
} catch (
|
| 43 |
setLoading(false); setPin('');
|
| 44 |
};
|
| 45 |
|
| 46 |
-
const triggerShake = (msg: string) => { setError(msg); setShake(true); setTimeout(() => setShake(false), 500); };
|
| 47 |
-
|
| 48 |
-
// PIN dots visualization
|
| 49 |
const dots = Array.from({ length: 8 }, (_, i) => i < pin.length);
|
| 50 |
|
| 51 |
return (
|
| 52 |
-
<div className="h-screen flex flex-col items-center justify-center
|
| 53 |
-
<
|
| 54 |
-
|
| 55 |
-
<div className="w-full max-w-xs relative z-10">
|
| 56 |
{/* Logo */}
|
| 57 |
-
<div className="text-center mb-
|
| 58 |
-
<div className="w-
|
| 59 |
-
<span className="text-
|
| 60 |
-
</div>
|
| 61 |
-
<h1 className="text-3xl font-extrabold gradient-text mb-1">SolVox</h1>
|
| 62 |
-
<p className="text-xs text-sol-muted">Voice-First AI Wallet</p>
|
| 63 |
-
</div>
|
| 64 |
-
|
| 65 |
-
{/* Lock Visual */}
|
| 66 |
-
<div className="flex justify-center mb-8">
|
| 67 |
-
<div className="relative">
|
| 68 |
-
<div className="w-14 h-14 rounded-full bg-sol-card border-2 border-sol-border/50 flex items-center justify-center">
|
| 69 |
-
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#9945FF" strokeWidth="2" strokeLinecap="round">
|
| 70 |
-
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
| 71 |
-
</svg>
|
| 72 |
-
</div>
|
| 73 |
-
<div className="absolute -inset-3 rounded-full border border-sol-purple/10 animate-pulse" />
|
| 74 |
</div>
|
|
|
|
|
|
|
| 75 |
</div>
|
| 76 |
|
| 77 |
{/* PIN Dots */}
|
| 78 |
-
<div className=
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
<div key={i} className={`w-3 h-3 rounded-full transition-all duration-200 ${
|
| 82 |
-
filled ? 'bg-sol-purple scale-110 shadow-glow-sm' : 'bg-sol-border/40'
|
| 83 |
-
}`} />
|
| 84 |
))}
|
| 85 |
</div>
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
className="
|
| 92 |
-
|
| 93 |
-
{/* Tap to focus area */}
|
| 94 |
-
<div onClick={() => inputRef.current?.focus()}
|
| 95 |
-
className="glass-strong rounded-2xl p-4 text-center cursor-text mb-4 hover:border-sol-purple/20 transition-all">
|
| 96 |
-
<div className="text-2xl tracking-[0.6em] font-mono text-sol-text h-8 flex items-center justify-center">
|
| 97 |
-
{pin ? '●'.repeat(pin.length) : <span className="text-sm text-sol-muted tracking-normal">Tap to enter PIN</span>}
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
-
|
| 101 |
-
{
|
| 102 |
-
<div className="text-danger text-xs text-center bg-danger/8 border border-danger/15 rounded-xl p-2.5 mb-4 fade-in">
|
| 103 |
-
{error}
|
| 104 |
-
</div>
|
| 105 |
-
)}
|
| 106 |
-
|
| 107 |
-
<button type="submit" disabled={loading || pin.length < 6}
|
| 108 |
-
className="btn-primary w-full py-3.5 text-white disabled:opacity-40 disabled:shadow-none">
|
| 109 |
-
<span>{loading ? 'Unlocking...' : 'Unlock'}</span>
|
| 110 |
-
</button>
|
| 111 |
</form>
|
| 112 |
|
| 113 |
-
{
|
| 114 |
-
<button onClick={handleBiometric}
|
| 115 |
-
className="btn-ghost w-full mt-3 py-3 text-sm flex items-center justify-center gap-2">
|
| 116 |
-
<span>🫰</span> Use Touch ID
|
| 117 |
-
</button>
|
| 118 |
-
)}
|
| 119 |
|
| 120 |
-
<div className="mt-8 text-center">
|
| 121 |
-
<div className="
|
| 122 |
-
|
| 123 |
-
Encrypted locally • No cloud • QVAC AI on-device
|
| 124 |
-
</div>
|
| 125 |
</div>
|
| 126 |
</div>
|
| 127 |
-
|
| 128 |
-
{/* Shake keyframes */}
|
| 129 |
-
<style>{`@keyframes shake { 0%,100% { transform: translateX(0); } 20%,60% { transform: translateX(-8px); } 40%,80% { transform: translateX(8px); } }`}</style>
|
| 130 |
</div>
|
| 131 |
);
|
| 132 |
}
|
|
|
|
| 1 |
import React, { useState, useRef, useEffect } from 'react';
|
|
|
|
| 2 |
|
| 3 |
+
interface Props { onUnlock: (pk: string) => void; }
|
| 4 |
|
| 5 |
+
export default function LockScreen({ onUnlock }: Props) {
|
| 6 |
const [pin, setPin] = useState('');
|
| 7 |
const [error, setError] = useState('');
|
| 8 |
const [loading, setLoading] = useState(false);
|
| 9 |
+
const [bio, setBio] = useState(false);
|
| 10 |
+
const ref = useRef<HTMLInputElement>(null);
|
|
|
|
| 11 |
|
| 12 |
+
useEffect(() => { ref.current?.focus(); if (window.solvox) window.solvox.auth.biometricAvailable().then(b => { setBio(b); if (b) doBio(); }); }, []);
|
| 13 |
|
| 14 |
+
const doBio = async () => {
|
| 15 |
+
if (!window.solvox) return; setLoading(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
const r = await window.solvox.auth.biometric('Unlock SolVox');
|
| 17 |
if (r.success) { const pk = await window.solvox.wallet.getPublicKey(); if (pk) onUnlock(pk); }
|
| 18 |
+
else setError(r.error || 'Failed');
|
| 19 |
setLoading(false);
|
| 20 |
};
|
| 21 |
|
| 22 |
+
const submit = async (e: React.FormEvent) => {
|
| 23 |
e.preventDefault();
|
| 24 |
+
if (pin.length < 6) return setError('PIN must be at least 6 digits');
|
| 25 |
setLoading(true); setError('');
|
| 26 |
try {
|
| 27 |
if (window.solvox) {
|
| 28 |
const r = await window.solvox.auth.unlock(pin);
|
| 29 |
if (r.success) { const pk = await window.solvox.wallet.getPublicKey(); if (pk) onUnlock(pk); }
|
| 30 |
+
else setError(r.remainingAttempts !== undefined ? `${r.error} (${r.remainingAttempts} left)` : r.error || 'Failed');
|
| 31 |
+
} else onUnlock('Dev' + Date.now());
|
| 32 |
+
} catch (e: any) { setError(e.message); }
|
| 33 |
setLoading(false); setPin('');
|
| 34 |
};
|
| 35 |
|
|
|
|
|
|
|
|
|
|
| 36 |
const dots = Array.from({ length: 8 }, (_, i) => i < pin.length);
|
| 37 |
|
| 38 |
return (
|
| 39 |
+
<div className="h-screen flex flex-col items-center justify-center bg-canvas">
|
| 40 |
+
<div className="w-full max-w-xs">
|
|
|
|
|
|
|
| 41 |
{/* Logo */}
|
| 42 |
+
<div className="text-center mb-12">
|
| 43 |
+
<div className="w-12 h-12 mx-auto rounded-full bg-primary flex items-center justify-center mb-4">
|
| 44 |
+
<span className="text-on-primary font-bold text-title-md">SV</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
</div>
|
| 46 |
+
<h1 className="display-text text-display-sm text-ink mb-1">SolVox</h1>
|
| 47 |
+
<p className="text-body-sm text-muted">Voice-First AI Wallet</p>
|
| 48 |
</div>
|
| 49 |
|
| 50 |
{/* PIN Dots */}
|
| 51 |
+
<div className="flex justify-center gap-3 mb-6">
|
| 52 |
+
{dots.map((f, i) => (
|
| 53 |
+
<div key={i} className={`w-3 h-3 rounded-full transition-all duration-200 ${f ? 'bg-primary' : 'bg-surface-strong'}`} />
|
|
|
|
|
|
|
|
|
|
| 54 |
))}
|
| 55 |
</div>
|
| 56 |
|
| 57 |
+
<form onSubmit={submit}>
|
| 58 |
+
<input ref={ref} type="password" inputMode="numeric" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, ''))}
|
| 59 |
+
maxLength={8} className="sr-only" disabled={loading} />
|
| 60 |
+
<div onClick={() => ref.current?.focus()} className="bg-surface-soft rounded-xl p-4 text-center cursor-text mb-4 border border-hairline hover:border-primary/30 transition-colors">
|
| 61 |
+
<div className="text-title-lg font-mono text-ink h-8 flex items-center justify-center tracking-[0.5em]">
|
| 62 |
+
{pin ? '●'.repeat(pin.length) : <span className="text-body-sm text-muted tracking-normal">Tap to enter PIN</span>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</div>
|
| 64 |
</div>
|
| 65 |
+
{error && <div className="text-body-sm text-semantic-down text-center mb-4 bg-semantic-down/5 rounded-md p-2">{error}</div>}
|
| 66 |
+
<button type="submit" disabled={loading || pin.length < 6} className="btn-primary w-full disabled:opacity-40">{loading ? 'Unlocking…' : 'Unlock'}</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
</form>
|
| 68 |
|
| 69 |
+
{bio && <button onClick={doBio} className="btn-secondary w-full mt-3">Use Touch ID</button>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
+
<div className="mt-8 text-center text-caption text-muted-soft flex items-center justify-center gap-1.5">
|
| 72 |
+
<div className="w-1.5 h-1.5 rounded-full bg-semantic-up" />
|
| 73 |
+
Encrypted locally · No cloud · QVAC on-device
|
|
|
|
|
|
|
| 74 |
</div>
|
| 75 |
</div>
|
|
|
|
|
|
|
|
|
|
| 76 |
</div>
|
| 77 |
);
|
| 78 |
}
|
src/renderer/pages/OnboardingScreen.tsx
CHANGED
|
@@ -1,168 +1,122 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
-
import {
|
| 3 |
|
| 4 |
-
interface
|
| 5 |
-
type Step = 'welcome' | '
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
const stepIndex = (s: Step) => s === 'welcome' ? 0 : s === 'create_or_import' ? 1 : s === 'import' ? 1 : s === 'pin' ? 2 : 3;
|
| 9 |
-
|
| 10 |
-
export default function OnboardingScreen({ onComplete }: OnboardingScreenProps) {
|
| 11 |
const [step, setStep] = useState<Step>('welcome');
|
| 12 |
const [mnemonic, setMnemonic] = useState('');
|
| 13 |
-
const [pin, setPin] = useState('');
|
| 14 |
-
const [
|
| 15 |
-
const [
|
| 16 |
-
const [
|
| 17 |
-
const [loading, setLoading] = useState(false);
|
| 18 |
|
| 19 |
-
const
|
| 20 |
-
|
| 21 |
try {
|
| 22 |
-
if (window.solvox) {
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
} else { setPublicKey('DevMode' + Date.now()); setStep('pin'); }
|
| 27 |
-
} catch (e: any) { setError(e.message); }
|
| 28 |
-
setLoading(false);
|
| 29 |
};
|
| 30 |
|
| 31 |
-
const
|
| 32 |
-
if (!mnemonic.trim()) return
|
| 33 |
-
const w = mnemonic.trim().split(/\s+/);
|
| 34 |
-
|
| 35 |
-
setLoading(true); setError('');
|
| 36 |
try {
|
| 37 |
-
if (window.solvox) {
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
} else { setPublicKey('DevImport' + Date.now()); setStep('pin'); }
|
| 42 |
-
} catch (e: any) { setError(e.message); }
|
| 43 |
-
setLoading(false);
|
| 44 |
};
|
| 45 |
|
| 46 |
-
const
|
| 47 |
-
if (pin.length < 6) return
|
| 48 |
-
if (pin !==
|
| 49 |
-
|
| 50 |
-
try {
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
} catch (e: any) { setError(e.message); }
|
| 54 |
-
setLoading(false);
|
| 55 |
};
|
| 56 |
|
| 57 |
return (
|
| 58 |
-
<div className="h-screen flex items-center justify-center
|
| 59 |
-
<
|
| 60 |
-
|
| 61 |
-
{step !== 'welcome' && <StepIndicator steps={STEP_LABELS} current={stepIndex(step)} />}
|
| 62 |
|
| 63 |
{step === 'welcome' && (
|
| 64 |
-
<div className="text-center
|
| 65 |
-
<div className="w-
|
| 66 |
-
<span className="text-
|
| 67 |
</div>
|
| 68 |
-
<h1 className="
|
| 69 |
-
<p className="text-
|
| 70 |
-
<p className="text-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
{ icon: '🔒', label: 'Self-Custody', desc: 'Your keys only' },
|
| 77 |
-
].map(f => (
|
| 78 |
-
<div key={f.label} className="glass glass-hover rounded-2xl p-4 text-center">
|
| 79 |
-
<div className="text-2xl mb-2">{f.icon}</div>
|
| 80 |
-
<div className="text-xs font-semibold text-sol-text">{f.label}</div>
|
| 81 |
-
<div className="text-[10px] text-sol-muted mt-0.5">{f.desc}</div>
|
| 82 |
</div>
|
| 83 |
))}
|
| 84 |
</div>
|
| 85 |
-
|
| 86 |
-
<button onClick={() => setStep('create_or_import')} className="btn-primary px-10 py-4 text-white text-lg">
|
| 87 |
-
<span>Get Started</span>
|
| 88 |
-
</button>
|
| 89 |
</div>
|
| 90 |
)}
|
| 91 |
|
| 92 |
-
{step === '
|
| 93 |
-
<div className="space-y-4
|
| 94 |
-
<h2 className="text-
|
| 95 |
-
<button onClick={
|
| 96 |
-
className="
|
| 97 |
-
<div className="
|
| 98 |
-
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-sol-purple to-sol-purple-light flex items-center justify-center shadow-glow-sm group-hover:scale-105 transition-transform">
|
| 99 |
-
<span className="text-white text-xl">✦</span>
|
| 100 |
-
</div>
|
| 101 |
-
<div>
|
| 102 |
-
<div className="text-base font-bold group-hover:text-sol-purple transition-colors">Create New Wallet</div>
|
| 103 |
-
<div className="text-xs text-sol-muted">Generate a fresh 24-word recovery phrase</div>
|
| 104 |
-
</div>
|
| 105 |
-
</div>
|
| 106 |
</button>
|
| 107 |
-
<button onClick={() => setStep('import')}
|
| 108 |
-
className="
|
| 109 |
-
<div className="
|
| 110 |
-
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-tether-green to-tether-dark flex items-center justify-center shadow-glow-sm group-hover:scale-105 transition-transform">
|
| 111 |
-
<span className="text-white text-xl">↓</span>
|
| 112 |
-
</div>
|
| 113 |
-
<div>
|
| 114 |
-
<div className="text-base font-bold group-hover:text-tether-green transition-colors">Import Existing</div>
|
| 115 |
-
<div className="text-xs text-sol-muted">Use a 12 or 24 word recovery phrase</div>
|
| 116 |
-
</div>
|
| 117 |
-
</div>
|
| 118 |
</button>
|
| 119 |
-
{
|
| 120 |
</div>
|
| 121 |
)}
|
| 122 |
|
| 123 |
{step === 'import' && (
|
| 124 |
-
<div className="space-y-5
|
| 125 |
-
<h2 className="text-
|
| 126 |
-
<p className="text-
|
| 127 |
-
<textarea value={mnemonic} onChange={e => setMnemonic(e.target.value)} placeholder="word1 word2 word3
|
| 128 |
-
|
| 129 |
-
{error && <div className="text-danger text-xs">{error}</div>}
|
| 130 |
<div className="flex gap-3">
|
| 131 |
-
<button onClick={() => { setStep('
|
| 132 |
-
<button onClick={
|
| 133 |
-
<span>{loading ? 'Importing...' : 'Import'}</span>
|
| 134 |
-
</button>
|
| 135 |
</div>
|
| 136 |
</div>
|
| 137 |
)}
|
| 138 |
|
| 139 |
{step === 'pin' && (
|
| 140 |
-
<div className="space-y-5
|
| 141 |
-
<h2 className="text-
|
| 142 |
-
<p className="text-
|
| 143 |
-
<input type="password" inputMode="numeric" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, ''))}
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
{
|
| 148 |
-
{error && <div className="text-danger text-xs text-center">{error}</div>}
|
| 149 |
-
<button onClick={setWalletPin} disabled={loading || pin.length < 6} className="btn-primary w-full py-3.5 text-white disabled:opacity-40">
|
| 150 |
-
<span>{loading ? 'Encrypting...' : 'Continue'}</span>
|
| 151 |
-
</button>
|
| 152 |
</div>
|
| 153 |
)}
|
| 154 |
|
| 155 |
{step === 'done' && (
|
| 156 |
-
<div className="text-center
|
| 157 |
-
<div className="w-
|
| 158 |
-
<svg width="
|
| 159 |
</div>
|
| 160 |
-
<h2 className="
|
| 161 |
-
<p className="text-
|
| 162 |
-
<div className="
|
| 163 |
-
<button onClick={() => onComplete(
|
| 164 |
-
<span>Open SolVox</span>
|
| 165 |
-
</button>
|
| 166 |
</div>
|
| 167 |
)}
|
| 168 |
</div>
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
+
import { StepIndicator } from '../components/ui/index';
|
| 3 |
|
| 4 |
+
interface Props { onComplete: (pk: string) => void; }
|
| 5 |
+
type Step = 'welcome' | 'choose' | 'import' | 'pin' | 'done';
|
| 6 |
+
const LABELS = ['Welcome', 'Wallet', 'Secure', 'Done'];
|
| 7 |
+
const idx = (s: Step) => s === 'welcome' ? 0 : s === 'choose' || s === 'import' ? 1 : s === 'pin' ? 2 : 3;
|
| 8 |
|
| 9 |
+
export default function OnboardingScreen({ onComplete }: Props) {
|
|
|
|
|
|
|
|
|
|
| 10 |
const [step, setStep] = useState<Step>('welcome');
|
| 11 |
const [mnemonic, setMnemonic] = useState('');
|
| 12 |
+
const [pin, setPin] = useState(''); const [pinC, setPinC] = useState('');
|
| 13 |
+
const [pk, setPk] = useState('');
|
| 14 |
+
const [err, setErr] = useState('');
|
| 15 |
+
const [busy, setBusy] = useState(false);
|
|
|
|
| 16 |
|
| 17 |
+
const create = async () => {
|
| 18 |
+
setBusy(true); setErr('');
|
| 19 |
try {
|
| 20 |
+
if (window.solvox) { const r = await window.solvox.wallet.create(); if (r.success && r.publicKey) { setPk(r.publicKey); setStep('pin'); } else setErr(r.error || 'Failed'); }
|
| 21 |
+
else { setPk('Dev' + Date.now()); setStep('pin'); }
|
| 22 |
+
} catch (e: any) { setErr(e.message); }
|
| 23 |
+
setBusy(false);
|
|
|
|
|
|
|
|
|
|
| 24 |
};
|
| 25 |
|
| 26 |
+
const imp = async () => {
|
| 27 |
+
if (!mnemonic.trim()) return setErr('Enter recovery phrase');
|
| 28 |
+
const w = mnemonic.trim().split(/\s+/); if (w.length !== 12 && w.length !== 24) return setErr('Must be 12 or 24 words');
|
| 29 |
+
setBusy(true); setErr('');
|
|
|
|
| 30 |
try {
|
| 31 |
+
if (window.solvox) { const r = await window.solvox.wallet.import(mnemonic.trim()); if (r.success && r.publicKey) { setPk(r.publicKey); setStep('pin'); } else setErr(r.error || 'Invalid'); }
|
| 32 |
+
else { setPk('DevImp' + Date.now()); setStep('pin'); }
|
| 33 |
+
} catch (e: any) { setErr(e.message); }
|
| 34 |
+
setBusy(false);
|
|
|
|
|
|
|
|
|
|
| 35 |
};
|
| 36 |
|
| 37 |
+
const setP = async () => {
|
| 38 |
+
if (pin.length < 6) return setErr('Min 6 digits');
|
| 39 |
+
if (pin !== pinC) return setErr('PINs don\'t match');
|
| 40 |
+
setBusy(true); setErr('');
|
| 41 |
+
try { if (window.solvox) { const r = await window.solvox.auth.setPin(pin); if (!r.success) { setErr(r.error || 'Failed'); setBusy(false); return; } } setStep('done'); }
|
| 42 |
+
catch (e: any) { setErr(e.message); }
|
| 43 |
+
setBusy(false);
|
|
|
|
|
|
|
| 44 |
};
|
| 45 |
|
| 46 |
return (
|
| 47 |
+
<div className="h-screen flex items-center justify-center bg-canvas">
|
| 48 |
+
<div className="w-full max-w-md px-6">
|
| 49 |
+
{step !== 'welcome' && <StepIndicator steps={LABELS} current={idx(step)} />}
|
|
|
|
| 50 |
|
| 51 |
{step === 'welcome' && (
|
| 52 |
+
<div className="text-center page-enter">
|
| 53 |
+
<div className="w-14 h-14 mx-auto rounded-full bg-primary flex items-center justify-center mb-6">
|
| 54 |
+
<span className="text-on-primary font-bold text-title-lg">SV</span>
|
| 55 |
</div>
|
| 56 |
+
<h1 className="display-text text-display-lg text-ink mb-3">SolVox</h1>
|
| 57 |
+
<p className="text-body-md text-body mb-2">Voice-First Private AI Wallet</p>
|
| 58 |
+
<p className="text-body-sm text-muted mb-10">Powered by QVAC SDK · 100% Local AI · Zero Cloud</p>
|
| 59 |
+
<div className="grid grid-cols-3 gap-3 mb-10">
|
| 60 |
+
{[{ t: 'Voice Control', d: 'Talk to your wallet' }, { t: 'Local AI', d: '6 QVAC modules' }, { t: 'Self-Custody', d: 'Your keys only' }].map(f => (
|
| 61 |
+
<div key={f.t} className="card text-center" style={{ padding: '20px' }}>
|
| 62 |
+
<div className="text-title-sm text-ink">{f.t}</div>
|
| 63 |
+
<div className="text-caption text-muted mt-1">{f.d}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
</div>
|
| 65 |
))}
|
| 66 |
</div>
|
| 67 |
+
<button onClick={() => setStep('choose')} className="btn-primary-lg">Get started</button>
|
|
|
|
|
|
|
|
|
|
| 68 |
</div>
|
| 69 |
)}
|
| 70 |
|
| 71 |
+
{step === 'choose' && (
|
| 72 |
+
<div className="space-y-4 page-enter">
|
| 73 |
+
<h2 className="display-text text-display-sm text-ink text-center mb-6">Set up your wallet</h2>
|
| 74 |
+
<button onClick={create} disabled={busy} className="w-full card text-left hover:border-primary/30 hover:shadow-soft transition-all">
|
| 75 |
+
<div className="text-title-md text-ink">Create new wallet</div>
|
| 76 |
+
<div className="text-body-sm text-body mt-1">Generate a fresh 24-word recovery phrase</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
</button>
|
| 78 |
+
<button onClick={() => setStep('import')} className="w-full card text-left hover:border-primary/30 hover:shadow-soft transition-all">
|
| 79 |
+
<div className="text-title-md text-ink">Import existing wallet</div>
|
| 80 |
+
<div className="text-body-sm text-body mt-1">Use a 12 or 24 word recovery phrase</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
</button>
|
| 82 |
+
{err && <div className="text-body-sm text-semantic-down text-center">{err}</div>}
|
| 83 |
</div>
|
| 84 |
)}
|
| 85 |
|
| 86 |
{step === 'import' && (
|
| 87 |
+
<div className="space-y-5 page-enter">
|
| 88 |
+
<h2 className="display-text text-display-sm text-ink text-center">Import recovery phrase</h2>
|
| 89 |
+
<p className="text-body-sm text-muted text-center">Stays on your device — never sent anywhere.</p>
|
| 90 |
+
<textarea value={mnemonic} onChange={e => setMnemonic(e.target.value)} placeholder="word1 word2 word3 …" rows={4} className="input-field font-mono text-body-sm resize-none" style={{ height: 'auto' }} />
|
| 91 |
+
{err && <div className="text-body-sm text-semantic-down">{err}</div>}
|
|
|
|
| 92 |
<div className="flex gap-3">
|
| 93 |
+
<button onClick={() => { setStep('choose'); setErr(''); }} className="btn-secondary flex-1">Back</button>
|
| 94 |
+
<button onClick={imp} disabled={busy} className="btn-primary flex-1 disabled:opacity-50">{busy ? 'Importing…' : 'Import'}</button>
|
|
|
|
|
|
|
| 95 |
</div>
|
| 96 |
</div>
|
| 97 |
)}
|
| 98 |
|
| 99 |
{step === 'pin' && (
|
| 100 |
+
<div className="space-y-5 page-enter">
|
| 101 |
+
<h2 className="display-text text-display-sm text-ink text-center">Set your PIN</h2>
|
| 102 |
+
<p className="text-body-sm text-muted text-center">Encrypts your wallet with AES-256-GCM.</p>
|
| 103 |
+
<input type="password" inputMode="numeric" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, ''))} placeholder="Enter PIN (min 6)" maxLength={12} className="input-field text-center text-title-lg tracking-[0.5em] font-mono" />
|
| 104 |
+
<input type="password" inputMode="numeric" value={pinC} onChange={e => setPinC(e.target.value.replace(/\D/g, ''))} placeholder="Confirm PIN" maxLength={12} className="input-field text-center text-title-lg tracking-[0.5em] font-mono" />
|
| 105 |
+
{pin.length >= 6 && pin === pinC && <div className="text-center"><span className="badge-pill-green badge-pill">PINs match</span></div>}
|
| 106 |
+
{err && <div className="text-body-sm text-semantic-down text-center">{err}</div>}
|
| 107 |
+
<button onClick={setP} disabled={busy || pin.length < 6} className="btn-primary w-full disabled:opacity-40">{busy ? 'Encrypting…' : 'Continue'}</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
</div>
|
| 109 |
)}
|
| 110 |
|
| 111 |
{step === 'done' && (
|
| 112 |
+
<div className="text-center page-enter">
|
| 113 |
+
<div className="w-14 h-14 mx-auto rounded-full bg-semantic-up/10 flex items-center justify-center mb-6">
|
| 114 |
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#05b169" strokeWidth="2.5" strokeLinecap="round"><polyline points="20 6 9 17 4 12"/></svg>
|
| 115 |
</div>
|
| 116 |
+
<h2 className="display-text text-display-sm text-ink mb-2">You're all set</h2>
|
| 117 |
+
<p className="text-body-md text-body mb-4">Wallet encrypted & secured. AI runs 100% locally.</p>
|
| 118 |
+
<div className="bg-surface-soft rounded-xl p-3 font-mono text-caption text-muted break-all mb-6">{pk}</div>
|
| 119 |
+
<button onClick={() => onComplete(pk)} className="btn-primary-lg">Open SolVox</button>
|
|
|
|
|
|
|
| 120 |
</div>
|
| 121 |
)}
|
| 122 |
</div>
|
src/renderer/pages/SecurityPage.tsx
CHANGED
|
@@ -1,164 +1,105 @@
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
-
import {
|
| 3 |
|
| 4 |
export default function SecurityPage() {
|
| 5 |
-
const [
|
| 6 |
-
const [
|
| 7 |
const [anomalies, setAnomalies] = useState<any[]>([]);
|
| 8 |
-
const [
|
| 9 |
-
const [
|
| 10 |
-
const [error, setError] = useState('');
|
| 11 |
const { addToast } = useToast();
|
| 12 |
|
| 13 |
useEffect(() => { load(); }, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
const
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
const u = { ...settings, [k]: v };
|
| 25 |
-
setSettings(u);
|
| 26 |
-
if (window.solvox) { await window.solvox.security.updateSettings(u); addToast({ type: 'success', title: 'Saved', duration: 1500 }); }
|
| 27 |
-
};
|
| 28 |
-
|
| 29 |
-
const addWL = async () => {
|
| 30 |
-
if (!newAddr.trim() || !newLabel.trim()) return setError('Both required');
|
| 31 |
-
if (window.solvox) {
|
| 32 |
-
const r = await window.solvox.security.addWhitelist(newAddr.trim(), newLabel.trim());
|
| 33 |
-
if (r.success) { setNewAddr(''); setNewLabel(''); setError(''); load(); addToast({ type: 'success', title: 'Address whitelisted' }); }
|
| 34 |
-
else setError(r.error || 'Failed');
|
| 35 |
-
}
|
| 36 |
-
};
|
| 37 |
-
|
| 38 |
-
const removeWL = async (addr: string) => { if (window.solvox) { await window.solvox.security.removeWhitelist(addr); load(); } };
|
| 39 |
-
|
| 40 |
-
// Security score
|
| 41 |
-
const features = [settings.whitelistEnabled, settings.anomalyDetection, settings.requireConfirmation, (settings.maxSingleTx || 0) < 5000, (settings.velocityLimit || 0) <= 10];
|
| 42 |
-
const score = Math.round((features.filter(Boolean).length / features.length) * 100);
|
| 43 |
-
const scoreColor = score >= 80 ? '#14F195' : score >= 50 ? '#FFB830' : '#FF4466';
|
| 44 |
|
| 45 |
return (
|
| 46 |
-
<div className="
|
| 47 |
-
<h2 className="text-
|
| 48 |
|
| 49 |
-
{/*
|
| 50 |
-
<div className="
|
| 51 |
-
<div className="
|
| 52 |
-
|
| 53 |
-
<div className="text-center">
|
| 54 |
-
<div className="text-lg font-extrabold" style={{ color: scoreColor }}>{score}</div>
|
| 55 |
-
<div className="text-[8px] text-sol-muted">/ 100</div>
|
| 56 |
-
</div>
|
| 57 |
-
</CircularProgress>
|
| 58 |
-
<div className="text-xs font-semibold text-sol-text mt-3">Security Score</div>
|
| 59 |
-
</div>
|
| 60 |
-
|
| 61 |
-
<div className="col-span-2 glass rounded-2xl p-5">
|
| 62 |
-
<div className="text-xs text-sol-muted font-semibold uppercase tracking-wider mb-3">Transaction Limits</div>
|
| 63 |
-
<div className="grid grid-cols-2 gap-3">
|
| 64 |
-
{[
|
| 65 |
-
{ key: 'maxSingleTx', label: 'Max per TX', suffix: 'tokens', val: settings.maxSingleTx || 1000 },
|
| 66 |
-
{ key: 'maxDailyVolume', label: 'Daily limit', suffix: '/day', val: settings.maxDailyVolume || 5000 },
|
| 67 |
-
{ key: 'velocityLimit', label: 'Max TX/hour', suffix: '', val: settings.velocityLimit || 10 },
|
| 68 |
-
{ key: 'cooldownMinutes', label: 'Cooldown', suffix: 'min', val: settings.cooldownMinutes || 1 },
|
| 69 |
-
].map(f => (
|
| 70 |
-
<div key={f.key}>
|
| 71 |
-
<label className="text-[10px] text-sol-muted block mb-1">{f.label}</label>
|
| 72 |
-
<div className="flex items-center gap-1.5">
|
| 73 |
-
<input type="number" value={f.val} onChange={e => update(f.key, Number(e.target.value))}
|
| 74 |
-
className="input-field text-xs py-1.5 w-full" />
|
| 75 |
-
{f.suffix && <span className="text-[10px] text-sol-muted whitespace-nowrap">{f.suffix}</span>}
|
| 76 |
-
</div>
|
| 77 |
-
</div>
|
| 78 |
-
))}
|
| 79 |
-
</div>
|
| 80 |
-
</div>
|
| 81 |
-
</div>
|
| 82 |
-
|
| 83 |
-
{/* Toggles */}
|
| 84 |
-
<div className="glass rounded-2xl p-5">
|
| 85 |
-
<div className="text-xs text-sol-muted font-semibold uppercase tracking-wider mb-4">Security Features</div>
|
| 86 |
-
<div className="space-y-3 stagger">
|
| 87 |
{[
|
| 88 |
-
{
|
| 89 |
-
{
|
| 90 |
-
{
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
</div>
|
| 99 |
</div>
|
| 100 |
-
<button onClick={() => update(t.key, !settings[t.key])}
|
| 101 |
-
className={`relative w-11 h-6 rounded-full transition-all duration-300 ${settings[t.key] ? 'bg-sol-green' : 'bg-sol-border/60'}`}>
|
| 102 |
-
<span className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all duration-300 ${settings[t.key] ? 'left-[22px]' : 'left-0.5'}`} />
|
| 103 |
-
</button>
|
| 104 |
</div>
|
| 105 |
))}
|
| 106 |
</div>
|
| 107 |
</div>
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
{/* Whitelist */}
|
| 110 |
-
<div className="
|
| 111 |
-
<div className="text-
|
| 112 |
<div className="flex gap-2 mb-3">
|
| 113 |
-
<input value={
|
| 114 |
-
<input value={
|
| 115 |
-
<button onClick={
|
| 116 |
</div>
|
| 117 |
-
{
|
| 118 |
-
{
|
| 119 |
-
<div className="text-center py-6 text-xs text-sol-muted/60">No addresses whitelisted</div>
|
| 120 |
-
) : (
|
| 121 |
<div className="space-y-2">
|
| 122 |
-
{
|
| 123 |
-
<div key={i} className="flex items-center justify-between bg-
|
| 124 |
-
<div>
|
| 125 |
-
|
| 126 |
-
<div className="text-[10px] font-mono text-sol-muted">{e.address.slice(0, 10)}...{e.address.slice(-6)}</div>
|
| 127 |
-
</div>
|
| 128 |
-
<button onClick={() => removeWL(e.address)} className="text-danger text-[10px] hover:underline">Remove</button>
|
| 129 |
</div>
|
| 130 |
))}
|
| 131 |
</div>
|
| 132 |
)}
|
| 133 |
</div>
|
| 134 |
|
| 135 |
-
{/*
|
| 136 |
-
<div className="
|
| 137 |
<div className="flex items-center justify-between mb-3">
|
| 138 |
-
<div className="text-
|
| 139 |
-
<
|
| 140 |
</div>
|
| 141 |
{anomalies.length === 0 ? (
|
| 142 |
-
<div className="text-center py-
|
| 143 |
-
<div className="
|
| 144 |
-
|
| 145 |
-
</div>
|
| 146 |
-
<div className="text-xs text-sol-muted">All clear — no anomalies detected</div>
|
| 147 |
</div>
|
| 148 |
) : (
|
| 149 |
-
<div className="space-y-2
|
| 150 |
{anomalies.map((a, i) => (
|
| 151 |
-
<div key={i} className={`rounded-
|
| 152 |
-
a.severity === 'high' ? 'bg-danger/5 border-danger/15' : a.severity === 'medium' ? 'bg-warning/5 border-warning/15' : 'bg-sol-dark/30 border-sol-border/30'
|
| 153 |
-
}`}>
|
| 154 |
<div className="flex items-center gap-2 mb-1">
|
| 155 |
-
<
|
| 156 |
-
|
| 157 |
-
</div>
|
| 158 |
-
<span className="text-[10px] text-sol-muted">{a.type}</span>
|
| 159 |
</div>
|
| 160 |
-
<div className="text-
|
| 161 |
-
<div className="text-
|
| 162 |
</div>
|
| 163 |
))}
|
| 164 |
</div>
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { useToast } from '../components/ui/index';
|
| 3 |
|
| 4 |
export default function SecurityPage() {
|
| 5 |
+
const [s, setS] = useState<any>({});
|
| 6 |
+
const [wl, setWl] = useState<any[]>([]);
|
| 7 |
const [anomalies, setAnomalies] = useState<any[]>([]);
|
| 8 |
+
const [addr, setAddr] = useState(''); const [label, setLabel] = useState('');
|
| 9 |
+
const [err, setErr] = useState('');
|
|
|
|
| 10 |
const { addToast } = useToast();
|
| 11 |
|
| 12 |
useEffect(() => { load(); }, []);
|
| 13 |
+
const load = async () => { if (!window.solvox) return; try { const [a, b, c] = await Promise.all([window.solvox.security.getSettings(), window.solvox.security.getWhitelist(), window.solvox.security.getAnomalies()]); setS(a); setWl(b); setAnomalies(c); } catch {} };
|
| 14 |
+
const upd = async (k: string, v: any) => { const n = { ...s, [k]: v }; setS(n); if (window.solvox) { await window.solvox.security.updateSettings(n); addToast({ type: 'success', title: 'Saved' }); } };
|
| 15 |
+
const addWl = async () => { if (!addr.trim() || !label.trim()) return setErr('Both required'); if (window.solvox) { const r = await window.solvox.security.addWhitelist(addr.trim(), label.trim()); if (r.success) { setAddr(''); setLabel(''); setErr(''); load(); } else setErr(r.error || 'Failed'); } };
|
| 16 |
+
const rmWl = async (a: string) => { if (window.solvox) { await window.solvox.security.removeWhitelist(a); load(); } };
|
| 17 |
|
| 18 |
+
const Toggle = ({ k, label, desc }: { k: string; label: string; desc: string }) => (
|
| 19 |
+
<div className="flex items-center justify-between py-3 border-b border-hairline-soft last:border-0">
|
| 20 |
+
<div><div className="text-title-sm text-ink">{label}</div><div className="text-caption text-muted">{desc}</div></div>
|
| 21 |
+
<button onClick={() => upd(k, !s[k])} className="toggle">
|
| 22 |
+
<div className={`toggle-track ${s[k] ? 'active' : ''}`} />
|
| 23 |
+
<div className={`toggle-thumb ${s[k] ? 'active' : ''}`} />
|
| 24 |
+
</button>
|
| 25 |
+
</div>
|
| 26 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
return (
|
| 29 |
+
<div className="max-w-3xl mx-auto px-8 py-section">
|
| 30 |
+
<h2 className="display-text text-title-lg text-ink mb-8">Security</h2>
|
| 31 |
|
| 32 |
+
{/* Limits */}
|
| 33 |
+
<div className="card mb-6">
|
| 34 |
+
<div className="text-caption-strong text-muted uppercase tracking-wider mb-4">Transaction limits</div>
|
| 35 |
+
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
{[
|
| 37 |
+
{ k: 'maxSingleTx', l: 'Max per transaction', u: 'tokens', v: s.maxSingleTx || 1000 },
|
| 38 |
+
{ k: 'maxDailyVolume', l: 'Daily limit', u: '/day', v: s.maxDailyVolume || 5000 },
|
| 39 |
+
{ k: 'velocityLimit', l: 'Max TX/hour', u: '', v: s.velocityLimit || 10 },
|
| 40 |
+
{ k: 'cooldownMinutes', l: 'Cooldown', u: 'min', v: s.cooldownMinutes || 1 },
|
| 41 |
+
].map(f => (
|
| 42 |
+
<div key={f.k}>
|
| 43 |
+
<label className="text-caption text-muted block mb-1">{f.l}</label>
|
| 44 |
+
<div className="flex items-center gap-2">
|
| 45 |
+
<input type="number" value={f.v} onChange={e => upd(f.k, Number(e.target.value))} className="input-field text-body-sm" />
|
| 46 |
+
{f.u && <span className="text-caption text-muted whitespace-nowrap">{f.u}</span>}
|
|
|
|
| 47 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
</div>
|
| 49 |
))}
|
| 50 |
</div>
|
| 51 |
</div>
|
| 52 |
|
| 53 |
+
{/* Toggles */}
|
| 54 |
+
<div className="card mb-6">
|
| 55 |
+
<div className="text-caption-strong text-muted uppercase tracking-wider mb-4">Features</div>
|
| 56 |
+
<Toggle k="whitelistEnabled" label="Address whitelisting" desc="Only allow sends to approved addresses" />
|
| 57 |
+
<Toggle k="anomalyDetection" label="AI anomaly detection" desc="LLM analyzes spending patterns locally" />
|
| 58 |
+
<Toggle k="requireConfirmation" label="Transaction confirmation" desc="Always require explicit approval" />
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
{/* Whitelist */}
|
| 62 |
+
<div className="card mb-6">
|
| 63 |
+
<div className="text-caption-strong text-muted uppercase tracking-wider mb-3">Address whitelist</div>
|
| 64 |
<div className="flex gap-2 mb-3">
|
| 65 |
+
<input value={label} onChange={e => setLabel(e.target.value)} placeholder="Label" className="input-field text-body-sm w-28" />
|
| 66 |
+
<input value={addr} onChange={e => setAddr(e.target.value)} placeholder="Solana address" className="input-field text-body-sm flex-1 font-mono" />
|
| 67 |
+
<button onClick={addWl} className="btn-primary text-body-sm py-2 px-4">Add</button>
|
| 68 |
</div>
|
| 69 |
+
{err && <div className="text-caption text-semantic-down mb-2">{err}</div>}
|
| 70 |
+
{wl.length === 0 ? <div className="text-body-sm text-muted text-center py-4">No addresses whitelisted</div> : (
|
|
|
|
|
|
|
| 71 |
<div className="space-y-2">
|
| 72 |
+
{wl.map((e, i) => (
|
| 73 |
+
<div key={i} className="flex items-center justify-between bg-surface-soft rounded-lg p-3">
|
| 74 |
+
<div><div className="text-title-sm text-ink">{e.label}</div><div className="text-caption font-mono text-muted">{e.address.slice(0, 10)}…{e.address.slice(-6)}</div></div>
|
| 75 |
+
<button onClick={() => rmWl(e.address)} className="btn-text text-semantic-down text-body-sm">Remove</button>
|
|
|
|
|
|
|
|
|
|
| 76 |
</div>
|
| 77 |
))}
|
| 78 |
</div>
|
| 79 |
)}
|
| 80 |
</div>
|
| 81 |
|
| 82 |
+
{/* Anomalies */}
|
| 83 |
+
<div className="card">
|
| 84 |
<div className="flex items-center justify-between mb-3">
|
| 85 |
+
<div className="text-caption-strong text-muted uppercase tracking-wider">Anomaly log</div>
|
| 86 |
+
<span className="badge-pill-blue badge-pill text-[10px]">AI-POWERED</span>
|
| 87 |
</div>
|
| 88 |
{anomalies.length === 0 ? (
|
| 89 |
+
<div className="text-center py-6">
|
| 90 |
+
<div className="text-body-sm text-semantic-up">All clear — no anomalies detected ✓</div>
|
| 91 |
+
<div className="text-caption text-muted mt-1">Analyzed by @qvac/llm-llamacpp + @qvac/embed-llamacpp</div>
|
|
|
|
|
|
|
| 92 |
</div>
|
| 93 |
) : (
|
| 94 |
+
<div className="space-y-2">
|
| 95 |
{anomalies.map((a, i) => (
|
| 96 |
+
<div key={i} className={`rounded-lg p-3 border ${a.severity === 'high' ? 'bg-semantic-down/5 border-semantic-down/15' : a.severity === 'medium' ? 'bg-accent-yellow/5 border-accent-yellow/15' : 'bg-surface-soft border-hairline'}`}>
|
|
|
|
|
|
|
| 97 |
<div className="flex items-center gap-2 mb-1">
|
| 98 |
+
<span className={`badge-pill text-[10px] ${a.severity === 'high' ? 'badge-pill-red' : ''}`}>{a.severity.toUpperCase()}</span>
|
| 99 |
+
<span className="text-caption text-muted">{a.type}</span>
|
|
|
|
|
|
|
| 100 |
</div>
|
| 101 |
+
<div className="text-body-sm text-ink">{a.description}</div>
|
| 102 |
+
<div className="text-caption text-muted-soft mt-1">{new Date(a.timestamp).toLocaleString()}</div>
|
| 103 |
</div>
|
| 104 |
))}
|
| 105 |
</div>
|
src/renderer/pages/SendPage.tsx
CHANGED
|
@@ -1,28 +1,35 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
-
import {
|
| 3 |
|
| 4 |
-
interface
|
| 5 |
|
| 6 |
-
export default function SendPage({ balance, onSent }:
|
| 7 |
const [token, setToken] = useState<'SOL' | 'USDT'>('SOL');
|
| 8 |
const [to, setTo] = useState('');
|
| 9 |
const [amount, setAmount] = useState('');
|
| 10 |
const [loading, setLoading] = useState(false);
|
| 11 |
const [error, setError] = useState('');
|
| 12 |
-
const [success, setSuccess] = useState<
|
| 13 |
-
const [step, setStep] = useState<'form' | 'confirm' | '
|
|
|
|
| 14 |
const { addToast } = useToast();
|
| 15 |
|
| 16 |
const max = token === 'SOL' ? balance.sol : balance.usdt;
|
| 17 |
-
const
|
| 18 |
-
const pct = max > 0 ? Math.min(100, (amtNum / max) * 100) : 0;
|
| 19 |
|
| 20 |
-
const review = () => {
|
| 21 |
setError('');
|
| 22 |
-
if (!to.trim()) return setError('Recipient address
|
| 23 |
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(to.trim())) return setError('Invalid Solana address');
|
| 24 |
-
if (!
|
| 25 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
setStep('confirm');
|
| 27 |
};
|
| 28 |
|
|
@@ -30,139 +37,101 @@ export default function SendPage({ balance, onSent }: SendPageProps) {
|
|
| 30 |
setLoading(true); setError('');
|
| 31 |
try {
|
| 32 |
const r = window.solvox
|
| 33 |
-
?
|
| 34 |
-
: { success: true, signature: '
|
| 35 |
if (r.success) {
|
| 36 |
-
setSuccess(
|
| 37 |
-
|
| 38 |
-
onSent();
|
| 39 |
-
addToast({ type: 'success', title: 'Transaction Sent', message: `${amount} ${token} sent successfully` });
|
| 40 |
} else { setError(r.error || 'Transaction failed'); setStep('form'); }
|
| 41 |
} catch (e: any) { setError(e.message); setStep('form'); }
|
| 42 |
setLoading(false);
|
| 43 |
};
|
| 44 |
|
| 45 |
-
const reset = () => { setTo(''); setAmount(''); setError(''); setSuccess(null); setStep('form'); };
|
| 46 |
|
| 47 |
return (
|
| 48 |
-
<div className="max-w-md mx-auto
|
| 49 |
-
|
| 50 |
-
<
|
| 51 |
-
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${token === 'SOL' ? 'bg-gradient-to-br from-sol-purple to-sol-purple-light' : 'bg-gradient-to-br from-tether-green to-tether-dark'}`}>
|
| 52 |
-
<span className="text-white font-bold">{token === 'SOL' ? '◎' : '₮'}</span>
|
| 53 |
-
</div>
|
| 54 |
-
<div>
|
| 55 |
-
<h2 className="text-xl font-bold">Send {token}</h2>
|
| 56 |
-
<div className="text-xs text-sol-muted">Balance: <AnimatedNumber value={max} decimals={token === 'SOL' ? 4 : 2} /> {token}</div>
|
| 57 |
-
</div>
|
| 58 |
-
</div>
|
| 59 |
|
| 60 |
{step === 'form' && (
|
| 61 |
-
<div className="
|
| 62 |
-
{/* Token
|
| 63 |
-
<div className="flex gap-2 p-1 bg-
|
| 64 |
{(['SOL', 'USDT'] as const).map(t => (
|
| 65 |
-
<button key={t} onClick={() => setToken(t)}
|
| 66 |
-
className={`flex-1 py-2.5 rounded-lg text-sm font-semibold transition-all duration-300 ${
|
| 67 |
-
token === t ? (t === 'SOL' ? 'bg-gradient-to-r from-sol-purple to-sol-purple-light text-white shadow-glow-sm' : 'bg-gradient-to-r from-tether-green to-tether-dark text-white shadow-glow-sm')
|
| 68 |
-
: 'text-sol-muted hover:text-sol-text'
|
| 69 |
-
}`}>
|
| 70 |
{t === 'SOL' ? '◎' : '₮'} {t}
|
| 71 |
</button>
|
| 72 |
))}
|
| 73 |
</div>
|
| 74 |
-
|
| 75 |
{/* Recipient */}
|
| 76 |
<div>
|
| 77 |
-
<label className="text-
|
| 78 |
-
<input value={to} onChange={e => setTo(e.target.value)} placeholder="
|
| 79 |
-
className="input-field font-mono text-xs" />
|
| 80 |
</div>
|
| 81 |
-
|
| 82 |
{/* Amount */}
|
| 83 |
<div>
|
| 84 |
-
<div className="flex justify-between items-center mb-
|
| 85 |
-
<label className="text-
|
| 86 |
-
<button onClick={() => setAmount(max.toString())} className="
|
| 87 |
-
MAX {max.toFixed(token === 'SOL' ? 4 : 2)}
|
| 88 |
-
</button>
|
| 89 |
-
</div>
|
| 90 |
-
<div className="relative">
|
| 91 |
-
<input type="number" value={amount} onChange={e => setAmount(e.target.value)}
|
| 92 |
-
placeholder="0.00" step="any" min="0" className="input-field text-xl font-bold pr-16 text-center" />
|
| 93 |
-
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-sol-muted font-semibold text-sm">{token}</span>
|
| 94 |
</div>
|
| 95 |
-
{
|
| 96 |
-
<div className="mt-2 flex items-center gap-2">
|
| 97 |
-
<div className="progress-bar flex-1"><div className="progress-fill" style={{ width: `${pct}%` }} /></div>
|
| 98 |
-
<span className="text-[10px] text-sol-muted w-10 text-right">{pct.toFixed(0)}%</span>
|
| 99 |
-
</div>
|
| 100 |
-
{/* Quick amounts */}
|
| 101 |
<div className="flex gap-2 mt-2">
|
| 102 |
{[25, 50, 75, 100].map(p => (
|
| 103 |
-
<button key={p} onClick={() => setAmount((max * p / 100).toFixed(token === 'SOL' ? 4 : 2))}
|
| 104 |
-
className="flex-1 py-1 rounded-lg text-[10px] font-medium text-sol-muted bg-sol-dark/40 border border-sol-border/30 hover:border-sol-purple/30 hover:text-sol-purple transition-all">
|
| 105 |
-
{p}%
|
| 106 |
-
</button>
|
| 107 |
))}
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
<button onClick={review} className="btn-primary w-full py-3.5 text-white">
|
| 114 |
-
<span>Review Transaction</span>
|
| 115 |
-
</button>
|
| 116 |
</div>
|
| 117 |
)}
|
| 118 |
|
| 119 |
{step === 'confirm' && (
|
| 120 |
-
<div className="
|
| 121 |
<div className="text-center mb-2">
|
| 122 |
-
<div className="text-
|
| 123 |
-
<div className="text-
|
| 124 |
</div>
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
<div className="
|
| 128 |
-
<div className="
|
| 129 |
-
<div className="flex justify-between"><span className="text-
|
| 130 |
-
<div className="flex justify-between"><span className="text-sol-muted">Network</span><span className="badge badge-warn text-[10px]">Devnet</span></div>
|
| 131 |
</div>
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
<
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
</div>
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
{error && <div className="text-danger text-xs text-center">{error}</div>}
|
| 142 |
-
|
| 143 |
<div className="flex gap-3">
|
| 144 |
-
<button onClick={() => setStep('form')} className="btn-
|
| 145 |
-
<button onClick={send} disabled={loading} className="btn-primary flex-1
|
| 146 |
-
<span>{loading ? 'Sending...' : `Send ${token}`}</span>
|
| 147 |
-
</button>
|
| 148 |
</div>
|
| 149 |
</div>
|
| 150 |
)}
|
| 151 |
|
| 152 |
-
{step === '
|
| 153 |
-
<div className="
|
| 154 |
-
<div className="w-
|
| 155 |
-
<svg width="
|
| 156 |
-
</div>
|
| 157 |
-
<div>
|
| 158 |
-
<h3 className="text-2xl font-bold">Sent!</h3>
|
| 159 |
-
<p className="text-sol-muted text-sm mt-1">{amount} {token} sent successfully</p>
|
| 160 |
</div>
|
| 161 |
-
<div className="
|
| 162 |
-
<
|
| 163 |
-
|
| 164 |
-
</a>
|
| 165 |
-
<button onClick={reset} className="btn-primary w-full
|
| 166 |
</div>
|
| 167 |
)}
|
| 168 |
</div>
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
+
import { Num, useToast } from '../components/ui/index';
|
| 3 |
|
| 4 |
+
interface Props { balance: { sol: number; usdt: number }; onSent: () => void; }
|
| 5 |
|
| 6 |
+
export default function SendPage({ balance, onSent }: Props) {
|
| 7 |
const [token, setToken] = useState<'SOL' | 'USDT'>('SOL');
|
| 8 |
const [to, setTo] = useState('');
|
| 9 |
const [amount, setAmount] = useState('');
|
| 10 |
const [loading, setLoading] = useState(false);
|
| 11 |
const [error, setError] = useState('');
|
| 12 |
+
const [success, setSuccess] = useState<any>(null);
|
| 13 |
+
const [step, setStep] = useState<'form' | 'confirm' | 'done'>('form');
|
| 14 |
+
const [risk, setRisk] = useState<any>(null);
|
| 15 |
const { addToast } = useToast();
|
| 16 |
|
| 17 |
const max = token === 'SOL' ? balance.sol : balance.usdt;
|
| 18 |
+
const amt = parseFloat(amount) || 0;
|
|
|
|
| 19 |
|
| 20 |
+
const review = async () => {
|
| 21 |
setError('');
|
| 22 |
+
if (!to.trim()) return setError('Recipient address required');
|
| 23 |
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(to.trim())) return setError('Invalid Solana address');
|
| 24 |
+
if (!amt || amt <= 0) return setError('Enter a valid amount');
|
| 25 |
+
if (amt > max) return setError(`Insufficient balance. Max: ${max.toFixed(token === 'SOL' ? 4 : 2)} ${token}`);
|
| 26 |
+
// AI risk assessment before showing confirmation
|
| 27 |
+
if (window.solvox) {
|
| 28 |
+
try {
|
| 29 |
+
const r = await window.solvox.ai.assessRisk({ amount: amt, token, to: to.trim() });
|
| 30 |
+
if (r.success) setRisk(r.risk);
|
| 31 |
+
} catch {}
|
| 32 |
+
}
|
| 33 |
setStep('confirm');
|
| 34 |
};
|
| 35 |
|
|
|
|
| 37 |
setLoading(true); setError('');
|
| 38 |
try {
|
| 39 |
const r = window.solvox
|
| 40 |
+
? await window.solvox.ai.executeConfirmed({ token, amount: amt, to: to.trim() })
|
| 41 |
+
: { success: true, signature: 'dev_' + Date.now(), explorer: '#' };
|
| 42 |
if (r.success) {
|
| 43 |
+
setSuccess(r); setStep('done'); onSent();
|
| 44 |
+
addToast({ type: 'success', title: `${amount} ${token} sent` });
|
|
|
|
|
|
|
| 45 |
} else { setError(r.error || 'Transaction failed'); setStep('form'); }
|
| 46 |
} catch (e: any) { setError(e.message); setStep('form'); }
|
| 47 |
setLoading(false);
|
| 48 |
};
|
| 49 |
|
| 50 |
+
const reset = () => { setTo(''); setAmount(''); setError(''); setSuccess(null); setRisk(null); setStep('form'); };
|
| 51 |
|
| 52 |
return (
|
| 53 |
+
<div className="max-w-md mx-auto px-8 py-section">
|
| 54 |
+
<h2 className="text-title-lg display-text text-ink mb-2">Send {token}</h2>
|
| 55 |
+
<p className="text-body-sm text-body mb-8">Balance: <span className="number-mono"><Num value={max} decimals={token === 'SOL' ? 4 : 2} /></span> {token}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
{step === 'form' && (
|
| 58 |
+
<div className="card space-y-5">
|
| 59 |
+
{/* Token */}
|
| 60 |
+
<div className="flex gap-2 p-1 bg-surface-soft rounded-pill">
|
| 61 |
{(['SOL', 'USDT'] as const).map(t => (
|
| 62 |
+
<button key={t} onClick={() => setToken(t)} className={`flex-1 py-2 rounded-pill text-button transition-colors ${token === t ? 'bg-primary text-on-primary' : 'text-body hover:text-ink'}`}>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
{t === 'SOL' ? '◎' : '₮'} {t}
|
| 64 |
</button>
|
| 65 |
))}
|
| 66 |
</div>
|
|
|
|
| 67 |
{/* Recipient */}
|
| 68 |
<div>
|
| 69 |
+
<label className="text-caption-strong text-muted uppercase tracking-wider block mb-1.5">Recipient</label>
|
| 70 |
+
<input value={to} onChange={e => setTo(e.target.value)} placeholder="Solana address" className="input-field font-mono text-body-sm" />
|
|
|
|
| 71 |
</div>
|
|
|
|
| 72 |
{/* Amount */}
|
| 73 |
<div>
|
| 74 |
+
<div className="flex justify-between items-center mb-1.5">
|
| 75 |
+
<label className="text-caption-strong text-muted uppercase tracking-wider">Amount</label>
|
| 76 |
+
<button onClick={() => setAmount(max.toString())} className="btn-text text-body-sm py-0 px-1">Max</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
</div>
|
| 78 |
+
<input type="number" value={amount} onChange={e => setAmount(e.target.value)} placeholder="0.00" step="any" className="input-field number-mono text-title-lg text-center" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
<div className="flex gap-2 mt-2">
|
| 80 |
{[25, 50, 75, 100].map(p => (
|
| 81 |
+
<button key={p} onClick={() => setAmount((max * p / 100).toFixed(token === 'SOL' ? 4 : 2))} className="flex-1 py-1.5 rounded-pill text-caption-strong text-muted bg-surface-soft hover:bg-surface-strong transition-colors">{p}%</button>
|
|
|
|
|
|
|
|
|
|
| 82 |
))}
|
| 83 |
</div>
|
| 84 |
</div>
|
| 85 |
+
{error && <div className="text-body-sm text-semantic-down bg-semantic-down/5 rounded-md p-3">{error}</div>}
|
| 86 |
+
<button onClick={review} className="btn-primary w-full">Review transaction</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
</div>
|
| 88 |
)}
|
| 89 |
|
| 90 |
{step === 'confirm' && (
|
| 91 |
+
<div className="card space-y-5 page-enter">
|
| 92 |
<div className="text-center mb-2">
|
| 93 |
+
<div className="text-body text-muted">Confirm transaction</div>
|
| 94 |
+
<div className="display-text text-display-sm text-ink mt-1">{amount} {token}</div>
|
| 95 |
</div>
|
| 96 |
+
<div className="bg-surface-soft rounded-xl p-4 space-y-3 text-body-sm">
|
| 97 |
+
<div className="flex justify-between"><span className="text-muted">To</span><span className="font-mono text-caption">{to.slice(0, 10)}…{to.slice(-6)}</span></div>
|
| 98 |
+
<div className="hairline-soft" />
|
| 99 |
+
<div className="flex justify-between"><span className="text-muted">Fee</span><span className="number-mono text-caption">~0.000005 SOL</span></div>
|
| 100 |
+
<div className="flex justify-between"><span className="text-muted">Network</span><span className="badge-pill text-[10px]">DEVNET</span></div>
|
|
|
|
| 101 |
</div>
|
| 102 |
+
{/* AI Risk Assessment */}
|
| 103 |
+
{risk && (
|
| 104 |
+
<div className={`rounded-xl p-4 text-body-sm ${risk.level === 'safe' ? 'bg-semantic-up/5' : risk.level === 'danger' ? 'bg-semantic-down/5' : 'bg-accent-yellow/5'}`}>
|
| 105 |
+
<div className="flex items-center gap-2 mb-1">
|
| 106 |
+
<span className={`badge-pill text-[10px] ${risk.level === 'safe' ? 'badge-pill-green' : 'badge-pill-red'}`}>AI RISK: {risk.level.toUpperCase()}</span>
|
| 107 |
+
<span className="number-mono text-caption text-muted">{risk.score}/100</span>
|
| 108 |
+
</div>
|
| 109 |
+
{risk.factors?.length > 0 && <ul className="text-caption text-body mt-1 space-y-0.5">{risk.factors.map((f: string, i: number) => <li key={i}>· {f}</li>)}</ul>}
|
| 110 |
+
{risk.recommendation && <div className="text-caption text-muted mt-1">{risk.recommendation}</div>}
|
| 111 |
+
<div className="text-caption text-muted-soft mt-1 flex items-center gap-1">
|
| 112 |
+
<div className="w-1 h-1 rounded-full bg-primary" />
|
| 113 |
+
Analyzed by @qvac/llm-llamacpp + @qvac/embed-llamacpp
|
| 114 |
+
</div>
|
| 115 |
</div>
|
| 116 |
+
)}
|
| 117 |
+
{error && <div className="text-body-sm text-semantic-down">{error}</div>}
|
|
|
|
|
|
|
| 118 |
<div className="flex gap-3">
|
| 119 |
+
<button onClick={() => setStep('form')} className="btn-secondary flex-1">Cancel</button>
|
| 120 |
+
<button onClick={send} disabled={loading} className="btn-primary flex-1 disabled:opacity-50">{loading ? 'Sending…' : `Send ${token}`}</button>
|
|
|
|
|
|
|
| 121 |
</div>
|
| 122 |
</div>
|
| 123 |
)}
|
| 124 |
|
| 125 |
+
{step === 'done' && success && (
|
| 126 |
+
<div className="card text-center space-y-5 page-enter">
|
| 127 |
+
<div className="w-14 h-14 mx-auto rounded-full bg-semantic-up/10 flex items-center justify-center">
|
| 128 |
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#05b169" strokeWidth="2.5" strokeLinecap="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
</div>
|
| 130 |
+
<div className="display-text text-display-sm text-ink">Sent</div>
|
| 131 |
+
<p className="text-body text-muted">{amount} {token} sent successfully</p>
|
| 132 |
+
<div className="bg-surface-soft rounded-xl p-3 font-mono text-caption text-muted break-all">{success.signature}</div>
|
| 133 |
+
<a href={success.explorer} target="_blank" rel="noopener noreferrer" className="btn-text text-body-sm">View on Solscan →</a>
|
| 134 |
+
<button onClick={reset} className="btn-primary w-full">Send another</button>
|
| 135 |
</div>
|
| 136 |
)}
|
| 137 |
</div>
|
src/renderer/pages/SettingsPage.tsx
CHANGED
|
@@ -1,104 +1,87 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
|
| 3 |
-
interface
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
{ name: 'Embeddings', model: 'Nomic Embed Text v1.5', quant: 'Q4_K_M', size: '260 MB', pkg: 'embed-llamacpp', color: '#B06CFF' },
|
| 11 |
-
{ name: 'Speech-to-Text', model: 'Whisper Base.en', quant: 'GGML', size: '150 MB', pkg: 'transcription-whispercpp', color: '#14F195' },
|
| 12 |
-
{ name: 'Text-to-Speech', model: 'Piper Amy (en_US)', quant: 'ONNX', size: '75 MB', pkg: 'tts-onnx', color: '#26A17B' },
|
| 13 |
-
{ name: 'Translation', model: 'OPUS MT (EN↔ES)', quant: 'NMT', size: '50 MB', pkg: 'translation-nmtcpp', color: '#FFB830' },
|
| 14 |
-
{ name: 'OCR', model: 'PaddleOCR v4', quant: 'ONNX', size: '30 MB', pkg: 'ocr-onnx', color: '#3B82F6' },
|
| 15 |
-
];
|
| 16 |
|
| 17 |
return (
|
| 18 |
-
<div className="
|
| 19 |
-
<h2 className="text-
|
| 20 |
|
| 21 |
{/* Network */}
|
| 22 |
-
<div className="
|
| 23 |
-
<div className="text-
|
| 24 |
-
<div className="flex gap-2 p-1 bg-
|
| 25 |
-
{[
|
| 26 |
-
{ id
|
| 27 |
-
{ id: 'mainnet-beta', label: 'Mainnet', color: 'danger' },
|
| 28 |
-
{ id: 'testnet', label: 'Testnet', color: 'muted' },
|
| 29 |
-
].map(n => (
|
| 30 |
-
<button key={n.id} onClick={() => setNetwork(n.id)}
|
| 31 |
-
className={`flex-1 py-2 rounded-lg text-xs font-semibold transition-all ${
|
| 32 |
-
network === n.id ? 'bg-sol-card text-sol-text shadow' : 'text-sol-muted hover:text-sol-text'
|
| 33 |
-
}`}>
|
| 34 |
-
{network === n.id && <span className={`inline-block w-1.5 h-1.5 rounded-full mr-1.5 ${n.color === 'danger' ? 'bg-danger' : n.color === 'warning' ? 'bg-warning' : 'bg-sol-muted'}`} />}
|
| 35 |
-
{n.label}
|
| 36 |
-
</button>
|
| 37 |
))}
|
| 38 |
</div>
|
| 39 |
-
<p className="text-
|
| 40 |
-
{network === 'devnet' && '
|
| 41 |
-
{network === 'mainnet-beta' && '
|
| 42 |
-
{network === 'testnet' && '
|
| 43 |
</p>
|
| 44 |
</div>
|
| 45 |
|
| 46 |
-
{/* AI Models */}
|
| 47 |
-
<div className="
|
| 48 |
<div className="flex items-center justify-between mb-4">
|
| 49 |
-
<div className="text-
|
| 50 |
-
<
|
| 51 |
</div>
|
| 52 |
-
<div className="space-y-
|
| 53 |
{models.map(m => (
|
| 54 |
-
<div key={m.name} className="flex items-center
|
| 55 |
-
<div
|
| 56 |
-
|
| 57 |
-
<div className="
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
<
|
| 62 |
-
<
|
| 63 |
</div>
|
| 64 |
-
<div className="text-[10px] text-sol-muted font-medium">{m.size}</div>
|
| 65 |
</div>
|
| 66 |
))}
|
| 67 |
</div>
|
| 68 |
-
<div className="
|
| 69 |
-
<div className="flex items-center gap-
|
| 70 |
-
<div className="w-1.5 h-1.5 rounded-full bg-
|
| 71 |
All models run locally via QVAC Fabric (Vulkan GPU) — no CUDA required
|
| 72 |
</div>
|
| 73 |
</div>
|
| 74 |
|
| 75 |
{/* About */}
|
| 76 |
-
<div className="
|
| 77 |
-
<div className="text-
|
| 78 |
-
<div className="
|
| 79 |
-
<p><strong className="text-
|
| 80 |
-
<p>Powered by <strong className="text-
|
| 81 |
-
<div className="flex flex-wrap gap-1.5 mt-3">
|
| 82 |
-
{['Electron', 'React', 'TypeScript', 'QVAC SDK', 'Solana', 'Vulkan', 'TailwindCSS'].map(t => (
|
| 83 |
-
<span key={t} className="badge badge-muted text-[9px]">{t}</span>
|
| 84 |
-
))}
|
| 85 |
-
</div>
|
| 86 |
</div>
|
| 87 |
-
<div className="
|
| 88 |
-
|
| 89 |
-
|
|
|
|
| 90 |
</div>
|
|
|
|
|
|
|
| 91 |
</div>
|
| 92 |
|
| 93 |
-
{/*
|
| 94 |
-
<div className="
|
| 95 |
-
<div className="text-
|
| 96 |
-
<button onClick={onLock}
|
| 97 |
-
|
| 98 |
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
| 99 |
-
Lock Wallet Now
|
| 100 |
-
</button>
|
| 101 |
-
<p className="text-[10px] text-sol-muted/60 text-center mt-2">Zeroes private key from memory. PIN required to unlock.</p>
|
| 102 |
</div>
|
| 103 |
</div>
|
| 104 |
);
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
|
| 3 |
+
interface Props { onLock: () => void; }
|
| 4 |
|
| 5 |
+
const models = [
|
| 6 |
+
{ name: 'LLM', model: 'Llama 3.2 3B Instruct', q: 'Q4_K_M', size: '2.0 GB', pkg: 'llm-llamacpp' },
|
| 7 |
+
{ name: 'Embeddings', model: 'Nomic Embed Text v1.5', q: 'Q4_K_M', size: '260 MB', pkg: 'embed-llamacpp' },
|
| 8 |
+
{ name: 'Speech-to-Text', model: 'Whisper Base.en', q: 'GGML', size: '150 MB', pkg: 'transcription-whispercpp' },
|
| 9 |
+
{ name: 'Text-to-Speech', model: 'Piper Amy', q: 'ONNX', size: '75 MB', pkg: 'tts-onnx' },
|
| 10 |
+
{ name: 'Translation', model: 'OPUS MT (EN↔ES)', q: 'NMT', size: '50 MB', pkg: 'translation-nmtcpp' },
|
| 11 |
+
{ name: 'OCR', model: 'PaddleOCR v4', q: 'ONNX', size: '30 MB', pkg: 'ocr-onnx' },
|
| 12 |
+
];
|
| 13 |
|
| 14 |
+
export default function SettingsPage({ onLock }: Props) {
|
| 15 |
+
const [network, setNetwork] = useState('devnet');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
return (
|
| 18 |
+
<div className="max-w-2xl mx-auto px-8 py-section">
|
| 19 |
+
<h2 className="display-text text-title-lg text-ink mb-8">Settings</h2>
|
| 20 |
|
| 21 |
{/* Network */}
|
| 22 |
+
<div className="card mb-6">
|
| 23 |
+
<div className="text-caption-strong text-muted uppercase tracking-wider mb-3">Network</div>
|
| 24 |
+
<div className="flex gap-2 p-1 bg-surface-soft rounded-pill">
|
| 25 |
+
{[{ id: 'devnet', l: 'Devnet' }, { id: 'mainnet-beta', l: 'Mainnet' }, { id: 'testnet', l: 'Testnet' }].map(n => (
|
| 26 |
+
<button key={n.id} onClick={() => setNetwork(n.id)} className={`flex-1 py-2 rounded-pill text-button transition-colors ${network === n.id ? 'bg-primary text-on-primary' : 'text-body hover:text-ink'}`}>{n.l}</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
))}
|
| 28 |
</div>
|
| 29 |
+
<p className="text-caption text-muted mt-2">
|
| 30 |
+
{network === 'devnet' && 'Test tokens only. No real value.'}
|
| 31 |
+
{network === 'mainnet-beta' && 'Real tokens. Transactions irreversible.'}
|
| 32 |
+
{network === 'testnet' && 'Development purposes only.'}
|
| 33 |
</p>
|
| 34 |
</div>
|
| 35 |
|
| 36 |
+
{/* AI Models — Coinbase pricing-tier-card pattern */}
|
| 37 |
+
<div className="card mb-6">
|
| 38 |
<div className="flex items-center justify-between mb-4">
|
| 39 |
+
<div className="text-caption-strong text-muted uppercase tracking-wider">QVAC AI Models</div>
|
| 40 |
+
<span className="badge-pill text-[10px]">6 MODULES · ~2.6 GB</span>
|
| 41 |
</div>
|
| 42 |
+
<div className="space-y-0">
|
| 43 |
{models.map(m => (
|
| 44 |
+
<div key={m.name} className="flex items-center justify-between py-3 border-b border-hairline-soft last:border-0">
|
| 45 |
+
<div>
|
| 46 |
+
<div className="text-title-sm text-ink">{m.name}</div>
|
| 47 |
+
<div className="text-caption text-muted">{m.model}</div>
|
| 48 |
+
<div className="text-caption text-muted-soft font-mono">@qvac/{m.pkg}</div>
|
| 49 |
+
</div>
|
| 50 |
+
<div className="text-right">
|
| 51 |
+
<span className="badge-pill text-[10px] mr-2">{m.q}</span>
|
| 52 |
+
<span className="number-mono text-caption text-muted">{m.size}</span>
|
| 53 |
</div>
|
|
|
|
| 54 |
</div>
|
| 55 |
))}
|
| 56 |
</div>
|
| 57 |
+
<div className="hairline-soft mt-3 mb-3" />
|
| 58 |
+
<div className="text-caption text-muted flex items-center gap-1.5">
|
| 59 |
+
<div className="w-1.5 h-1.5 rounded-full bg-semantic-up" />
|
| 60 |
All models run locally via QVAC Fabric (Vulkan GPU) — no CUDA required
|
| 61 |
</div>
|
| 62 |
</div>
|
| 63 |
|
| 64 |
{/* About */}
|
| 65 |
+
<div className="card mb-6">
|
| 66 |
+
<div className="text-caption-strong text-muted uppercase tracking-wider mb-3">About</div>
|
| 67 |
+
<div className="text-body-sm text-body space-y-2">
|
| 68 |
+
<p><strong className="text-ink">SolVox</strong> — voice-first, privacy-preserving AI wallet for Solana.</p>
|
| 69 |
+
<p>Powered by <strong className="text-primary">Tether QVAC SDK</strong> — local AI that never phones home.</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
</div>
|
| 71 |
+
<div className="flex flex-wrap gap-1.5 mt-3">
|
| 72 |
+
{['Electron', 'React', 'TypeScript', 'QVAC SDK', 'Solana', 'Vulkan'].map(t => (
|
| 73 |
+
<span key={t} className="badge-pill text-[10px]">{t}</span>
|
| 74 |
+
))}
|
| 75 |
</div>
|
| 76 |
+
<div className="hairline-soft my-3" />
|
| 77 |
+
<div className="text-caption text-muted-soft">Colosseum Frontier Hackathon · Tether QVAC Track</div>
|
| 78 |
</div>
|
| 79 |
|
| 80 |
+
{/* Lock */}
|
| 81 |
+
<div className="card border-semantic-down/20" style={{ borderColor: 'rgba(207, 32, 47, 0.15)' }}>
|
| 82 |
+
<div className="text-caption-strong text-semantic-down uppercase tracking-wider mb-3">Danger zone</div>
|
| 83 |
+
<button onClick={onLock} className="w-full py-3 rounded-pill border border-semantic-down/30 text-semantic-down text-button hover:bg-semantic-down/5 transition-colors">Lock wallet</button>
|
| 84 |
+
<p className="text-caption text-muted text-center mt-2">Zeroes private key from memory. PIN required to unlock.</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
</div>
|
| 86 |
</div>
|
| 87 |
);
|
src/renderer/pages/VoicePage.tsx
CHANGED
|
@@ -1,237 +1,151 @@
|
|
| 1 |
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
-
import {
|
| 3 |
|
| 4 |
-
interface
|
| 5 |
-
interface
|
| 6 |
|
| 7 |
-
const SUGGESTIONS = [
|
| 8 |
-
'What is my balance?',
|
| 9 |
-
'Send 5 SOL to alice.sol',
|
| 10 |
-
'Show my recent transactions',
|
| 11 |
-
'How much USDT do I have?',
|
| 12 |
-
'Send 100 USDT to bob.sol',
|
| 13 |
-
'Help me understand gas fees',
|
| 14 |
-
];
|
| 15 |
|
| 16 |
-
export default function VoicePage({ aiStatus }:
|
| 17 |
-
const [
|
| 18 |
-
{ id: '0', role: 'assistant', text: "
|
| 19 |
]);
|
| 20 |
const [input, setInput] = useState('');
|
| 21 |
-
const [
|
| 22 |
-
const [
|
| 23 |
-
const [
|
| 24 |
-
const
|
| 25 |
-
const
|
| 26 |
-
const
|
| 27 |
-
const
|
| 28 |
-
const
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
const startRecording = async () => {
|
| 35 |
try {
|
| 36 |
const stream = await navigator.mediaDevices.getUserMedia({ audio: { sampleRate: 16000, channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
| 37 |
-
const ctx = new AudioContext();
|
| 38 |
-
const
|
| 39 |
-
const
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
const update = () => {
|
| 45 |
-
if (!analyserRef.current) return;
|
| 46 |
-
const d = new Uint8Array(analyserRef.current.frequencyBinCount);
|
| 47 |
-
analyserRef.current.getByteFrequencyData(d);
|
| 48 |
-
setWaveformData(Array.from(d).slice(0, 48).map(v => Math.max(2, v / 6)));
|
| 49 |
-
animFrameRef.current = requestAnimationFrame(update);
|
| 50 |
-
};
|
| 51 |
-
update();
|
| 52 |
-
|
| 53 |
-
const rec = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
| 54 |
-
audioChunks.current = [];
|
| 55 |
-
rec.ondataavailable = e => { if (e.data.size > 0) audioChunks.current.push(e.data); };
|
| 56 |
-
rec.onstop = async () => {
|
| 57 |
-
stream.getTracks().forEach(t => t.stop());
|
| 58 |
-
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
| 59 |
-
if (timerRef.current) clearInterval(timerRef.current);
|
| 60 |
-
setWaveformData(new Array(48).fill(2));
|
| 61 |
-
setRecordingTime(0);
|
| 62 |
-
const blob = new Blob(audioChunks.current, { type: 'audio/webm' });
|
| 63 |
-
processVoice(await blob.arrayBuffer());
|
| 64 |
-
};
|
| 65 |
-
|
| 66 |
-
mediaRecorder.current = rec;
|
| 67 |
-
rec.start();
|
| 68 |
-
setIsRecording(true);
|
| 69 |
-
setRecordingTime(0);
|
| 70 |
-
timerRef.current = setInterval(() => setRecordingTime(p => p + 1), 1000);
|
| 71 |
-
} catch { addMsg('system', 'Microphone access denied. Please enable mic permissions.'); }
|
| 72 |
};
|
| 73 |
|
| 74 |
-
const
|
| 75 |
-
if (mediaRecorder.current && isRecording) { mediaRecorder.current.stop(); setIsRecording(false); }
|
| 76 |
-
};
|
| 77 |
|
| 78 |
const processVoice = async (data: ArrayBuffer) => {
|
| 79 |
-
|
| 80 |
try {
|
| 81 |
if (window.solvox) {
|
| 82 |
const r = await window.solvox.ai.processVoice(data);
|
| 83 |
if (r.success) {
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
if (r.
|
| 87 |
-
} else
|
| 88 |
-
} else {
|
| 89 |
-
} catch (e: any) {
|
| 90 |
-
|
| 91 |
};
|
| 92 |
|
| 93 |
const handleSend = async () => {
|
| 94 |
-
if (!input.trim()) return;
|
| 95 |
-
const text = input.trim();
|
| 96 |
-
setInput('');
|
| 97 |
-
addMsg('user', text);
|
| 98 |
-
setIsProcessing(true);
|
| 99 |
try {
|
| 100 |
if (window.solvox) {
|
| 101 |
-
const
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
const addMsg = (role: 'user' | 'assistant' | 'system', text: string, intent?: any) => {
|
| 109 |
-
setMessages(p => [...p, { id: Date.now().toString(36), role, text, intent, timestamp: new Date() }]);
|
| 110 |
};
|
| 111 |
|
| 112 |
-
const
|
| 113 |
-
|
| 114 |
};
|
| 115 |
|
| 116 |
-
const
|
| 117 |
|
| 118 |
return (
|
| 119 |
-
<div className="flex flex-col h-full
|
| 120 |
{/* Header */}
|
| 121 |
<div className="flex items-center justify-between mb-4">
|
| 122 |
<div>
|
| 123 |
-
<h2 className="text-
|
| 124 |
-
<p className="text-
|
| 125 |
-
</div>
|
| 126 |
-
<div className="flex items-center gap-2">
|
| 127 |
-
<div className={`badge ${activeModules >= 4 ? 'badge-green' : activeModules > 0 ? 'badge-warn' : 'badge-muted'}`}>
|
| 128 |
-
<div className={`w-1.5 h-1.5 rounded-full ${activeModules > 0 ? 'bg-current animate-pulse' : 'bg-sol-muted/30'}`} />
|
| 129 |
-
{activeModules >= 4 ? 'AI Online' : activeModules > 0 ? 'Partial' : 'Loading...'}
|
| 130 |
-
</div>
|
| 131 |
</div>
|
|
|
|
|
|
|
|
|
|
| 132 |
</div>
|
| 133 |
|
| 134 |
-
{/* Chat
|
| 135 |
-
<div className="flex-1 overflow-y-auto
|
| 136 |
-
{
|
| 137 |
-
<div key={
|
| 138 |
-
<div className={`max-w-[
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
'bg-
|
| 142 |
}`}>
|
| 143 |
-
{
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
)}
|
| 151 |
-
<p className="text-[13px] leading-relaxed whitespace-pre-wrap">{msg.text}</p>
|
| 152 |
-
{msg.intent && msg.intent.action !== 'unknown' && (
|
| 153 |
-
<div className="mt-2 pt-2 border-t border-white/10">
|
| 154 |
-
<div className="flex flex-wrap gap-1.5">
|
| 155 |
-
<span className="badge badge-purple text-[10px]">{msg.intent.action}</span>
|
| 156 |
-
{msg.intent.confidence && <span className="badge badge-muted text-[10px]">{(msg.intent.confidence*100).toFixed(0)}%</span>}
|
| 157 |
-
{msg.intent.amount && <span className="badge badge-green text-[10px]">{msg.intent.amount} {msg.intent.token || ''}</span>}
|
| 158 |
-
{msg.intent.to && <span className="badge badge-tether text-[10px]">→ {msg.intent.to}</span>}
|
| 159 |
-
</div>
|
| 160 |
</div>
|
| 161 |
)}
|
| 162 |
-
|
|
|
|
| 163 |
</div>
|
| 164 |
</div>
|
| 165 |
))}
|
| 166 |
-
{
|
| 167 |
<div className="flex justify-start">
|
| 168 |
-
<div className="bg-
|
| 169 |
-
<
|
| 170 |
-
<span className="text-
|
| 171 |
</div>
|
| 172 |
</div>
|
| 173 |
)}
|
| 174 |
-
<div ref={
|
| 175 |
</div>
|
| 176 |
|
| 177 |
-
{/*
|
| 178 |
-
{
|
| 179 |
-
<div className="flex flex-wrap gap-2 mb-3
|
| 180 |
-
{SUGGESTIONS.
|
| 181 |
-
<button key={s} onClick={() =>
|
| 182 |
-
className="px-3 py-1.5 rounded-full border border-sol-border/40 text-[11px] text-sol-muted hover:text-sol-purple hover:border-sol-purple/30 transition-all">
|
| 183 |
-
{s}
|
| 184 |
-
</button>
|
| 185 |
))}
|
| 186 |
</div>
|
| 187 |
)}
|
| 188 |
|
| 189 |
-
{/* Waveform
|
| 190 |
-
{
|
| 191 |
-
<div className="mb-3
|
| 192 |
-
|
| 193 |
-
<div className="
|
| 194 |
-
|
| 195 |
-
<div key={i} className="w-[3px] rounded-full transition-all duration-75"
|
| 196 |
-
style={{ height: `${h}px`, background: `linear-gradient(180deg, #9945FF ${100 - h*2}%, #14F195)`, opacity: 0.4 + (h / 40) }} />
|
| 197 |
-
))}
|
| 198 |
-
</div>
|
| 199 |
-
<div className="flex items-center justify-center gap-2 mt-2">
|
| 200 |
-
<div className="w-2 h-2 rounded-full bg-danger animate-pulse" />
|
| 201 |
-
<span className="text-xs text-danger font-medium">Recording — {recordingTime}s</span>
|
| 202 |
-
</div>
|
| 203 |
-
</div>
|
| 204 |
</div>
|
| 205 |
)}
|
| 206 |
|
| 207 |
-
{/* Input
|
| 208 |
<div className="flex items-center gap-3">
|
| 209 |
-
<button
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
className={`w-12 h-12 rounded-2xl flex items-center justify-center transition-all duration-300 ${
|
| 214 |
-
isRecording ? 'bg-danger voice-btn-active' : 'bg-gradient-to-br from-sol-purple to-sol-purple-light voice-btn-idle'
|
| 215 |
-
} disabled:opacity-40 disabled:shadow-none`}
|
| 216 |
-
title="Hold to speak"
|
| 217 |
-
>
|
| 218 |
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="white" className={isRecording ? 'animate-pulse' : ''}>
|
| 219 |
-
{isRecording
|
| 220 |
-
? <rect x="6" y="6" width="12" height="12" rx="2" />
|
| 221 |
-
: <><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round"/><line x1="12" y1="19" x2="12" y2="23" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round"/></>
|
| 222 |
-
}
|
| 223 |
</svg>
|
| 224 |
</button>
|
| 225 |
-
|
| 226 |
<div className="flex-1 relative">
|
| 227 |
-
<input value={input} onChange={e => setInput(e.target.value)}
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
className="input-field pr-20" disabled={isProcessing || isRecording} />
|
| 231 |
-
<button onClick={handleSend} disabled={!input.trim() || isProcessing}
|
| 232 |
-
className="absolute right-1.5 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all disabled:opacity-20 bg-sol-purple/20 text-sol-purple hover:bg-sol-purple/30">
|
| 233 |
-
Send ↵
|
| 234 |
-
</button>
|
| 235 |
</div>
|
| 236 |
</div>
|
| 237 |
</div>
|
|
|
|
| 1 |
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { PipelineTrace } from '../components/ui/index';
|
| 3 |
|
| 4 |
+
interface Props { aiStatus: any; }
|
| 5 |
+
interface Msg { id: string; role: 'user' | 'assistant' | 'system'; text: string; pipeline?: any[]; actions?: any[]; ts: Date; }
|
| 6 |
|
| 7 |
+
const SUGGESTIONS = ['What is my balance?', 'Send 5 SOL to alice.sol', 'Show recent transactions', 'Help me understand gas fees'];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
export default function VoicePage({ aiStatus }: Props) {
|
| 10 |
+
const [msgs, setMsgs] = useState<Msg[]>([
|
| 11 |
+
{ id: '0', role: 'assistant', text: "I'm your SolVox AI assistant — running 100% locally via 6 QVAC modules. Ask me to send tokens, check balances, search transactions, or explain anything about Solana.", pipeline: [], actions: [], ts: new Date() },
|
| 12 |
]);
|
| 13 |
const [input, setInput] = useState('');
|
| 14 |
+
const [recording, setRecording] = useState(false);
|
| 15 |
+
const [processing, setProcessing] = useState(false);
|
| 16 |
+
const [wave, setWave] = useState<number[]>(new Array(40).fill(1));
|
| 17 |
+
const recRef = useRef<MediaRecorder | null>(null);
|
| 18 |
+
const chunks = useRef<Blob[]>([]);
|
| 19 |
+
const endRef = useRef<HTMLDivElement>(null);
|
| 20 |
+
const analyser = useRef<AnalyserNode | null>(null);
|
| 21 |
+
const raf = useRef<number | null>(null);
|
| 22 |
+
|
| 23 |
+
useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [msgs]);
|
| 24 |
+
|
| 25 |
+
const startRec = async () => {
|
|
|
|
|
|
|
| 26 |
try {
|
| 27 |
const stream = await navigator.mediaDevices.getUserMedia({ audio: { sampleRate: 16000, channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
| 28 |
+
const ctx = new AudioContext(); const src = ctx.createMediaStreamSource(stream); const a = ctx.createAnalyser(); a.fftSize = 128; src.connect(a); analyser.current = a;
|
| 29 |
+
const tick = () => { if (!analyser.current) return; const d = new Uint8Array(analyser.current.frequencyBinCount); analyser.current.getByteFrequencyData(d); setWave(Array.from(d).slice(0, 40).map(v => Math.max(1, v / 8))); raf.current = requestAnimationFrame(tick); }; tick();
|
| 30 |
+
const rec = new MediaRecorder(stream, { mimeType: 'audio/webm' }); chunks.current = [];
|
| 31 |
+
rec.ondataavailable = e => { if (e.data.size > 0) chunks.current.push(e.data); };
|
| 32 |
+
rec.onstop = async () => { stream.getTracks().forEach(t => t.stop()); if (raf.current) cancelAnimationFrame(raf.current); setWave(new Array(40).fill(1)); const b = new Blob(chunks.current, { type: 'audio/webm' }); processVoice(await b.arrayBuffer()); };
|
| 33 |
+
recRef.current = rec; rec.start(); setRecording(true);
|
| 34 |
+
} catch { add('system', 'Microphone access denied.'); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
};
|
| 36 |
|
| 37 |
+
const stopRec = () => { if (recRef.current && recording) { recRef.current.stop(); setRecording(false); } };
|
|
|
|
|
|
|
| 38 |
|
| 39 |
const processVoice = async (data: ArrayBuffer) => {
|
| 40 |
+
setProcessing(true);
|
| 41 |
try {
|
| 42 |
if (window.solvox) {
|
| 43 |
const r = await window.solvox.ai.processVoice(data);
|
| 44 |
if (r.success) {
|
| 45 |
+
add('user', r.transcription || '[voice]');
|
| 46 |
+
add('assistant', r.agentResult?.response || 'Done.', r.pipelineSteps, r.agentResult?.actions);
|
| 47 |
+
if (r.responseAudio) { const u = URL.createObjectURL(new Blob([r.responseAudio], { type: 'audio/wav' })); new Audio(u).play().catch(() => {}); }
|
| 48 |
+
} else add('system', r.error || 'Voice processing failed');
|
| 49 |
+
} else { add('user', '[voice — dev]'); add('assistant', 'QVAC models needed. Type commands instead.'); }
|
| 50 |
+
} catch (e: any) { add('system', e.message); }
|
| 51 |
+
setProcessing(false);
|
| 52 |
};
|
| 53 |
|
| 54 |
const handleSend = async () => {
|
| 55 |
+
if (!input.trim()) return; const text = input.trim(); setInput(''); add('user', text); setProcessing(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
try {
|
| 57 |
if (window.solvox) {
|
| 58 |
+
const r = await window.solvox.ai.chat(text);
|
| 59 |
+
if (r.success) add('assistant', r.response, r.pipelineSteps, r.actions);
|
| 60 |
+
else add('assistant', r.error || 'Could not process.');
|
| 61 |
+
} else add('assistant', `[Dev] "${text}" — needs QVAC models.`);
|
| 62 |
+
} catch (e: any) { add('system', e.message); }
|
| 63 |
+
setProcessing(false);
|
|
|
|
|
|
|
|
|
|
| 64 |
};
|
| 65 |
|
| 66 |
+
const add = (role: Msg['role'], text: string, pipeline?: any[], actions?: any[]) => {
|
| 67 |
+
setMsgs(p => [...p, { id: Date.now().toString(36), role, text, pipeline, actions, ts: new Date() }]);
|
| 68 |
};
|
| 69 |
|
| 70 |
+
const mods = aiStatus ? [aiStatus.llm, aiStatus.transcription, aiStatus.tts, aiStatus.embed, aiStatus.translation, aiStatus.ocr].filter(Boolean).length : 0;
|
| 71 |
|
| 72 |
return (
|
| 73 |
+
<div className="flex flex-col h-full max-w-content mx-auto px-8 py-6">
|
| 74 |
{/* Header */}
|
| 75 |
<div className="flex items-center justify-between mb-4">
|
| 76 |
<div>
|
| 77 |
+
<h2 className="text-title-lg display-text text-ink">Voice AI Assistant</h2>
|
| 78 |
+
<p className="text-body-sm text-body">Powered by 6 QVAC packages · All inference local</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
</div>
|
| 80 |
+
<span className={`badge-pill ${mods >= 4 ? 'badge-pill-green' : mods > 0 ? 'badge-pill-blue' : ''}`}>
|
| 81 |
+
{mods >= 4 ? 'AI ONLINE' : mods > 0 ? 'PARTIAL' : 'LOADING'}
|
| 82 |
+
</span>
|
| 83 |
</div>
|
| 84 |
|
| 85 |
+
{/* Chat */}
|
| 86 |
+
<div className="flex-1 overflow-y-auto card mb-4 space-y-4" style={{ padding: '24px' }}>
|
| 87 |
+
{msgs.map(m => (
|
| 88 |
+
<div key={m.id} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'} page-enter`}>
|
| 89 |
+
<div className={`max-w-[75%] ${
|
| 90 |
+
m.role === 'user' ? 'bg-primary text-on-primary rounded-xl rounded-br-sm px-4 py-3' :
|
| 91 |
+
m.role === 'system' ? 'bg-surface-soft text-muted border border-hairline rounded-xl px-4 py-3' :
|
| 92 |
+
'bg-surface-soft text-ink rounded-xl rounded-bl-sm px-4 py-3'
|
| 93 |
}`}>
|
| 94 |
+
{m.role === 'assistant' && <div className="text-caption-strong text-primary mb-1">SolVox AI</div>}
|
| 95 |
+
<p className="text-body-sm whitespace-pre-wrap">{m.text}</p>
|
| 96 |
+
{m.actions && m.actions.length > 0 && (
|
| 97 |
+
<div className="flex flex-wrap gap-1 mt-2">
|
| 98 |
+
{m.actions.map((a: any, i: number) => (
|
| 99 |
+
<span key={i} className="badge-pill-blue badge-pill text-[10px]">{a.tool}</span>
|
| 100 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
</div>
|
| 102 |
)}
|
| 103 |
+
{m.pipeline && m.pipeline.length > 0 && <PipelineTrace steps={m.pipeline} />}
|
| 104 |
+
<div className="text-caption text-muted-soft mt-1.5">{m.ts.toLocaleTimeString()}</div>
|
| 105 |
</div>
|
| 106 |
</div>
|
| 107 |
))}
|
| 108 |
+
{processing && (
|
| 109 |
<div className="flex justify-start">
|
| 110 |
+
<div className="bg-surface-soft rounded-xl px-4 py-3 flex items-center gap-2">
|
| 111 |
+
<div className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
| 112 |
+
<span className="text-body-sm text-muted">Processing locally…</span>
|
| 113 |
</div>
|
| 114 |
</div>
|
| 115 |
)}
|
| 116 |
+
<div ref={endRef} />
|
| 117 |
</div>
|
| 118 |
|
| 119 |
+
{/* Suggestions */}
|
| 120 |
+
{msgs.length <= 2 && !recording && (
|
| 121 |
+
<div className="flex flex-wrap gap-2 mb-3">
|
| 122 |
+
{SUGGESTIONS.map(s => (
|
| 123 |
+
<button key={s} onClick={() => setInput(s)} className="btn-secondary text-body-sm py-1.5 px-3">{s}</button>
|
|
|
|
|
|
|
|
|
|
| 124 |
))}
|
| 125 |
</div>
|
| 126 |
)}
|
| 127 |
|
| 128 |
+
{/* Waveform */}
|
| 129 |
+
{recording && (
|
| 130 |
+
<div className="bg-surface-soft rounded-xl p-3 mb-3 flex items-center justify-center gap-px h-12">
|
| 131 |
+
{wave.map((h, i) => (
|
| 132 |
+
<div key={i} className="w-[3px] rounded-full bg-primary transition-all duration-75" style={{ height: `${h}px`, opacity: 0.3 + h / 40 }} />
|
| 133 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
</div>
|
| 135 |
)}
|
| 136 |
|
| 137 |
+
{/* Input */}
|
| 138 |
<div className="flex items-center gap-3">
|
| 139 |
+
<button onMouseDown={startRec} onMouseUp={stopRec} onMouseLeave={stopRec} onTouchStart={startRec} onTouchEnd={stopRec} disabled={processing}
|
| 140 |
+
className={`w-11 h-11 rounded-pill flex items-center justify-center transition-colors ${recording ? 'bg-semantic-down' : 'bg-primary'} disabled:opacity-40`}>
|
| 141 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="white">
|
| 142 |
+
{recording ? <rect x="6" y="6" width="12" height="12" rx="2" /> : <><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round"/><line x1="12" y1="19" x2="12" y2="23" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round"/></>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
</svg>
|
| 144 |
</button>
|
|
|
|
| 145 |
<div className="flex-1 relative">
|
| 146 |
+
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSend()}
|
| 147 |
+
placeholder={recording ? 'Listening…' : 'Type a command or hold mic…'} className="input-field pr-20" disabled={processing || recording} />
|
| 148 |
+
<button onClick={handleSend} disabled={!input.trim() || processing} className="absolute right-1.5 top-1/2 -translate-y-1/2 btn-text text-body-sm disabled:opacity-20">Send</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
</div>
|
| 150 |
</div>
|
| 151 |
</div>
|
tailwind.config.js
CHANGED
|
@@ -1,87 +1,86 @@
|
|
| 1 |
/** @type {import('tailwindcss').Config} */
|
| 2 |
module.exports = {
|
| 3 |
content: ['./src/renderer/**/*.{ts,tsx,html}'],
|
| 4 |
-
darkMode: 'class',
|
| 5 |
theme: {
|
| 6 |
extend: {
|
| 7 |
colors: {
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
'
|
| 11 |
-
'
|
| 12 |
-
'
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
'
|
| 16 |
-
'
|
| 17 |
-
'
|
| 18 |
-
'
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
'
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
},
|
| 25 |
fontFamily: {
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
},
|
| 29 |
borderRadius: {
|
| 30 |
-
'
|
| 31 |
-
'
|
| 32 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
},
|
| 34 |
boxShadow: {
|
| 35 |
-
'
|
| 36 |
-
'
|
| 37 |
-
'glow-lg': '0 0 48px rgba(153, 69, 255, 0.25)',
|
| 38 |
-
'card': '0 4px 24px rgba(0, 0, 0, 0.3)',
|
| 39 |
-
'card-hover': '0 8px 40px rgba(0, 0, 0, 0.4)',
|
| 40 |
-
},
|
| 41 |
-
animation: {
|
| 42 |
-
'pulse-glow': 'pulse-glow 3s ease-in-out infinite',
|
| 43 |
-
'waveform': 'waveform 0.5s ease-in-out infinite alternate',
|
| 44 |
-
'slide-up': 'slide-up 0.35s cubic-bezier(0.4, 0, 0.2, 1)',
|
| 45 |
-
'fade-in': 'fadein 0.3s ease-out',
|
| 46 |
-
'scale-in': 'scalein 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
| 47 |
-
'spin-slow': 'spin 4s linear infinite',
|
| 48 |
-
'spin-reverse': 'spin-rev 3s linear infinite',
|
| 49 |
-
'float': 'float 6s ease-in-out infinite',
|
| 50 |
-
'gradient': 'gradient-shift 6s ease infinite',
|
| 51 |
},
|
| 52 |
-
|
| 53 |
-
'
|
| 54 |
-
'0%, 100%': { boxShadow: '0 0 20px rgba(153, 69, 255, 0.2)' },
|
| 55 |
-
'50%': { boxShadow: '0 0 48px rgba(153, 69, 255, 0.5)' },
|
| 56 |
-
},
|
| 57 |
-
'waveform': {
|
| 58 |
-
'0%': { height: '3px' },
|
| 59 |
-
'100%': { height: '28px' },
|
| 60 |
-
},
|
| 61 |
-
'slide-up': {
|
| 62 |
-
'0%': { transform: 'translateY(24px)', opacity: '0' },
|
| 63 |
-
'100%': { transform: 'translateY(0)', opacity: '1' },
|
| 64 |
-
},
|
| 65 |
-
'fadein': {
|
| 66 |
-
'0%': { opacity: '0' },
|
| 67 |
-
'100%': { opacity: '1' },
|
| 68 |
-
},
|
| 69 |
-
'scalein': {
|
| 70 |
-
'0%': { opacity: '0', transform: 'scale(0.9)' },
|
| 71 |
-
'100%': { opacity: '1', transform: 'scale(1)' },
|
| 72 |
-
},
|
| 73 |
-
'spin-rev': {
|
| 74 |
-
'0%': { transform: 'rotate(0deg)' },
|
| 75 |
-
'100%': { transform: 'rotate(-360deg)' },
|
| 76 |
-
},
|
| 77 |
-
'float': {
|
| 78 |
-
'0%, 100%': { transform: 'translateY(0)' },
|
| 79 |
-
'50%': { transform: 'translateY(-12px)' },
|
| 80 |
-
},
|
| 81 |
-
'gradient-shift': {
|
| 82 |
-
'0%, 100%': { backgroundPosition: '0% 50%' },
|
| 83 |
-
'50%': { backgroundPosition: '100% 50%' },
|
| 84 |
-
},
|
| 85 |
},
|
| 86 |
},
|
| 87 |
},
|
|
|
|
| 1 |
/** @type {import('tailwindcss').Config} */
|
| 2 |
module.exports = {
|
| 3 |
content: ['./src/renderer/**/*.{ts,tsx,html}'],
|
|
|
|
| 4 |
theme: {
|
| 5 |
extend: {
|
| 6 |
colors: {
|
| 7 |
+
// Brand & Accent
|
| 8 |
+
primary: '#0052ff',
|
| 9 |
+
'primary-active': '#003ecc',
|
| 10 |
+
'primary-disabled': '#a8b8cc',
|
| 11 |
+
'accent-yellow': '#f4b000',
|
| 12 |
+
// Surfaces
|
| 13 |
+
canvas: '#ffffff',
|
| 14 |
+
'surface-soft': '#f7f7f7',
|
| 15 |
+
'surface-strong': '#eef0f3',
|
| 16 |
+
'surface-dark': '#0a0b0d',
|
| 17 |
+
'surface-dark-elevated': '#16181c',
|
| 18 |
+
// Hairlines
|
| 19 |
+
hairline: '#dee1e6',
|
| 20 |
+
'hairline-soft': '#eef0f3',
|
| 21 |
+
// Text
|
| 22 |
+
ink: '#0a0b0d',
|
| 23 |
+
body: '#5b616e',
|
| 24 |
+
'body-strong': '#0a0b0d',
|
| 25 |
+
muted: '#7c828a',
|
| 26 |
+
'muted-soft': '#a8acb3',
|
| 27 |
+
'on-primary': '#ffffff',
|
| 28 |
+
'on-dark': '#ffffff',
|
| 29 |
+
'on-dark-soft': '#a8acb3',
|
| 30 |
+
// Trading
|
| 31 |
+
'semantic-up': '#05b169',
|
| 32 |
+
'semantic-down': '#cf202f',
|
| 33 |
},
|
| 34 |
fontFamily: {
|
| 35 |
+
display: ['Inter', '-apple-system', 'system-ui', 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', 'sans-serif'],
|
| 36 |
+
sans: ['Inter', '-apple-system', 'system-ui', 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', 'sans-serif'],
|
| 37 |
+
mono: ['JetBrains Mono', 'Geist Mono', 'monospace'],
|
| 38 |
+
},
|
| 39 |
+
fontSize: {
|
| 40 |
+
'display-mega': ['80px', { lineHeight: '1.0', letterSpacing: '-2px', fontWeight: '400' }],
|
| 41 |
+
'display-xl': ['64px', { lineHeight: '1.0', letterSpacing: '-1.6px', fontWeight: '400' }],
|
| 42 |
+
'display-lg': ['52px', { lineHeight: '1.0', letterSpacing: '-1.3px', fontWeight: '400' }],
|
| 43 |
+
'display-md': ['44px', { lineHeight: '1.09', letterSpacing: '-1px', fontWeight: '400' }],
|
| 44 |
+
'display-sm': ['36px', { lineHeight: '1.11', letterSpacing: '-0.5px', fontWeight: '400' }],
|
| 45 |
+
'title-lg': ['32px', { lineHeight: '1.13', letterSpacing: '-0.4px', fontWeight: '400' }],
|
| 46 |
+
'title-md': ['18px', { lineHeight: '1.33', letterSpacing: '0', fontWeight: '600' }],
|
| 47 |
+
'title-sm': ['16px', { lineHeight: '1.25', letterSpacing: '0', fontWeight: '600' }],
|
| 48 |
+
'body-md': ['16px', { lineHeight: '1.5', letterSpacing: '0', fontWeight: '400' }],
|
| 49 |
+
'body-strong': ['16px', { lineHeight: '1.5', letterSpacing: '0', fontWeight: '700' }],
|
| 50 |
+
'body-sm': ['14px', { lineHeight: '1.5', letterSpacing: '0', fontWeight: '400' }],
|
| 51 |
+
'caption': ['13px', { lineHeight: '1.5', letterSpacing: '0', fontWeight: '400' }],
|
| 52 |
+
'caption-strong': ['12px', { lineHeight: '1.5', letterSpacing: '0', fontWeight: '600' }],
|
| 53 |
+
'number-display': ['18px', { lineHeight: '1.4', letterSpacing: '0', fontWeight: '500' }],
|
| 54 |
+
'button': ['16px', { lineHeight: '1.15', letterSpacing: '0', fontWeight: '600' }],
|
| 55 |
+
'nav-link': ['14px', { lineHeight: '1.4', letterSpacing: '0', fontWeight: '500' }],
|
| 56 |
+
},
|
| 57 |
+
spacing: {
|
| 58 |
+
'xxs': '4px',
|
| 59 |
+
'xs': '8px',
|
| 60 |
+
'sm': '12px',
|
| 61 |
+
'base': '16px',
|
| 62 |
+
'md': '20px',
|
| 63 |
+
'lg': '24px',
|
| 64 |
+
'xl': '32px',
|
| 65 |
+
'xxl': '48px',
|
| 66 |
+
'section': '96px',
|
| 67 |
},
|
| 68 |
borderRadius: {
|
| 69 |
+
'none': '0px',
|
| 70 |
+
'xs': '4px',
|
| 71 |
+
'sm': '8px',
|
| 72 |
+
'md': '12px',
|
| 73 |
+
'lg': '16px',
|
| 74 |
+
'xl': '24px',
|
| 75 |
+
'pill': '100px',
|
| 76 |
+
'full': '9999px',
|
| 77 |
},
|
| 78 |
boxShadow: {
|
| 79 |
+
'soft': '0 4px 12px rgba(0, 0, 0, 0.04)',
|
| 80 |
+
'none': 'none',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
},
|
| 82 |
+
maxWidth: {
|
| 83 |
+
'content': '1200px',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
},
|
| 85 |
},
|
| 86 |
},
|