✨ Major frontend enhancement: glassmorphism, animations, toast system, premium component library, orbit loaders, particles, animated counters, security score ring, PIN dot visualization, waveform visualizer, staggered animations, 40+ CSS animations
Browse files- src/renderer/App.tsx +80 -80
- src/renderer/components/Sidebar.tsx +98 -35
- src/renderer/components/TopBar.tsx +58 -43
- src/renderer/components/ui/index.tsx +262 -0
- src/renderer/index.css +219 -54
- src/renderer/pages/Dashboard.tsx +142 -119
- src/renderer/pages/HistoryPage.tsx +63 -104
- src/renderer/pages/LockScreen.tsx +79 -89
- src/renderer/pages/OnboardingScreen.tsx +95 -182
- src/renderer/pages/SecurityPage.tsx +101 -201
- src/renderer/pages/SendPage.tsx +98 -161
- src/renderer/pages/SettingsPage.tsx +71 -80
- src/renderer/pages/VoicePage.tsx +142 -204
- tailwind.config.js +53 -14
src/renderer/App.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
-
import React, { useState, useEffect
|
| 2 |
import './types';
|
|
|
|
| 3 |
import LockScreen from './pages/LockScreen';
|
| 4 |
import OnboardingScreen from './pages/OnboardingScreen';
|
| 5 |
import Dashboard from './pages/Dashboard';
|
|
@@ -14,58 +15,41 @@ import TopBar from './components/TopBar';
|
|
| 14 |
type Page = 'dashboard' | 'send' | 'history' | 'voice' | 'security' | 'settings';
|
| 15 |
type AppState = 'loading' | 'onboarding' | 'locked' | 'unlocked';
|
| 16 |
|
| 17 |
-
|
| 18 |
const [appState, setAppState] = useState<AppState>('loading');
|
| 19 |
const [currentPage, setCurrentPage] = useState<Page>('dashboard');
|
|
|
|
| 20 |
const [publicKey, setPublicKey] = useState<string | null>(null);
|
| 21 |
const [balance, setBalance] = useState({ sol: 0, usdt: 0 });
|
| 22 |
const [aiStatus, setAiStatus] = useState<any>(null);
|
|
|
|
| 23 |
|
| 24 |
-
|
| 25 |
-
useEffect(() => {
|
| 26 |
-
checkWalletState();
|
| 27 |
-
}, []);
|
| 28 |
|
| 29 |
-
// Listen for lock events
|
| 30 |
useEffect(() => {
|
| 31 |
if (!window.solvox) return;
|
| 32 |
-
|
| 33 |
-
setAppState('locked');
|
| 34 |
-
});
|
| 35 |
-
return cleanup;
|
| 36 |
}, []);
|
| 37 |
|
| 38 |
const checkWalletState = async () => {
|
| 39 |
try {
|
| 40 |
-
if (!window.solvox) {
|
| 41 |
-
// Development mode — show dashboard
|
| 42 |
-
setAppState('unlocked');
|
| 43 |
-
return;
|
| 44 |
-
}
|
| 45 |
const exists = await window.solvox.wallet.exists();
|
| 46 |
-
if (!exists) {
|
| 47 |
-
setAppState('onboarding');
|
| 48 |
-
return;
|
| 49 |
-
}
|
| 50 |
const unlocked = await window.solvox.wallet.isUnlocked();
|
| 51 |
setAppState(unlocked ? 'unlocked' : 'locked');
|
| 52 |
if (unlocked) {
|
| 53 |
-
|
| 54 |
-
setPublicKey(pk);
|
| 55 |
refreshBalance();
|
| 56 |
}
|
| 57 |
-
} catch {
|
| 58 |
-
setAppState('onboarding');
|
| 59 |
-
}
|
| 60 |
};
|
| 61 |
|
| 62 |
const refreshBalance = async () => {
|
| 63 |
try {
|
| 64 |
if (!window.solvox) return;
|
| 65 |
-
const
|
| 66 |
-
if (
|
| 67 |
-
setBalance({ sol: result.sol || 0, usdt: result.usdt || 0 });
|
| 68 |
-
}
|
| 69 |
} catch {}
|
| 70 |
};
|
| 71 |
|
|
@@ -73,11 +57,12 @@ export default function App() {
|
|
| 73 |
setPublicKey(pk);
|
| 74 |
setAppState('unlocked');
|
| 75 |
refreshBalance();
|
| 76 |
-
|
| 77 |
if (window.solvox) {
|
| 78 |
-
window.solvox.ai.initialize().then(
|
| 79 |
-
if (
|
| 80 |
window.solvox.ai.getStatus().then(setAiStatus);
|
|
|
|
| 81 |
}
|
| 82 |
});
|
| 83 |
}
|
|
@@ -87,79 +72,94 @@ export default function App() {
|
|
| 87 |
setPublicKey(pk);
|
| 88 |
setAppState('unlocked');
|
| 89 |
refreshBalance();
|
|
|
|
| 90 |
};
|
| 91 |
|
| 92 |
const handleLock = async () => {
|
| 93 |
-
if (window.solvox)
|
| 94 |
-
await window.solvox.wallet.lock();
|
| 95 |
-
}
|
| 96 |
setAppState('locked');
|
| 97 |
};
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
if (appState === 'onboarding') {
|
| 105 |
-
return <OnboardingScreen onComplete={handleOnboardingComplete} />;
|
| 106 |
-
}
|
| 107 |
|
| 108 |
-
if (appState === '
|
| 109 |
-
|
| 110 |
-
}
|
| 111 |
|
| 112 |
const renderPage = () => {
|
|
|
|
| 113 |
switch (currentPage) {
|
| 114 |
-
case 'dashboard':
|
| 115 |
-
|
| 116 |
-
case '
|
| 117 |
-
|
| 118 |
-
case '
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
return <VoicePage aiStatus={aiStatus} />;
|
| 122 |
-
case 'security':
|
| 123 |
-
return <SecurityPage />;
|
| 124 |
-
case 'settings':
|
| 125 |
-
return <SettingsPage onLock={handleLock} />;
|
| 126 |
-
default:
|
| 127 |
-
return <Dashboard balance={balance} publicKey={publicKey} onRefresh={refreshBalance} />;
|
| 128 |
}
|
| 129 |
};
|
| 130 |
|
| 131 |
return (
|
| 132 |
-
<div className="flex h-screen
|
| 133 |
-
<Sidebar currentPage={currentPage} onNavigate={
|
| 134 |
<div className="flex-1 flex flex-col overflow-hidden">
|
| 135 |
-
<TopBar
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
aiStatus={aiStatus}
|
| 139 |
-
onLock={handleLock}
|
| 140 |
-
/>
|
| 141 |
-
<main className="flex-1 overflow-y-auto p-6">
|
| 142 |
-
{renderPage()}
|
| 143 |
</main>
|
| 144 |
</div>
|
| 145 |
</div>
|
| 146 |
);
|
| 147 |
}
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
function LoadingScreen() {
|
| 150 |
return (
|
| 151 |
-
<div className="h-screen flex flex-col items-center justify-center
|
| 152 |
-
|
| 153 |
-
<div className="
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
| 161 |
))}
|
| 162 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
</div>
|
| 164 |
);
|
| 165 |
}
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
import './types';
|
| 3 |
+
import { ToastProvider, useToast } from './components/ui/index';
|
| 4 |
import LockScreen from './pages/LockScreen';
|
| 5 |
import OnboardingScreen from './pages/OnboardingScreen';
|
| 6 |
import Dashboard from './pages/Dashboard';
|
|
|
|
| 15 |
type Page = 'dashboard' | 'send' | 'history' | 'voice' | 'security' | 'settings';
|
| 16 |
type AppState = 'loading' | 'onboarding' | 'locked' | 'unlocked';
|
| 17 |
|
| 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(() => { checkWalletState(); }, []);
|
|
|
|
|
|
|
|
|
|
| 28 |
|
|
|
|
| 29 |
useEffect(() => {
|
| 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 |
|
|
|
|
| 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 |
}
|
|
|
|
| 72 |
setPublicKey(pk);
|
| 73 |
setAppState('unlocked');
|
| 74 |
refreshBalance();
|
| 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 <LoadingScreen />;
|
| 89 |
+
if (appState === 'onboarding') return <OnboardingScreen onComplete={handleOnboardingComplete} />;
|
| 90 |
+
if (appState === 'locked') return <LockScreen onUnlock={handleUnlock} />;
|
| 91 |
|
| 92 |
const renderPage = () => {
|
| 93 |
+
const props = { key: currentPage };
|
| 94 |
switch (currentPage) {
|
| 95 |
+
case 'dashboard': return <Dashboard {...props} balance={balance} publicKey={publicKey} onRefresh={refreshBalance} onNavigate={navigate} />;
|
| 96 |
+
case 'send': return <SendPage {...props} balance={balance} onSent={refreshBalance} />;
|
| 97 |
+
case 'history': return <HistoryPage {...props} />;
|
| 98 |
+
case 'voice': return <VoicePage {...props} aiStatus={aiStatus} />;
|
| 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 mesh-bg">
|
| 107 |
+
<Sidebar currentPage={currentPage} onNavigate={navigate} />
|
| 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 p-5">
|
| 111 |
+
<div className="page-enter">{renderPage()}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 LoadingScreen() {
|
| 127 |
return (
|
| 128 |
+
<div className="h-screen flex flex-col items-center justify-center mesh-bg relative">
|
| 129 |
+
{/* Background particles */}
|
| 130 |
+
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
| 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 |
+
{/* Logo */}
|
| 145 |
+
<div className="relative z-10 flex flex-col items-center">
|
| 146 |
+
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-sol-purple to-sol-green flex items-center justify-center mb-6 shadow-glow-lg animate-float">
|
| 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 |
}
|
src/renderer/components/Sidebar.tsx
CHANGED
|
@@ -1,55 +1,118 @@
|
|
| 1 |
-
import React from 'react';
|
| 2 |
|
| 3 |
interface SidebarProps {
|
| 4 |
currentPage: string;
|
| 5 |
onNavigate: (page: any) => void;
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
const navItems = [
|
| 9 |
-
{ id: 'dashboard', label: 'Dashboard', icon: '
|
| 10 |
-
{ id: 'voice',
|
| 11 |
-
{ id: 'send',
|
| 12 |
-
{ id: 'history',
|
| 13 |
-
{ id: 'security',
|
| 14 |
-
{ id: 'settings',
|
| 15 |
];
|
| 16 |
|
| 17 |
export default function Sidebar({ currentPage, onNavigate }: SidebarProps) {
|
|
|
|
|
|
|
| 18 |
return (
|
| 19 |
-
<aside className="w-
|
| 20 |
{/* Logo */}
|
| 21 |
-
<div className="
|
| 22 |
-
<
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
</div>
|
| 25 |
|
|
|
|
|
|
|
| 26 |
{/* Navigation */}
|
| 27 |
-
<nav className="flex-1
|
| 28 |
-
{navItems.map(item =>
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
</nav>
|
| 43 |
|
| 44 |
-
{/* QVAC Badge */}
|
| 45 |
-
<div className="
|
| 46 |
-
<div className="
|
| 47 |
-
|
| 48 |
-
<div className="
|
| 49 |
-
|
| 50 |
-
<div className="
|
| 51 |
-
<
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
</div>
|
| 54 |
</div>
|
| 55 |
</div>
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
|
| 3 |
interface SidebarProps {
|
| 4 |
currentPage: string;
|
| 5 |
onNavigate: (page: any) => void;
|
| 6 |
+
collapsed?: boolean;
|
| 7 |
}
|
| 8 |
|
| 9 |
const navItems = [
|
| 10 |
+
{ id: 'dashboard', label: 'Dashboard', icon: '◉', activeIcon: '◉' },
|
| 11 |
+
{ id: 'voice', label: 'Voice AI', icon: '◎', activeIcon: '◎' },
|
| 12 |
+
{ id: 'send', label: 'Send', icon: '↗', activeIcon: '↗' },
|
| 13 |
+
{ id: 'history', label: 'History', icon: '☰', activeIcon: '☰' },
|
| 14 |
+
{ id: 'security', label: 'Security', icon: '◈', activeIcon: '◈' },
|
| 15 |
+
{ id: 'settings', label: 'Settings', icon: '⚙', activeIcon: '⚙' },
|
| 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-[220px] flex flex-col bg-sol-darker/50 border-r border-sol-border/40">
|
| 23 |
{/* Logo */}
|
| 24 |
+
<div className="px-5 pt-7 pb-5">
|
| 25 |
+
<div className="flex items-center gap-2.5">
|
| 26 |
+
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-sol-purple to-sol-green flex items-center justify-center shadow-glow-sm">
|
| 27 |
+
<span className="text-white text-sm font-black">SV</span>
|
| 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="divider mx-5" />
|
| 37 |
+
|
| 38 |
{/* Navigation */}
|
| 39 |
+
<nav className="flex-1 px-3 py-4 space-y-0.5">
|
| 40 |
+
{navItems.map(item => {
|
| 41 |
+
const isActive = currentPage === item.id;
|
| 42 |
+
const isHovered = hovered === item.id;
|
| 43 |
+
return (
|
| 44 |
+
<button
|
| 45 |
+
key={item.id}
|
| 46 |
+
onClick={() => onNavigate(item.id)}
|
| 47 |
+
onMouseEnter={() => setHovered(item.id)}
|
| 48 |
+
onMouseLeave={() => setHovered(null)}
|
| 49 |
+
className={`relative w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-all duration-300 group ${
|
| 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 Engine Badge */}
|
| 84 |
+
<div className="px-3 pb-4">
|
| 85 |
+
<div className="relative overflow-hidden rounded-2xl border border-sol-border/40 p-4">
|
| 86 |
+
{/* Subtle background gradient */}
|
| 87 |
+
<div className="absolute inset-0 bg-gradient-to-br from-tether-green/5 to-sol-purple/5" />
|
| 88 |
+
|
| 89 |
+
<div className="relative">
|
| 90 |
+
<div className="flex items-center gap-2 mb-2.5">
|
| 91 |
+
<div className="w-6 h-6 rounded-md bg-tether-green/15 flex items-center justify-center">
|
| 92 |
+
<span className="text-tether-green text-xs font-bold">Q</span>
|
| 93 |
+
</div>
|
| 94 |
+
<div>
|
| 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>
|
src/renderer/components/TopBar.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
-
import React from 'react';
|
|
|
|
| 2 |
|
| 3 |
interface TopBarProps {
|
| 4 |
publicKey: string | null;
|
|
@@ -8,77 +9,91 @@ interface TopBarProps {
|
|
| 8 |
}
|
| 9 |
|
| 10 |
export default function TopBar({ publicKey, balance, aiStatus, onLock }: TopBarProps) {
|
| 11 |
-
const
|
| 12 |
-
|
| 13 |
-
: '—';
|
| 14 |
|
| 15 |
const copyAddress = () => {
|
| 16 |
if (publicKey) {
|
| 17 |
navigator.clipboard.writeText(publicKey);
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
};
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
return (
|
| 22 |
-
<header className="h-
|
| 23 |
-
{/* Left:
|
| 24 |
-
<div className="flex items-center gap-
|
| 25 |
<button
|
| 26 |
onClick={copyAddress}
|
| 27 |
-
className="flex items-center gap-2 px-
|
| 28 |
-
title="
|
| 29 |
>
|
| 30 |
-
<
|
| 31 |
-
<span className="text-
|
| 32 |
-
<span className="text-
|
| 33 |
</button>
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
</div>
|
| 36 |
|
| 37 |
-
{/* Center:
|
| 38 |
-
<div className="flex items-center gap-
|
| 39 |
-
<div className="
|
| 40 |
-
<div className="
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
</div>
|
| 44 |
</div>
|
| 45 |
-
|
| 46 |
-
<div className="
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
</div>
|
| 51 |
</div>
|
| 52 |
</div>
|
| 53 |
|
| 54 |
{/* Right: AI Status + Lock */}
|
| 55 |
<div className="flex items-center gap-3">
|
| 56 |
-
{/* AI Status
|
| 57 |
-
<div className="flex items-center gap-1.5">
|
| 58 |
-
<
|
| 59 |
-
<
|
| 60 |
-
|
| 61 |
-
<
|
| 62 |
-
<StatusDot active={aiStatus?.translation} label="NMT" />
|
| 63 |
-
<StatusDot active={aiStatus?.ocr} label="OCR" />
|
| 64 |
</div>
|
| 65 |
|
|
|
|
| 66 |
<button
|
| 67 |
onClick={onLock}
|
| 68 |
-
className="px-
|
| 69 |
>
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
| 71 |
</button>
|
| 72 |
</div>
|
| 73 |
</header>
|
| 74 |
);
|
| 75 |
}
|
| 76 |
-
|
| 77 |
-
function StatusDot({ active, label }: { active: boolean; label: string }) {
|
| 78 |
-
return (
|
| 79 |
-
<div className="flex flex-col items-center" title={`${label}: ${active ? 'Active' : 'Offline'}`}>
|
| 80 |
-
<div className={`w-1.5 h-1.5 rounded-full ${active ? 'bg-sol-green' : 'bg-sol-muted/30'}`} />
|
| 81 |
-
<span className="text-[8px] text-sol-muted mt-0.5">{label}</span>
|
| 82 |
-
</div>
|
| 83 |
-
);
|
| 84 |
-
}
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { AnimatedNumber } from './ui/index';
|
| 3 |
|
| 4 |
interface TopBarProps {
|
| 5 |
publicKey: string | null;
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
export default function TopBar({ publicKey, balance, aiStatus, onLock }: TopBarProps) {
|
| 12 |
+
const [copied, setCopied] = useState(false);
|
| 13 |
+
const shortAddress = publicKey ? `${publicKey.slice(0, 4)}...${publicKey.slice(-4)}` : '—';
|
|
|
|
| 14 |
|
| 15 |
const copyAddress = () => {
|
| 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-14 border-b border-sol-border/30 flex items-center justify-between px-5 bg-sol-darker/30 backdrop-blur-sm">
|
| 29 |
+
{/* Left: Address + Network */}
|
| 30 |
+
<div className="flex items-center gap-3">
|
| 31 |
<button
|
| 32 |
onClick={copyAddress}
|
| 33 |
+
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg bg-sol-card/50 border border-sol-border/40 hover:border-sol-purple/30 transition-all group"
|
| 34 |
+
title="Copy full address"
|
| 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: Balances */}
|
| 48 |
+
<div className="flex items-center gap-5">
|
| 49 |
+
<div className="flex items-center gap-2">
|
| 50 |
+
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-sol-purple to-sol-purple-light flex items-center justify-center">
|
| 51 |
+
<span className="text-white text-[10px] font-bold">◎</span>
|
| 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="w-px h-7 bg-sol-border/40" />
|
| 62 |
+
|
| 63 |
+
<div className="flex items-center gap-2">
|
| 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: AI Status + Lock */}
|
| 77 |
<div className="flex items-center gap-3">
|
| 78 |
+
{/* AI Status */}
|
| 79 |
+
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-sol-card/30 border border-sol-border/30">
|
| 80 |
+
<div className={`w-1.5 h-1.5 rounded-full ${activeModules > 0 ? 'bg-sol-green animate-pulse' : 'bg-sol-muted/30'}`} />
|
| 81 |
+
<span className="text-[10px] font-medium text-sol-muted">
|
| 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 |
);
|
| 99 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/renderer/components/ui/index.tsx
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
|
| 2 |
+
|
| 3 |
+
/* ═══════════════════════════════════════════════════════════════════════
|
| 4 |
+
TOAST SYSTEM
|
| 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) + Math.random().toString(36).slice(2);
|
| 19 |
+
setToasts(prev => [...prev, { ...t, id }]);
|
| 20 |
+
setTimeout(() => setToasts(prev => prev.filter(x => x.id !== id)), t.duration ?? 4000);
|
| 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 pointer-events-none">
|
| 27 |
+
{toasts.map(t => (
|
| 28 |
+
<div key={t.id} className="toast-enter pointer-events-auto" onClick={() => setToasts(prev => prev.filter(x => x.id !== t.id))}>
|
| 29 |
+
<div className={`glass-strong rounded-xl px-4 py-3 min-w-[280px] max-w-[380px] shadow-card flex items-start gap-3 cursor-pointer hover:opacity-90 transition-opacity ${
|
| 30 |
+
t.type === 'success' ? 'border-l-2 border-l-sol-green' :
|
| 31 |
+
t.type === 'error' ? 'border-l-2 border-l-danger' :
|
| 32 |
+
t.type === 'warning' ? 'border-l-2 border-l-warning' :
|
| 33 |
+
'border-l-2 border-l-info'
|
| 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>
|
| 44 |
+
))}
|
| 45 |
+
</div>
|
| 46 |
+
</ToastContext.Provider>
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* ═══════════════════════════════════════════════════════════════════════
|
| 51 |
+
MODAL
|
| 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.001) { setDisplay(value); return; }
|
| 114 |
+
const steps = 20;
|
| 115 |
+
let step = 0;
|
| 116 |
+
const interval = setInterval(() => {
|
| 117 |
+
step++;
|
| 118 |
+
const progress = step / steps;
|
| 119 |
+
const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
|
| 120 |
+
setDisplay(display + diff * eased);
|
| 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 |
+
CIRCULAR PROGRESS (for security score)
|
| 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="relative" style={{ width: size, height: size }}>
|
| 198 |
+
<div className="orbit-ring absolute inset-0" />
|
| 199 |
+
<div className="orbit-ring orbit-ring-2 absolute" style={{ inset: size * 0.15 }} />
|
| 200 |
+
<div className="orbit-ring orbit-ring-3 absolute" style={{ inset: size * 0.3 }} />
|
| 201 |
+
<div className="absolute inset-0 flex items-center justify-center">
|
| 202 |
+
<div className="w-2 h-2 rounded-full bg-sol-purple animate-pulse" />
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/* ═══════════════════════════════════════════════════════════════════════
|
| 209 |
+
STEP INDICATOR (for onboarding)
|
| 210 |
+
═══════════════════════════════════════════════════════════════════════ */
|
| 211 |
+
|
| 212 |
+
export function StepIndicator({ steps, current }: { steps: string[]; current: number }) {
|
| 213 |
+
return (
|
| 214 |
+
<div className="flex items-center justify-center gap-2 mb-8">
|
| 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-xs font-bold transition-all duration-500 ${
|
| 219 |
+
i < current ? 'bg-sol-green text-sol-dark' :
|
| 220 |
+
i === current ? 'bg-sol-purple text-white glow-purple' :
|
| 221 |
+
'bg-sol-border text-sol-muted'
|
| 222 |
+
}`}>
|
| 223 |
+
{i < current ? '✓' : i + 1}
|
| 224 |
+
</div>
|
| 225 |
+
<span className={`text-[10px] ${i <= current ? 'text-sol-text' : 'text-sol-muted'}`}>{label}</span>
|
| 226 |
+
</div>
|
| 227 |
+
{i < steps.length - 1 && (
|
| 228 |
+
<div className={`w-12 h-0.5 rounded-full transition-all duration-500 -mt-4 ${
|
| 229 |
+
i < current ? 'bg-sol-green' : 'bg-sol-border'
|
| 230 |
+
}`} />
|
| 231 |
+
)}
|
| 232 |
+
</React.Fragment>
|
| 233 |
+
))}
|
| 234 |
+
</div>
|
| 235 |
+
);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
/* ═══════════════════════════════════════════════════════════════════════
|
| 239 |
+
ICON BUTTON
|
| 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 |
+
<button onClick={onClick} className="flex flex-col items-center gap-1.5 group">
|
| 256 |
+
<div className={`${sizes[size]} rounded-2xl border flex items-center justify-center transition-all duration-300 ${colors[color]} group-hover:scale-105`}>
|
| 257 |
+
{icon}
|
| 258 |
+
</div>
|
| 259 |
+
<span className="text-[11px] font-medium text-sol-muted group-hover:text-sol-text transition-colors">{label}</span>
|
| 260 |
+
</button>
|
| 261 |
+
);
|
| 262 |
+
}
|
src/renderer/index.css
CHANGED
|
@@ -2,91 +2,256 @@
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
-
*
|
| 6 |
-
|
| 7 |
-
padding: 0;
|
| 8 |
-
box-sizing: border-box;
|
| 9 |
-
}
|
| 10 |
|
| 11 |
body {
|
| 12 |
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 13 |
-
background: #
|
| 14 |
color: #E0E0FF;
|
| 15 |
overflow: hidden;
|
| 16 |
height: 100vh;
|
| 17 |
-webkit-app-region: drag;
|
| 18 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
-
/*
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
| 28 |
-
|
| 29 |
-
background:
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
/*
|
| 40 |
-
.
|
| 41 |
-
|
|
|
|
|
|
|
| 42 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
100% {
|
|
|
|
| 47 |
}
|
|
|
|
| 48 |
|
| 49 |
-
/*
|
| 50 |
-
.
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
-
.
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
|
| 60 |
-
/*
|
| 61 |
-
.
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
| 73 |
}
|
| 74 |
|
| 75 |
-
/*
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
50% { opacity: 0.5; transform: scale(1.1); }
|
| 79 |
}
|
| 80 |
-
.
|
| 81 |
-
|
|
|
|
| 82 |
}
|
| 83 |
|
| 84 |
-
/*
|
| 85 |
-
.
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
}
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
+
/* ─── Reset & Base ───────────────────────────────────────────────────── */
|
| 6 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
body {
|
| 9 |
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 10 |
+
background: #0A0A1E;
|
| 11 |
color: #E0E0FF;
|
| 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: 5px; }
|
| 20 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 21 |
+
::-webkit-scrollbar-thumb { background: #2D2D5E; border-radius: 10px; }
|
| 22 |
+
::-webkit-scrollbar-thumb:hover { background: #9945FF; }
|
| 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 |
+
/* ─── Glassmorphism ──────────────────────────────────────────────────── */
|
| 34 |
+
.glass {
|
| 35 |
+
background: rgba(22, 22, 52, 0.65);
|
| 36 |
+
backdrop-filter: blur(24px) saturate(1.4);
|
| 37 |
+
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
| 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 |
+
/* ─── Gradient Text ──────────────────────────────────────────────────── */
|
| 55 |
+
.gradient-text {
|
| 56 |
+
background: linear-gradient(135deg, #9945FF 0%, #14F195 50%, #26A17B 100%);
|
| 57 |
+
background-size: 200% 200%;
|
| 58 |
+
animation: gradient-shift 6s ease infinite;
|
| 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 |
+
/* ─── Glow FX ────────────────────────────────────────────────────────── */
|
| 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 |
+
/* ─── Waveform Animation ─────────────────────────────────────────────── */
|
| 98 |
+
.waveform-bar { animation: waveform var(--delay, 0.5s) ease-in-out infinite alternate; }
|
| 99 |
+
@keyframes waveform { 0% { height: 3px; } 100% { height: var(--max-height, 28px); } }
|
| 100 |
+
|
| 101 |
+
/* ─── Shimmer Loader ─────────────────────────────────────────────────── */
|
| 102 |
+
.shimmer {
|
| 103 |
+
background: linear-gradient(90deg, rgba(26,26,62,0.4) 25%, rgba(45,45,94,0.6) 50%, rgba(26,26,62,0.4) 75%);
|
| 104 |
+
background-size: 200% 100%;
|
| 105 |
+
animation: shimmer 1.5s infinite;
|
| 106 |
+
}
|
| 107 |
+
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
| 108 |
|
| 109 |
+
/* ─── Recording Pulse ────────────────────────────────────────────────── */
|
| 110 |
+
@keyframes rec-pulse {
|
| 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 |
+
.recording-pulse { animation: rec-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
|
| 115 |
|
| 116 |
+
/* ─── Orbit Rings (loading / voice) ──────────────────────────────────── */
|
| 117 |
+
.orbit-ring {
|
| 118 |
+
border: 2px solid transparent;
|
| 119 |
+
border-top-color: #9945FF;
|
| 120 |
+
border-radius: 50%;
|
| 121 |
+
animation: orbit 2s linear infinite;
|
| 122 |
}
|
| 123 |
+
.orbit-ring-2 { border-top-color: #14F195; animation-duration: 3s; animation-direction: reverse; }
|
| 124 |
+
.orbit-ring-3 { border-top-color: #26A17B; animation-duration: 4s; }
|
| 125 |
+
@keyframes orbit { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
| 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 |
+
@keyframes float {
|
| 135 |
+
0%, 100% { transform: translate(0, 0) scale(1); }
|
| 136 |
+
25% { transform: translate(var(--x1, 20px), var(--y1, -30px)) scale(1.1); }
|
| 137 |
+
50% { transform: translate(var(--x2, -15px), var(--y2, -50px)) scale(0.9); }
|
| 138 |
+
75% { transform: translate(var(--x3, 25px), var(--y3, -20px)) scale(1.05); }
|
| 139 |
}
|
| 140 |
|
| 141 |
+
/* ─── Animated Counter ───────────────────────────────────────────────── */
|
| 142 |
+
.num-scroll { display: inline-block; transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1); }
|
| 143 |
+
|
| 144 |
+
/* ─── Button Hover FX ────────────────────────────────────────────────── */
|
| 145 |
+
.btn-primary {
|
| 146 |
+
@apply relative overflow-hidden rounded-xl font-semibold transition-all duration-300;
|
| 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 |
+
.btn-primary:hover::before { @apply opacity-100; }
|
| 155 |
+
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 8px 24px rgba(153, 69, 255, 0.3); }
|
| 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 |
+
.btn-ghost:hover {
|
| 163 |
+
@apply text-sol-text border-sol-purple/40;
|
| 164 |
+
background: rgba(153, 69, 255, 0.06);
|
| 165 |
}
|
| 166 |
|
| 167 |
+
/* ─── Input Focus Ring ───────────────────────────────────────────────── */
|
| 168 |
+
.input-field {
|
| 169 |
+
@apply w-full px-4 py-3 bg-sol-dark/80 border border-sol-border rounded-xl text-sm transition-all duration-200;
|
|
|
|
| 170 |
}
|
| 171 |
+
.input-field:focus {
|
| 172 |
+
@apply outline-none border-sol-purple/60;
|
| 173 |
+
box-shadow: 0 0 0 3px rgba(153, 69, 255, 0.1);
|
| 174 |
}
|
| 175 |
|
| 176 |
+
/* ─── Badge ──────────────────────────────────────────────────────────── */
|
| 177 |
+
.badge { @apply inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium; }
|
| 178 |
+
.badge-green { @apply bg-sol-green/15 text-sol-green; }
|
| 179 |
+
.badge-purple { @apply bg-sol-purple/15 text-sol-purple; }
|
| 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 |
+
/* ─── Progress Bar ───────────────────────────────────────────────────── */
|
| 197 |
+
.progress-bar {
|
| 198 |
+
@apply h-1.5 rounded-full overflow-hidden bg-sol-border/30;
|
| 199 |
+
}
|
| 200 |
+
.progress-fill {
|
| 201 |
+
@apply h-full rounded-full transition-all duration-700 ease-out;
|
| 202 |
+
background: linear-gradient(90deg, #9945FF, #14F195);
|
| 203 |
}
|
| 204 |
+
|
| 205 |
+
/* ─── Divider ────────────────────────────────────────────────────────── */
|
| 206 |
+
.divider {
|
| 207 |
+
@apply w-full h-px;
|
| 208 |
+
background: linear-gradient(90deg, transparent, rgba(153, 69, 255, 0.15), transparent);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/* ─── Sidebar Active Indicator ───────────────────────────────────────── */
|
| 212 |
+
.nav-active-bar {
|
| 213 |
+
@apply absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-8 rounded-r-full;
|
| 214 |
+
background: linear-gradient(180deg, #9945FF, #14F195);
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
/* ─── Scrollbar for chat ─────────────────────────────────────────────── */
|
| 218 |
+
.chat-scroll::-webkit-scrollbar { width: 4px; }
|
| 219 |
+
.chat-scroll::-webkit-scrollbar-thumb { background: rgba(153, 69, 255, 0.2); border-radius: 10px; }
|
| 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 |
+
@keyframes voice-idle {
|
| 230 |
+
0%, 100% { box-shadow: 0 0 0 0 rgba(153, 69, 255, 0.4), 0 0 20px rgba(153, 69, 255, 0.2); }
|
| 231 |
+
50% { box-shadow: 0 0 0 12px rgba(153, 69, 255, 0), 0 0 40px rgba(153, 69, 255, 0.3); }
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.voice-btn-active {
|
| 235 |
+
box-shadow:
|
| 236 |
+
0 0 0 0 rgba(255, 68, 102, 0.5),
|
| 237 |
+
0 0 30px rgba(255, 68, 102, 0.3);
|
| 238 |
+
animation: voice-active 1s ease-in-out infinite;
|
| 239 |
+
}
|
| 240 |
+
@keyframes voice-active {
|
| 241 |
+
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 68, 102, 0.5), 0 0 30px rgba(255, 68, 102, 0.3); }
|
| 242 |
+
50% { box-shadow: 0 0 0 20px rgba(255, 68, 102, 0), 0 0 50px rgba(255, 68, 102, 0.4); }
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
/* ─── Staggered Children ─────────────────────────────────────────────── */
|
| 246 |
+
.stagger > * { animation: slide-up 0.35s cubic-bezier(0.4, 0, 0.2, 1) backwards; }
|
| 247 |
+
.stagger > *:nth-child(1) { animation-delay: 0ms; }
|
| 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); }
|
src/renderer/pages/Dashboard.tsx
CHANGED
|
@@ -1,162 +1,182 @@
|
|
| 1 |
import React, { useEffect, useState } from 'react';
|
|
|
|
| 2 |
|
| 3 |
interface DashboardProps {
|
| 4 |
balance: { sol: number; usdt: number };
|
| 5 |
publicKey: string | null;
|
| 6 |
onRefresh: () => void;
|
|
|
|
| 7 |
}
|
| 8 |
|
| 9 |
-
export default function Dashboard({ balance, publicKey, onRefresh }: DashboardProps) {
|
| 10 |
const [recentTxs, setRecentTxs] = useState<any[]>([]);
|
| 11 |
const [aiStatus, setAiStatus] = useState<any>(null);
|
|
|
|
| 12 |
|
| 13 |
-
useEffect(() => {
|
| 14 |
-
loadData();
|
| 15 |
-
}, []);
|
| 16 |
|
| 17 |
const loadData = async () => {
|
| 18 |
-
if (!window.solvox) return;
|
| 19 |
try {
|
| 20 |
-
const [
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
]);
|
| 24 |
-
if (historyResult.success) setRecentTxs(historyResult.history || []);
|
| 25 |
-
setAiStatus(statusResult);
|
| 26 |
} catch {}
|
|
|
|
| 27 |
};
|
| 28 |
|
| 29 |
-
const totalUSD = balance.sol * 170 + balance.usdt;
|
|
|
|
| 30 |
|
| 31 |
return (
|
| 32 |
-
<div className="space-y-
|
| 33 |
-
{/* Balance
|
| 34 |
-
<div className="
|
| 35 |
-
<div className="
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
</div>
|
| 40 |
-
<button
|
| 41 |
-
onClick={onRefresh}
|
| 42 |
-
className="mt-3 text-xs text-sol-muted hover:text-sol-purple transition-colors"
|
| 43 |
-
>
|
| 44 |
-
↻ Refresh
|
| 45 |
-
</button>
|
| 46 |
</div>
|
|
|
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
</div>
|
| 53 |
-
<div className="text-sm text-sol-muted">SOL</div>
|
| 54 |
</div>
|
| 55 |
-
<div className="
|
| 56 |
-
|
| 57 |
-
|
| 58 |
</div>
|
| 59 |
</div>
|
| 60 |
|
| 61 |
-
<div className="glass rounded-2xl p-
|
| 62 |
-
<div className="flex items-center gap-
|
| 63 |
-
<div className="w-
|
| 64 |
-
<span className="text-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
</div>
|
| 66 |
-
<div className="text-sm text-sol-muted">USDT</div>
|
| 67 |
</div>
|
| 68 |
-
<div className="
|
| 69 |
-
|
| 70 |
-
|
| 71 |
</div>
|
| 72 |
</div>
|
| 73 |
</div>
|
| 74 |
|
| 75 |
-
{/* Quick Actions */}
|
| 76 |
-
<div className="
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
<
|
| 84 |
-
|
| 85 |
-
className={`glass rounded-xl p-4 text-center hover:border-${action.color} transition-all group`}
|
| 86 |
-
>
|
| 87 |
-
<div className="text-2xl mb-1">{action.icon}</div>
|
| 88 |
-
<div className="text-xs font-medium text-sol-muted group-hover:text-sol-text">
|
| 89 |
-
{action.label}
|
| 90 |
-
</div>
|
| 91 |
-
</button>
|
| 92 |
-
))}
|
| 93 |
</div>
|
| 94 |
|
| 95 |
-
{/* AI Status
|
| 96 |
-
<div className="grid grid-cols-
|
| 97 |
-
{/* AI Engine
|
| 98 |
-
<div className="glass rounded-2xl p-
|
| 99 |
-
<
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
{[
|
| 102 |
-
{ name: 'LLM
|
| 103 |
-
{ name: 'Speech-to-Text',
|
| 104 |
-
{ name: 'Text-to-Speech',
|
| 105 |
-
{ name: 'Embeddings',
|
| 106 |
-
{ name: 'Translation',
|
| 107 |
-
{ name: 'OCR',
|
| 108 |
-
].map(
|
| 109 |
-
<div key={
|
| 110 |
-
<div>
|
| 111 |
-
|
| 112 |
-
<div className="text-xs text-sol-
|
|
|
|
| 113 |
</div>
|
| 114 |
-
<div className={`
|
| 115 |
-
|
| 116 |
-
{model.active ? 'Active' : 'Loading...'}
|
| 117 |
</div>
|
| 118 |
</div>
|
| 119 |
))}
|
| 120 |
</div>
|
| 121 |
-
<div className="mt-4
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
</div>
|
| 126 |
</div>
|
| 127 |
</div>
|
| 128 |
|
| 129 |
-
{/* Recent
|
| 130 |
-
<div className="glass rounded-2xl p-
|
| 131 |
-
<
|
| 132 |
-
{
|
| 133 |
-
<div className="
|
| 134 |
-
<div className="
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
| 138 |
</div>
|
|
|
|
|
|
|
| 139 |
</div>
|
| 140 |
) : (
|
| 141 |
-
<div className="space-y-
|
| 142 |
{recentTxs.map((tx, i) => (
|
| 143 |
-
<div key={i} className="flex items-center
|
| 144 |
-
<div className=
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
<div>
|
| 151 |
-
|
| 152 |
-
{tx.signature?.slice(0, 12)}...
|
| 153 |
-
</div>
|
| 154 |
-
<div className="text-xs text-sol-muted">
|
| 155 |
-
{tx.timestamp ? new Date(tx.timestamp).toLocaleDateString() : 'Pending'}
|
| 156 |
-
</div>
|
| 157 |
-
</div>
|
| 158 |
</div>
|
| 159 |
-
<div className={`text-
|
| 160 |
{tx.status}
|
| 161 |
</div>
|
| 162 |
</div>
|
|
@@ -166,18 +186,21 @@ export default function Dashboard({ balance, publicKey, onRefresh }: DashboardPr
|
|
| 166 |
</div>
|
| 167 |
</div>
|
| 168 |
|
| 169 |
-
{/* Wallet Address */}
|
| 170 |
<div className="glass rounded-2xl p-4">
|
| 171 |
<div className="flex items-center justify-between">
|
| 172 |
-
<div>
|
| 173 |
-
<div className="
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
</div>
|
| 176 |
-
<button
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
>
|
| 180 |
-
📋 Copy
|
| 181 |
</button>
|
| 182 |
</div>
|
| 183 |
</div>
|
|
|
|
| 1 |
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import { AnimatedNumber, IconButton, ShimmerCard, CircularProgress } from '../components/ui/index';
|
| 3 |
|
| 4 |
interface DashboardProps {
|
| 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 }: DashboardProps) {
|
| 12 |
const [recentTxs, setRecentTxs] = useState<any[]>([]);
|
| 13 |
const [aiStatus, setAiStatus] = useState<any>(null);
|
| 14 |
+
const [loading, setLoading] = useState(true);
|
| 15 |
|
| 16 |
+
useEffect(() => { loadData(); }, []);
|
|
|
|
|
|
|
| 17 |
|
| 18 |
const loadData = async () => {
|
| 19 |
+
if (!window.solvox) { setLoading(false); return; }
|
| 20 |
try {
|
| 21 |
+
const [h, s] = await Promise.all([window.solvox.wallet.getHistory(5), window.solvox.ai.getStatus()]);
|
| 22 |
+
if (h.success) setRecentTxs(h.history || []);
|
| 23 |
+
setAiStatus(s);
|
|
|
|
|
|
|
|
|
|
| 24 |
} catch {}
|
| 25 |
+
setLoading(false);
|
| 26 |
};
|
| 27 |
|
| 28 |
+
const totalUSD = balance.sol * 170 + balance.usdt;
|
| 29 |
+
const activeModules = aiStatus ? [aiStatus.llm, aiStatus.transcription, aiStatus.tts, aiStatus.embed, aiStatus.translation, aiStatus.ocr].filter(Boolean).length : 0;
|
| 30 |
|
| 31 |
return (
|
| 32 |
+
<div className="space-y-5 stagger">
|
| 33 |
+
{/* ── Hero Balance Card ─────────────────────────────────────────── */}
|
| 34 |
+
<div className="relative overflow-hidden rounded-3xl p-6 bg-gradient-to-br from-sol-purple/10 via-sol-card to-tether-green/5 border border-sol-purple/10">
|
| 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="flex items-start justify-between">
|
| 39 |
+
<div>
|
| 40 |
+
<div className="text-sm text-sol-muted mb-1 font-medium">Total Portfolio Value</div>
|
| 41 |
+
<div className="text-4xl font-extrabold gradient-text-static mb-1">
|
| 42 |
+
<AnimatedNumber value={totalUSD} decimals={2} prefix="$" />
|
| 43 |
+
</div>
|
| 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 |
+
</div>
|
| 64 |
|
| 65 |
+
{/* ── Token Cards ───────────────────────────────────────────────── */}
|
| 66 |
+
<div className="grid grid-cols-2 gap-4">
|
| 67 |
+
<div className="glass glass-hover rounded-2xl p-5">
|
| 68 |
+
<div className="flex items-center gap-3 mb-3">
|
| 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="flex items-center justify-between">
|
| 78 |
+
<span className="text-xs text-sol-muted">≈ <AnimatedNumber value={balance.sol * 170} decimals={2} prefix="$" /></span>
|
| 79 |
+
<div className="progress-bar w-20"><div className="progress-fill" style={{ width: `${Math.min(100, balance.sol * 10)}%` }} /></div>
|
| 80 |
</div>
|
| 81 |
</div>
|
| 82 |
|
| 83 |
+
<div className="glass glass-hover rounded-2xl p-5">
|
| 84 |
+
<div className="flex items-center gap-3 mb-3">
|
| 85 |
+
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-tether-green to-tether-dark flex items-center justify-center shadow-glow-sm">
|
| 86 |
+
<span className="text-white font-bold text-sm">₮</span>
|
| 87 |
+
</div>
|
| 88 |
+
<div>
|
| 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 |
</div>
|
| 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 Status + Recent Activity ───────────────────────────────── */}
|
| 113 |
+
<div className="grid grid-cols-5 gap-4">
|
| 114 |
+
{/* AI Engine — 3 cols */}
|
| 115 |
+
<div className="col-span-3 glass rounded-2xl p-5">
|
| 116 |
+
<div className="flex items-center justify-between mb-4">
|
| 117 |
+
<div className="text-sm font-bold text-sol-text">QVAC AI Engine</div>
|
| 118 |
+
<div className="badge badge-tether">
|
| 119 |
+
<div className="w-1 h-1 rounded-full bg-tether-green animate-pulse" />
|
| 120 |
+
Local
|
| 121 |
+
</div>
|
| 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 |
+
<div className="divider mt-4 mb-3" />
|
| 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 |
+
{/* Recent Activity — 2 cols */}
|
| 152 |
+
<div className="col-span-2 glass rounded-2xl p-5">
|
| 153 |
+
<div className="text-sm font-bold text-sol-text mb-4">Recent Activity</div>
|
| 154 |
+
{loading ? (
|
| 155 |
+
<div className="space-y-3">
|
| 156 |
+
{[1,2,3].map(i => <div key={i} className="shimmer rounded-lg h-12" />)}
|
| 157 |
+
</div>
|
| 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 |
+
<div className="text-xs text-sol-muted">No transactions yet</div>
|
| 164 |
+
<div className="text-[10px] text-sol-muted/60 mt-1">Try "Send 1 SOL to..."</div>
|
| 165 |
</div>
|
| 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>
|
|
|
|
| 186 |
</div>
|
| 187 |
</div>
|
| 188 |
|
| 189 |
+
{/* ── Wallet Address ────────────────────────────────────────────── */}
|
| 190 |
<div className="glass rounded-2xl p-4">
|
| 191 |
<div className="flex items-center justify-between">
|
| 192 |
+
<div className="flex items-center gap-3">
|
| 193 |
+
<div className="w-8 h-8 rounded-lg bg-sol-purple/10 flex items-center justify-center">
|
| 194 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#9945FF" strokeWidth="2"><rect x="1" y="4" width="22" height="16" rx="2"/><line x1="1" y1="10" x2="23" y2="10"/></svg>
|
| 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 |
</div>
|
src/renderer/pages/HistoryPage.tsx
CHANGED
|
@@ -1,81 +1,63 @@
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
|
| 3 |
export default function HistoryPage() {
|
| 4 |
-
const [
|
| 5 |
const [loading, setLoading] = useState(true);
|
| 6 |
-
const [
|
| 7 |
const [ragResults, setRagResults] = useState<any[]>([]);
|
|
|
|
| 8 |
|
| 9 |
-
useEffect(() => {
|
| 10 |
-
loadHistory();
|
| 11 |
-
}, []);
|
| 12 |
|
| 13 |
-
const
|
| 14 |
setLoading(true);
|
| 15 |
-
try {
|
| 16 |
-
if (window.solvox) {
|
| 17 |
-
const result = await window.solvox.wallet.getHistory(20);
|
| 18 |
-
if (result.success) {
|
| 19 |
-
setTransactions(result.history || []);
|
| 20 |
-
}
|
| 21 |
-
}
|
| 22 |
-
} catch {}
|
| 23 |
setLoading(false);
|
| 24 |
};
|
| 25 |
|
| 26 |
-
const
|
| 27 |
-
if (!
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
if (result.success) {
|
| 32 |
-
setRagResults(result.results || []);
|
| 33 |
-
}
|
| 34 |
-
}
|
| 35 |
-
} catch {}
|
| 36 |
};
|
| 37 |
|
| 38 |
return (
|
| 39 |
-
<div className="space-y-
|
| 40 |
<div className="flex items-center justify-between">
|
| 41 |
-
<h2 className="text-
|
| 42 |
-
<button
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
>
|
| 46 |
-
↻ Refresh
|
| 47 |
</button>
|
| 48 |
</div>
|
| 49 |
|
| 50 |
-
{/*
|
| 51 |
-
<div className="glass rounded-
|
| 52 |
<div className="flex gap-2">
|
| 53 |
-
<
|
| 54 |
-
value={
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
<button
|
| 61 |
-
onClick={handleSearch}
|
| 62 |
-
className="px-4 py-2 rounded-lg bg-tether-green text-white text-sm hover:bg-tether-green/90 transition-colors"
|
| 63 |
-
>
|
| 64 |
-
🔍 AI Search
|
| 65 |
</button>
|
| 66 |
</div>
|
| 67 |
-
<div className="
|
| 68 |
-
|
|
|
|
| 69 |
</div>
|
| 70 |
|
| 71 |
{ragResults.length > 0 && (
|
| 72 |
-
<div className="mt-3 space-y-2">
|
| 73 |
-
<div className="text-
|
| 74 |
{ragResults.map((r, i) => (
|
| 75 |
-
<div key={i} className="bg-sol-dark rounded-
|
| 76 |
-
<div>{r.text}</div>
|
| 77 |
-
<div className="
|
| 78 |
-
|
|
|
|
| 79 |
</div>
|
| 80 |
</div>
|
| 81 |
))}
|
|
@@ -83,62 +65,39 @@ export default function HistoryPage() {
|
|
| 83 |
)}
|
| 84 |
</div>
|
| 85 |
|
| 86 |
-
{/* Transaction
|
| 87 |
<div className="glass rounded-2xl overflow-hidden">
|
| 88 |
{loading ? (
|
| 89 |
-
<div className="p-
|
| 90 |
-
|
| 91 |
-
<div
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
<div className="
|
| 95 |
-
|
| 96 |
</div>
|
|
|
|
|
|
|
| 97 |
</div>
|
| 98 |
) : (
|
| 99 |
-
<
|
| 100 |
-
|
| 101 |
-
<
|
| 102 |
-
<
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
<
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
{tx.status === 'success' ? '✓' : '✗'} {tx.status}
|
| 118 |
-
</span>
|
| 119 |
-
</td>
|
| 120 |
-
<td className="px-6 py-4 font-mono text-xs text-sol-muted">
|
| 121 |
-
{tx.signature?.slice(0, 20)}...
|
| 122 |
-
</td>
|
| 123 |
-
<td className="px-6 py-4 text-sm text-sol-muted">
|
| 124 |
-
{tx.timestamp
|
| 125 |
-
? new Date(tx.timestamp).toLocaleString()
|
| 126 |
-
: 'Pending'}
|
| 127 |
-
</td>
|
| 128 |
-
<td className="px-6 py-4 text-right">
|
| 129 |
-
<a
|
| 130 |
-
href={`https://solscan.io/tx/${tx.signature}`}
|
| 131 |
-
target="_blank"
|
| 132 |
-
rel="noopener noreferrer"
|
| 133 |
-
className="text-sol-purple text-xs hover:underline"
|
| 134 |
-
>
|
| 135 |
-
View →
|
| 136 |
-
</a>
|
| 137 |
-
</td>
|
| 138 |
-
</tr>
|
| 139 |
-
))}
|
| 140 |
-
</tbody>
|
| 141 |
-
</table>
|
| 142 |
)}
|
| 143 |
</div>
|
| 144 |
</div>
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
|
| 3 |
export default function HistoryPage() {
|
| 4 |
+
const [txs, setTxs] = useState<any[]>([]);
|
| 5 |
const [loading, setLoading] = useState(true);
|
| 6 |
+
const [query, setQuery] = useState('');
|
| 7 |
const [ragResults, setRagResults] = useState<any[]>([]);
|
| 8 |
+
const [searching, setSearching] = useState(false);
|
| 9 |
|
| 10 |
+
useEffect(() => { load(); }, []);
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
const load = async () => {
|
| 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="space-y-5 page-enter">
|
| 27 |
<div className="flex items-center justify-between">
|
| 28 |
+
<h2 className="text-xl font-bold">Transaction History</h2>
|
| 29 |
+
<button onClick={load} className="btn-ghost px-3 py-1.5 text-xs flex items-center gap-1.5">
|
| 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="glass rounded-2xl p-4">
|
| 37 |
<div className="flex gap-2">
|
| 38 |
+
<div className="flex-1 relative">
|
| 39 |
+
<input value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && search()}
|
| 40 |
+
placeholder='Search with AI — "last payment to Alice"' className="input-field text-xs pl-9" />
|
| 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 mt-2">
|
| 48 |
+
<div className="w-1 h-1 rounded-full bg-tether-green" />
|
| 49 |
+
<span className="text-[10px] text-sol-muted">Powered by @qvac/embed-llamacpp — 100% local semantic search</span>
|
| 50 |
</div>
|
| 51 |
|
| 52 |
{ragResults.length > 0 && (
|
| 53 |
+
<div className="mt-3 space-y-2 stagger">
|
| 54 |
+
<div className="text-xs font-semibold text-sol-purple">AI Results</div>
|
| 55 |
{ragResults.map((r, i) => (
|
| 56 |
+
<div key={i} className="bg-sol-dark/50 rounded-xl p-3 text-xs border border-sol-border/30">
|
| 57 |
+
<div className="text-sol-text">{r.text}</div>
|
| 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 |
))}
|
|
|
|
| 65 |
)}
|
| 66 |
</div>
|
| 67 |
|
| 68 |
+
{/* Transaction Table */}
|
| 69 |
<div className="glass rounded-2xl overflow-hidden">
|
| 70 |
{loading ? (
|
| 71 |
+
<div className="p-8 space-y-3">
|
| 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="flex flex-col items-center justify-center py-16 text-center">
|
| 76 |
+
<div className="w-16 h-16 rounded-2xl bg-sol-card flex items-center justify-center mb-4">
|
| 77 |
+
<span className="text-3xl opacity-30">📭</span>
|
| 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 className="divide-y divide-sol-border/20">
|
| 84 |
+
{txs.map((tx, i) => (
|
| 85 |
+
<div key={i} className="flex items-center gap-4 px-5 py-3.5 hover:bg-sol-dark/30 transition-colors">
|
| 86 |
+
<div className={`w-9 h-9 rounded-xl flex items-center justify-center text-xs font-bold ${
|
| 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-xs font-mono text-sol-muted truncate">{tx.signature?.slice(0, 24)}...</div>
|
| 93 |
+
<div className="text-[10px] text-sol-muted/60">{tx.timestamp ? new Date(tx.timestamp).toLocaleString() : 'Pending'}</div>
|
| 94 |
+
</div>
|
| 95 |
+
<div className={`badge ${tx.status === 'success' ? 'badge-green' : 'badge-danger'}`}>{tx.status}</div>
|
| 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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
)}
|
| 102 |
</div>
|
| 103 |
</div>
|
src/renderer/pages/LockScreen.tsx
CHANGED
|
@@ -1,142 +1,132 @@
|
|
| 1 |
import React, { useState, useRef, useEffect } from 'react';
|
|
|
|
| 2 |
|
| 3 |
-
interface LockScreenProps {
|
| 4 |
-
onUnlock: (publicKey: string) => void;
|
| 5 |
-
}
|
| 6 |
|
| 7 |
export default function LockScreen({ onUnlock }: LockScreenProps) {
|
| 8 |
const [pin, setPin] = useState('');
|
| 9 |
const [error, setError] = useState('');
|
| 10 |
const [loading, setLoading] = useState(false);
|
| 11 |
const [biometricAvailable, setBiometricAvailable] = useState(false);
|
|
|
|
| 12 |
const inputRef = useRef<HTMLInputElement>(null);
|
| 13 |
|
| 14 |
-
useEffect(() => {
|
| 15 |
-
inputRef.current?.focus();
|
| 16 |
-
checkBiometric();
|
| 17 |
-
}, []);
|
| 18 |
|
| 19 |
const checkBiometric = async () => {
|
| 20 |
-
if (window.solvox)
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
handleBiometric();
|
| 25 |
-
}
|
| 26 |
-
}
|
| 27 |
};
|
| 28 |
|
| 29 |
const handleBiometric = async () => {
|
| 30 |
if (!window.solvox) return;
|
| 31 |
setLoading(true);
|
| 32 |
-
const
|
| 33 |
-
if (
|
| 34 |
-
|
| 35 |
-
if (pk) onUnlock(pk);
|
| 36 |
-
} else {
|
| 37 |
-
setError(result.error || 'Biometric failed');
|
| 38 |
-
}
|
| 39 |
setLoading(false);
|
| 40 |
};
|
| 41 |
|
| 42 |
-
const
|
| 43 |
e.preventDefault();
|
| 44 |
-
if (pin.length < 6) {
|
| 45 |
-
|
| 46 |
-
return;
|
| 47 |
-
}
|
| 48 |
-
setLoading(true);
|
| 49 |
-
setError('');
|
| 50 |
-
|
| 51 |
try {
|
| 52 |
if (window.solvox) {
|
| 53 |
-
const
|
| 54 |
-
if (
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
if (result.remainingAttempts !== undefined) {
|
| 60 |
-
setError(`${result.error} (${result.remainingAttempts} attempts remaining)`);
|
| 61 |
-
}
|
| 62 |
-
}
|
| 63 |
-
} else {
|
| 64 |
-
// Dev mode
|
| 65 |
-
onUnlock('DevModePublicKey123456789');
|
| 66 |
-
}
|
| 67 |
-
} catch (err: any) {
|
| 68 |
-
setError(err.message);
|
| 69 |
-
}
|
| 70 |
-
setLoading(false);
|
| 71 |
-
setPin('');
|
| 72 |
};
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
return (
|
| 75 |
-
<div className="h-screen flex flex-col items-center justify-center
|
| 76 |
-
<
|
|
|
|
|
|
|
| 77 |
{/* Logo */}
|
| 78 |
-
<div className="text-center mb-
|
| 79 |
-
<
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
| 81 |
</div>
|
| 82 |
|
| 83 |
-
{/* Lock
|
| 84 |
-
<div className="
|
| 85 |
-
<div className="
|
| 86 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
</div>
|
| 88 |
</div>
|
| 89 |
|
| 90 |
-
{/* PIN
|
| 91 |
-
<
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
</div>
|
| 106 |
|
| 107 |
{error && (
|
| 108 |
-
<div className="text-danger text-
|
| 109 |
{error}
|
| 110 |
</div>
|
| 111 |
)}
|
| 112 |
|
| 113 |
-
<button
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
className="w-full py-3 rounded-xl bg-sol-purple text-white font-semibold hover:bg-sol-purple/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
| 117 |
-
>
|
| 118 |
-
{loading ? 'Unlocking...' : 'Unlock'}
|
| 119 |
</button>
|
| 120 |
</form>
|
| 121 |
|
| 122 |
-
{/* Biometric */}
|
| 123 |
{biometricAvailable && (
|
| 124 |
-
<button
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
>
|
| 128 |
-
🫰 Use Touch ID
|
| 129 |
</button>
|
| 130 |
)}
|
| 131 |
|
| 132 |
-
{/* Security Badge */}
|
| 133 |
<div className="mt-8 text-center">
|
| 134 |
-
<div className="inline-flex items-center gap-2 text-
|
| 135 |
-
<
|
| 136 |
-
|
| 137 |
</div>
|
| 138 |
</div>
|
| 139 |
</div>
|
|
|
|
|
|
|
|
|
|
| 140 |
</div>
|
| 141 |
);
|
| 142 |
}
|
|
|
|
| 1 |
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { ParticlesBackground } from '../components/ui/index';
|
| 3 |
|
| 4 |
+
interface LockScreenProps { onUnlock: (publicKey: string) => void; }
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export default function LockScreen({ onUnlock }: LockScreenProps) {
|
| 7 |
const [pin, setPin] = useState('');
|
| 8 |
const [error, setError] = useState('');
|
| 9 |
const [loading, setLoading] = useState(false);
|
| 10 |
const [biometricAvailable, setBiometricAvailable] = useState(false);
|
| 11 |
+
const [shake, setShake] = useState(false);
|
| 12 |
const inputRef = useRef<HTMLInputElement>(null);
|
| 13 |
|
| 14 |
+
useEffect(() => { inputRef.current?.focus(); checkBiometric(); }, []);
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
const checkBiometric = async () => {
|
| 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 { setError(r.error || 'Biometric failed'); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
setLoading(false);
|
| 30 |
};
|
| 31 |
|
| 32 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 33 |
e.preventDefault();
|
| 34 |
+
if (pin.length < 6) { triggerShake('PIN must be at least 6 digits'); return; }
|
| 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 { triggerShake(r.remainingAttempts !== undefined ? `${r.error} (${r.remainingAttempts} left)` : r.error || 'Unlock failed'); }
|
| 41 |
+
} else { onUnlock('DevMode' + Date.now()); }
|
| 42 |
+
} catch (err: any) { triggerShake(err.message); }
|
| 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 mesh-bg relative">
|
| 53 |
+
<ParticlesBackground />
|
| 54 |
+
|
| 55 |
+
<div className="w-full max-w-xs relative z-10">
|
| 56 |
{/* Logo */}
|
| 57 |
+
<div className="text-center mb-10 fade-in">
|
| 58 |
+
<div className="w-16 h-16 mx-auto rounded-2xl bg-gradient-to-br from-sol-purple to-sol-green flex items-center justify-center mb-4 shadow-glow-lg animate-float">
|
| 59 |
+
<span className="text-white text-xl font-black">SV</span>
|
| 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={`flex justify-center gap-3 mb-6 transition-transform ${shake ? 'animate-[shake_0.5s_ease-in-out]' : ''}`}
|
| 79 |
+
style={shake ? { animation: 'shake 0.4s ease-in-out' } : {}}>
|
| 80 |
+
{dots.map((filled, i) => (
|
| 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 |
+
{/* Hidden PIN Input */}
|
| 88 |
+
<form onSubmit={handleSubmit}>
|
| 89 |
+
<input ref={inputRef} type="password" inputMode="numeric" pattern="[0-9]*" value={pin}
|
| 90 |
+
onChange={e => setPin(e.target.value.replace(/\D/g, ''))} maxLength={8}
|
| 91 |
+
className="sr-only" autoFocus disabled={loading} />
|
| 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 |
{error && (
|
| 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 |
{biometricAvailable && (
|
| 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="inline-flex items-center gap-2 text-[10px] text-sol-muted/60">
|
| 122 |
+
<div className="w-1.5 h-1.5 rounded-full bg-sol-green/60" />
|
| 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 |
}
|
src/renderer/pages/OnboardingScreen.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
import React, { useState } from 'react';
|
|
|
|
| 2 |
|
| 3 |
-
interface OnboardingScreenProps {
|
| 4 |
-
|
| 5 |
-
}
|
| 6 |
|
| 7 |
-
|
|
|
|
| 8 |
|
| 9 |
export default function OnboardingScreen({ onComplete }: OnboardingScreenProps) {
|
| 10 |
const [step, setStep] = useState<Step>('welcome');
|
|
@@ -15,240 +16,152 @@ export default function OnboardingScreen({ onComplete }: OnboardingScreenProps)
|
|
| 15 |
const [error, setError] = useState('');
|
| 16 |
const [loading, setLoading] = useState(false);
|
| 17 |
|
| 18 |
-
const
|
| 19 |
-
setLoading(true);
|
| 20 |
-
setError('');
|
| 21 |
try {
|
| 22 |
if (window.solvox) {
|
| 23 |
-
const
|
| 24 |
-
if (
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
setError(result.error || 'Failed to create wallet');
|
| 29 |
-
}
|
| 30 |
-
} else {
|
| 31 |
-
setPublicKey('DevMode123');
|
| 32 |
-
setStep('pin');
|
| 33 |
-
}
|
| 34 |
-
} catch (err: any) {
|
| 35 |
-
setError(err.message);
|
| 36 |
-
}
|
| 37 |
setLoading(false);
|
| 38 |
};
|
| 39 |
|
| 40 |
-
const
|
| 41 |
-
if (!mnemonic.trim())
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
const words = mnemonic.trim().split(/\s+/);
|
| 46 |
-
if (words.length !== 12 && words.length !== 24) {
|
| 47 |
-
setError('Recovery phrase must be 12 or 24 words');
|
| 48 |
-
return;
|
| 49 |
-
}
|
| 50 |
-
setLoading(true);
|
| 51 |
-
setError('');
|
| 52 |
try {
|
| 53 |
if (window.solvox) {
|
| 54 |
-
const
|
| 55 |
-
if (
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
setError(result.error || 'Invalid recovery phrase');
|
| 60 |
-
}
|
| 61 |
-
} else {
|
| 62 |
-
setPublicKey('DevImported123');
|
| 63 |
-
setStep('pin');
|
| 64 |
-
}
|
| 65 |
-
} catch (err: any) {
|
| 66 |
-
setError(err.message);
|
| 67 |
-
}
|
| 68 |
setLoading(false);
|
| 69 |
};
|
| 70 |
|
| 71 |
-
const
|
| 72 |
-
if (pin.length < 6)
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
}
|
| 76 |
-
if (pin !== pinConfirm) {
|
| 77 |
-
setError('PINs do not match');
|
| 78 |
-
return;
|
| 79 |
-
}
|
| 80 |
-
setLoading(true);
|
| 81 |
-
setError('');
|
| 82 |
try {
|
| 83 |
-
if (window.solvox) {
|
| 84 |
-
const result = await window.solvox.auth.setPin(pin);
|
| 85 |
-
if (!result.success) {
|
| 86 |
-
setError(result.error || 'Failed to set PIN');
|
| 87 |
-
setLoading(false);
|
| 88 |
-
return;
|
| 89 |
-
}
|
| 90 |
-
}
|
| 91 |
setStep('done');
|
| 92 |
-
} catch (
|
| 93 |
-
setError(err.message);
|
| 94 |
-
}
|
| 95 |
setLoading(false);
|
| 96 |
};
|
| 97 |
|
| 98 |
return (
|
| 99 |
-
<div className="h-screen flex items-center justify-center bg-
|
| 100 |
-
<
|
|
|
|
|
|
|
|
|
|
| 101 |
{step === 'welcome' && (
|
| 102 |
<div className="text-center slide-enter">
|
| 103 |
-
<
|
| 104 |
-
|
| 105 |
-
<
|
| 106 |
-
|
| 107 |
-
</p>
|
| 108 |
-
<
|
|
|
|
|
|
|
| 109 |
{[
|
| 110 |
{ icon: '🎤', label: 'Voice Control', desc: 'Talk to your wallet' },
|
| 111 |
-
{ icon: '🧠', label: 'Local AI', desc: '
|
| 112 |
-
{ icon: '🔒', label: 'Self-Custody', desc: 'Your keys
|
| 113 |
].map(f => (
|
| 114 |
-
<div key={f.label} className="glass rounded-
|
| 115 |
-
<div className="text-
|
| 116 |
-
<div className="text-
|
| 117 |
-
<div className="text-
|
| 118 |
</div>
|
| 119 |
))}
|
| 120 |
</div>
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
>
|
| 125 |
-
Get Started
|
| 126 |
</button>
|
| 127 |
</div>
|
| 128 |
)}
|
| 129 |
|
| 130 |
{step === 'create_or_import' && (
|
| 131 |
-
<div className="space-y-
|
| 132 |
-
<h2 className="text-
|
| 133 |
-
<button
|
| 134 |
-
|
| 135 |
-
disabled={loading}
|
| 136 |
-
className="w-full p-6 rounded-xl glass border-2 border-transparent hover:border-sol-purple transition-all text-left group"
|
| 137 |
-
>
|
| 138 |
<div className="flex items-center gap-4">
|
| 139 |
-
<
|
|
|
|
|
|
|
| 140 |
<div>
|
| 141 |
-
<div className="text-
|
| 142 |
-
|
| 143 |
-
</div>
|
| 144 |
-
<div className="text-sm text-sol-muted">
|
| 145 |
-
Generate a fresh wallet with a new recovery phrase
|
| 146 |
-
</div>
|
| 147 |
</div>
|
| 148 |
</div>
|
| 149 |
</button>
|
| 150 |
-
<button
|
| 151 |
-
|
| 152 |
-
className="w-full p-6 rounded-xl glass border-2 border-transparent hover:border-tether-green transition-all text-left group"
|
| 153 |
-
>
|
| 154 |
<div className="flex items-center gap-4">
|
| 155 |
-
<
|
|
|
|
|
|
|
| 156 |
<div>
|
| 157 |
-
<div className="text-
|
| 158 |
-
|
| 159 |
-
</div>
|
| 160 |
-
<div className="text-sm text-sol-muted">
|
| 161 |
-
Use a 12 or 24 word recovery phrase
|
| 162 |
-
</div>
|
| 163 |
</div>
|
| 164 |
</div>
|
| 165 |
</button>
|
| 166 |
-
{error && <div className="text-danger text-
|
| 167 |
</div>
|
| 168 |
)}
|
| 169 |
|
| 170 |
{step === 'import' && (
|
| 171 |
-
<div className="space-y-
|
| 172 |
-
<h2 className="text-
|
| 173 |
-
<p className="text-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
<
|
| 177 |
-
value={mnemonic}
|
| 178 |
-
onChange={(e) => setMnemonic(e.target.value)}
|
| 179 |
-
placeholder="word1 word2 word3 ..."
|
| 180 |
-
rows={4}
|
| 181 |
-
className="w-full px-4 py-3 bg-sol-card border border-sol-border rounded-xl text-sol-text font-mono text-sm focus:border-sol-purple focus:outline-none resize-none"
|
| 182 |
-
/>
|
| 183 |
-
{error && <div className="text-danger text-sm">{error}</div>}
|
| 184 |
<div className="flex gap-3">
|
| 185 |
-
<button
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
>
|
| 189 |
-
Back
|
| 190 |
-
</button>
|
| 191 |
-
<button
|
| 192 |
-
onClick={handleImportWallet}
|
| 193 |
-
disabled={loading}
|
| 194 |
-
className="flex-1 py-3 rounded-xl bg-tether-green text-white font-semibold hover:bg-tether-green/90 disabled:opacity-50 transition-all"
|
| 195 |
-
>
|
| 196 |
-
{loading ? 'Importing...' : 'Import'}
|
| 197 |
</button>
|
| 198 |
</div>
|
| 199 |
</div>
|
| 200 |
)}
|
| 201 |
|
| 202 |
{step === 'pin' && (
|
| 203 |
-
<div className="space-y-
|
| 204 |
-
<h2 className="text-
|
| 205 |
-
<p className="text-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
<input
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
maxLength={12}
|
| 215 |
-
className="w-full px-4 py-3 bg-sol-card border border-sol-border rounded-xl text-center text-2xl tracking-[0.5em] font-mono focus:border-sol-purple focus:outline-none"
|
| 216 |
-
/>
|
| 217 |
-
<input
|
| 218 |
-
type="password"
|
| 219 |
-
inputMode="numeric"
|
| 220 |
-
value={pinConfirm}
|
| 221 |
-
onChange={(e) => setPinConfirm(e.target.value.replace(/\D/g, ''))}
|
| 222 |
-
placeholder="Confirm PIN"
|
| 223 |
-
maxLength={12}
|
| 224 |
-
className="w-full px-4 py-3 bg-sol-card border border-sol-border rounded-xl text-center text-2xl tracking-[0.5em] font-mono focus:border-sol-purple focus:outline-none"
|
| 225 |
-
/>
|
| 226 |
-
{error && <div className="text-danger text-sm text-center">{error}</div>}
|
| 227 |
-
<button
|
| 228 |
-
onClick={handleSetPin}
|
| 229 |
-
disabled={loading || pin.length < 6}
|
| 230 |
-
className="w-full py-3 rounded-xl bg-sol-purple text-white font-semibold hover:bg-sol-purple/90 disabled:opacity-50 transition-all"
|
| 231 |
-
>
|
| 232 |
-
{loading ? 'Setting up...' : 'Continue'}
|
| 233 |
</button>
|
| 234 |
</div>
|
| 235 |
)}
|
| 236 |
|
| 237 |
{step === 'done' && (
|
| 238 |
-
<div className="text-center space-y-6
|
| 239 |
-
<div className="
|
| 240 |
-
|
| 241 |
-
<p className="text-sol-muted">
|
| 242 |
-
Your wallet is created and secured. Your AI assistant runs 100% locally — no data ever leaves your device.
|
| 243 |
-
</p>
|
| 244 |
-
<div className="glass rounded-xl p-4 font-mono text-xs text-sol-muted break-all">
|
| 245 |
-
{publicKey}
|
| 246 |
</div>
|
| 247 |
-
<
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
>
|
| 251 |
-
Open SolVox
|
| 252 |
</button>
|
| 253 |
</div>
|
| 254 |
)}
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
+
import { ParticlesBackground, StepIndicator } from '../components/ui/index';
|
| 3 |
|
| 4 |
+
interface OnboardingScreenProps { onComplete: (publicKey: string) => void; }
|
| 5 |
+
type Step = 'welcome' | 'create_or_import' | 'import' | 'pin' | 'done';
|
|
|
|
| 6 |
|
| 7 |
+
const STEP_LABELS = ['Welcome', 'Wallet', 'Secure', 'Done'];
|
| 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');
|
|
|
|
| 16 |
const [error, setError] = useState('');
|
| 17 |
const [loading, setLoading] = useState(false);
|
| 18 |
|
| 19 |
+
const createWallet = async () => {
|
| 20 |
+
setLoading(true); setError('');
|
|
|
|
| 21 |
try {
|
| 22 |
if (window.solvox) {
|
| 23 |
+
const r = await window.solvox.wallet.create();
|
| 24 |
+
if (r.success && r.publicKey) { setPublicKey(r.publicKey); setStep('pin'); }
|
| 25 |
+
else setError(r.error || 'Failed');
|
| 26 |
+
} else { setPublicKey('DevMode' + Date.now()); setStep('pin'); }
|
| 27 |
+
} catch (e: any) { setError(e.message); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
setLoading(false);
|
| 29 |
};
|
| 30 |
|
| 31 |
+
const importWallet = async () => {
|
| 32 |
+
if (!mnemonic.trim()) return setError('Enter your recovery phrase');
|
| 33 |
+
const w = mnemonic.trim().split(/\s+/);
|
| 34 |
+
if (w.length !== 12 && w.length !== 24) return setError('Must be 12 or 24 words');
|
| 35 |
+
setLoading(true); setError('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
try {
|
| 37 |
if (window.solvox) {
|
| 38 |
+
const r = await window.solvox.wallet.import(mnemonic.trim());
|
| 39 |
+
if (r.success && r.publicKey) { setPublicKey(r.publicKey); setStep('pin'); }
|
| 40 |
+
else setError(r.error || 'Invalid phrase');
|
| 41 |
+
} else { setPublicKey('DevImport' + Date.now()); setStep('pin'); }
|
| 42 |
+
} catch (e: any) { setError(e.message); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
setLoading(false);
|
| 44 |
};
|
| 45 |
|
| 46 |
+
const setWalletPin = async () => {
|
| 47 |
+
if (pin.length < 6) return setError('PIN must be at least 6 digits');
|
| 48 |
+
if (pin !== pinConfirm) return setError('PINs do not match');
|
| 49 |
+
setLoading(true); setError('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
try {
|
| 51 |
+
if (window.solvox) { const r = await window.solvox.auth.setPin(pin); if (!r.success) { setError(r.error || 'Failed'); setLoading(false); return; } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
setStep('done');
|
| 53 |
+
} catch (e: any) { setError(e.message); }
|
|
|
|
|
|
|
| 54 |
setLoading(false);
|
| 55 |
};
|
| 56 |
|
| 57 |
return (
|
| 58 |
+
<div className="h-screen flex items-center justify-center mesh-bg relative overflow-hidden">
|
| 59 |
+
<ParticlesBackground />
|
| 60 |
+
<div className="w-full max-w-md p-6 relative z-10">
|
| 61 |
+
{step !== 'welcome' && <StepIndicator steps={STEP_LABELS} current={stepIndex(step)} />}
|
| 62 |
+
|
| 63 |
{step === 'welcome' && (
|
| 64 |
<div className="text-center slide-enter">
|
| 65 |
+
<div className="w-20 h-20 mx-auto rounded-3xl bg-gradient-to-br from-sol-purple to-sol-green flex items-center justify-center mb-6 shadow-glow-lg animate-float">
|
| 66 |
+
<span className="text-white text-2xl font-black">SV</span>
|
| 67 |
+
</div>
|
| 68 |
+
<h1 className="text-5xl font-extrabold gradient-text mb-3">SolVox</h1>
|
| 69 |
+
<p className="text-lg text-sol-muted mb-1">Voice-First Private AI Wallet</p>
|
| 70 |
+
<p className="text-xs text-sol-muted/60 mb-10">Powered by QVAC • 100% Local AI • Zero Cloud</p>
|
| 71 |
+
|
| 72 |
+
<div className="grid grid-cols-3 gap-3 mb-10 stagger">
|
| 73 |
{[
|
| 74 |
{ icon: '🎤', label: 'Voice Control', desc: 'Talk to your wallet' },
|
| 75 |
+
{ icon: '🧠', label: 'Local AI', desc: '6 QVAC modules' },
|
| 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 === 'create_or_import' && (
|
| 93 |
+
<div className="space-y-4 slide-enter">
|
| 94 |
+
<h2 className="text-2xl font-bold text-center mb-6">Create Your Wallet</h2>
|
| 95 |
+
<button onClick={createWallet} disabled={loading}
|
| 96 |
+
className="w-full glass glass-hover rounded-2xl p-5 text-left group border-2 border-transparent hover:border-sol-purple/20 transition-all">
|
|
|
|
|
|
|
|
|
|
| 97 |
<div className="flex items-center gap-4">
|
| 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="w-full glass glass-hover rounded-2xl p-5 text-left group border-2 border-transparent hover:border-tether-green/20 transition-all">
|
|
|
|
|
|
|
| 109 |
<div className="flex items-center gap-4">
|
| 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 |
+
{error && <div className="text-danger text-xs text-center">{error}</div>}
|
| 120 |
</div>
|
| 121 |
)}
|
| 122 |
|
| 123 |
{step === 'import' && (
|
| 124 |
+
<div className="space-y-5 slide-enter">
|
| 125 |
+
<h2 className="text-xl font-bold text-center">Import Recovery Phrase</h2>
|
| 126 |
+
<p className="text-xs text-sol-muted text-center">Stays on your device — never sent anywhere.</p>
|
| 127 |
+
<textarea value={mnemonic} onChange={e => setMnemonic(e.target.value)} placeholder="word1 word2 word3 ..." rows={4}
|
| 128 |
+
className="input-field font-mono text-xs resize-none" />
|
| 129 |
+
{error && <div className="text-danger text-xs">{error}</div>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
<div className="flex gap-3">
|
| 131 |
+
<button onClick={() => { setStep('create_or_import'); setError(''); }} className="btn-ghost flex-1 py-3">Back</button>
|
| 132 |
+
<button onClick={importWallet} disabled={loading} className="btn-primary flex-1 py-3 text-white disabled:opacity-50">
|
| 133 |
+
<span>{loading ? 'Importing...' : 'Import'}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
</button>
|
| 135 |
</div>
|
| 136 |
</div>
|
| 137 |
)}
|
| 138 |
|
| 139 |
{step === 'pin' && (
|
| 140 |
+
<div className="space-y-5 slide-enter">
|
| 141 |
+
<h2 className="text-xl font-bold text-center">Set Your PIN</h2>
|
| 142 |
+
<p className="text-xs text-sol-muted text-center">This encrypts your wallet with AES-256-GCM + PBKDF2.</p>
|
| 143 |
+
<input type="password" inputMode="numeric" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, ''))}
|
| 144 |
+
placeholder="Enter PIN (min 6 digits)" maxLength={12} className="input-field text-center text-xl tracking-[0.5em] font-mono" />
|
| 145 |
+
<input type="password" inputMode="numeric" value={pinConfirm} onChange={e => setPinConfirm(e.target.value.replace(/\D/g, ''))}
|
| 146 |
+
placeholder="Confirm PIN" maxLength={12} className="input-field text-center text-xl tracking-[0.5em] font-mono" />
|
| 147 |
+
{pin.length >= 6 && pin === pinConfirm && <div className="badge badge-green mx-auto">✓ PINs match</div>}
|
| 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 space-y-6 scale-in">
|
| 157 |
+
<div className="w-16 h-16 mx-auto rounded-full bg-sol-green/15 flex items-center justify-center">
|
| 158 |
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#14F195" strokeWidth="2.5" strokeLinecap="round"><polyline points="20 6 9 17 4 12"/></svg>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
</div>
|
| 160 |
+
<h2 className="text-3xl font-bold gradient-text-static">You're All Set!</h2>
|
| 161 |
+
<p className="text-sm text-sol-muted">Wallet encrypted & secured. Your AI assistant runs 100% locally.</p>
|
| 162 |
+
<div className="glass rounded-xl p-3 font-mono text-[10px] text-sol-muted break-all">{publicKey}</div>
|
| 163 |
+
<button onClick={() => onComplete(publicKey)} className="btn-primary px-10 py-4 text-white text-lg">
|
| 164 |
+
<span>Open SolVox</span>
|
| 165 |
</button>
|
| 166 |
</div>
|
| 167 |
)}
|
src/renderer/pages/SecurityPage.tsx
CHANGED
|
@@ -1,226 +1,131 @@
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
|
|
|
| 2 |
|
| 3 |
export default function SecurityPage() {
|
| 4 |
const [settings, setSettings] = useState<any>({});
|
| 5 |
const [whitelist, setWhitelist] = useState<any[]>([]);
|
| 6 |
const [anomalies, setAnomalies] = useState<any[]>([]);
|
| 7 |
-
const [
|
| 8 |
const [newLabel, setNewLabel] = useState('');
|
| 9 |
const [error, setError] = useState('');
|
| 10 |
-
const
|
| 11 |
|
| 12 |
-
useEffect(() => {
|
| 13 |
-
loadSecurity();
|
| 14 |
-
}, []);
|
| 15 |
|
| 16 |
-
const
|
| 17 |
if (!window.solvox) return;
|
| 18 |
try {
|
| 19 |
-
const [
|
| 20 |
-
|
| 21 |
-
window.solvox.security.getWhitelist(),
|
| 22 |
-
window.solvox.security.getAnomalies(),
|
| 23 |
-
]);
|
| 24 |
-
setSettings(settingsResult);
|
| 25 |
-
setWhitelist(whitelistResult);
|
| 26 |
-
setAnomalies(anomalyResult);
|
| 27 |
} catch {}
|
| 28 |
};
|
| 29 |
|
| 30 |
-
const
|
| 31 |
-
const
|
| 32 |
-
setSettings(
|
| 33 |
-
if (window.solvox) {
|
| 34 |
-
await window.solvox.security.updateSettings(updated);
|
| 35 |
-
setSaved(true);
|
| 36 |
-
setTimeout(() => setSaved(false), 2000);
|
| 37 |
-
}
|
| 38 |
};
|
| 39 |
|
| 40 |
-
const
|
| 41 |
-
if (!
|
| 42 |
-
setError('Address and label are required');
|
| 43 |
-
return;
|
| 44 |
-
}
|
| 45 |
if (window.solvox) {
|
| 46 |
-
const
|
| 47 |
-
if (
|
| 48 |
-
|
| 49 |
-
setNewLabel('');
|
| 50 |
-
setError('');
|
| 51 |
-
loadSecurity();
|
| 52 |
-
} else {
|
| 53 |
-
setError(result.error || 'Failed to add');
|
| 54 |
-
}
|
| 55 |
}
|
| 56 |
};
|
| 57 |
|
| 58 |
-
const
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
|
| 65 |
return (
|
| 66 |
-
<div className="space-y-
|
| 67 |
-
<h2 className="text-
|
| 68 |
|
| 69 |
-
{
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</div>
|
| 73 |
-
)}
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
className="flex
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
<div className="flex items-center gap-2">
|
| 94 |
-
<input
|
| 95 |
-
type="number"
|
| 96 |
-
value={settings.maxDailyVolume || 5000}
|
| 97 |
-
onChange={(e) => handleUpdateSettings('maxDailyVolume', Number(e.target.value))}
|
| 98 |
-
className="flex-1 px-3 py-2 bg-sol-dark border border-sol-border rounded-lg text-sm focus:border-sol-purple focus:outline-none"
|
| 99 |
-
/>
|
| 100 |
-
<span className="text-sol-muted text-sm">tokens/day</span>
|
| 101 |
-
</div>
|
| 102 |
-
</div>
|
| 103 |
-
<div>
|
| 104 |
-
<label className="text-sm text-sol-muted block mb-1">Max Transactions/Hour</label>
|
| 105 |
-
<input
|
| 106 |
-
type="number"
|
| 107 |
-
value={settings.velocityLimit || 10}
|
| 108 |
-
onChange={(e) => handleUpdateSettings('velocityLimit', Number(e.target.value))}
|
| 109 |
-
className="w-full px-3 py-2 bg-sol-dark border border-sol-border rounded-lg text-sm focus:border-sol-purple focus:outline-none"
|
| 110 |
-
/>
|
| 111 |
-
</div>
|
| 112 |
-
<div>
|
| 113 |
-
<label className="text-sm text-sol-muted block mb-1">Cooldown (minutes)</label>
|
| 114 |
-
<input
|
| 115 |
-
type="number"
|
| 116 |
-
value={settings.cooldownMinutes || 1}
|
| 117 |
-
onChange={(e) => handleUpdateSettings('cooldownMinutes', Number(e.target.value))}
|
| 118 |
-
className="w-full px-3 py-2 bg-sol-dark border border-sol-border rounded-lg text-sm focus:border-sol-purple focus:outline-none"
|
| 119 |
-
/>
|
| 120 |
</div>
|
| 121 |
</div>
|
| 122 |
</div>
|
| 123 |
|
| 124 |
-
{/*
|
| 125 |
-
<div className="glass rounded-2xl p-
|
| 126 |
-
<
|
| 127 |
-
<div className="space-y-
|
| 128 |
{[
|
| 129 |
-
{
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
},
|
| 135 |
-
{
|
| 136 |
-
key: 'anomalyDetection',
|
| 137 |
-
label: 'AI Anomaly Detection',
|
| 138 |
-
desc: 'Detect unusual transaction patterns using local AI',
|
| 139 |
-
icon: '🧠',
|
| 140 |
-
},
|
| 141 |
-
{
|
| 142 |
-
key: 'requireConfirmation',
|
| 143 |
-
label: 'Transaction Confirmation',
|
| 144 |
-
desc: 'Always require explicit confirmation before sending',
|
| 145 |
-
icon: '✅',
|
| 146 |
-
},
|
| 147 |
-
].map(toggle => (
|
| 148 |
-
<div key={toggle.key} className="flex items-center justify-between py-2">
|
| 149 |
<div className="flex items-center gap-3">
|
| 150 |
-
<
|
| 151 |
<div>
|
| 152 |
-
<div className="text-
|
| 153 |
-
<div className="text-
|
| 154 |
</div>
|
| 155 |
</div>
|
| 156 |
-
<button
|
| 157 |
-
|
| 158 |
-
className={`
|
| 159 |
-
settings[toggle.key] ? 'bg-sol-green' : 'bg-sol-border'
|
| 160 |
-
}`}
|
| 161 |
-
>
|
| 162 |
-
<span
|
| 163 |
-
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform ${
|
| 164 |
-
settings[toggle.key] ? 'translate-x-6' : 'translate-x-0.5'
|
| 165 |
-
}`}
|
| 166 |
-
/>
|
| 167 |
</button>
|
| 168 |
</div>
|
| 169 |
))}
|
| 170 |
</div>
|
| 171 |
</div>
|
| 172 |
|
| 173 |
-
{/*
|
| 174 |
-
<div className="glass rounded-2xl p-
|
| 175 |
-
<
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
<
|
| 180 |
-
value={newLabel}
|
| 181 |
-
onChange={(e) => setNewLabel(e.target.value)}
|
| 182 |
-
placeholder="Label (e.g. Alice)"
|
| 183 |
-
className="w-32 px-3 py-2 bg-sol-dark border border-sol-border rounded-lg text-sm focus:border-sol-purple focus:outline-none"
|
| 184 |
-
/>
|
| 185 |
-
<input
|
| 186 |
-
value={newAddress}
|
| 187 |
-
onChange={(e) => setNewAddress(e.target.value)}
|
| 188 |
-
placeholder="Solana address"
|
| 189 |
-
className="flex-1 px-3 py-2 bg-sol-dark border border-sol-border rounded-lg text-sm font-mono focus:border-sol-purple focus:outline-none"
|
| 190 |
-
/>
|
| 191 |
-
<button
|
| 192 |
-
onClick={handleAddWhitelist}
|
| 193 |
-
className="px-4 py-2 rounded-lg bg-sol-purple text-white text-sm hover:bg-sol-purple/90 transition-colors"
|
| 194 |
-
>
|
| 195 |
-
+ Add
|
| 196 |
-
</button>
|
| 197 |
</div>
|
| 198 |
-
{error && <div className="text-danger text-
|
| 199 |
-
|
| 200 |
-
{/* Whitelist Entries */}
|
| 201 |
{whitelist.length === 0 ? (
|
| 202 |
-
<div className="text-center py-6 text-
|
| 203 |
-
No whitelisted addresses yet
|
| 204 |
-
</div>
|
| 205 |
) : (
|
| 206 |
<div className="space-y-2">
|
| 207 |
-
{whitelist.map((
|
| 208 |
-
<div key={i} className="flex items-center justify-between bg-sol-dark rounded-
|
| 209 |
<div>
|
| 210 |
-
<div className="text-
|
| 211 |
-
<div className="text-
|
| 212 |
-
{entry.address.slice(0, 12)}...{entry.address.slice(-8)}
|
| 213 |
-
</div>
|
| 214 |
-
<div className="text-xs text-sol-muted mt-0.5">
|
| 215 |
-
Added: {new Date(entry.addedAt).toLocaleDateString()}
|
| 216 |
-
</div>
|
| 217 |
</div>
|
| 218 |
-
<button
|
| 219 |
-
onClick={() => handleRemoveWhitelist(entry.address)}
|
| 220 |
-
className="text-danger text-xs hover:underline"
|
| 221 |
-
>
|
| 222 |
-
Remove
|
| 223 |
-
</button>
|
| 224 |
</div>
|
| 225 |
))}
|
| 226 |
</div>
|
|
@@ -228,37 +133,32 @@ export default function SecurityPage() {
|
|
| 228 |
</div>
|
| 229 |
|
| 230 |
{/* Anomaly Log */}
|
| 231 |
-
<div className="glass rounded-2xl p-
|
| 232 |
-
<
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
</
|
| 236 |
{anomalies.length === 0 ? (
|
| 237 |
-
<div className="text-center py-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
| 239 |
</div>
|
| 240 |
) : (
|
| 241 |
-
<div className="space-y-2">
|
| 242 |
-
{anomalies.map((
|
| 243 |
-
<div key={i} className={`rounded-
|
| 244 |
-
|
| 245 |
-
anomaly.severity === 'medium' ? 'bg-warning/10 border border-warning/30' :
|
| 246 |
-
'bg-sol-dark border border-sol-border'
|
| 247 |
}`}>
|
| 248 |
<div className="flex items-center gap-2 mb-1">
|
| 249 |
-
<
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
}`}>
|
| 254 |
-
{anomaly.severity.toUpperCase()}
|
| 255 |
-
</span>
|
| 256 |
-
<span className="text-xs text-sol-muted">{anomaly.type}</span>
|
| 257 |
-
</div>
|
| 258 |
-
<div className="text-sm">{anomaly.description}</div>
|
| 259 |
-
<div className="text-xs text-sol-muted mt-1">
|
| 260 |
-
{new Date(anomaly.timestamp).toLocaleString()}
|
| 261 |
</div>
|
|
|
|
|
|
|
| 262 |
</div>
|
| 263 |
))}
|
| 264 |
</div>
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { CircularProgress, useToast } from '../components/ui/index';
|
| 3 |
|
| 4 |
export default function SecurityPage() {
|
| 5 |
const [settings, setSettings] = useState<any>({});
|
| 6 |
const [whitelist, setWhitelist] = useState<any[]>([]);
|
| 7 |
const [anomalies, setAnomalies] = useState<any[]>([]);
|
| 8 |
+
const [newAddr, setNewAddr] = useState('');
|
| 9 |
const [newLabel, setNewLabel] = useState('');
|
| 10 |
const [error, setError] = useState('');
|
| 11 |
+
const { addToast } = useToast();
|
| 12 |
|
| 13 |
+
useEffect(() => { load(); }, []);
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
const load = async () => {
|
| 16 |
if (!window.solvox) return;
|
| 17 |
try {
|
| 18 |
+
const [s, w, a] = await Promise.all([window.solvox.security.getSettings(), window.solvox.security.getWhitelist(), window.solvox.security.getAnomalies()]);
|
| 19 |
+
setSettings(s); setWhitelist(w); setAnomalies(a);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
} catch {}
|
| 21 |
};
|
| 22 |
|
| 23 |
+
const update = async (k: string, v: any) => {
|
| 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="space-y-5 max-w-3xl page-enter">
|
| 47 |
+
<h2 className="text-xl font-bold">Security Center</h2>
|
| 48 |
|
| 49 |
+
{/* Security Score + Limits */}
|
| 50 |
+
<div className="grid grid-cols-3 gap-4">
|
| 51 |
+
<div className="glass rounded-2xl p-5 flex flex-col items-center justify-center">
|
| 52 |
+
<CircularProgress value={score} size={90} strokeWidth={6} color={scoreColor}>
|
| 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 |
+
{ key: 'whitelistEnabled', label: 'Address Whitelisting', desc: 'Only allow sends to approved addresses', icon: '📋' },
|
| 89 |
+
{ key: 'anomalyDetection', label: 'AI Anomaly Detection', desc: 'Local AI flags unusual patterns', icon: '🧠' },
|
| 90 |
+
{ key: 'requireConfirmation', label: 'Transaction Confirmation', desc: 'Always require explicit approval', icon: '✅' },
|
| 91 |
+
].map(t => (
|
| 92 |
+
<div key={t.key} className="flex items-center justify-between py-2 px-3 rounded-xl hover:bg-sol-dark/30 transition-colors">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
<div className="flex items-center gap-3">
|
| 94 |
+
<div className="w-9 h-9 rounded-lg bg-sol-card flex items-center justify-center text-lg">{t.icon}</div>
|
| 95 |
<div>
|
| 96 |
+
<div className="text-xs font-semibold text-sol-text">{t.label}</div>
|
| 97 |
+
<div className="text-[10px] text-sol-muted">{t.desc}</div>
|
| 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="glass rounded-2xl p-5">
|
| 111 |
+
<div className="text-xs text-sol-muted font-semibold uppercase tracking-wider mb-3">Address Whitelist</div>
|
| 112 |
+
<div className="flex gap-2 mb-3">
|
| 113 |
+
<input value={newLabel} onChange={e => setNewLabel(e.target.value)} placeholder="Label" className="input-field text-xs w-28 py-1.5" />
|
| 114 |
+
<input value={newAddr} onChange={e => setNewAddr(e.target.value)} placeholder="Solana address" className="input-field text-xs flex-1 py-1.5 font-mono" />
|
| 115 |
+
<button onClick={addWL} className="btn-primary px-3 py-1.5 text-white text-xs"><span>Add</span></button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
</div>
|
| 117 |
+
{error && <div className="text-danger text-[10px] mb-2">{error}</div>}
|
|
|
|
|
|
|
| 118 |
{whitelist.length === 0 ? (
|
| 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 |
+
{whitelist.map((e, i) => (
|
| 123 |
+
<div key={i} className="flex items-center justify-between bg-sol-dark/40 rounded-xl p-3">
|
| 124 |
<div>
|
| 125 |
+
<div className="text-xs font-semibold">{e.label}</div>
|
| 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>
|
|
|
|
| 133 |
</div>
|
| 134 |
|
| 135 |
{/* Anomaly Log */}
|
| 136 |
+
<div className="glass rounded-2xl p-5">
|
| 137 |
+
<div className="flex items-center justify-between mb-3">
|
| 138 |
+
<div className="text-xs text-sol-muted font-semibold uppercase tracking-wider">Anomaly Detection Log</div>
|
| 139 |
+
<div className="badge badge-tether text-[10px]">AI-Powered • Local</div>
|
| 140 |
+
</div>
|
| 141 |
{anomalies.length === 0 ? (
|
| 142 |
+
<div className="text-center py-8">
|
| 143 |
+
<div className="w-12 h-12 mx-auto rounded-xl bg-sol-green/10 flex items-center justify-center mb-2">
|
| 144 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#14F195" strokeWidth="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
| 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 stagger">
|
| 150 |
+
{anomalies.map((a, i) => (
|
| 151 |
+
<div key={i} className={`rounded-xl p-3 border ${
|
| 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 |
+
<div className={`badge text-[10px] ${a.severity === 'high' ? 'badge-danger' : a.severity === 'medium' ? 'badge-warn' : 'badge-muted'}`}>
|
| 156 |
+
{a.severity.toUpperCase()}
|
| 157 |
+
</div>
|
| 158 |
+
<span className="text-[10px] text-sol-muted">{a.type}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
</div>
|
| 160 |
+
<div className="text-xs text-sol-text">{a.description}</div>
|
| 161 |
+
<div className="text-[10px] text-sol-muted/60 mt-1">{new Date(a.timestamp).toLocaleString()}</div>
|
| 162 |
</div>
|
| 163 |
))}
|
| 164 |
</div>
|
src/renderer/pages/SendPage.tsx
CHANGED
|
@@ -1,101 +1,72 @@
|
|
| 1 |
import React, { useState } from 'react';
|
|
|
|
| 2 |
|
| 3 |
-
interface SendPageProps {
|
| 4 |
-
balance: { sol: number; usdt: number };
|
| 5 |
-
onSent: () => void;
|
| 6 |
-
}
|
| 7 |
|
| 8 |
export default function SendPage({ balance, onSent }: SendPageProps) {
|
| 9 |
const [token, setToken] = useState<'SOL' | 'USDT'>('SOL');
|
| 10 |
const [to, setTo] = useState('');
|
| 11 |
const [amount, setAmount] = useState('');
|
| 12 |
-
const [memo, setMemo] = useState('');
|
| 13 |
const [loading, setLoading] = useState(false);
|
| 14 |
const [error, setError] = useState('');
|
| 15 |
const [success, setSuccess] = useState<{ signature: string; explorer: string } | null>(null);
|
| 16 |
const [step, setStep] = useState<'form' | 'confirm' | 'result'>('form');
|
|
|
|
| 17 |
|
| 18 |
-
const
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
const
|
| 21 |
setError('');
|
| 22 |
-
if (!to.trim())
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
}
|
| 26 |
-
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(to.trim())) {
|
| 27 |
-
setError('Invalid Solana address');
|
| 28 |
-
return;
|
| 29 |
-
}
|
| 30 |
-
const amountNum = parseFloat(amount);
|
| 31 |
-
if (!amountNum || amountNum <= 0) {
|
| 32 |
-
setError('Enter a valid amount');
|
| 33 |
-
return;
|
| 34 |
-
}
|
| 35 |
-
if (amountNum > maxAmount) {
|
| 36 |
-
setError(`Insufficient balance. Max: ${maxAmount.toFixed(token === 'SOL' ? 4 : 2)} ${token}`);
|
| 37 |
-
return;
|
| 38 |
-
}
|
| 39 |
setStep('confirm');
|
| 40 |
};
|
| 41 |
|
| 42 |
-
const
|
| 43 |
-
setLoading(true);
|
| 44 |
-
setError('');
|
| 45 |
try {
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
result = await window.solvox.wallet.sendUSDT(to.trim(), parseFloat(amount));
|
| 52 |
-
}
|
| 53 |
-
} else {
|
| 54 |
-
result = { success: true, signature: 'dev_sig_123', explorer: '#' };
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
if (result.success) {
|
| 58 |
-
setSuccess({ signature: result.signature!, explorer: result.explorer! });
|
| 59 |
setStep('result');
|
| 60 |
onSent();
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
}
|
| 65 |
-
} catch (err: any) {
|
| 66 |
-
setError(err.message);
|
| 67 |
-
setStep('form');
|
| 68 |
-
}
|
| 69 |
setLoading(false);
|
| 70 |
};
|
| 71 |
|
| 72 |
-
const reset = () => {
|
| 73 |
-
setTo('');
|
| 74 |
-
setAmount('');
|
| 75 |
-
setMemo('');
|
| 76 |
-
setError('');
|
| 77 |
-
setSuccess(null);
|
| 78 |
-
setStep('form');
|
| 79 |
-
};
|
| 80 |
|
| 81 |
return (
|
| 82 |
-
<div className="max-w-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
{step === 'form' && (
|
| 86 |
-
<div className="glass rounded-
|
| 87 |
{/* Token Selector */}
|
| 88 |
-
<div className="flex gap-2">
|
| 89 |
{(['SOL', 'USDT'] as const).map(t => (
|
| 90 |
-
<button
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
? t === 'SOL' ? 'bg-sol-purple text-white' : 'bg-tether-green text-white'
|
| 96 |
-
: 'bg-sol-dark text-sol-muted border border-sol-border'
|
| 97 |
-
}`}
|
| 98 |
-
>
|
| 99 |
{t === 'SOL' ? '◎' : '₮'} {t}
|
| 100 |
</button>
|
| 101 |
))}
|
|
@@ -103,129 +74,95 @@ export default function SendPage({ balance, onSent }: SendPageProps) {
|
|
| 103 |
|
| 104 |
{/* Recipient */}
|
| 105 |
<div>
|
| 106 |
-
<label className="text-
|
| 107 |
-
<input
|
| 108 |
-
|
| 109 |
-
onChange={(e) => setTo(e.target.value)}
|
| 110 |
-
placeholder="Enter Solana address"
|
| 111 |
-
className="w-full px-4 py-3 bg-sol-dark border border-sol-border rounded-xl font-mono text-sm focus:border-sol-purple focus:outline-none"
|
| 112 |
-
/>
|
| 113 |
</div>
|
| 114 |
|
| 115 |
{/* Amount */}
|
| 116 |
<div>
|
| 117 |
-
<div className="flex justify-between items-center mb-
|
| 118 |
-
<label className="text-
|
| 119 |
-
<button
|
| 120 |
-
|
| 121 |
-
className="text-xs text-sol-purple hover:underline"
|
| 122 |
-
>
|
| 123 |
-
Max: {maxAmount.toFixed(token === 'SOL' ? 4 : 2)} {token}
|
| 124 |
</button>
|
| 125 |
</div>
|
| 126 |
<div className="relative">
|
| 127 |
-
<input
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
</div>
|
| 140 |
</div>
|
| 141 |
|
| 142 |
-
{error &&
|
| 143 |
-
<div className="text-danger text-sm bg-danger/10 rounded-lg p-3">{error}</div>
|
| 144 |
-
)}
|
| 145 |
|
| 146 |
-
<button
|
| 147 |
-
|
| 148 |
-
className="w-full py-3 rounded-xl bg-sol-purple text-white font-semibold hover:bg-sol-purple/90 transition-all"
|
| 149 |
-
>
|
| 150 |
-
Review Transaction
|
| 151 |
</button>
|
| 152 |
</div>
|
| 153 |
)}
|
| 154 |
|
| 155 |
{step === 'confirm' && (
|
| 156 |
-
<div className="glass rounded-
|
| 157 |
-
<
|
| 158 |
-
|
| 159 |
-
<div className="
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
<div className="flex justify-between">
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
</div>
|
| 167 |
-
<div className="flex justify-between">
|
| 168 |
-
<span className="text-sol-muted">To</span>
|
| 169 |
-
<span className="font-mono text-xs">{to.slice(0, 12)}...{to.slice(-8)}</span>
|
| 170 |
-
</div>
|
| 171 |
-
<div className="flex justify-between">
|
| 172 |
-
<span className="text-sol-muted">Network Fee</span>
|
| 173 |
-
<span className="text-sm">~0.000005 SOL</span>
|
| 174 |
-
</div>
|
| 175 |
</div>
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
<div
|
| 180 |
-
|
| 181 |
-
|
| 182 |
</div>
|
| 183 |
</div>
|
| 184 |
|
| 185 |
-
{error && <div className="text-danger text-
|
| 186 |
|
| 187 |
<div className="flex gap-3">
|
| 188 |
-
<button
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
>
|
| 192 |
-
Cancel
|
| 193 |
-
</button>
|
| 194 |
-
<button
|
| 195 |
-
onClick={handleSend}
|
| 196 |
-
disabled={loading}
|
| 197 |
-
className="flex-1 py-3 rounded-xl bg-sol-purple text-white font-semibold hover:bg-sol-purple/90 disabled:opacity-50 transition-all"
|
| 198 |
-
>
|
| 199 |
-
{loading ? 'Sending...' : `Send ${token}`}
|
| 200 |
</button>
|
| 201 |
</div>
|
| 202 |
</div>
|
| 203 |
)}
|
| 204 |
|
| 205 |
{step === 'result' && success && (
|
| 206 |
-
<div className="glass rounded-
|
| 207 |
-
<div className="
|
| 208 |
-
|
| 209 |
-
<
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
{success.signature}
|
| 214 |
</div>
|
| 215 |
-
<
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
rel="noopener noreferrer"
|
| 219 |
-
className="block text-sol-purple text-sm hover:underline"
|
| 220 |
-
>
|
| 221 |
-
View on Solscan →
|
| 222 |
</a>
|
| 223 |
-
<button
|
| 224 |
-
onClick={reset}
|
| 225 |
-
className="w-full py-3 rounded-xl bg-sol-purple text-white font-semibold hover:bg-sol-purple/90 transition-all"
|
| 226 |
-
>
|
| 227 |
-
Send Another
|
| 228 |
-
</button>
|
| 229 |
</div>
|
| 230 |
)}
|
| 231 |
</div>
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
+
import { AnimatedNumber, useToast } from '../components/ui/index';
|
| 3 |
|
| 4 |
+
interface SendPageProps { balance: { sol: number; usdt: number }; onSent: () => void; }
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export default function SendPage({ balance, onSent }: SendPageProps) {
|
| 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<{ signature: string; explorer: string } | null>(null);
|
| 13 |
const [step, setStep] = useState<'form' | 'confirm' | 'result'>('form');
|
| 14 |
+
const { addToast } = useToast();
|
| 15 |
|
| 16 |
+
const max = token === 'SOL' ? balance.sol : balance.usdt;
|
| 17 |
+
const amtNum = parseFloat(amount) || 0;
|
| 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 is required');
|
| 23 |
+
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(to.trim())) return setError('Invalid Solana address');
|
| 24 |
+
if (!amtNum || amtNum <= 0) return setError('Enter a valid amount');
|
| 25 |
+
if (amtNum > max) return setError(`Insufficient balance. Max: ${max.toFixed(token === 'SOL' ? 4 : 2)} ${token}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
setStep('confirm');
|
| 27 |
};
|
| 28 |
|
| 29 |
+
const send = async () => {
|
| 30 |
+
setLoading(true); setError('');
|
|
|
|
| 31 |
try {
|
| 32 |
+
const r = window.solvox
|
| 33 |
+
? token === 'SOL' ? await window.solvox.wallet.sendSOL(to.trim(), amtNum) : await window.solvox.wallet.sendUSDT(to.trim(), amtNum)
|
| 34 |
+
: { success: true, signature: 'dev_sig_' + Date.now(), explorer: '#' };
|
| 35 |
+
if (r.success) {
|
| 36 |
+
setSuccess({ signature: r.signature!, explorer: r.explorer! });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
setStep('result');
|
| 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 page-enter">
|
| 49 |
+
{/* Header */}
|
| 50 |
+
<div className="flex items-center gap-3 mb-6">
|
| 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="glass rounded-3xl p-6 space-y-5 slide-enter">
|
| 62 |
{/* Token Selector */}
|
| 63 |
+
<div className="flex gap-2 p-1 bg-sol-dark/60 rounded-xl">
|
| 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 |
))}
|
|
|
|
| 74 |
|
| 75 |
{/* Recipient */}
|
| 76 |
<div>
|
| 77 |
+
<label className="text-xs text-sol-muted font-semibold uppercase tracking-wider block mb-2">Recipient</label>
|
| 78 |
+
<input value={to} onChange={e => setTo(e.target.value)} placeholder="Enter Solana address"
|
| 79 |
+
className="input-field font-mono text-xs" />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
</div>
|
| 81 |
|
| 82 |
{/* Amount */}
|
| 83 |
<div>
|
| 84 |
+
<div className="flex justify-between items-center mb-2">
|
| 85 |
+
<label className="text-xs text-sol-muted font-semibold uppercase tracking-wider">Amount</label>
|
| 86 |
+
<button onClick={() => setAmount(max.toString())} className="text-[11px] text-sol-purple hover:underline font-medium">
|
| 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 |
+
{/* Percentage bar */}
|
| 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 |
+
{error && <div className="badge-danger bg-danger/8 border border-danger/20 rounded-xl p-3 text-xs">{error}</div>}
|
|
|
|
|
|
|
| 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="glass rounded-3xl p-6 space-y-5 scale-in">
|
| 121 |
+
<div className="text-center mb-2">
|
| 122 |
+
<div className="text-sm text-sol-muted font-medium">Confirm Transaction</div>
|
| 123 |
+
<div className="text-3xl font-extrabold mt-1">{amount} <span className={token === 'SOL' ? 'text-sol-purple' : 'text-tether-green'}>{token}</span></div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<div className="bg-sol-dark/50 rounded-xl p-4 space-y-3 text-sm">
|
| 127 |
+
<div className="flex justify-between"><span className="text-sol-muted">To</span><span className="font-mono text-xs">{to.slice(0, 10)}...{to.slice(-6)}</span></div>
|
| 128 |
+
<div className="divider" />
|
| 129 |
+
<div className="flex justify-between"><span className="text-sol-muted">Network Fee</span><span className="text-xs">~0.000005 SOL</span></div>
|
| 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 |
+
<div className="bg-warning/6 border border-warning/15 rounded-xl p-3 flex items-start gap-2.5">
|
| 134 |
+
<span className="text-warning text-sm mt-0.5">⚠</span>
|
| 135 |
+
<div>
|
| 136 |
+
<div className="text-xs font-semibold text-warning">Verify carefully</div>
|
| 137 |
+
<div className="text-[11px] text-sol-muted mt-0.5">Blockchain transactions are irreversible. Double-check the address.</div>
|
| 138 |
</div>
|
| 139 |
</div>
|
| 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-ghost flex-1 py-3">Cancel</button>
|
| 145 |
+
<button onClick={send} disabled={loading} className="btn-primary flex-1 py-3 text-white disabled:opacity-50">
|
| 146 |
+
<span>{loading ? 'Sending...' : `Send ${token}`}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
</button>
|
| 148 |
</div>
|
| 149 |
</div>
|
| 150 |
)}
|
| 151 |
|
| 152 |
{step === 'result' && success && (
|
| 153 |
+
<div className="glass rounded-3xl p-8 text-center space-y-5 scale-in">
|
| 154 |
+
<div className="w-16 h-16 mx-auto rounded-full bg-sol-green/15 flex items-center justify-center">
|
| 155 |
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#14F195" strokeWidth="2.5" strokeLinecap="round"><polyline points="20 6 9 17 4 12"/></svg>
|
| 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="bg-sol-dark/50 rounded-xl p-3 font-mono text-[11px] text-sol-muted break-all">{success.signature}</div>
|
| 162 |
+
<a href={success.explorer} target="_blank" rel="noopener noreferrer" className="text-sol-purple text-xs hover:underline inline-flex items-center gap-1">
|
| 163 |
+
View on Solscan <span>↗</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
</a>
|
| 165 |
+
<button onClick={reset} className="btn-primary w-full py-3 text-white"><span>Send Another</span></button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
</div>
|
| 167 |
)}
|
| 168 |
</div>
|
src/renderer/pages/SettingsPage.tsx
CHANGED
|
@@ -1,113 +1,104 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
|
| 3 |
-
interface SettingsPageProps {
|
| 4 |
-
onLock: () => void;
|
| 5 |
-
}
|
| 6 |
|
| 7 |
export default function SettingsPage({ onLock }: SettingsPageProps) {
|
| 8 |
const [network, setNetwork] = useState('devnet');
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
return (
|
| 11 |
-
<div className="space-y-
|
| 12 |
-
<h2 className="text-
|
| 13 |
|
| 14 |
{/* Network */}
|
| 15 |
-
<div className="glass rounded-2xl p-
|
| 16 |
-
<
|
| 17 |
-
<div className="flex gap-2">
|
| 18 |
-
{[
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
}`}
|
| 27 |
-
|
| 28 |
-
{
|
| 29 |
</button>
|
| 30 |
))}
|
| 31 |
</div>
|
| 32 |
-
<p className="text-
|
| 33 |
-
{network === 'devnet' && '⚠
|
| 34 |
-
{network === 'mainnet-beta' && '🔴 Mainnet — real tokens
|
| 35 |
-
{network === 'testnet' && '⚠
|
| 36 |
</p>
|
| 37 |
</div>
|
| 38 |
|
| 39 |
{/* AI Models */}
|
| 40 |
-
<div className="glass rounded-2xl p-
|
| 41 |
-
<
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
</
|
| 45 |
-
<div className="space-y-
|
| 46 |
-
{
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
<div className="text-sm font-semibold">{m.name}</div>
|
| 57 |
-
<div className="text-xs text-sol-muted">{m.model}</div>
|
| 58 |
-
<div className="text-xs text-sol-muted font-mono">{m.pkg}</div>
|
| 59 |
-
</div>
|
| 60 |
-
<div className="text-right">
|
| 61 |
-
<div className="text-xs text-sol-muted">{m.size}</div>
|
| 62 |
</div>
|
|
|
|
| 63 |
</div>
|
| 64 |
))}
|
| 65 |
</div>
|
| 66 |
-
<div className="
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
</p>
|
| 71 |
</div>
|
| 72 |
</div>
|
| 73 |
|
| 74 |
{/* About */}
|
| 75 |
-
<div className="glass rounded-2xl p-
|
| 76 |
-
<
|
| 77 |
-
<div className="space-y-2 text-
|
| 78 |
-
<p><strong className="text-sol-text">SolVox</strong>
|
| 79 |
-
<p>
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
<span className="px-2 py-0.5 bg-sol-dark rounded text-xs">Electron</span>
|
| 85 |
-
<span className="px-2 py-0.5 bg-sol-dark rounded text-xs">React</span>
|
| 86 |
-
<span className="px-2 py-0.5 bg-sol-dark rounded text-xs">TypeScript</span>
|
| 87 |
-
<span className="px-2 py-0.5 bg-sol-dark rounded text-xs">QVAC SDK</span>
|
| 88 |
-
<span className="px-2 py-0.5 bg-sol-dark rounded text-xs">Solana</span>
|
| 89 |
-
<span className="px-2 py-0.5 bg-sol-dark rounded text-xs">Vulkan</span>
|
| 90 |
</div>
|
| 91 |
</div>
|
| 92 |
-
<div className="
|
| 93 |
-
|
|
|
|
| 94 |
</div>
|
| 95 |
</div>
|
| 96 |
|
| 97 |
{/* Danger Zone */}
|
| 98 |
-
<div className="rounded-2xl border
|
| 99 |
-
<
|
| 100 |
-
<
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
</button>
|
| 107 |
-
<p className="text-xs text-sol-muted text-center">
|
| 108 |
-
Locking zeroes your private key from memory. You'll need your PIN to unlock.
|
| 109 |
-
</p>
|
| 110 |
-
</div>
|
| 111 |
</div>
|
| 112 |
</div>
|
| 113 |
);
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
|
| 3 |
+
interface SettingsPageProps { onLock: () => void; }
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export default function SettingsPage({ onLock }: SettingsPageProps) {
|
| 6 |
const [network, setNetwork] = useState('devnet');
|
| 7 |
|
| 8 |
+
const models = [
|
| 9 |
+
{ name: 'LLM', model: 'Llama 3.2 3B Instruct', quant: 'Q4_K_M', size: '2.0 GB', pkg: 'llm-llamacpp', color: '#9945FF' },
|
| 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="space-y-5 max-w-2xl page-enter">
|
| 19 |
+
<h2 className="text-xl font-bold">Settings</h2>
|
| 20 |
|
| 21 |
{/* Network */}
|
| 22 |
+
<div className="glass rounded-2xl p-5">
|
| 23 |
+
<div className="text-xs text-sol-muted font-semibold uppercase tracking-wider mb-3">Network</div>
|
| 24 |
+
<div className="flex gap-2 p-1 bg-sol-dark/60 rounded-xl">
|
| 25 |
+
{[
|
| 26 |
+
{ id: 'devnet', label: 'Devnet', color: 'warning' },
|
| 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-[10px] text-sol-muted mt-2">
|
| 40 |
+
{network === 'devnet' && '⚠ Devnet — test tokens only'}
|
| 41 |
+
{network === 'mainnet-beta' && '🔴 Mainnet — real tokens, irreversible'}
|
| 42 |
+
{network === 'testnet' && '⚠ Testnet — development only'}
|
| 43 |
</p>
|
| 44 |
</div>
|
| 45 |
|
| 46 |
{/* AI Models */}
|
| 47 |
+
<div className="glass rounded-2xl p-5">
|
| 48 |
+
<div className="flex items-center justify-between mb-4">
|
| 49 |
+
<div className="text-xs text-sol-muted font-semibold uppercase tracking-wider">QVAC AI Models</div>
|
| 50 |
+
<div className="badge badge-tether text-[10px]">6 modules • ~2.6 GB</div>
|
| 51 |
+
</div>
|
| 52 |
+
<div className="space-y-2 stagger">
|
| 53 |
+
{models.map(m => (
|
| 54 |
+
<div key={m.name} className="flex items-center gap-3 bg-sol-dark/40 rounded-xl p-3 hover:bg-sol-dark/60 transition-colors">
|
| 55 |
+
<div className="w-1 h-10 rounded-full" style={{ background: m.color }} />
|
| 56 |
+
<div className="flex-1 min-w-0">
|
| 57 |
+
<div className="flex items-center gap-2">
|
| 58 |
+
<span className="text-xs font-semibold text-sol-text">{m.name}</span>
|
| 59 |
+
<span className="badge badge-muted text-[9px]">{m.quant}</span>
|
| 60 |
+
</div>
|
| 61 |
+
<div className="text-[10px] text-sol-muted">{m.model}</div>
|
| 62 |
+
<div className="text-[9px] text-sol-muted/60 font-mono">@qvac/{m.pkg}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</div>
|
| 64 |
+
<div className="text-[10px] text-sol-muted font-medium">{m.size}</div>
|
| 65 |
</div>
|
| 66 |
))}
|
| 67 |
</div>
|
| 68 |
+
<div className="divider my-3" />
|
| 69 |
+
<div className="flex items-center gap-2 text-[10px] text-sol-muted">
|
| 70 |
+
<div className="w-1.5 h-1.5 rounded-full bg-tether-green" />
|
| 71 |
+
All models run locally via QVAC Fabric (Vulkan GPU) — no CUDA required
|
|
|
|
| 72 |
</div>
|
| 73 |
</div>
|
| 74 |
|
| 75 |
{/* About */}
|
| 76 |
+
<div className="glass rounded-2xl p-5">
|
| 77 |
+
<div className="text-xs text-sol-muted font-semibold uppercase tracking-wider mb-3">About SolVox</div>
|
| 78 |
+
<div className="space-y-2 text-xs text-sol-muted">
|
| 79 |
+
<p><strong className="text-sol-text">SolVox</strong> — voice-first, privacy-preserving AI wallet for Solana.</p>
|
| 80 |
+
<p>Powered by <strong className="text-tether-green">Tether QVAC SDK</strong> — local-first AI that never phones home.</p>
|
| 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="divider my-3" />
|
| 88 |
+
<div className="text-[10px] text-sol-muted/60">
|
| 89 |
+
Built for Colosseum Frontier Hackathon • Tether QVAC Track
|
| 90 |
</div>
|
| 91 |
</div>
|
| 92 |
|
| 93 |
{/* Danger Zone */}
|
| 94 |
+
<div className="rounded-2xl border border-danger/20 p-5 bg-danger/[0.02]">
|
| 95 |
+
<div className="text-xs text-danger font-semibold uppercase tracking-wider mb-3">Danger Zone</div>
|
| 96 |
+
<button onClick={onLock}
|
| 97 |
+
className="w-full py-3 rounded-xl border border-danger/30 text-danger text-sm font-medium hover:bg-danger/8 transition-all flex items-center justify-center gap-2">
|
| 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 |
);
|
src/renderer/pages/VoicePage.tsx
CHANGED
|
@@ -1,298 +1,236 @@
|
|
| 1 |
-
import React, { useState, useRef,
|
|
|
|
| 2 |
|
| 3 |
-
interface VoicePageProps {
|
| 4 |
-
|
| 5 |
-
}
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
| 14 |
|
| 15 |
export default function VoicePage({ aiStatus }: VoicePageProps) {
|
| 16 |
const [messages, setMessages] = useState<Message[]>([
|
| 17 |
-
{
|
| 18 |
-
id: '0',
|
| 19 |
-
role: 'assistant',
|
| 20 |
-
text: 'Hello! I\'m your SolVox AI assistant. I can help you send SOL and USDT, check your balance, view transactions, and more. Try saying "What is my balance?" or type a command below.',
|
| 21 |
-
timestamp: new Date(),
|
| 22 |
-
},
|
| 23 |
]);
|
| 24 |
const [input, setInput] = useState('');
|
| 25 |
const [isRecording, setIsRecording] = useState(false);
|
| 26 |
const [isProcessing, setIsProcessing] = useState(false);
|
| 27 |
-
const [waveformData, setWaveformData] = useState<number[]>(new Array(
|
|
|
|
| 28 |
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
| 29 |
const audioChunks = useRef<Blob[]>([]);
|
| 30 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 31 |
const analyserRef = useRef<AnalyserNode | null>(null);
|
| 32 |
const animFrameRef = useRef<number | null>(null);
|
|
|
|
| 33 |
|
| 34 |
-
useEffect(() => {
|
| 35 |
-
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 36 |
-
}, [messages]);
|
| 37 |
|
| 38 |
-
// ── Voice Recording ──
|
| 39 |
const startRecording = async () => {
|
| 40 |
try {
|
| 41 |
-
const stream = await navigator.mediaDevices.getUserMedia({
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
},
|
| 48 |
-
});
|
| 49 |
-
|
| 50 |
-
// Waveform visualization
|
| 51 |
-
const audioContext = new AudioContext();
|
| 52 |
-
const source = audioContext.createMediaStreamSource(stream);
|
| 53 |
-
const analyser = audioContext.createAnalyser();
|
| 54 |
-
analyser.fftSize = 64;
|
| 55 |
-
source.connect(analyser);
|
| 56 |
analyserRef.current = analyser;
|
| 57 |
|
| 58 |
-
const
|
| 59 |
if (!analyserRef.current) return;
|
| 60 |
-
const
|
| 61 |
-
analyserRef.current.getByteFrequencyData(
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
animFrameRef.current = requestAnimationFrame(updateWaveform);
|
| 65 |
};
|
| 66 |
-
|
| 67 |
|
| 68 |
-
const
|
| 69 |
audioChunks.current = [];
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
if (e.data.size > 0) audioChunks.current.push(e.data);
|
| 73 |
-
};
|
| 74 |
-
|
| 75 |
-
recorder.onstop = async () => {
|
| 76 |
stream.getTracks().forEach(t => t.stop());
|
| 77 |
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
| 78 |
-
|
| 79 |
-
|
|
|
|
| 80 |
const blob = new Blob(audioChunks.current, { type: 'audio/webm' });
|
| 81 |
-
|
| 82 |
-
processVoice(buffer);
|
| 83 |
};
|
| 84 |
|
| 85 |
-
mediaRecorder.current =
|
| 86 |
-
|
| 87 |
setIsRecording(true);
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
}
|
| 91 |
};
|
| 92 |
|
| 93 |
const stopRecording = () => {
|
| 94 |
-
if (mediaRecorder.current && isRecording) {
|
| 95 |
-
mediaRecorder.current.stop();
|
| 96 |
-
setIsRecording(false);
|
| 97 |
-
}
|
| 98 |
};
|
| 99 |
|
| 100 |
-
|
| 101 |
-
const processVoice = async (audioData: ArrayBuffer) => {
|
| 102 |
setIsProcessing(true);
|
| 103 |
try {
|
| 104 |
if (window.solvox) {
|
| 105 |
-
const
|
| 106 |
-
if (
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
}
|
| 114 |
-
} else {
|
| 115 |
-
addMessage('system', `Voice processing failed: ${result.error}`);
|
| 116 |
-
}
|
| 117 |
-
} else {
|
| 118 |
-
addMessage('user', '[voice input - dev mode]');
|
| 119 |
-
addMessage('assistant', 'Voice processing requires QVAC models. In dev mode, use text commands.');
|
| 120 |
-
}
|
| 121 |
-
} catch (err: any) {
|
| 122 |
-
addMessage('system', `Error: ${err.message}`);
|
| 123 |
-
}
|
| 124 |
setIsProcessing(false);
|
| 125 |
};
|
| 126 |
|
| 127 |
-
|
| 128 |
-
const handleSendMessage = async () => {
|
| 129 |
if (!input.trim()) return;
|
| 130 |
const text = input.trim();
|
| 131 |
setInput('');
|
| 132 |
-
|
| 133 |
-
addMessage('user', text);
|
| 134 |
setIsProcessing(true);
|
| 135 |
-
|
| 136 |
try {
|
| 137 |
if (window.solvox) {
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
// Then get AI response
|
| 143 |
-
const chatResult = await window.solvox.ai.chat(text);
|
| 144 |
-
if (chatResult.success) {
|
| 145 |
-
addMessage('assistant', chatResult.response!, intent);
|
| 146 |
-
} else {
|
| 147 |
-
addMessage('assistant', chatResult.error || 'Sorry, I could not process that.');
|
| 148 |
-
}
|
| 149 |
-
} else {
|
| 150 |
-
// Dev mode fallback
|
| 151 |
-
addMessage('assistant', `[Dev mode] You said: "${text}". QVAC models needed for AI responses.`);
|
| 152 |
-
}
|
| 153 |
-
} catch (err: any) {
|
| 154 |
-
addMessage('system', `Error: ${err.message}`);
|
| 155 |
-
}
|
| 156 |
setIsProcessing(false);
|
| 157 |
};
|
| 158 |
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
setMessages(prev => [...prev, {
|
| 162 |
-
id: Date.now().toString(),
|
| 163 |
-
role,
|
| 164 |
-
text,
|
| 165 |
-
intent,
|
| 166 |
-
timestamp: new Date(),
|
| 167 |
-
}]);
|
| 168 |
};
|
| 169 |
|
| 170 |
-
const playAudio = (
|
| 171 |
-
try {
|
| 172 |
-
const blob = new Blob([audioData], { type: 'audio/wav' });
|
| 173 |
-
const url = URL.createObjectURL(blob);
|
| 174 |
-
const audio = new Audio(url);
|
| 175 |
-
audio.play().catch(() => {});
|
| 176 |
-
audio.onended = () => URL.revokeObjectURL(url);
|
| 177 |
-
} catch {}
|
| 178 |
};
|
| 179 |
|
|
|
|
|
|
|
| 180 |
return (
|
| 181 |
-
<div className="flex flex-col h-full
|
| 182 |
{/* Header */}
|
| 183 |
<div className="flex items-center justify-between mb-4">
|
| 184 |
<div>
|
| 185 |
-
<h2 className="text-
|
| 186 |
-
<p className="text-
|
| 187 |
-
Powered by 6 QVAC packages • All local, all private
|
| 188 |
-
</p>
|
| 189 |
</div>
|
| 190 |
<div className="flex items-center gap-2">
|
| 191 |
-
{
|
| 192 |
-
<
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
) : (
|
| 196 |
-
<span className="px-3 py-1 rounded-full bg-warning/20 text-warning text-xs">
|
| 197 |
-
Loading Models...
|
| 198 |
-
</span>
|
| 199 |
-
)}
|
| 200 |
</div>
|
| 201 |
</div>
|
| 202 |
|
| 203 |
-
{/* Chat
|
| 204 |
-
<div className="flex-1 overflow-y-auto glass rounded-2xl p-4 mb-4 space-y-
|
| 205 |
{messages.map(msg => (
|
| 206 |
-
<div
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
msg.role === 'user'
|
| 212 |
-
? 'bg-sol-purple text-white'
|
| 213 |
-
: msg.role === 'system'
|
| 214 |
-
? 'bg-warning/10 text-warning border border-warning/30'
|
| 215 |
-
: 'bg-sol-dark border border-sol-border'
|
| 216 |
}`}>
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
</div>
|
| 225 |
</div>
|
| 226 |
)}
|
| 227 |
-
<div className="text-
|
| 228 |
-
{msg.timestamp.toLocaleTimeString()}
|
| 229 |
-
</div>
|
| 230 |
</div>
|
| 231 |
</div>
|
| 232 |
))}
|
| 233 |
{isProcessing && (
|
| 234 |
<div className="flex justify-start">
|
| 235 |
-
<div className="bg-sol-dark border border-sol-border rounded-2xl px-
|
| 236 |
-
<
|
| 237 |
-
|
| 238 |
-
<div className="w-2 h-2 bg-sol-purple rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
| 239 |
-
<div className="w-2 h-2 bg-sol-purple rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
| 240 |
-
</div>
|
| 241 |
</div>
|
| 242 |
</div>
|
| 243 |
)}
|
| 244 |
<div ref={messagesEndRef} />
|
| 245 |
</div>
|
| 246 |
|
| 247 |
-
{/*
|
| 248 |
-
{isRecording && (
|
| 249 |
-
<div className="flex
|
| 250 |
-
{
|
| 251 |
-
<
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
/>
|
| 256 |
))}
|
| 257 |
</div>
|
| 258 |
)}
|
| 259 |
|
| 260 |
-
{/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
<div className="flex items-center gap-3">
|
| 262 |
-
{/* Voice Button */}
|
| 263 |
<button
|
| 264 |
-
onMouseDown={startRecording}
|
| 265 |
-
|
| 266 |
-
onMouseLeave={stopRecording}
|
| 267 |
-
onTouchStart={startRecording}
|
| 268 |
-
onTouchEnd={stopRecording}
|
| 269 |
disabled={isProcessing}
|
| 270 |
-
className={`w-
|
| 271 |
-
isRecording
|
| 272 |
-
|
| 273 |
-
: 'bg-sol-purple hover:bg-sol-purple/80 glow-purple'
|
| 274 |
-
} disabled:opacity-50`}
|
| 275 |
title="Hold to speak"
|
| 276 |
>
|
| 277 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
</button>
|
| 279 |
|
| 280 |
-
{/* Text Input */}
|
| 281 |
<div className="flex-1 relative">
|
| 282 |
-
<input
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
className="
|
| 288 |
-
|
| 289 |
-
/>
|
| 290 |
-
<button
|
| 291 |
-
onClick={handleSendMessage}
|
| 292 |
-
disabled={!input.trim() || isProcessing}
|
| 293 |
-
className="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1.5 rounded-lg bg-sol-purple text-white text-sm disabled:opacity-30 hover:bg-sol-purple/80 transition-all"
|
| 294 |
-
>
|
| 295 |
-
Send
|
| 296 |
</button>
|
| 297 |
</div>
|
| 298 |
</div>
|
|
|
|
| 1 |
+
import React, { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { OrbitLoader } from '../components/ui/index';
|
| 3 |
|
| 4 |
+
interface VoicePageProps { aiStatus: any; }
|
| 5 |
+
interface Message { id: string; role: 'user' | 'assistant' | 'system'; text: string; intent?: any; timestamp: Date; }
|
|
|
|
| 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 }: VoicePageProps) {
|
| 17 |
const [messages, setMessages] = useState<Message[]>([
|
| 18 |
+
{ id: '0', role: 'assistant', text: "Hey! I'm your SolVox AI assistant — running 100% locally on your device. I can help you send SOL & USDT, check balances, search transactions, and more.\n\nTry holding the mic button or type a command below.", timestamp: new Date() },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
]);
|
| 20 |
const [input, setInput] = useState('');
|
| 21 |
const [isRecording, setIsRecording] = useState(false);
|
| 22 |
const [isProcessing, setIsProcessing] = useState(false);
|
| 23 |
+
const [waveformData, setWaveformData] = useState<number[]>(new Array(48).fill(2));
|
| 24 |
+
const [recordingTime, setRecordingTime] = useState(0);
|
| 25 |
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
| 26 |
const audioChunks = useRef<Blob[]>([]);
|
| 27 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 28 |
const analyserRef = useRef<AnalyserNode | null>(null);
|
| 29 |
const animFrameRef = useRef<number | null>(null);
|
| 30 |
+
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
| 31 |
|
| 32 |
+
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
|
|
|
|
|
|
| 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 src = ctx.createMediaStreamSource(stream);
|
| 39 |
+
const analyser = ctx.createAnalyser();
|
| 40 |
+
analyser.fftSize = 128;
|
| 41 |
+
src.connect(analyser);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
analyserRef.current = analyser;
|
| 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 stopRecording = () => {
|
| 75 |
+
if (mediaRecorder.current && isRecording) { mediaRecorder.current.stop(); setIsRecording(false); }
|
|
|
|
|
|
|
|
|
|
| 76 |
};
|
| 77 |
|
| 78 |
+
const processVoice = async (data: ArrayBuffer) => {
|
|
|
|
| 79 |
setIsProcessing(true);
|
| 80 |
try {
|
| 81 |
if (window.solvox) {
|
| 82 |
+
const r = await window.solvox.ai.processVoice(data);
|
| 83 |
+
if (r.success) {
|
| 84 |
+
addMsg('user', r.transcription || '[voice]');
|
| 85 |
+
addMsg('assistant', r.response || 'Done.', r.intent);
|
| 86 |
+
if (r.audio) playAudio(r.audio);
|
| 87 |
+
} else { addMsg('system', `Voice processing failed: ${r.error}`); }
|
| 88 |
+
} else { addMsg('user', '[voice — dev mode]'); addMsg('assistant', 'QVAC models needed for voice. Type commands below.'); }
|
| 89 |
+
} catch (e: any) { addMsg('system', e.message); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
setIsProcessing(false);
|
| 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 [intentR, chatR] = await Promise.all([window.solvox.ai.parseIntent(text), window.solvox.ai.chat(text)]);
|
| 102 |
+
addMsg('assistant', chatR.success ? chatR.response! : 'Sorry, could not process that.', intentR.success ? intentR.intent : undefined);
|
| 103 |
+
} else { addMsg('assistant', `[Dev mode] "${text}" — QVAC models needed.`); }
|
| 104 |
+
} catch (e: any) { addMsg('system', e.message); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
setIsProcessing(false);
|
| 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 playAudio = (d: ArrayBuffer) => {
|
| 113 |
+
try { const u = URL.createObjectURL(new Blob([d], { type: 'audio/wav' })); const a = new Audio(u); a.play().catch(() => {}); a.onended = () => URL.revokeObjectURL(u); } catch {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
};
|
| 115 |
|
| 116 |
+
const activeModules = aiStatus ? [aiStatus.llm, aiStatus.transcription, aiStatus.tts, aiStatus.embed, aiStatus.translation, aiStatus.ocr].filter(Boolean).length : 0;
|
| 117 |
+
|
| 118 |
return (
|
| 119 |
+
<div className="flex flex-col h-full page-enter">
|
| 120 |
{/* Header */}
|
| 121 |
<div className="flex items-center justify-between mb-4">
|
| 122 |
<div>
|
| 123 |
+
<h2 className="text-xl font-bold text-sol-text">Voice AI Assistant</h2>
|
| 124 |
+
<p className="text-[11px] text-sol-muted">Powered by 6 QVAC packages • All local, all private</p>
|
|
|
|
|
|
|
| 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 Area */}
|
| 135 |
+
<div className="flex-1 overflow-y-auto chat-scroll glass rounded-2xl p-4 mb-4 space-y-3">
|
| 136 |
{messages.map(msg => (
|
| 137 |
+
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} slide-enter`}>
|
| 138 |
+
<div className={`max-w-[78%] rounded-2xl px-4 py-3 ${
|
| 139 |
+
msg.role === 'user' ? 'bg-gradient-to-br from-sol-purple to-sol-purple-light text-white shadow-glow-sm' :
|
| 140 |
+
msg.role === 'system' ? 'bg-warning/8 text-warning border border-warning/20' :
|
| 141 |
+
'bg-sol-dark/60 border border-sol-border/40'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
}`}>
|
| 143 |
+
{msg.role === 'assistant' && (
|
| 144 |
+
<div className="flex items-center gap-1.5 mb-1.5">
|
| 145 |
+
<div className="w-4 h-4 rounded bg-tether-green/20 flex items-center justify-center">
|
| 146 |
+
<span className="text-[8px] text-tether-green font-bold">Q</span>
|
| 147 |
+
</div>
|
| 148 |
+
<span className="text-[10px] text-sol-muted font-medium">SolVox AI</span>
|
| 149 |
+
</div>
|
| 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 |
+
<div className="text-[10px] opacity-40 mt-1.5">{msg.timestamp.toLocaleTimeString()}</div>
|
|
|
|
|
|
|
| 163 |
</div>
|
| 164 |
</div>
|
| 165 |
))}
|
| 166 |
{isProcessing && (
|
| 167 |
<div className="flex justify-start">
|
| 168 |
+
<div className="bg-sol-dark/60 border border-sol-border/40 rounded-2xl px-5 py-4 flex items-center gap-3">
|
| 169 |
+
<OrbitLoader size={24} />
|
| 170 |
+
<span className="text-xs text-sol-muted">Processing locally...</span>
|
|
|
|
|
|
|
|
|
|
| 171 |
</div>
|
| 172 |
</div>
|
| 173 |
)}
|
| 174 |
<div ref={messagesEndRef} />
|
| 175 |
</div>
|
| 176 |
|
| 177 |
+
{/* Suggestion Chips */}
|
| 178 |
+
{messages.length <= 2 && !isRecording && (
|
| 179 |
+
<div className="flex flex-wrap gap-2 mb-3 fade-in">
|
| 180 |
+
{SUGGESTIONS.slice(0, 4).map(s => (
|
| 181 |
+
<button key={s} onClick={() => { setInput(s); }}
|
| 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 (recording) */}
|
| 190 |
+
{isRecording && (
|
| 191 |
+
<div className="mb-3 fade-in">
|
| 192 |
+
<div className="glass rounded-xl p-3">
|
| 193 |
+
<div className="flex items-center justify-center gap-[2px] h-10">
|
| 194 |
+
{waveformData.map((h, i) => (
|
| 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 Bar */}
|
| 208 |
<div className="flex items-center gap-3">
|
|
|
|
| 209 |
<button
|
| 210 |
+
onMouseDown={startRecording} onMouseUp={stopRecording} onMouseLeave={stopRecording}
|
| 211 |
+
onTouchStart={startRecording} onTouchEnd={stopRecording}
|
|
|
|
|
|
|
|
|
|
| 212 |
disabled={isProcessing}
|
| 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 |
+
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
| 229 |
+
placeholder={isRecording ? 'Listening...' : 'Type a command or hold mic to speak...'}
|
| 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>
|
tailwind.config.js
CHANGED
|
@@ -6,43 +6,82 @@ module.exports = {
|
|
| 6 |
extend: {
|
| 7 |
colors: {
|
| 8 |
'sol-purple': '#9945FF',
|
|
|
|
| 9 |
'sol-green': '#14F195',
|
| 10 |
-
'sol-dark': '#
|
| 11 |
-
'sol-
|
| 12 |
-
'sol-
|
| 13 |
-
'sol-
|
| 14 |
-
'sol-
|
|
|
|
|
|
|
| 15 |
'tether-green': '#26A17B',
|
|
|
|
| 16 |
'danger': '#FF4466',
|
|
|
|
| 17 |
'warning': '#FFB830',
|
|
|
|
| 18 |
},
|
| 19 |
fontFamily: {
|
| 20 |
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
| 21 |
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
| 22 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
animation: {
|
| 24 |
-
'pulse-glow': 'pulse-glow
|
| 25 |
'waveform': 'waveform 0.5s ease-in-out infinite alternate',
|
| 26 |
-
'slide-up': 'slide-up 0.
|
| 27 |
-
'fade-in': '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
},
|
| 29 |
keyframes: {
|
| 30 |
'pulse-glow': {
|
| 31 |
-
'0%, 100%': { boxShadow: '0 0 20px rgba(153, 69, 255, 0.
|
| 32 |
-
'50%': { boxShadow: '0 0
|
| 33 |
},
|
| 34 |
'waveform': {
|
| 35 |
-
'0%': { height: '
|
| 36 |
-
'100%': { height: '
|
| 37 |
},
|
| 38 |
'slide-up': {
|
| 39 |
-
'0%': { transform: 'translateY(
|
| 40 |
'100%': { transform: 'translateY(0)', opacity: '1' },
|
| 41 |
},
|
| 42 |
-
'
|
| 43 |
'0%': { opacity: '0' },
|
| 44 |
'100%': { opacity: '1' },
|
| 45 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
},
|
| 47 |
},
|
| 48 |
},
|
|
|
|
| 6 |
extend: {
|
| 7 |
colors: {
|
| 8 |
'sol-purple': '#9945FF',
|
| 9 |
+
'sol-purple-light': '#B06CFF',
|
| 10 |
'sol-green': '#14F195',
|
| 11 |
+
'sol-dark': '#0A0A1E',
|
| 12 |
+
'sol-darker': '#060612',
|
| 13 |
+
'sol-card': '#141432',
|
| 14 |
+
'sol-card-hover': '#1A1A3E',
|
| 15 |
+
'sol-border': '#1E1E48',
|
| 16 |
+
'sol-text': '#E8E8FF',
|
| 17 |
+
'sol-muted': '#7878AA',
|
| 18 |
'tether-green': '#26A17B',
|
| 19 |
+
'tether-dark': '#1D7D5F',
|
| 20 |
'danger': '#FF4466',
|
| 21 |
+
'danger-dark': '#CC3652',
|
| 22 |
'warning': '#FFB830',
|
| 23 |
+
'info': '#3B82F6',
|
| 24 |
},
|
| 25 |
fontFamily: {
|
| 26 |
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
| 27 |
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
| 28 |
},
|
| 29 |
+
borderRadius: {
|
| 30 |
+
'2xl': '16px',
|
| 31 |
+
'3xl': '20px',
|
| 32 |
+
'4xl': '24px',
|
| 33 |
+
},
|
| 34 |
+
boxShadow: {
|
| 35 |
+
'glow-sm': '0 0 12px rgba(153, 69, 255, 0.15)',
|
| 36 |
+
'glow-md': '0 0 24px rgba(153, 69, 255, 0.2)',
|
| 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 |
keyframes: {
|
| 53 |
'pulse-glow': {
|
| 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 |
},
|