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:
116
decnet_web/src/components/CommandPalette/CommandPalette.css
Normal file
116
decnet_web/src/components/CommandPalette/CommandPalette.css
Normal 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;
|
||||
}
|
||||
156
decnet_web/src/components/CommandPalette/CommandPalette.tsx
Normal file
156
decnet_web/src/components/CommandPalette/CommandPalette.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user