feat: implement ADR-053 design system across all frontend components
Create design-system.css with all ADR-053 tokens: - CSS custom properties: colors, spacing, fonts, panel dimensions - Typography scale classes (heading-xl through data-lg) - Form control and button base styles - Custom scrollbar, selection highlight, animations Update all components to use design system tokens: - Replace hardcoded colors with var(--bg-surface), var(--border), etc. - Replace generic monospace with var(--font-mono) (JetBrains Mono) - Replace system font stack with var(--font-sans) (Inter) - Replace spacing values with var(--space-N) tokens - StatusBadge: use var(--status-online/warning/error/info) - Dashboard: add stat cards with data-lg class, use StatusBadge - FlashFirmware: pulse animation on progress bar during writes - Settings: default bind_address 127.0.0.1 (matches ADR-050) Add status bar footer with "Powered by rUv", node count, server status. Load Inter + JetBrains Mono from Google Fonts in index.html. Update ADR-053 status from Proposed to Accepted. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
cab98df34a
commit
e75a3acacb
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| Status | Proposed |
|
| Status | Accepted |
|
||||||
| Date | 2026-03-06 |
|
| Date | 2026-03-06 |
|
||||||
| Deciders | ruv |
|
| Deciders | ruv |
|
||||||
| Depends on | ADR-052 (Tauri Desktop Frontend) |
|
| Depends on | ADR-052 (Tauri Desktop Frontend) |
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,9 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>RuView Desktop</title>
|
<title>RuView Desktop</title>
|
||||||
<style>
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
* {
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
margin: 0;
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
||||||
"Helvetica Neue", Arial, sans-serif;
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,9 @@ const NAV_ITEMS: NavItem[] = [
|
||||||
{ id: "nodes", label: "Nodes", shortcut: "N" },
|
{ id: "nodes", label: "Nodes", shortcut: "N" },
|
||||||
{ id: "flash", label: "Flash", shortcut: "F" },
|
{ id: "flash", label: "Flash", shortcut: "F" },
|
||||||
{ id: "ota", label: "OTA", shortcut: "O" },
|
{ id: "ota", label: "OTA", shortcut: "O" },
|
||||||
{ id: "wasm", label: "WASM", shortcut: "W" },
|
{ id: "wasm", label: "Edge Modules", shortcut: "W" },
|
||||||
{ id: "sensing", label: "Sensing", shortcut: "S" },
|
{ id: "sensing", label: "Sensing", shortcut: "S" },
|
||||||
{ id: "mesh", label: "Mesh", shortcut: "M" },
|
{ id: "mesh", label: "Mesh View", shortcut: "M" },
|
||||||
{ id: "settings", label: "Settings", shortcut: "G" },
|
{ id: "settings", label: "Settings", shortcut: "G" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -35,115 +35,178 @@ const App: React.FC = () => {
|
||||||
const [activePage, setActivePage] = useState<Page>("dashboard");
|
const [activePage, setActivePage] = useState<Page>("dashboard");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", height: "100vh" }}>
|
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
|
||||||
{/* Sidebar */}
|
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
||||||
<nav
|
{/* Sidebar */}
|
||||||
style={{
|
<nav
|
||||||
width: 200,
|
|
||||||
background: "#1e293b",
|
|
||||||
borderRight: "1px solid #334155",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
padding: "16px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
padding: "0 16px 16px",
|
width: "var(--sidebar-width)",
|
||||||
borderBottom: "1px solid #334155",
|
minWidth: "var(--sidebar-width)",
|
||||||
marginBottom: 8,
|
background: "var(--bg-surface)",
|
||||||
|
borderRight: "1px solid var(--border)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h1
|
{/* Brand */}
|
||||||
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 18,
|
padding: "var(--space-4)",
|
||||||
fontWeight: 700,
|
borderBottom: "1px solid var(--border)",
|
||||||
color: "#38bdf8",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
RuView
|
<h1
|
||||||
</h1>
|
|
||||||
<span style={{ fontSize: 11, color: "#64748b" }}>
|
|
||||||
WiFi DensePose Desktop
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{NAV_ITEMS.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => setActivePage(item.id)}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
width: "100%",
|
|
||||||
padding: "10px 16px",
|
|
||||||
border: "none",
|
|
||||||
background:
|
|
||||||
activePage === item.id ? "#334155" : "transparent",
|
|
||||||
color:
|
|
||||||
activePage === item.id ? "#f1f5f9" : "#94a3b8",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 14,
|
|
||||||
textAlign: "left",
|
|
||||||
borderLeft:
|
|
||||||
activePage === item.id
|
|
||||||
? "3px solid #38bdf8"
|
|
||||||
: "3px solid transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
style={{
|
||||||
width: 20,
|
fontSize: 18,
|
||||||
height: 20,
|
|
||||||
borderRadius: 4,
|
|
||||||
background:
|
|
||||||
activePage === item.id ? "#38bdf8" : "#475569",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color:
|
color: "var(--accent)",
|
||||||
activePage === item.id ? "#0f172a" : "#94a3b8",
|
fontFamily: "var(--font-sans)",
|
||||||
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.shortcut}
|
RuView
|
||||||
|
</h1>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
WiFi DensePose Desktop
|
||||||
</span>
|
</span>
|
||||||
{item.label}
|
</div>
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div style={{ flex: 1 }} />
|
{/* Nav items */}
|
||||||
<div
|
<div style={{ flex: 1, paddingTop: "var(--space-2)" }}>
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const isActive = activePage === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setActivePage(item.id)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "var(--space-2)",
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px var(--space-4)",
|
||||||
|
background: isActive ? "var(--bg-active)" : "transparent",
|
||||||
|
color: isActive ? "var(--text-primary)" : "var(--text-secondary)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
textAlign: "left",
|
||||||
|
borderLeft: isActive
|
||||||
|
? "3px solid var(--accent)"
|
||||||
|
: "3px solid transparent",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: isActive ? "var(--accent)" : "var(--bg-hover)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
color: isActive ? "#fff" : "var(--text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.shortcut}
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "var(--space-2) var(--space-4)",
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
v0.3.0
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main
|
||||||
style={{
|
style={{
|
||||||
padding: "8px 16px",
|
flex: 1,
|
||||||
fontSize: 11,
|
overflow: "auto",
|
||||||
color: "#475569",
|
background: "var(--bg-base)",
|
||||||
borderTop: "1px solid #334155",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
v0.3.0
|
{activePage === "dashboard" && <Dashboard />}
|
||||||
</div>
|
{activePage === "nodes" && <Nodes />}
|
||||||
</nav>
|
{activePage === "flash" && <FlashFirmware />}
|
||||||
|
{activePage === "settings" && <Settings />}
|
||||||
|
{!["dashboard", "nodes", "flash", "settings"].includes(activePage) && (
|
||||||
|
<div style={{ padding: "var(--space-5)" }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: "var(--space-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{NAV_ITEMS.find((n) => n.id === activePage)?.label}
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||||
|
This page is not yet implemented.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Status Bar */}
|
||||||
<main style={{ flex: 1, overflow: "auto", padding: 24 }}>
|
<footer
|
||||||
{activePage === "dashboard" && <Dashboard />}
|
style={{
|
||||||
{activePage === "nodes" && <Nodes />}
|
height: "var(--statusbar-height)",
|
||||||
{activePage === "flash" && <FlashFirmware />}
|
minHeight: "var(--statusbar-height)",
|
||||||
{activePage === "settings" && <Settings />}
|
background: "var(--bg-surface)",
|
||||||
{!["dashboard", "nodes", "flash", "settings"].includes(activePage) && (
|
borderTop: "1px solid var(--border)",
|
||||||
<div>
|
display: "flex",
|
||||||
<h2 style={{ fontSize: 24, marginBottom: 8 }}>
|
alignItems: "center",
|
||||||
{NAV_ITEMS.find((n) => n.id === activePage)?.label}
|
padding: "0 var(--space-4)",
|
||||||
</h2>
|
gap: "var(--space-4)",
|
||||||
<p style={{ color: "#64748b" }}>
|
fontSize: 11,
|
||||||
This page is not yet implemented.
|
fontFamily: "var(--font-sans)",
|
||||||
</p>
|
color: "var(--text-muted)",
|
||||||
</div>
|
}}
|
||||||
)}
|
>
|
||||||
</main>
|
<span style={{ color: "var(--text-muted)" }}>Powered by rUv</span>
|
||||||
|
<span style={{ color: "var(--border)" }}>|</span>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--status-online)",
|
||||||
|
marginRight: 4,
|
||||||
|
verticalAlign: "middle",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
0 nodes online
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--border)" }}>|</span>
|
||||||
|
<span>Server: stopped</span>
|
||||||
|
<span style={{ color: "var(--border)" }}>|</span>
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)" }}>Port: 8080</span>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,7 @@ function formatUptime(secs: number | null): string {
|
||||||
function formatLastSeen(iso: string): string {
|
function formatLastSeen(iso: string): string {
|
||||||
try {
|
try {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
const now = Date.now();
|
const diffMs = Date.now() - d.getTime();
|
||||||
const diffMs = now - d.getTime();
|
|
||||||
if (diffMs < 60_000) return "just now";
|
if (diffMs < 60_000) return "just now";
|
||||||
if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
|
if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
|
||||||
if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
|
if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
|
||||||
|
|
@ -35,21 +34,21 @@ export function NodeCard({ node, onClick }: NodeCardProps) {
|
||||||
<div
|
<div
|
||||||
onClick={() => onClick?.(node)}
|
onClick={() => onClick?.(node)}
|
||||||
style={{
|
style={{
|
||||||
background: "var(--card-bg, #1e1e2e)",
|
background: "var(--bg-elevated)",
|
||||||
border: "1px solid var(--border, #2e2e3e)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
borderRadius: 8,
|
||||||
padding: "16px",
|
padding: "var(--space-4)",
|
||||||
cursor: onClick ? "pointer" : "default",
|
cursor: onClick ? "pointer" : "default",
|
||||||
opacity: isOnline ? 1 : 0.6,
|
opacity: isOnline ? 1 : 0.6,
|
||||||
transition: "border-color 0.15s, box-shadow 0.15s",
|
transition: "border-color 0.15s, background 0.15s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = "var(--accent, #6366f1)";
|
e.currentTarget.style.borderColor = "var(--accent)";
|
||||||
e.currentTarget.style.boxShadow = "0 0 0 1px var(--accent, #6366f1)";
|
e.currentTarget.style.background = "var(--bg-hover)";
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.borderColor = "var(--border, #2e2e3e)";
|
e.currentTarget.style.borderColor = "var(--border)";
|
||||||
e.currentTarget.style.boxShadow = "none";
|
e.currentTarget.style.background = "var(--bg-elevated)";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -58,25 +57,26 @@ export function NodeCard({ node, onClick }: NodeCardProps) {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
marginBottom: "12px",
|
marginBottom: "var(--space-3)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "14px",
|
fontSize: 14,
|
||||||
fontWeight: 700,
|
fontWeight: 600,
|
||||||
color: "var(--text-primary, #e2e8f0)",
|
color: "var(--text-primary)",
|
||||||
marginBottom: "2px",
|
fontFamily: "var(--font-sans)",
|
||||||
|
marginBottom: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{node.friendly_name || node.hostname || `Node ${node.node_id}`}
|
{node.friendly_name || node.hostname || `Node ${node.node_id}`}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "12px",
|
fontSize: 12,
|
||||||
color: "var(--text-secondary, #94a3b8)",
|
color: "var(--text-secondary)",
|
||||||
fontFamily: "monospace",
|
fontFamily: "var(--font-mono)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{node.ip}
|
{node.ip}
|
||||||
|
|
@ -90,12 +90,12 @@ export function NodeCard({ node, onClick }: NodeCardProps) {
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr",
|
gridTemplateColumns: "1fr 1fr",
|
||||||
gap: "8px 16px",
|
gap: "var(--space-2) var(--space-4)",
|
||||||
fontSize: "12px",
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DetailRow label="MAC" value={node.mac ?? "--"} mono />
|
<DetailRow label="MAC" value={node.mac ?? "--"} mono />
|
||||||
<DetailRow label="Firmware" value={node.firmware_version ?? "--"} />
|
<DetailRow label="Firmware" value={node.firmware_version ?? "--"} mono />
|
||||||
<DetailRow label="Chip" value={node.chip.toUpperCase()} />
|
<DetailRow label="Chip" value={node.chip.toUpperCase()} />
|
||||||
<DetailRow label="Role" value={node.mesh_role} />
|
<DetailRow label="Role" value={node.mesh_role} />
|
||||||
<DetailRow
|
<DetailRow
|
||||||
|
|
@ -105,12 +105,13 @@ export function NodeCard({ node, onClick }: NodeCardProps) {
|
||||||
? `${node.tdm_slot}/${node.tdm_total}`
|
? `${node.tdm_slot}/${node.tdm_total}`
|
||||||
: "--"
|
: "--"
|
||||||
}
|
}
|
||||||
|
mono
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Edge Tier"
|
label="Edge Tier"
|
||||||
value={node.edge_tier != null ? String(node.edge_tier) : "--"}
|
value={node.edge_tier != null ? String(node.edge_tier) : "--"}
|
||||||
/>
|
/>
|
||||||
<DetailRow label="Uptime" value={formatUptime(node.uptime_secs)} />
|
<DetailRow label="Uptime" value={formatUptime(node.uptime_secs)} mono />
|
||||||
<DetailRow label="Seen" value={formatLastSeen(node.last_seen)} />
|
<DetailRow label="Seen" value={formatLastSeen(node.last_seen)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -130,20 +131,21 @@ function DetailRow({
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
color: "var(--text-muted, #64748b)",
|
color: "var(--text-muted)",
|
||||||
fontSize: "10px",
|
fontSize: 10,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.05em",
|
letterSpacing: "0.05em",
|
||||||
marginBottom: "1px",
|
marginBottom: 1,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
color: "var(--text-secondary, #94a3b8)",
|
color: "var(--text-secondary)",
|
||||||
fontFamily: mono ? "monospace" : "inherit",
|
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||||
fontSize: "12px",
|
fontSize: 12,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
|
|
|
||||||
|
|
@ -2,69 +2,53 @@ import type { HealthStatus } from "../types";
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
interface StatusBadgeProps {
|
||||||
status: HealthStatus;
|
status: HealthStatus;
|
||||||
/** Optional size variant. Default: "sm" */
|
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_STYLES: Record<HealthStatus, { bg: string; text: string; label: string }> = {
|
const STATUS_STYLES: Record<HealthStatus, { color: string; label: string }> = {
|
||||||
online: {
|
online: { color: "var(--status-online)", label: "Online" },
|
||||||
bg: "rgba(34, 197, 94, 0.15)",
|
offline: { color: "var(--status-error)", label: "Offline" },
|
||||||
text: "#22c55e",
|
degraded: { color: "var(--status-warning)", label: "Degraded" },
|
||||||
label: "Online",
|
unknown: { color: "var(--text-muted)", label: "Unknown" },
|
||||||
},
|
|
||||||
offline: {
|
|
||||||
bg: "rgba(239, 68, 68, 0.15)",
|
|
||||||
text: "#ef4444",
|
|
||||||
label: "Offline",
|
|
||||||
},
|
|
||||||
degraded: {
|
|
||||||
bg: "rgba(234, 179, 8, 0.15)",
|
|
||||||
text: "#eab308",
|
|
||||||
label: "Degraded",
|
|
||||||
},
|
|
||||||
unknown: {
|
|
||||||
bg: "rgba(148, 163, 184, 0.15)",
|
|
||||||
text: "#94a3b8",
|
|
||||||
label: "Unknown",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIZE_STYLES: Record<string, { fontSize: string; padding: string; dot: string }> = {
|
const SIZE_STYLES: Record<string, { fontSize: number; padding: string; dot: number }> = {
|
||||||
sm: { fontSize: "11px", padding: "2px 8px", dot: "6px" },
|
sm: { fontSize: 11, padding: "2px 8px", dot: 6 },
|
||||||
md: { fontSize: "13px", padding: "4px 12px", dot: "8px" },
|
md: { fontSize: 13, padding: "4px 12px", dot: 8 },
|
||||||
lg: { fontSize: "15px", padding: "6px 16px", dot: "10px" },
|
lg: { fontSize: 15, padding: "6px 16px", dot: 10 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatusBadge({ status, size = "sm" }: StatusBadgeProps) {
|
export function StatusBadge({ status, size = "sm" }: StatusBadgeProps) {
|
||||||
const style = STATUS_STYLES[status];
|
const { color, label } = STATUS_STYLES[status];
|
||||||
const sizeStyle = SIZE_STYLES[size];
|
const s = SIZE_STYLES[size];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "6px",
|
gap: 6,
|
||||||
backgroundColor: style.bg,
|
color,
|
||||||
color: style.text,
|
fontSize: s.fontSize,
|
||||||
fontSize: sizeStyle.fontSize,
|
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
padding: sizeStyle.padding,
|
fontFamily: "var(--font-sans)",
|
||||||
borderRadius: "9999px",
|
padding: s.padding,
|
||||||
|
borderRadius: 9999,
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
|
background: "rgba(255, 255, 255, 0.04)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
width: sizeStyle.dot,
|
width: s.dot,
|
||||||
height: sizeStyle.dot,
|
height: s.dot,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
backgroundColor: style.text,
|
backgroundColor: color,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{style.label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
/*
|
||||||
|
* RuView Design System (ADR-053)
|
||||||
|
* Dark professional + Unity-inspired interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===== Design Tokens ===== */
|
||||||
|
:root {
|
||||||
|
/* Background layers */
|
||||||
|
--bg-base: #0d1117;
|
||||||
|
--bg-surface: #161b22;
|
||||||
|
--bg-elevated: #1c2333;
|
||||||
|
--bg-hover: #242d3d;
|
||||||
|
--bg-active: #2d3748;
|
||||||
|
|
||||||
|
/* Text hierarchy */
|
||||||
|
--text-primary: #e6edf3;
|
||||||
|
--text-secondary: #8b949e;
|
||||||
|
--text-muted: #484f58;
|
||||||
|
|
||||||
|
/* Status indicators */
|
||||||
|
--status-online: #3fb950;
|
||||||
|
--status-warning: #d29922;
|
||||||
|
--status-error: #f85149;
|
||||||
|
--status-info: #58a6ff;
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--accent: #7c3aed;
|
||||||
|
--accent-hover: #6d28d9;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border: #30363d;
|
||||||
|
--border-active: #58a6ff;
|
||||||
|
|
||||||
|
/* Fonts */
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
|
||||||
|
/* Spacing (4px base grid) */
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 24px;
|
||||||
|
--space-6: 32px;
|
||||||
|
--space-8: 48px;
|
||||||
|
|
||||||
|
/* Panel dimensions */
|
||||||
|
--sidebar-width: 220px;
|
||||||
|
--sidebar-collapsed: 52px;
|
||||||
|
--statusbar-height: 28px;
|
||||||
|
--toolbar-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Reset ===== */
|
||||||
|
*, *::before, *::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Typography Scale ===== */
|
||||||
|
.heading-xl { font: 600 28px/1.2 var(--font-sans); color: var(--text-primary); }
|
||||||
|
.heading-lg { font: 600 20px/1.3 var(--font-sans); color: var(--text-primary); }
|
||||||
|
.heading-md { font: 600 16px/1.4 var(--font-sans); color: var(--text-primary); }
|
||||||
|
.heading-sm { font: 600 13px/1.4 var(--font-sans); color: var(--text-secondary); }
|
||||||
|
.body { font: 400 14px/1.6 var(--font-sans); color: var(--text-primary); }
|
||||||
|
.body-sm { font: 400 12px/1.5 var(--font-sans); color: var(--text-secondary); }
|
||||||
|
.data { font: 400 13px/1.4 var(--font-mono); color: var(--text-secondary); }
|
||||||
|
.data-lg { font: 500 18px/1.2 var(--font-mono); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ===== Scrollbar ===== */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--bg-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Form Controls ===== */
|
||||||
|
input, select, textarea {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
input:disabled, select:disabled, textarea:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
input[type="number"] {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Buttons ===== */
|
||||||
|
button {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Animations ===== */
|
||||||
|
@keyframes pulse-accent {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Selection ===== */
|
||||||
|
::selection {
|
||||||
|
background: rgba(124, 58, 237, 0.3);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import "./design-system.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import type { HealthStatus } from "../types";
|
||||||
|
|
||||||
interface DiscoveredNode {
|
interface DiscoveredNode {
|
||||||
ip: string;
|
ip: string;
|
||||||
|
|
@ -6,7 +8,7 @@ interface DiscoveredNode {
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
node_id: number;
|
node_id: number;
|
||||||
firmware_version: string | null;
|
firmware_version: string | null;
|
||||||
health: string;
|
health: HealthStatus;
|
||||||
last_seen: string;
|
last_seen: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,27 +54,29 @@ const Dashboard: React.FC = () => {
|
||||||
fetchServerStatus();
|
fetchServerStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const onlineCount = nodes.filter((n) => n.health === "online").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ padding: "var(--space-5)" }}>
|
||||||
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: 24,
|
marginBottom: "var(--space-5)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ fontSize: 24 }}>Dashboard</h2>
|
<h2 className="heading-lg">Dashboard</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleScan}
|
onClick={handleScan}
|
||||||
disabled={scanning}
|
disabled={scanning}
|
||||||
style={{
|
style={{
|
||||||
padding: "8px 16px",
|
padding: "var(--space-2) var(--space-4)",
|
||||||
background: scanning ? "#475569" : "#38bdf8",
|
background: scanning ? "var(--bg-active)" : "var(--accent)",
|
||||||
color: "#0f172a",
|
color: scanning ? "var(--text-muted)" : "#fff",
|
||||||
border: "none",
|
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
cursor: scanning ? "not-allowed" : "pointer",
|
fontSize: 13,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -80,45 +84,74 @@ const Dashboard: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
|
||||||
|
gap: "var(--space-4)",
|
||||||
|
marginBottom: "var(--space-5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatCard label="Total Nodes" value={String(nodes.length)} />
|
||||||
|
<StatCard label="Online" value={String(onlineCount)} color="var(--status-online)" />
|
||||||
|
<StatCard label="Offline" value={String(nodes.length - onlineCount)} color="var(--status-error)" />
|
||||||
|
<StatCard
|
||||||
|
label="Server"
|
||||||
|
value={serverStatus?.running ? "Running" : "Stopped"}
|
||||||
|
color={serverStatus?.running ? "var(--status-online)" : "var(--status-error)"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Server status panel */}
|
{/* Server status panel */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "#1e293b",
|
background: "var(--bg-surface)",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: 16,
|
padding: "var(--space-4)",
|
||||||
marginBottom: 24,
|
marginBottom: "var(--space-5)",
|
||||||
border: "1px solid #334155",
|
border: "1px solid var(--border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3 style={{ fontSize: 14, color: "#94a3b8", marginBottom: 8 }}>
|
<h3 className="heading-sm" style={{ marginBottom: "var(--space-2)" }}>
|
||||||
Sensing Server
|
Sensing Server
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)" }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
width: 10,
|
width: 8,
|
||||||
height: 10,
|
height: 8,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
background: serverStatus?.running ? "#22c55e" : "#ef4444",
|
background: serverStatus?.running ? "var(--status-online)" : "var(--status-error)",
|
||||||
display: "inline-block",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>
|
||||||
{serverStatus?.running
|
{serverStatus?.running
|
||||||
? `Running (PID ${serverStatus.pid})`
|
? `Running (PID ${serverStatus.pid})`
|
||||||
: "Stopped"}
|
: "Stopped"}
|
||||||
</span>
|
</span>
|
||||||
|
{serverStatus?.running && serverStatus.http_port && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
marginLeft: "var(--space-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
:{serverStatus.http_port}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Node grid */}
|
{/* Node list */}
|
||||||
<h3
|
<h3
|
||||||
|
className="heading-sm"
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
marginBottom: "var(--space-3)",
|
||||||
color: "#94a3b8",
|
|
||||||
marginBottom: 12,
|
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: "0.05em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Discovered Nodes ({nodes.length})
|
Discovered Nodes ({nodes.length})
|
||||||
|
|
@ -127,12 +160,13 @@ const Dashboard: React.FC = () => {
|
||||||
{nodes.length === 0 ? (
|
{nodes.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "#1e293b",
|
background: "var(--bg-surface)",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: 32,
|
padding: "var(--space-6)",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
color: "#64748b",
|
color: "var(--text-muted)",
|
||||||
border: "1px solid #334155",
|
border: "1px solid var(--border)",
|
||||||
|
fontSize: 13,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
No nodes discovered. Click "Scan Network" to search.
|
No nodes discovered. Click "Scan Network" to search.
|
||||||
|
|
@ -142,55 +176,64 @@ const Dashboard: React.FC = () => {
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||||
gap: 16,
|
gap: "var(--space-4)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{nodes.map((node, i) => (
|
{nodes.map((node, i) => (
|
||||||
<div
|
<div
|
||||||
key={node.mac || i}
|
key={node.mac || i}
|
||||||
style={{
|
style={{
|
||||||
background: "#1e293b",
|
background: "var(--bg-surface)",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: 16,
|
padding: "var(--space-4)",
|
||||||
border: "1px solid #334155",
|
border: "1px solid var(--border)",
|
||||||
|
transition: "border-color 0.15s",
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.borderColor = "var(--bg-active)")
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.borderColor = "var(--border)")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "start",
|
alignItems: "start",
|
||||||
marginBottom: 12,
|
marginBottom: "var(--space-3)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600 }}>
|
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||||
{node.hostname || `Node ${node.node_id}`}
|
{node.hostname || `Node ${node.node_id}`}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: "#64748b" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{node.ip}
|
{node.ip}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<StatusBadge status={node.health} />
|
||||||
style={{
|
|
||||||
padding: "2px 8px",
|
|
||||||
borderRadius: 12,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 600,
|
|
||||||
background:
|
|
||||||
node.health === "online" ? "#064e3b" : "#7f1d1d",
|
|
||||||
color:
|
|
||||||
node.health === "online" ? "#34d399" : "#fca5a5",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{node.health}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: 13, color: "#94a3b8" }}>
|
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||||
<div>MAC: {node.mac || "unknown"}</div>
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 2 }}>
|
||||||
<div>Firmware: {node.firmware_version || "unknown"}</div>
|
<span style={{ color: "var(--text-muted)" }}>MAC</span>
|
||||||
<div>Node ID: {node.node_id}</div>
|
<span style={{ fontFamily: "var(--font-mono)" }}>{node.mac || "--"}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 2 }}>
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>Firmware</span>
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)" }}>{node.firmware_version || "--"}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>Node ID</span>
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)" }}>{node.node_id}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -200,4 +243,44 @@ const Dashboard: React.FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "var(--space-4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
marginBottom: "var(--space-1)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="data-lg"
|
||||||
|
style={{ color: color || "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default Dashboard;
|
export default Dashboard;
|
||||||
|
|
|
||||||
|
|
@ -6,32 +6,25 @@ import type { SerialPort, Chip, FlashProgress, FlashPhase } from "../types";
|
||||||
type WizardStep = 1 | 2 | 3;
|
type WizardStep = 1 | 2 | 3;
|
||||||
|
|
||||||
export function FlashFirmware() {
|
export function FlashFirmware() {
|
||||||
// --- State ---
|
|
||||||
const [step, setStep] = useState<WizardStep>(1);
|
const [step, setStep] = useState<WizardStep>(1);
|
||||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||||
const [selectedPort, setSelectedPort] = useState<string>("");
|
const [selectedPort, setSelectedPort] = useState("");
|
||||||
const [firmwarePath, setFirmwarePath] = useState<string>("");
|
const [firmwarePath, setFirmwarePath] = useState("");
|
||||||
const [chip, setChip] = useState<Chip>("esp32s3");
|
const [chip, setChip] = useState<Chip>("esp32s3");
|
||||||
const [baud, setBaud] = useState<number>(460800);
|
const [baud, setBaud] = useState(460800);
|
||||||
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
|
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
|
||||||
const [progress, setProgress] = useState<FlashProgress | null>(null);
|
const [progress, setProgress] = useState<FlashProgress | null>(null);
|
||||||
const [isFlashing, setIsFlashing] = useState(false);
|
const [isFlashing, setIsFlashing] = useState(false);
|
||||||
const [flashResult, setFlashResult] = useState<{
|
const [flashResult, setFlashResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// --- Load serial ports ---
|
|
||||||
const loadPorts = useCallback(async () => {
|
const loadPorts = useCallback(async () => {
|
||||||
setIsLoadingPorts(true);
|
setIsLoadingPorts(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await invoke<SerialPort[]>("list_serial_ports");
|
const result = await invoke<SerialPort[]>("list_serial_ports");
|
||||||
setPorts(result);
|
setPorts(result);
|
||||||
if (result.length === 1) {
|
if (result.length === 1) setSelectedPort(result[0].name);
|
||||||
setSelectedPort(result[0].name);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -39,26 +32,16 @@ export function FlashFirmware() {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadPorts(); }, [loadPorts]);
|
||||||
loadPorts();
|
|
||||||
}, [loadPorts]);
|
|
||||||
|
|
||||||
// --- Listen for flash progress events ---
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
|
|
||||||
listen<FlashProgress>("flash-progress", (event) => {
|
listen<FlashProgress>("flash-progress", (event) => {
|
||||||
setProgress(event.payload);
|
setProgress(event.payload);
|
||||||
}).then((fn) => {
|
}).then((fn) => { unlisten = fn; });
|
||||||
unlisten = fn;
|
return () => { unlisten?.(); };
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlisten?.();
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- File picker ---
|
|
||||||
const pickFirmware = async () => {
|
const pickFirmware = async () => {
|
||||||
try {
|
try {
|
||||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
|
@ -69,43 +52,28 @@ export function FlashFirmware() {
|
||||||
{ name: "All Files", extensions: ["*"] },
|
{ name: "All Files", extensions: ["*"] },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (selected && typeof selected === "string") {
|
if (selected && typeof selected === "string") setFirmwarePath(selected);
|
||||||
setFirmwarePath(selected);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Flash ---
|
|
||||||
const startFlash = async () => {
|
const startFlash = async () => {
|
||||||
if (!selectedPort || !firmwarePath) return;
|
if (!selectedPort || !firmwarePath) return;
|
||||||
|
|
||||||
setIsFlashing(true);
|
setIsFlashing(true);
|
||||||
setFlashResult(null);
|
setFlashResult(null);
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke("flash_firmware", {
|
await invoke("flash_firmware", { port: selectedPort, firmwarePath, chip, baud });
|
||||||
port: selectedPort,
|
setFlashResult({ success: true, message: "Firmware flashed successfully." });
|
||||||
firmwarePath,
|
|
||||||
chip,
|
|
||||||
baud,
|
|
||||||
});
|
|
||||||
setFlashResult({
|
|
||||||
success: true,
|
|
||||||
message: "Firmware flashed successfully.",
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
setFlashResult({ success: false, message: err instanceof Error ? err.message : String(err) });
|
||||||
setFlashResult({ success: false, message: msg });
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsFlashing(false);
|
setIsFlashing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Step validation ---
|
|
||||||
const canProceed = (s: WizardStep): boolean => {
|
const canProceed = (s: WizardStep): boolean => {
|
||||||
if (s === 1) return selectedPort !== "";
|
if (s === 1) return selectedPort !== "";
|
||||||
if (s === 2) return firmwarePath !== "";
|
if (s === 2) return firmwarePath !== "";
|
||||||
|
|
@ -113,43 +81,16 @@ export function FlashFirmware() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "24px", maxWidth: "700px" }}>
|
<div style={{ padding: "var(--space-5)", maxWidth: 700 }}>
|
||||||
<h1
|
<h1 className="heading-lg" style={{ margin: "0 0 var(--space-1)" }}>Flash Firmware</h1>
|
||||||
style={{
|
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: "var(--space-5)" }}>
|
||||||
fontSize: "22px",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--text-primary, #e2e8f0)",
|
|
||||||
margin: "0 0 4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Flash Firmware
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "13px",
|
|
||||||
color: "var(--text-secondary, #94a3b8)",
|
|
||||||
marginBottom: "24px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Flash firmware to an ESP32 via serial connection
|
Flash firmware to an ESP32 via serial connection
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Step indicator */}
|
|
||||||
<StepIndicator current={step} />
|
<StepIndicator current={step} />
|
||||||
|
|
||||||
{/* Error banner */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div style={bannerStyle("var(--status-error)")}>
|
||||||
style={{
|
|
||||||
background: "rgba(239, 68, 68, 0.1)",
|
|
||||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "12px 16px",
|
|
||||||
marginBottom: "16px",
|
|
||||||
fontSize: "13px",
|
|
||||||
color: "#fca5a5",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -157,47 +98,33 @@ export function FlashFirmware() {
|
||||||
{/* Step 1: Select Serial Port */}
|
{/* Step 1: Select Serial Port */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<div style={cardStyle}>
|
<div style={cardStyle}>
|
||||||
<h2 style={stepTitle}>Step 1: Select Serial Port</h2>
|
<h2 style={stepTitleStyle}>Step 1: Select Serial Port</h2>
|
||||||
<p style={stepDesc}>
|
<p style={stepDescStyle}>Connect your ESP32 via USB and select the serial port.</p>
|
||||||
Connect your ESP32 via USB and select the serial port.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "16px" }}>
|
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||||
<label style={labelStyle}>Serial Port</label>
|
<label style={labelStyle}>Serial Port</label>
|
||||||
<div style={{ display: "flex", gap: "8px" }}>
|
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||||
<select
|
<select
|
||||||
value={selectedPort}
|
value={selectedPort}
|
||||||
onChange={(e) => setSelectedPort(e.target.value)}
|
onChange={(e) => setSelectedPort(e.target.value)}
|
||||||
style={{ ...inputStyle, flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
disabled={isLoadingPorts}
|
disabled={isLoadingPorts}
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{isLoadingPorts
|
{isLoadingPorts ? "Loading..." : ports.length === 0 ? "No ports detected" : "Select a port..."}
|
||||||
? "Loading..."
|
|
||||||
: ports.length === 0
|
|
||||||
? "No ports detected"
|
|
||||||
: "Select a port..."}
|
|
||||||
</option>
|
</option>
|
||||||
{ports.map((p) => (
|
{ports.map((p) => (
|
||||||
<option key={p.name} value={p.name}>
|
<option key={p.name} value={p.name}>
|
||||||
{p.name}
|
{p.name}{p.description ? ` - ${p.description}` : ""}{p.chip ? ` (${p.chip.toUpperCase()})` : ""}
|
||||||
{p.description ? ` - ${p.description}` : ""}
|
|
||||||
{p.chip ? ` (${p.chip.toUpperCase()})` : ""}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button onClick={loadPorts} style={secondaryBtnStyle} disabled={isLoadingPorts}>
|
<button onClick={loadPorts} style={secondaryBtn} disabled={isLoadingPorts}>Refresh</button>
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||||
<button
|
<button onClick={() => setStep(2)} disabled={!canProceed(1)} style={canProceed(1) ? primaryBtn : disabledBtn}>
|
||||||
onClick={() => setStep(2)}
|
|
||||||
disabled={!canProceed(1)}
|
|
||||||
style={canProceed(1) ? primaryBtnStyle : disabledBtnStyle}
|
|
||||||
>
|
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -207,42 +134,21 @@ export function FlashFirmware() {
|
||||||
{/* Step 2: Select Firmware */}
|
{/* Step 2: Select Firmware */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div style={cardStyle}>
|
<div style={cardStyle}>
|
||||||
<h2 style={stepTitle}>Step 2: Select Firmware</h2>
|
<h2 style={stepTitleStyle}>Step 2: Select Firmware</h2>
|
||||||
<p style={stepDesc}>
|
<p style={stepDescStyle}>Choose the firmware binary file and chip configuration.</p>
|
||||||
Choose the firmware binary file and chip configuration.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "16px" }}>
|
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||||
<label style={labelStyle}>Firmware Binary (.bin)</label>
|
<label style={labelStyle}>Firmware Binary (.bin)</label>
|
||||||
<div style={{ display: "flex", gap: "8px" }}>
|
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||||
<input
|
<input type="text" value={firmwarePath} readOnly placeholder="No file selected" style={{ flex: 1 }} />
|
||||||
type="text"
|
<button onClick={pickFirmware} style={secondaryBtn}>Browse</button>
|
||||||
value={firmwarePath}
|
|
||||||
readOnly
|
|
||||||
placeholder="No file selected"
|
|
||||||
style={{ ...inputStyle, flex: 1 }}
|
|
||||||
/>
|
|
||||||
<button onClick={pickFirmware} style={secondaryBtnStyle}>
|
|
||||||
Browse
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)", marginBottom: "var(--space-4)" }}>
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr 1fr",
|
|
||||||
gap: "16px",
|
|
||||||
marginBottom: "16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Chip</label>
|
<label style={labelStyle}>Chip</label>
|
||||||
<select
|
<select value={chip} onChange={(e) => setChip(e.target.value as Chip)}>
|
||||||
value={chip}
|
|
||||||
onChange={(e) => setChip(e.target.value as Chip)}
|
|
||||||
style={inputStyle}
|
|
||||||
>
|
|
||||||
<option value="esp32">ESP32</option>
|
<option value="esp32">ESP32</option>
|
||||||
<option value="esp32s3">ESP32-S3</option>
|
<option value="esp32s3">ESP32-S3</option>
|
||||||
<option value="esp32c3">ESP32-C3</option>
|
<option value="esp32c3">ESP32-C3</option>
|
||||||
|
|
@ -250,11 +156,7 @@ export function FlashFirmware() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Baud Rate</label>
|
<label style={labelStyle}>Baud Rate</label>
|
||||||
<select
|
<select value={baud} onChange={(e) => setBaud(Number(e.target.value))}>
|
||||||
value={baud}
|
|
||||||
onChange={(e) => setBaud(Number(e.target.value))}
|
|
||||||
style={inputStyle}
|
|
||||||
>
|
|
||||||
<option value={115200}>115200</option>
|
<option value={115200}>115200</option>
|
||||||
<option value={230400}>230400</option>
|
<option value={230400}>230400</option>
|
||||||
<option value={460800}>460800</option>
|
<option value={460800}>460800</option>
|
||||||
|
|
@ -264,14 +166,8 @@ export function FlashFirmware() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
<button onClick={() => setStep(1)} style={secondaryBtnStyle}>
|
<button onClick={() => setStep(1)} style={secondaryBtn}>Back</button>
|
||||||
Back
|
<button onClick={() => setStep(3)} disabled={!canProceed(2)} style={canProceed(2) ? primaryBtn : disabledBtn}>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setStep(3)}
|
|
||||||
disabled={!canProceed(2)}
|
|
||||||
style={canProceed(2) ? primaryBtnStyle : disabledBtnStyle}
|
|
||||||
>
|
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -281,87 +177,58 @@ export function FlashFirmware() {
|
||||||
{/* Step 3: Flash */}
|
{/* Step 3: Flash */}
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<div style={cardStyle}>
|
<div style={cardStyle}>
|
||||||
<h2 style={stepTitle}>Step 3: Flash</h2>
|
<h2 style={stepTitleStyle}>Step 3: Flash</h2>
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(0,0,0,0.15)",
|
background: "var(--bg-base)",
|
||||||
borderRadius: "6px",
|
borderRadius: 6,
|
||||||
padding: "12px 16px",
|
padding: "var(--space-3) var(--space-4)",
|
||||||
marginBottom: "16px",
|
marginBottom: "var(--space-4)",
|
||||||
fontSize: "12px",
|
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr",
|
gridTemplateColumns: "1fr 1fr",
|
||||||
gap: "8px",
|
gap: "var(--space-2)",
|
||||||
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SummaryField label="Port" value={selectedPort} />
|
<SummaryField label="Port" value={selectedPort} />
|
||||||
<SummaryField
|
<SummaryField label="Firmware" value={firmwarePath.split(/[\\/]/).pop() ?? firmwarePath} />
|
||||||
label="Firmware"
|
|
||||||
value={firmwarePath.split(/[\\/]/).pop() ?? firmwarePath}
|
|
||||||
/>
|
|
||||||
<SummaryField label="Chip" value={chip.toUpperCase()} />
|
<SummaryField label="Chip" value={chip.toUpperCase()} />
|
||||||
<SummaryField label="Baud" value={String(baud)} />
|
<SummaryField label="Baud" value={String(baud)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
{(isFlashing || progress) && !flashResult && (
|
{(isFlashing || progress) && !flashResult && (
|
||||||
<div style={{ marginBottom: "16px" }}>
|
<div style={{ marginBottom: "var(--space-4)" }}>
|
||||||
<ProgressBar progress={progress} />
|
<ProgressBar progress={progress} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Result */}
|
{/* Result */}
|
||||||
{flashResult && (
|
{flashResult && (
|
||||||
<div
|
<div style={bannerStyle(flashResult.success ? "var(--status-online)" : "var(--status-error)")}>
|
||||||
style={{
|
|
||||||
background: flashResult.success
|
|
||||||
? "rgba(34, 197, 94, 0.1)"
|
|
||||||
: "rgba(239, 68, 68, 0.1)",
|
|
||||||
border: `1px solid ${flashResult.success ? "rgba(34, 197, 94, 0.3)" : "rgba(239, 68, 68, 0.3)"}`,
|
|
||||||
borderRadius: "6px",
|
|
||||||
padding: "12px 16px",
|
|
||||||
marginBottom: "16px",
|
|
||||||
fontSize: "13px",
|
|
||||||
color: flashResult.success ? "#86efac" : "#fca5a5",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{flashResult.message}
|
{flashResult.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => { setStep(2); setFlashResult(null); setProgress(null); }}
|
||||||
setStep(2);
|
style={secondaryBtn}
|
||||||
setFlashResult(null);
|
|
||||||
setProgress(null);
|
|
||||||
}}
|
|
||||||
style={secondaryBtnStyle}
|
|
||||||
disabled={isFlashing}
|
disabled={isFlashing}
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
{flashResult ? (
|
{flashResult ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => { setStep(1); setFlashResult(null); setProgress(null); setFirmwarePath(""); setSelectedPort(""); }}
|
||||||
setStep(1);
|
style={primaryBtn}
|
||||||
setFlashResult(null);
|
|
||||||
setProgress(null);
|
|
||||||
setFirmwarePath("");
|
|
||||||
setSelectedPort("");
|
|
||||||
}}
|
|
||||||
style={primaryBtnStyle}
|
|
||||||
>
|
>
|
||||||
Flash Another
|
Flash Another
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button onClick={startFlash} disabled={isFlashing} style={isFlashing ? disabledBtn : primaryBtn}>
|
||||||
onClick={startFlash}
|
|
||||||
disabled={isFlashing}
|
|
||||||
style={isFlashing ? disabledBtnStyle : primaryBtnStyle}
|
|
||||||
>
|
|
||||||
{isFlashing ? "Flashing..." : "Start Flash"}
|
{isFlashing ? "Flashing..." : "Start Flash"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -372,9 +239,7 @@ export function FlashFirmware() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// --- Sub-components ---
|
||||||
// Sub-components
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function StepIndicator({ current }: { current: WizardStep }) {
|
function StepIndicator({ current }: { current: WizardStep }) {
|
||||||
const steps = [
|
const steps = [
|
||||||
|
|
@ -384,65 +249,42 @@ function StepIndicator({ current }: { current: WizardStep }) {
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ display: "flex", alignItems: "center", marginBottom: "var(--space-5)" }}>
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0",
|
|
||||||
marginBottom: "24px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{steps.map(({ n, label }, i) => {
|
{steps.map(({ n, label }, i) => {
|
||||||
const isActive = n === current;
|
const isActive = n === current;
|
||||||
const isDone = n < current;
|
const isDone = n < current;
|
||||||
return (
|
return (
|
||||||
<div key={n} style={{ display: "flex", alignItems: "center" }}>
|
<div key={n} style={{ display: "flex", alignItems: "center" }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)" }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: "28px",
|
width: 28,
|
||||||
height: "28px",
|
height: 28,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
fontSize: "12px",
|
fontSize: 12,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
background: isActive
|
fontFamily: "var(--font-mono)",
|
||||||
? "var(--accent, #6366f1)"
|
background: isActive ? "var(--accent)" : isDone ? "rgba(63, 185, 80, 0.2)" : "var(--border)",
|
||||||
: isDone
|
color: isActive ? "#fff" : isDone ? "var(--status-online)" : "var(--text-muted)",
|
||||||
? "rgba(34, 197, 94, 0.2)"
|
|
||||||
: "var(--border, #2e2e3e)",
|
|
||||||
color: isActive
|
|
||||||
? "#fff"
|
|
||||||
: isDone
|
|
||||||
? "#22c55e"
|
|
||||||
: "var(--text-muted, #64748b)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isDone ? "\u2713" : n}
|
{isDone ? "\u2713" : n}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: "12px",
|
fontSize: 12,
|
||||||
fontWeight: isActive ? 600 : 400,
|
fontWeight: isActive ? 600 : 400,
|
||||||
color: isActive
|
color: isActive ? "var(--text-primary)" : "var(--text-muted)",
|
||||||
? "var(--text-primary, #e2e8f0)"
|
|
||||||
: "var(--text-muted, #64748b)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{i < steps.length - 1 && (
|
{i < steps.length - 1 && (
|
||||||
<div
|
<div style={{ width: 40, height: 1, background: "var(--border)", margin: "0 var(--space-3)" }} />
|
||||||
style={{
|
|
||||||
width: "40px",
|
|
||||||
height: "1px",
|
|
||||||
background: "var(--border, #2e2e3e)",
|
|
||||||
margin: "0 12px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -468,42 +310,21 @@ function ProgressBar({ progress }: { progress: FlashProgress | null }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 6 }}>
|
||||||
style={{
|
<span style={{ color: "var(--text-secondary)" }}>{PHASE_LABELS[phase]}</span>
|
||||||
display: "flex",
|
<span style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>
|
||||||
justifyContent: "space-between",
|
{pct.toFixed(1)}%{speedKB && ` | ${speedKB}`}
|
||||||
fontSize: "12px",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: "var(--text-secondary, #94a3b8)" }}>
|
|
||||||
{PHASE_LABELS[phase]}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: "var(--text-muted, #64748b)" }}>
|
|
||||||
{pct.toFixed(1)}% {speedKB && `| ${speedKB}`}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div style={{ width: "100%", height: 8, background: "var(--border)", borderRadius: 4, overflow: "hidden" }}>
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "8px",
|
|
||||||
background: "var(--border, #2e2e3e)",
|
|
||||||
borderRadius: "4px",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.min(pct, 100)}%`,
|
width: `${Math.min(pct, 100)}%`,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
background:
|
background: phase === "error" ? "var(--status-error)" : phase === "done" ? "var(--status-online)" : "var(--accent)",
|
||||||
phase === "error"
|
borderRadius: 4,
|
||||||
? "#ef4444"
|
|
||||||
: phase === "done"
|
|
||||||
? "#22c55e"
|
|
||||||
: "var(--accent, #6366f1)",
|
|
||||||
borderRadius: "4px",
|
|
||||||
transition: "width 0.3s ease",
|
transition: "width 0.3s ease",
|
||||||
|
animation: phase === "writing" ? "pulse-accent 2s infinite" : "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -514,102 +335,81 @@ function ProgressBar({ progress }: { progress: FlashProgress | null }) {
|
||||||
function SummaryField({ label, value }: { label: string; value: string }) {
|
function SummaryField({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div style={{ fontSize: 10, textTransform: "uppercase", letterSpacing: "0.05em", color: "var(--text-muted)", marginBottom: 1 }}>
|
||||||
style={{
|
|
||||||
fontSize: "10px",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
color: "var(--text-muted, #64748b)",
|
|
||||||
marginBottom: "1px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div style={{ color: "var(--text-secondary)", fontFamily: "var(--font-mono)", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
style={{
|
|
||||||
color: "var(--text-secondary, #94a3b8)",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "12px",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// --- Shared styles ---
|
||||||
// Shared styles
|
|
||||||
// ---------------------------------------------------------------------------
|
function bannerStyle(color: string): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
background: `color-mix(in srgb, ${color} 10%, transparent)`,
|
||||||
|
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "var(--space-3) var(--space-4)",
|
||||||
|
marginBottom: "var(--space-4)",
|
||||||
|
fontSize: 13,
|
||||||
|
color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
background: "var(--card-bg, #1e1e2e)",
|
background: "var(--bg-surface)",
|
||||||
border: "1px solid var(--border, #2e2e3e)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
borderRadius: 8,
|
||||||
padding: "20px",
|
padding: "var(--space-5)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const stepTitle: React.CSSProperties = {
|
const stepTitleStyle: React.CSSProperties = {
|
||||||
fontSize: "16px",
|
fontSize: 16,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "var(--text-primary, #e2e8f0)",
|
color: "var(--text-primary)",
|
||||||
margin: "0 0 4px",
|
margin: "0 0 var(--space-1)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const stepDesc: React.CSSProperties = {
|
const stepDescStyle: React.CSSProperties = {
|
||||||
fontSize: "13px",
|
fontSize: 13,
|
||||||
color: "var(--text-secondary, #94a3b8)",
|
color: "var(--text-secondary)",
|
||||||
marginBottom: "16px",
|
marginBottom: "var(--space-4)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const labelStyle: React.CSSProperties = {
|
const labelStyle: React.CSSProperties = {
|
||||||
display: "block",
|
display: "block",
|
||||||
fontSize: "12px",
|
fontSize: 12,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "var(--text-secondary, #94a3b8)",
|
color: "var(--text-secondary)",
|
||||||
marginBottom: "6px",
|
marginBottom: 6,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const primaryBtn: React.CSSProperties = {
|
||||||
width: "100%",
|
padding: "var(--space-2) 20px",
|
||||||
padding: "8px 12px",
|
borderRadius: 6,
|
||||||
border: "1px solid var(--border, #2e2e3e)",
|
background: "var(--accent)",
|
||||||
borderRadius: "6px",
|
|
||||||
background: "var(--input-bg, #12121a)",
|
|
||||||
color: "var(--text-primary, #e2e8f0)",
|
|
||||||
fontSize: "13px",
|
|
||||||
outline: "none",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
};
|
|
||||||
|
|
||||||
const primaryBtnStyle: React.CSSProperties = {
|
|
||||||
padding: "8px 20px",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
background: "var(--accent, #6366f1)",
|
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontSize: "13px",
|
fontSize: 13,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
cursor: "pointer",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const secondaryBtnStyle: React.CSSProperties = {
|
const secondaryBtn: React.CSSProperties = {
|
||||||
padding: "8px 16px",
|
padding: "var(--space-2) var(--space-4)",
|
||||||
border: "1px solid var(--border, #2e2e3e)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "6px",
|
borderRadius: 6,
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
color: "var(--text-secondary, #94a3b8)",
|
color: "var(--text-secondary)",
|
||||||
fontSize: "13px",
|
fontSize: 13,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
cursor: "pointer",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const disabledBtnStyle: React.CSSProperties = {
|
const disabledBtn: React.CSSProperties = {
|
||||||
...primaryBtnStyle,
|
...primaryBtn,
|
||||||
background: "var(--border, #2e2e3e)",
|
background: "var(--bg-active)",
|
||||||
color: "var(--text-muted, #64748b)",
|
color: "var(--text-muted)",
|
||||||
cursor: "not-allowed",
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,34 +16,19 @@ export function Nodes() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "24px", maxWidth: "1200px" }}>
|
<div style={{ padding: "var(--space-5)", maxWidth: 1200 }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: "20px",
|
marginBottom: "var(--space-5)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1
|
<h1 className="heading-lg" style={{ margin: 0 }}>Nodes</h1>
|
||||||
style={{
|
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginTop: "var(--space-1)" }}>
|
||||||
fontSize: "22px",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--text-primary, #e2e8f0)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Nodes
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "13px",
|
|
||||||
color: "var(--text-secondary, #94a3b8)",
|
|
||||||
marginTop: "4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{nodes.length} node{nodes.length !== 1 ? "s" : ""} in registry
|
{nodes.length} node{nodes.length !== 1 ? "s" : ""} in registry
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,16 +36,12 @@ export function Nodes() {
|
||||||
onClick={scan}
|
onClick={scan}
|
||||||
disabled={isScanning}
|
disabled={isScanning}
|
||||||
style={{
|
style={{
|
||||||
padding: "8px 16px",
|
padding: "var(--space-2) var(--space-4)",
|
||||||
border: "none",
|
borderRadius: 6,
|
||||||
borderRadius: "6px",
|
background: isScanning ? "var(--bg-active)" : "var(--accent)",
|
||||||
background: isScanning
|
color: isScanning ? "var(--text-muted)" : "#fff",
|
||||||
? "var(--border, #2e2e3e)"
|
fontSize: 13,
|
||||||
: "var(--accent, #6366f1)",
|
|
||||||
color: isScanning ? "var(--text-muted, #64748b)" : "#fff",
|
|
||||||
fontSize: "13px",
|
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
cursor: isScanning ? "not-allowed" : "pointer",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isScanning ? "Scanning..." : "Refresh"}
|
{isScanning ? "Scanning..." : "Refresh"}
|
||||||
|
|
@ -71,13 +52,13 @@ export function Nodes() {
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(239, 68, 68, 0.1)",
|
background: "rgba(248, 81, 73, 0.1)",
|
||||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
border: "1px solid rgba(248, 81, 73, 0.3)",
|
||||||
borderRadius: "6px",
|
borderRadius: 6,
|
||||||
padding: "12px 16px",
|
padding: "var(--space-3) var(--space-4)",
|
||||||
marginBottom: "16px",
|
marginBottom: "var(--space-4)",
|
||||||
fontSize: "13px",
|
fontSize: 13,
|
||||||
color: "#fca5a5",
|
color: "var(--status-error)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
|
|
@ -88,13 +69,13 @@ export function Nodes() {
|
||||||
{nodes.length === 0 ? (
|
{nodes.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "var(--card-bg, #1e1e2e)",
|
background: "var(--bg-surface)",
|
||||||
border: "1px solid var(--border, #2e2e3e)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
borderRadius: 8,
|
||||||
padding: "48px",
|
padding: "var(--space-8)",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
color: "var(--text-muted, #64748b)",
|
color: "var(--text-muted)",
|
||||||
fontSize: "13px",
|
fontSize: 13,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isScanning ? "Scanning for nodes..." : "No nodes found. Run a scan to discover ESP32 devices."}
|
{isScanning ? "Scanning for nodes..." : "No nodes found. Run a scan to discover ESP32 devices."}
|
||||||
|
|
@ -102,26 +83,15 @@ export function Nodes() {
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "var(--card-bg, #1e1e2e)",
|
background: "var(--bg-surface)",
|
||||||
border: "1px solid var(--border, #2e2e3e)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
borderRadius: 8,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<table
|
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
borderCollapse: "collapse",
|
|
||||||
fontSize: "13px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr
|
<tr style={{ borderBottom: "1px solid var(--border)", textAlign: "left" }}>
|
||||||
style={{
|
|
||||||
borderBottom: "1px solid var(--border, #2e2e3e)",
|
|
||||||
textAlign: "left",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Th>Status</Th>
|
<Th>Status</Th>
|
||||||
<Th>MAC</Th>
|
<Th>MAC</Th>
|
||||||
<Th>IP</Th>
|
<Th>IP</Th>
|
||||||
|
|
@ -133,12 +103,11 @@ export function Nodes() {
|
||||||
<tbody>
|
<tbody>
|
||||||
{nodes.map((node) => {
|
{nodes.map((node) => {
|
||||||
const key = node.mac ?? node.ip;
|
const key = node.mac ?? node.ip;
|
||||||
const isExpanded = expandedMac === key;
|
|
||||||
return (
|
return (
|
||||||
<NodeRow
|
<NodeRow
|
||||||
key={key}
|
key={key}
|
||||||
node={node}
|
node={node}
|
||||||
isExpanded={isExpanded}
|
isExpanded={expandedMac === key}
|
||||||
onToggle={() => toggleExpand(node)}
|
onToggle={() => toggleExpand(node)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -151,20 +120,17 @@ export function Nodes() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sub-components
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function Th({ children }: { children: React.ReactNode }) {
|
function Th({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
style={{
|
style={{
|
||||||
padding: "10px 16px",
|
padding: "10px var(--space-4)",
|
||||||
fontSize: "10px",
|
fontSize: 10,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.05em",
|
letterSpacing: "0.05em",
|
||||||
color: "var(--text-muted, #64748b)",
|
color: "var(--text-muted)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -172,20 +138,15 @@ function Th({ children }: { children: React.ReactNode }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Td({
|
function Td({ children, mono = false }: { children: React.ReactNode; mono?: boolean }) {
|
||||||
children,
|
|
||||||
mono = false,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
mono?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
padding: "10px 16px",
|
padding: "10px var(--space-4)",
|
||||||
color: "var(--text-secondary, #94a3b8)",
|
color: "var(--text-secondary)",
|
||||||
fontFamily: mono ? "monospace" : "inherit",
|
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
|
fontSize: 13,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -220,29 +181,23 @@ function NodeRow({
|
||||||
<tr
|
<tr
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
style={{
|
style={{
|
||||||
borderBottom: isExpanded ? "none" : "1px solid var(--border, #2e2e3e)",
|
borderBottom: isExpanded ? "none" : "1px solid var(--border)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
transition: "background 0.1s",
|
transition: "background 0.1s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) =>
|
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--bg-hover)")}
|
||||||
(e.currentTarget.style.background = "rgba(255,255,255,0.02)")
|
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.background = "transparent")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Td>
|
<Td><StatusBadge status={node.health} /></Td>
|
||||||
<StatusBadge status={node.health} />
|
|
||||||
</Td>
|
|
||||||
<Td mono>{node.mac ?? "--"}</Td>
|
<Td mono>{node.mac ?? "--"}</Td>
|
||||||
<Td mono>{node.ip}</Td>
|
<Td mono>{node.ip}</Td>
|
||||||
<Td>{node.firmware_version ?? "--"}</Td>
|
<Td mono>{node.firmware_version ?? "--"}</Td>
|
||||||
<Td>{node.chip.toUpperCase()}</Td>
|
<Td>{node.chip.toUpperCase()}</Td>
|
||||||
<Td>{formatLastSeen(node.last_seen)}</Td>
|
<Td>{formatLastSeen(node.last_seen)}</Td>
|
||||||
</tr>
|
</tr>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<tr style={{ borderBottom: "1px solid var(--border, #2e2e3e)" }}>
|
<tr style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
<td colSpan={6} style={{ padding: "0 16px 16px" }}>
|
<td colSpan={6} style={{ padding: "0 var(--space-4) var(--space-4)" }}>
|
||||||
<ExpandedDetails node={node} />
|
<ExpandedDetails node={node} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -255,17 +210,17 @@ function ExpandedDetails({ node }: { node: Node }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(0,0,0,0.15)",
|
background: "var(--bg-elevated)",
|
||||||
borderRadius: "6px",
|
borderRadius: 6,
|
||||||
padding: "16px",
|
padding: "var(--space-4)",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
|
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
|
||||||
gap: "12px 24px",
|
gap: "var(--space-3) var(--space-5)",
|
||||||
fontSize: "12px",
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DetailField label="Hostname" value={node.hostname ?? "--"} />
|
<DetailField label="Hostname" value={node.hostname ?? "--"} />
|
||||||
<DetailField label="Node ID" value={String(node.node_id)} />
|
<DetailField label="Node ID" value={String(node.node_id)} mono />
|
||||||
<DetailField label="Mesh Role" value={node.mesh_role} />
|
<DetailField label="Mesh Role" value={node.mesh_role} />
|
||||||
<DetailField
|
<DetailField
|
||||||
label="TDM Slot"
|
label="TDM Slot"
|
||||||
|
|
@ -274,10 +229,12 @@ function ExpandedDetails({ node }: { node: Node }) {
|
||||||
? `${node.tdm_slot} / ${node.tdm_total}`
|
? `${node.tdm_slot} / ${node.tdm_total}`
|
||||||
: "--"
|
: "--"
|
||||||
}
|
}
|
||||||
|
mono
|
||||||
/>
|
/>
|
||||||
<DetailField
|
<DetailField
|
||||||
label="Edge Tier"
|
label="Edge Tier"
|
||||||
value={node.edge_tier != null ? String(node.edge_tier) : "--"}
|
value={node.edge_tier != null ? String(node.edge_tier) : "--"}
|
||||||
|
mono
|
||||||
/>
|
/>
|
||||||
<DetailField
|
<DetailField
|
||||||
label="Uptime"
|
label="Uptime"
|
||||||
|
|
@ -286,6 +243,7 @@ function ExpandedDetails({ node }: { node: Node }) {
|
||||||
? `${Math.floor(node.uptime_secs / 3600)}h ${Math.floor((node.uptime_secs % 3600) / 60)}m`
|
? `${Math.floor(node.uptime_secs / 3600)}h ${Math.floor((node.uptime_secs % 3600) / 60)}m`
|
||||||
: "--"
|
: "--"
|
||||||
}
|
}
|
||||||
|
mono
|
||||||
/>
|
/>
|
||||||
<DetailField label="Discovery" value={node.discovery_method} />
|
<DetailField label="Discovery" value={node.discovery_method} />
|
||||||
<DetailField
|
<DetailField
|
||||||
|
|
@ -299,29 +257,30 @@ function ExpandedDetails({ node }: { node: Node }) {
|
||||||
: "--"
|
: "--"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{node.friendly_name && (
|
{node.friendly_name && <DetailField label="Name" value={node.friendly_name} />}
|
||||||
<DetailField label="Name" value={node.friendly_name} />
|
|
||||||
)}
|
|
||||||
{node.notes && <DetailField label="Notes" value={node.notes} />}
|
{node.notes && <DetailField label="Notes" value={node.notes} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailField({ label, value }: { label: string; value: string }) {
|
function DetailField({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "10px",
|
fontSize: 10,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.05em",
|
letterSpacing: "0.05em",
|
||||||
color: "var(--text-muted, #64748b)",
|
color: "var(--text-muted)",
|
||||||
marginBottom: "2px",
|
marginBottom: 2,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "var(--text-secondary, #94a3b8)" }}>{value}</div>
|
<div style={{ color: "var(--text-secondary)", fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)" }}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||||
server_http_port: 8080,
|
server_http_port: 8080,
|
||||||
server_ws_port: 8765,
|
server_ws_port: 8765,
|
||||||
server_udp_port: 5005,
|
server_udp_port: 5005,
|
||||||
bind_address: "0.0.0.0",
|
bind_address: "127.0.0.1",
|
||||||
ui_path: "",
|
ui_path: "",
|
||||||
ota_psk: "",
|
ota_psk: "",
|
||||||
auto_discover: true,
|
auto_discover: true,
|
||||||
|
|
@ -19,17 +19,14 @@ export function Settings() {
|
||||||
const [showPsk, setShowPsk] = useState(false);
|
const [showPsk, setShowPsk] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Load persisted settings on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const { invoke } = await import("@tauri-apps/api/core");
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
const persisted = await invoke<AppSettings | null>("get_settings");
|
const persisted = await invoke<AppSettings | null>("get_settings");
|
||||||
if (persisted) {
|
if (persisted) setSettings(persisted);
|
||||||
setSettings(persisted);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Settings command may not exist yet -- use defaults
|
// Settings command may not exist yet
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -60,55 +57,38 @@ export function Settings() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "24px", maxWidth: "600px" }}>
|
<div style={{ padding: "var(--space-5)", maxWidth: 600 }}>
|
||||||
<h1
|
<h1 className="heading-lg" style={{ margin: "0 0 var(--space-1)" }}>Settings</h1>
|
||||||
style={{
|
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: "var(--space-5)" }}>
|
||||||
fontSize: "22px",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--text-primary, #e2e8f0)",
|
|
||||||
margin: "0 0 4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "13px",
|
|
||||||
color: "var(--text-secondary, #94a3b8)",
|
|
||||||
marginBottom: "24px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Configure server, network, and application preferences
|
Configure server, network, and application preferences
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(239, 68, 68, 0.1)",
|
background: "rgba(248, 81, 73, 0.1)",
|
||||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
border: "1px solid rgba(248, 81, 73, 0.3)",
|
||||||
borderRadius: "6px",
|
borderRadius: 6,
|
||||||
padding: "12px 16px",
|
padding: "var(--space-3) var(--space-4)",
|
||||||
marginBottom: "16px",
|
marginBottom: "var(--space-4)",
|
||||||
fontSize: "13px",
|
fontSize: 13,
|
||||||
color: "#fca5a5",
|
color: "var(--status-error)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Saved toast */}
|
|
||||||
{saved && (
|
{saved && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(34, 197, 94, 0.1)",
|
background: "rgba(63, 185, 80, 0.1)",
|
||||||
border: "1px solid rgba(34, 197, 94, 0.3)",
|
border: "1px solid rgba(63, 185, 80, 0.3)",
|
||||||
borderRadius: "6px",
|
borderRadius: 6,
|
||||||
padding: "12px 16px",
|
padding: "var(--space-3) var(--space-4)",
|
||||||
marginBottom: "16px",
|
marginBottom: "var(--space-4)",
|
||||||
fontSize: "13px",
|
fontSize: 13,
|
||||||
color: "#86efac",
|
color: "var(--status-online)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Settings saved.
|
Settings saved.
|
||||||
|
|
@ -117,50 +97,32 @@ export function Settings() {
|
||||||
|
|
||||||
{/* Sensing Server */}
|
{/* Sensing Server */}
|
||||||
<Section title="Sensing Server">
|
<Section title="Sensing Server">
|
||||||
<div
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)" }}>
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr 1fr",
|
|
||||||
gap: "16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Field label="HTTP Port">
|
<Field label="HTTP Port">
|
||||||
<NumberInput
|
<NumberInput value={settings.server_http_port} onChange={(v) => update("server_http_port", v)} min={1} max={65535} />
|
||||||
value={settings.server_http_port}
|
|
||||||
onChange={(v) => update("server_http_port", v)}
|
|
||||||
min={1}
|
|
||||||
max={65535}
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="WebSocket Port">
|
<Field label="WebSocket Port">
|
||||||
<NumberInput
|
<NumberInput value={settings.server_ws_port} onChange={(v) => update("server_ws_port", v)} min={1} max={65535} />
|
||||||
value={settings.server_ws_port}
|
|
||||||
onChange={(v) => update("server_ws_port", v)}
|
|
||||||
min={1}
|
|
||||||
max={65535}
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="UDP Port">
|
<Field label="UDP Port">
|
||||||
<NumberInput
|
<NumberInput value={settings.server_udp_port} onChange={(v) => update("server_udp_port", v)} min={1} max={65535} />
|
||||||
value={settings.server_udp_port}
|
|
||||||
onChange={(v) => update("server_udp_port", v)}
|
|
||||||
min={1}
|
|
||||||
max={65535}
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Bind Address">
|
<Field label="Bind Address">
|
||||||
<TextInput
|
<input
|
||||||
|
type="text"
|
||||||
value={settings.bind_address}
|
value={settings.bind_address}
|
||||||
onChange={(v) => update("bind_address", v)}
|
onChange={(e) => update("bind_address", e.target.value)}
|
||||||
placeholder="0.0.0.0"
|
placeholder="127.0.0.1"
|
||||||
|
style={{ fontFamily: "var(--font-mono)" }}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: "16px" }}>
|
<div style={{ marginTop: "var(--space-4)" }}>
|
||||||
<Field label="UI Static Files Path">
|
<Field label="UI Static Files Path">
|
||||||
<TextInput
|
<input
|
||||||
|
type="text"
|
||||||
value={settings.ui_path}
|
value={settings.ui_path}
|
||||||
onChange={(v) => update("ui_path", v)}
|
onChange={(e) => update("ui_path", e.target.value)}
|
||||||
placeholder="Leave empty for default"
|
placeholder="Leave empty for default"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -170,28 +132,19 @@ export function Settings() {
|
||||||
{/* Security */}
|
{/* Security */}
|
||||||
<Section title="Security">
|
<Section title="Security">
|
||||||
<Field label="OTA Pre-Shared Key (PSK)">
|
<Field label="OTA Pre-Shared Key (PSK)">
|
||||||
<div style={{ display: "flex", gap: "8px" }}>
|
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||||
<input
|
<input
|
||||||
type={showPsk ? "text" : "password"}
|
type={showPsk ? "text" : "password"}
|
||||||
value={settings.ota_psk}
|
value={settings.ota_psk}
|
||||||
onChange={(e) => update("ota_psk", e.target.value)}
|
onChange={(e) => update("ota_psk", e.target.value)}
|
||||||
placeholder="Enter PSK for OTA authentication"
|
placeholder="Enter PSK for OTA authentication"
|
||||||
style={{ ...inputStyle, flex: 1 }}
|
style={{ flex: 1, fontFamily: "var(--font-mono)" }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button onClick={() => setShowPsk((prev) => !prev)} style={secondaryBtn}>
|
||||||
onClick={() => setShowPsk((prev) => !prev)}
|
|
||||||
style={secondaryBtnStyle}
|
|
||||||
>
|
|
||||||
{showPsk ? "Hide" : "Show"}
|
{showPsk ? "Hide" : "Show"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: "var(--space-1)" }}>
|
||||||
style={{
|
|
||||||
fontSize: "11px",
|
|
||||||
color: "var(--text-muted, #64748b)",
|
|
||||||
marginTop: "4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Used for authenticating OTA firmware updates to nodes.
|
Used for authenticating OTA firmware updates to nodes.
|
||||||
</p>
|
</p>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -199,36 +152,16 @@ export function Settings() {
|
||||||
|
|
||||||
{/* Discovery */}
|
{/* Discovery */}
|
||||||
<Section title="Network Discovery">
|
<Section title="Network Discovery">
|
||||||
<div
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)" }}>
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr 1fr",
|
|
||||||
gap: "16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Field label="Auto-Discover">
|
<Field label="Auto-Discover">
|
||||||
<label
|
<label style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", cursor: "pointer" }}>
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.auto_discover}
|
checked={settings.auto_discover}
|
||||||
onChange={(e) => update("auto_discover", e.target.checked)}
|
onChange={(e) => update("auto_discover", e.target.checked)}
|
||||||
style={{ accentColor: "var(--accent, #6366f1)" }}
|
style={{ accentColor: "var(--accent)" }}
|
||||||
/>
|
/>
|
||||||
<span
|
<span style={{ fontSize: 13, color: "var(--text-secondary)" }}>Enable periodic scanning</span>
|
||||||
style={{
|
|
||||||
fontSize: "13px",
|
|
||||||
color: "var(--text-secondary, #94a3b8)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Enable periodic scanning
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Scan Interval (ms)">
|
<Field label="Scan Interval (ms)">
|
||||||
|
|
@ -245,51 +178,34 @@ export function Settings() {
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div
|
<div style={{ display: "flex", justifyContent: "space-between", marginTop: "var(--space-5)" }}>
|
||||||
style={{
|
<button onClick={reset} style={secondaryBtn}>Reset to Defaults</button>
|
||||||
display: "flex",
|
<button onClick={save} style={primaryBtn}>Save Settings</button>
|
||||||
justifyContent: "space-between",
|
|
||||||
marginTop: "24px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button onClick={reset} style={secondaryBtnStyle}>
|
|
||||||
Reset to Defaults
|
|
||||||
</button>
|
|
||||||
<button onClick={save} style={primaryBtnStyle}>
|
|
||||||
Save Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// --- Sub-components ---
|
||||||
// Sub-components
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function Section({
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "var(--card-bg, #1e1e2e)",
|
background: "var(--bg-surface)",
|
||||||
border: "1px solid var(--border, #2e2e3e)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
borderRadius: 8,
|
||||||
padding: "20px",
|
padding: "var(--space-5)",
|
||||||
marginBottom: "16px",
|
marginBottom: "var(--space-4)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
style={{
|
style={{
|
||||||
fontSize: "14px",
|
fontSize: 14,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "var(--text-primary, #e2e8f0)",
|
color: "var(--text-primary)",
|
||||||
margin: "0 0 16px",
|
margin: "0 0 var(--space-4)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
|
|
@ -299,22 +215,17 @@ function Section({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
label,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
fontSize: "12px",
|
fontSize: 12,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "var(--text-secondary, #94a3b8)",
|
color: "var(--text-secondary)",
|
||||||
marginBottom: "6px",
|
marginBottom: 6,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|
@ -324,101 +235,42 @@ function Field({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
disabled = false,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled}
|
|
||||||
style={{
|
|
||||||
...inputStyle,
|
|
||||||
opacity: disabled ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumberInput({
|
function NumberInput({
|
||||||
value,
|
value, onChange, min, max, step = 1, disabled = false,
|
||||||
onChange,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
step = 1,
|
|
||||||
disabled = false,
|
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number; onChange: (v: number) => void; min?: number; max?: number; step?: number; disabled?: boolean;
|
||||||
onChange: (v: number) => void;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
step?: number;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => {
|
onChange={(e) => { const n = parseInt(e.target.value, 10); if (!isNaN(n)) onChange(n); }}
|
||||||
const n = parseInt(e.target.value, 10);
|
|
||||||
if (!isNaN(n)) onChange(n);
|
|
||||||
}}
|
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
step={step}
|
step={step}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={{
|
|
||||||
...inputStyle,
|
|
||||||
opacity: disabled ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// --- Shared styles ---
|
||||||
// Shared styles
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const primaryBtn: React.CSSProperties = {
|
||||||
width: "100%",
|
padding: "var(--space-2) 20px",
|
||||||
padding: "8px 12px",
|
|
||||||
border: "1px solid var(--border, #2e2e3e)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
background: "var(--input-bg, #12121a)",
|
|
||||||
color: "var(--text-primary, #e2e8f0)",
|
|
||||||
fontSize: "13px",
|
|
||||||
outline: "none",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
};
|
|
||||||
|
|
||||||
const primaryBtnStyle: React.CSSProperties = {
|
|
||||||
padding: "8px 20px",
|
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "6px",
|
borderRadius: 6,
|
||||||
background: "var(--accent, #6366f1)",
|
background: "var(--accent)",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontSize: "13px",
|
fontSize: 13,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
cursor: "pointer",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const secondaryBtnStyle: React.CSSProperties = {
|
const secondaryBtn: React.CSSProperties = {
|
||||||
padding: "8px 16px",
|
padding: "var(--space-2) var(--space-4)",
|
||||||
border: "1px solid var(--border, #2e2e3e)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "6px",
|
borderRadius: 6,
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
color: "var(--text-secondary, #94a3b8)",
|
color: "var(--text-secondary)",
|
||||||
fontSize: "13px",
|
fontSize: 13,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
cursor: "pointer",
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue