feat(web): command palette, toasts, and global shell chrome

- CommandPalette (Alt+K): fuzzy action launcher with keyboard nav.
- Toasts: ephemeral notification stack + provider.
- useGlobalHotkeys: Alt+K palette toggle, G-chord navigation
  (G D/F/M/L/B/A/S/U/E/C), respects editable-element focus.
- Layout/App: wire ToastProvider at root, mount the palette inside the
  authed shell, introduce the global search box in the top bar.
- MazeNETRoute now renders TopologyList inline when no ?topology is
  present, instead of bouncing through a redirect.
- index.css: a few global token tweaks consumed by the new chrome.

Fixes a latent breakage: Config.tsx and MazeNET already imported
./Toasts/useToast but the directory was never committed.
This commit is contained in:
2026-04-22 17:15:19 -04:00
parent dca6eddd5f
commit ccbe949238
13 changed files with 935 additions and 59 deletions

View File

@@ -0,0 +1,116 @@
.cmd-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 120;
display: flex;
justify-content: center;
padding-top: 12vh;
backdrop-filter: blur(1px);
}
.cmd-palette {
width: 620px;
max-width: 96vw;
height: fit-content;
background: var(--panel);
border: 1px solid var(--matrix);
box-shadow: var(--matrix-glow-lg);
display: flex;
flex-direction: column;
animation: cmd-in 0.18s var(--ease);
}
@keyframes cmd-in {
from { transform: translateY(-8px); opacity: 0; }
to { transform: none; opacity: 1; }
}
.cmd-input-wrap {
display: flex;
align-items: center;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
gap: 10px;
}
.cmd-input-wrap input {
background: transparent;
border: none;
outline: none;
color: var(--matrix);
font-family: inherit;
font-size: 0.95rem;
width: 100%;
letter-spacing: 1px;
padding: 0;
}
.cmd-input-wrap input:focus { box-shadow: none; }
.cmd-list {
max-height: 380px;
overflow-y: auto;
padding: 6px 0;
scrollbar-width: none;
-ms-overflow-style: none;
}
.cmd-list::-webkit-scrollbar {
display: none;
}
.cmd-group-label {
padding: 8px 16px 4px;
font-size: 0.6rem;
opacity: 0.4;
letter-spacing: 1.5px;
text-transform: uppercase;
}
.cmd-item {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 16px;
cursor: pointer;
font-size: 0.78rem;
border-left: 3px solid transparent;
}
.cmd-item-icon { opacity: 0.6; flex-shrink: 0; }
.cmd-item.active {
background: var(--matrix-tint-10);
border-left-color: var(--matrix);
padding-left: 13px;
}
.cmd-item.active .cmd-item-icon { opacity: 1; }
.cmd-kbd {
margin-left: auto;
font-size: 0.62rem;
opacity: 0.5;
border: 1px solid var(--border);
padding: 1px 5px;
letter-spacing: 1px;
}
.cmd-empty {
padding: 24px 16px;
text-align: center;
font-size: 0.7rem;
letter-spacing: 1px;
opacity: 0.5;
}
.cmd-hint {
padding: 8px 16px;
border-top: 1px solid var(--border);
font-size: 0.6rem;
opacity: 0.4;
letter-spacing: 1px;
display: flex;
justify-content: space-between;
}

View File

