feat(client): add ContextMenu component, environment cards, detail page, and breadcrumbs

- add ContextMenu component with click-outside/Escape dismiss, align prop, and danger variant
- replace EnvironmentsPage table with responsive card grid
- add cog button to each card opening a context menu with view and delete actions
- add EnvironmentDetailPage at /environments/:id with meta grid, URLs card, and delete
- add Breadcrumbs to all pages; detail page uses two-item trail with delete button inline
- add Table stories for pagination and Timestamp column
This commit is contained in:
2026-04-09 10:55:45 +03:00
parent 077cd6ad5f
commit 35997f8bcb
13 changed files with 556 additions and 42 deletions
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useRef, useState } from 'react';
import styles from './ContextMenu.module.css';
export interface ContextMenuItem {
label: string;
onClick: () => void;
variant?: 'default' | 'danger';
icon?: React.ReactNode;
}
export interface ContextMenuProps {
items: ContextMenuItem[];
/** The trigger element — a button rendered by the component */
trigger: React.ReactNode;
align?: 'left' | 'right';
}
export function ContextMenu({ items, trigger, align = 'right' }: ContextMenuProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleOutside(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleOutside);
return () => document.removeEventListener('mousedown', handleOutside);
}, [open]);
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [open]);
return (
<div className={styles.root} ref={rootRef}>
<div
className={styles.triggerWrap}
onClick={(e) => {
e.stopPropagation();
setOpen((v) => !v);
}}
>
{trigger}
</div>
{open && (
<ul
className={`${styles.menu} ${align === 'left' ? styles.alignLeft : styles.alignRight}`}
role="menu"
>
{items.map((item, i) => (
<li key={i} role="none">
<button
role="menuitem"
className={`${styles.item} ${item.variant === 'danger' ? styles.danger : ''}`}
onClick={(e) => {
e.stopPropagation();
setOpen(false);
item.onClick();
}}
>
{item.icon && (
<span className={styles.icon} aria-hidden="true">
{item.icon}
</span>
)}
{item.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}