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:
33
decnet_web/src/components/Toasts/ToastProvider.tsx
Normal file
33
decnet_web/src/components/Toasts/ToastProvider.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Toasts from './Toasts';
|
||||
import { ToastContext } from './toast-context';
|
||||
import type { Toast, ToastInput } from './toast-context';
|
||||
|
||||
const DISMISS_MS = 3200;
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [items, setItems] = useState<Toast[]>([]);
|
||||
const timers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const push = useCallback((t: ToastInput) => {
|
||||
const id = Date.now() + Math.random();
|
||||
setItems(prev => [...prev, { ...t, id }]);
|
||||
const timer = setTimeout(() => {
|
||||
setItems(prev => prev.filter(x => x.id !== id));
|
||||
timers.current.delete(id);
|
||||
}, DISMISS_MS);
|
||||
timers.current.set(id, timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const map = timers.current;
|
||||
return () => { map.forEach(clearTimeout); map.clear(); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ push }}>
|
||||
{children}
|
||||
<Toasts items={items} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
33
decnet_web/src/components/Toasts/Toasts.css
Normal file
33
decnet_web/src/components/Toasts/Toasts.css
Normal file
@@ -0,0 +1,33 @@
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 70;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--matrix);
|
||||
padding: 10px 14px;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 1px;
|
||||
min-width: 260px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: var(--matrix);
|
||||
animation: toast-in 0.25s var(--ease);
|
||||
}
|
||||
|
||||
.toast.matrix { border-color: var(--matrix); color: var(--matrix); }
|
||||
.toast.violet { border-color: var(--violet); color: var(--violet); }
|
||||
.toast.alert { border-color: var(--alert); color: var(--alert); }
|
||||
|
||||
@keyframes toast-in {
|
||||
from { transform: translateX(-20px); opacity: 0; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
44
decnet_web/src/components/Toasts/Toasts.tsx
Normal file
44
decnet_web/src/components/Toasts/Toasts.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckCircle, RefreshCw, Download, Upload, Pause, Play, AlertTriangle,
|
||||
Info, Terminal, Activity, ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import type { Toast } from './toast-context';
|
||||
import './Toasts.css';
|
||||
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ size?: number }>> = {
|
||||
'check-circle': CheckCircle,
|
||||
'refresh-cw': RefreshCw,
|
||||
'download': Download,
|
||||
'upload': Upload,
|
||||
'pause': Pause,
|
||||
'play': Play,
|
||||
'alert-triangle': AlertTriangle,
|
||||
'info': Info,
|
||||
'terminal': Terminal,
|
||||
'activity': Activity,
|
||||
'shield-alert': ShieldAlert,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
items: Toast[];
|
||||
}
|
||||
|
||||
const Toasts: React.FC<Props> = ({ items }) => {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="toast-stack">
|
||||
{items.map(t => {
|
||||
const Icon = ICON_MAP[t.icon ?? 'check-circle'] ?? CheckCircle;
|
||||
return (
|
||||
<div key={t.id} className={`toast ${t.tone ?? ''}`.trim()}>
|
||||
<Icon size={14} />
|
||||
<span>{t.text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toasts;
|
||||
19
decnet_web/src/components/Toasts/toast-context.ts
Normal file
19
decnet_web/src/components/Toasts/toast-context.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type ToastTone = 'matrix' | 'violet' | 'alert';
|
||||
|
||||
export interface ToastInput {
|
||||
text: string;
|
||||
icon?: string;
|
||||
tone?: ToastTone;
|
||||
}
|
||||
|
||||
export interface Toast extends ToastInput {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface ToastContextValue {
|
||||
push: (t: ToastInput) => void;
|
||||
}
|
||||
|
||||
export const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
9
decnet_web/src/components/Toasts/useToast.ts
Normal file
9
decnet_web/src/components/Toasts/useToast.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useContext } from 'react';
|
||||
import { ToastContext } from './toast-context';
|
||||
import type { ToastContextValue } from './toast-context';
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user