@@ -0,0 +1,156 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
LayoutDashboard, Server, Network, Terminal, Archive, Crosshair,
PlusCircle, Pause, RefreshCw, Download, HardDrive, Package, UserPlus, Settings,
} from 'lucide-react';
import './CommandPalette.css';
type IconComponent = React.ComponentType<{ size?: number; className?: string }>;
interface CmdItem {
section: 'GO TO' | 'ACTIONS';
label: string;
icon: IconComponent;
kbd?: string;
kind: 'nav' | 'action';
payload: string;
}
const ITEMS: CmdItem[] = [
{ section: 'GO TO', label: 'Dashboard', icon: LayoutDashboard, kbd: 'G D', kind: 'nav', payload: '/' },
{ section: 'GO TO', label: 'Decoy Fleet', icon: Server, kbd: 'G F', kind: 'nav', payload: '/fleet' },
{ section: 'GO TO', label: 'MazeNET', icon: Network, kbd: 'G M', kind: 'nav', payload: '/mazenet' },
{ section: 'GO TO', label: 'Logs', icon: Terminal, kbd: 'G L', kind: 'nav', payload: '/live-logs' },
{ section: 'GO TO', label: 'Bounty Vault', icon: Archive, kbd: 'G B', kind: 'nav', payload: '/bounty' },
{ section: 'GO TO', label: 'Attackers', icon: Crosshair, kbd: 'G A', kind: 'nav', payload: '/attackers' },
{ section: 'GO TO', label: 'SWARM Hosts', icon: HardDrive, kbd: 'G S', kind: 'nav', payload: '/swarm/hosts' },
{ section: 'GO TO', label: 'Remote Updates', icon: Package, kbd: 'G U', kind: 'nav', payload: '/swarm-updates' },
{ section: 'GO TO', label: 'Agent Enrollment', icon: UserPlus, kbd: 'G E', kind: 'nav', payload: '/swarm/enroll' },
{ section: 'GO TO', label: 'Config', icon: Settings, kbd: 'G C', kind: 'nav', payload: '/config' },
{ section: 'ACTIONS', label: 'Deploy new decky', icon: PlusCircle, kind: 'action', payload: 'deploy' },
{ section: 'ACTIONS', label: 'Pause live stream', icon: Pause, kind: 'action', payload: 'pause-logs' },
{ section: 'ACTIONS', label: 'Force mutate all deckies', icon: RefreshCw, kind: 'action', payload: 'mutate-all' },
{ section: 'ACTIONS', label: 'Export bounty to JSON', icon: Download, kind: 'action', payload: 'export-bounty' },
];
interface Props {
open: boolean;
onClose: () => void;
onNav: (path: string) => void;
onAction: (id: string) => void;
}
const CommandPalette: React.FC<Props> = ({ open, onClose, onNav, onAction }) => {
const [query, setQuery] = useState('');
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return ITEMS;
return ITEMS.filter(it =>
it.label.toLowerCase().includes(q) || it.section.toLowerCase().includes(q)
);
}, [query]);
useEffect(() => {
if (open) {
setQuery('');
setSel(0);
const t = setTimeout(() => inputRef.current?.focus(), 30);
return () => clearTimeout(t);
}
}, [open]);
useEffect(() => { setSel(0); }, [query]);
useEffect(() => {
const el = listRef.current?.querySelector<HTMLElement>(`[data-cmd-idx="${sel}"]`);
el?.scrollIntoView({ block: 'nearest' });
}, [sel]);
if (!open) return null;
const fire = (it: CmdItem) => {
if (it.kind === 'nav') onNav(it.payload);
else onAction(it.payload);
onClose();
};
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.key === 'ArrowDown') {
e.preventDefault();
setSel(s => (filtered.length ? (s + 1) % filtered.length : 0));
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSel(s => (filtered.length ? (s - 1 + filtered.length) % filtered.length : 0));
}
if (e.key === 'Enter') {
e.preventDefault();
const it = filtered[sel];
if (it) fire(it);
}
};
const groups = filtered.reduce<Record<string, CmdItem[]>>((acc, it) => {
(acc[it.section] ||= []).push(it);
return acc;
}, {});
let idx = -1;
return (
<div className="cmd-backdrop" onClick={onClose}>
<div className="cmd-palette" onClick={e => e.stopPropagation()}>
<div className="cmd-input-wrap">
<Terminal size={16} className="violet-accent" />
<input
ref={inputRef}
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKey}
placeholder="Type a command or search…"
/>
<span className="search-kbd">ESC</span>
</div>
<div className="cmd-list" ref={listRef}>
{Object.entries(groups).map(([section, items]) => (
<div key={section}>
<div className="cmd-group-label">{section}</div>
{items.map(it => {
idx++;
const active = idx === sel;
const Icon = it.icon;
return (
<div
key={it.label}
data-cmd-idx={idx}
className={`cmd-item ${active ? 'active' : ''}`}
onClick={() => fire(it)}
onMouseEnter={() => setSel(filtered.indexOf(it))}
>
<Icon size={14} className="cmd-item-icon" />
<span>{it.label}</span>
{it.kbd && <span className="cmd-kbd">{it.kbd}</span>}
</div>
);
})}
</div>
))}
{filtered.length === 0 && (
<div className="cmd-empty">NO COMMAND MATCHES</div>
)}
</div>
<div className="cmd-hint">
<span> NAVIGATE · SELECT</span>
<span>DECNET CLI</span>
</div>
</div>
</div>
);
};
export default CommandPalette;