diff --git a/decnet_web/src/components/EmptyState/EmptyState.css b/decnet_web/src/components/EmptyState/EmptyState.css new file mode 100644 index 00000000..c3d0b993 --- /dev/null +++ b/decnet_web/src/components/EmptyState/EmptyState.css @@ -0,0 +1,52 @@ +/* Shared EmptyState styling. Component-scoped .empty-state rules + (Attackers/Bounty/LiveLogs) still win over these base rules. */ + +.empty-state-icon { + opacity: 0.4; + color: var(--violet); +} + +.empty-state-title { + font-size: 0.75rem; + letter-spacing: 2px; + opacity: 0.7; +} + +.empty-state-hint { + font-size: 0.65rem; + opacity: 0.45; + letter-spacing: 1px; + margin-top: -4px; +} + +.empty-state-cta { + margin-top: 6px; + padding: 6px 12px; + background: transparent; + border: 1px solid var(--matrix); + color: var(--matrix); + font-family: var(--font-mono); + font-size: 0.65rem; + letter-spacing: 2px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + text-transform: uppercase; +} + +.empty-state-cta:hover { + background: rgba(0, 255, 65, 0.1); + box-shadow: 0 0 8px rgba(0, 255, 65, 0.3); +} + +.empty-state.empty-state-compact { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 4px; + font-size: 0.65rem; + letter-spacing: 1.5px; + opacity: 0.5; + color: var(--text-dim, #888); +} diff --git a/decnet_web/src/components/EmptyState/EmptyState.tsx b/decnet_web/src/components/EmptyState/EmptyState.tsx new file mode 100644 index 00000000..923cf6b4 --- /dev/null +++ b/decnet_web/src/components/EmptyState/EmptyState.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import type { LucideIcon } from 'lucide-react'; +import './EmptyState.css'; + +interface CTA { + label: string; + onClick: () => void; + icon?: LucideIcon; +} + +interface Props { + icon?: LucideIcon; + title: string; + hint?: string; + cta?: CTA; + size?: 'default' | 'compact'; + className?: string; +} + +const EmptyState: React.FC = ({ icon: Icon, title, hint, cta, size = 'default', className = '' }) => { + if (size === 'compact') { + return ( +
+ {Icon && } + {title} +
+ ); + } + + const CtaIcon = cta?.icon; + return ( +
+ {Icon && } +
{title}
+ {hint &&
{hint}
} + {cta && ( + + )} +
+ ); +}; + +export default EmptyState; diff --git a/decnet_web/src/components/Modal/Modal.css b/decnet_web/src/components/Modal/Modal.css new file mode 100644 index 00000000..a4703e01 --- /dev/null +++ b/decnet_web/src/components/Modal/Modal.css @@ -0,0 +1,24 @@ +/* Drawer-right variant: reuses .modal-backdrop + .modal base from DeckyFleet.css + and shifts the panel to the right edge as a full-height side panel. */ + +.modal-backdrop.drawer { + justify-content: flex-end; + align-items: stretch; +} + +.modal.modal-drawer-right { + width: 520px; + max-width: 96vw; + max-height: 100vh; + height: 100vh; + border-left: 1px solid var(--matrix); + border-top: none; + border-right: none; + border-bottom: none; + box-shadow: -8px 0 30px rgba(0, 0, 0, 0.6); +} + +.modal.modal-drawer-right.violet { + border-left-color: var(--violet); + box-shadow: -8px 0 30px rgba(238, 130, 238, 0.25); +} diff --git a/decnet_web/src/components/Modal/Modal.tsx b/decnet_web/src/components/Modal/Modal.tsx new file mode 100644 index 00000000..14289811 --- /dev/null +++ b/decnet_web/src/components/Modal/Modal.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useRef } from 'react'; +import { X, type LucideIcon } from 'lucide-react'; +import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useFocusTrap } from '../../hooks/useFocusTrap'; +import './Modal.css'; + +interface Props { + open: boolean; + onClose: () => void; + title?: string; + icon?: LucideIcon; + footer?: React.ReactNode; + accent?: 'matrix' | 'violet'; + width?: 'default' | 'wide'; + variant?: 'center' | 'drawer-right'; + children: React.ReactNode; + className?: string; +} + +const Modal: React.FC = ({ + open, + onClose, + title, + icon: Icon, + footer, + accent = 'matrix', + width = 'default', + variant = 'center', + children, + className = '', +}) => { + const panelRef = useRef(null); + + useEscapeKey(onClose, open); + useFocusTrap(panelRef, open); + + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + if (!open) return null; + + const panelClasses = [ + 'modal', + accent === 'violet' ? 'violet' : '', + width === 'wide' ? 'wide' : '', + variant === 'drawer-right' ? 'modal-drawer-right' : '', + className, + ].filter(Boolean).join(' '); + + const backdropClass = variant === 'drawer-right' ? 'modal-backdrop drawer' : 'modal-backdrop'; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + > + {title && ( +
+

+ {Icon && } + {title} +

+ +
+ )} + {children} + {footer &&
{footer}
} +
+
+ ); +}; + +export default Modal; diff --git a/decnet_web/src/hooks/useEscapeKey.ts b/decnet_web/src/hooks/useEscapeKey.ts new file mode 100644 index 00000000..ae242f91 --- /dev/null +++ b/decnet_web/src/hooks/useEscapeKey.ts @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; + +export function useEscapeKey(onEscape: () => void, active: boolean = true): void { + useEffect(() => { + if (!active) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onEscape(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [onEscape, active]); +} diff --git a/decnet_web/src/hooks/useFocusTrap.ts b/decnet_web/src/hooks/useFocusTrap.ts new file mode 100644 index 00000000..d063b720 --- /dev/null +++ b/decnet_web/src/hooks/useFocusTrap.ts @@ -0,0 +1,49 @@ +import { useEffect, type RefObject } from 'react'; + +const FOCUSABLE = + 'input:not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'; + +export function useFocusTrap( + ref: RefObject, + active: boolean, +): void { + useEffect(() => { + if (!active || !ref.current) return; + const root = ref.current; + const previouslyFocused = document.activeElement as HTMLElement | null; + + const focusables = () => + Array.from(root.querySelectorAll(FOCUSABLE)).filter( + (el) => !el.hasAttribute('aria-hidden') && el.offsetParent !== null, + ); + + const autoFocus = + root.querySelector('[data-autofocus]') ?? focusables()[0]; + autoFocus?.focus(); + + const handler = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + const items = focusables(); + if (items.length === 0) { + e.preventDefault(); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + const current = document.activeElement as HTMLElement | null; + if (e.shiftKey && current === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && current === last) { + e.preventDefault(); + first.focus(); + } + }; + + root.addEventListener('keydown', handler); + return () => { + root.removeEventListener('keydown', handler); + previouslyFocused?.focus?.(); + }; + }, [ref, active]); +}