solvox / src /renderer /components /ui /index.tsx
muthuk1's picture
πŸš€ Final: +ContactsPage +ScanPage +Sparklines, types.ts synced, TS 0 errors, Coinbase design, complete README
9ff7e0c verified
import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
/* ═══ TOAST SYSTEM ═══════════════════════════════════════════════════ */
type ToastType = 'success' | 'error' | 'info';
interface Toast { id: string; type: ToastType; title: string; message?: string; }
const ToastContext = createContext<{ addToast: (t: Omit<Toast, 'id'>) => void }>({ addToast: () => {} });
export const useToast = () => useContext(ToastContext);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((t: Omit<Toast, 'id'>) => {
const id = Date.now().toString(36);
setToasts(p => [...p, { ...t, id }]);
setTimeout(() => setToasts(p => p.filter(x => x.id !== id)), 4000);
}, []);
return (
<ToastContext.Provider value={{ addToast }}>
{children}
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
{toasts.map(t => (
<div key={t.id} className="bg-canvas rounded-xl p-4 shadow-soft border border-hairline min-w-[300px] page-enter cursor-pointer" onClick={() => setToasts(p => p.filter(x => x.id !== t.id))}>
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${t.type === 'success' ? 'bg-semantic-up' : t.type === 'error' ? 'bg-semantic-down' : 'bg-primary'}`} />
<div>
<div className="text-title-sm text-ink">{t.title}</div>
{t.message && <div className="text-body-sm text-body mt-0.5">{t.message}</div>}
</div>
</div>
</div>
))}
</div>
</ToastContext.Provider>
);
}
/* ═══ ANIMATED NUMBER (CoinbaseMono) ═════════════════════════════════ */
export function Num({ value, decimals = 2, prefix = '', suffix = '' }: { value: number; decimals?: number; prefix?: string; suffix?: string }) {
const [display, setDisplay] = useState(value);
useEffect(() => {
const diff = value - display;
if (Math.abs(diff) < 0.0001) { setDisplay(value); return; }
let step = 0;
const interval = setInterval(() => {
step++;
setDisplay(display + diff * (1 - Math.pow(1 - step / 16, 3)));
if (step >= 16) { setDisplay(value); clearInterval(interval); }
}, 30);
return () => clearInterval(interval);
}, [value]);
return <span className="number-mono">{prefix}{display.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}{suffix}</span>;
}
/* ═══ ASSET ROW ══════════════════════════════════════════════════════ */
export function AssetRow({ icon, name, ticker, price, change, onClick }: {
icon: string; name: string; ticker: string; price: number; change?: number; onClick?: () => void;
}) {
return (
<div className="asset-row px-4 cursor-pointer" onClick={onClick}>
<div className="asset-icon mr-3 text-sm font-bold text-ink">{icon}</div>
<div className="flex-1">
<div className="text-title-sm text-ink">{name}</div>
<div className="text-caption text-muted">{ticker}</div>
</div>
<div className="text-right">
<div className="number-mono text-number-display text-ink">${price.toLocaleString(undefined, { minimumFractionDigits: 2 })}</div>
{change !== undefined && (
<div className={`number-mono text-caption ${change >= 0 ? 'text-semantic-up' : 'text-semantic-down'}`}>
{change >= 0 ? '+' : ''}{change.toFixed(2)}%
</div>
)}
</div>
</div>
);
}
/* ═══ PRODUCT UI CARD (Dark) ═════════════════════════════════════════ */
export function ProductCard({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return <div className={`card-dark ${className}`}>{children}</div>;
}
/* ═══ STEP INDICATOR ═════════════════════════════════════════════════ */
export function StepIndicator({ steps, current }: { steps: string[]; current: number }) {
return (
<div className="flex items-center justify-center gap-3 mb-12">
{steps.map((label, i) => (
<React.Fragment key={i}>
<div className="flex flex-col items-center gap-1.5">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-caption-strong transition-colors ${
i < current ? 'bg-primary text-on-primary' :
i === current ? 'bg-primary text-on-primary' :
'bg-surface-strong text-muted'
}`}>
{i < current ? 'βœ“' : i + 1}
</div>
<span className={`text-caption ${i <= current ? 'text-ink' : 'text-muted'}`}>{label}</span>
</div>
{i < steps.length - 1 && (
<div className={`w-10 h-0.5 -mt-5 rounded-full ${i < current ? 'bg-primary' : 'bg-surface-strong'}`} />
)}
</React.Fragment>
))}
</div>
);
}
/* ═══ PIPELINE TRACE (shows which QVAC module did what) ══════════════ */
export function PipelineTrace({ steps }: { steps: Array<{ module: string; operation: string; input: string; output: string; durationMs: number }> }) {
if (!steps || steps.length === 0) return null;
return (
<div className="bg-surface-soft rounded-xl p-4 mt-3">
<div className="text-caption-strong text-muted uppercase tracking-wider mb-2">QVAC Pipeline Trace</div>
<div className="space-y-1.5">
{steps.map((s, i) => (
<div key={i} className="flex items-start gap-2 text-caption">
<div className="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 shrink-0" />
<div className="flex-1 min-w-0">
<span className="font-mono text-primary text-caption-strong">{s.module}</span>
<span className="text-muted mx-1">β†’</span>
<span className="text-body">{s.operation}</span>
<span className="text-muted-soft ml-1 number-mono">{s.durationMs}ms</span>
</div>
</div>
))}
</div>
</div>
);
}
/* ═══ RISK BADGE ═════════════════════════════════════════════════════ */
export function RiskBadge({ level, score }: { level: string; score: number }) {
const colors: Record<string, string> = { safe: 'badge-pill-green', caution: 'badge-pill', warning: 'badge-pill-red', danger: 'badge-pill-red' };
return <span className={`badge-pill ${colors[level] || 'badge-pill'}`}>{level.toUpperCase()} Β· {score}</span>;
}
/* ═══ SPARKLINE CHART (pure SVG β€” no library) ════════════════════════ */
export function Sparkline({ data, width = 120, height = 32, color = '#05b169' }: {
data: number[]; width?: number; height?: number; color?: string;
}) {
if (data.length < 2) return <div style={{ width, height }} />;
const min = Math.min(...data); const max = Math.max(...data); const range = max - min || 1;
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - ((v - min) / range) * (height - 4) - 2;
return `${x},${y}`;
}).join(' ');
const last = data[data.length - 1]; const prev = data[data.length - 2];
const trending = last >= prev;
const c = trending ? '#05b169' : '#cf202f';
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible">
<polyline fill="none" stroke={c} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" points={points} />
<circle cx={(data.length - 1) / (data.length - 1) * width} cy={height - ((last - min) / range) * (height - 4) - 2} r="2" fill={c} />
</svg>
);
}
/* ═══ KEYBOARD SHORTCUT HINT ═════════════════════════════════════════ */
export function Kbd({ children }: { children: string }) {
return <kbd className="inline-flex items-center px-1.5 py-0.5 bg-surface-strong text-muted rounded-xs text-[10px] font-mono border border-hairline">{children}</kbd>;
}