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
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Settings, Trash2, ExternalLink } from 'lucide-react';
import { Button } from '../../src/ui/Button/Button';
import { ContextMenu } from '../../src/ui/ContextMenu/ContextMenu';
const meta: Meta<typeof ContextMenu> = {
title: 'UI/ContextMenu',
component: ContextMenu,
parameters: { layout: 'centered' },
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof ContextMenu>;
const trigger = (
<Button variant="ghost" size="sm">
<Settings size={16} />
</Button>
);
export const Default: Story = {
args: {
trigger,
items: [
{ label: 'View details', icon: <ExternalLink size={14} />, onClick: () => {} },
{ label: 'Delete', icon: <Trash2 size={14} />, variant: 'danger', onClick: () => {} },
],
},
};
export const AlignLeft: Story = {
args: {
trigger,
align: 'left',
items: [
{ label: 'View details', icon: <ExternalLink size={14} />, onClick: () => {} },
{ label: 'Delete', icon: <Trash2 size={14} />, variant: 'danger', onClick: () => {} },
],
},
};
export const ManyItems: Story = {
args: {
trigger,
items: [
{ label: 'View details', icon: <ExternalLink size={14} />, onClick: () => {} },
{ label: 'Edit', icon: <Settings size={14} />, onClick: () => {} },
{ label: 'Duplicate', onClick: () => {} },
{ label: 'Delete', icon: <Trash2 size={14} />, variant: 'danger', onClick: () => {} },
],
},
};
+21 -3
View File
@@ -22,9 +22,27 @@ interface User {
const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString(); const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString();
const users: User[] = [ const users: User[] = [
{ id: 1, name: 'Alice Müller', email: 'alice@example.com', status: 'active', updatedAt: daysAgo(1) }, {
{ id: 2, name: 'Bob Nguyen', email: 'bob@example.com', status: 'inactive', updatedAt: daysAgo(5) }, id: 1,
{ id: 3, name: 'Carol Santos', email: 'carol@example.com', status: 'active', updatedAt: daysAgo(30) }, name: 'Alice Müller',
email: 'alice@example.com',
status: 'active',
updatedAt: daysAgo(1),
},
{
id: 2,
name: 'Bob Nguyen',
email: 'bob@example.com',
status: 'inactive',
updatedAt: daysAgo(5),
},
{
id: 3,
name: 'Carol Santos',
email: 'carol@example.com',
status: 'active',
updatedAt: daysAgo(30),
},
]; ];
const manyUsers: User[] = Array.from({ length: 47 }, (_, i) => ({ const manyUsers: User[] = Array.from({ length: 47 }, (_, i) => ({
+2
View File
@@ -1,6 +1,7 @@
import styles from './App.module.css'; import styles from './App.module.css';
import { SidePanel, ThemeSwitcher } from './ui'; import { SidePanel, ThemeSwitcher } from './ui';
import { EnvironmentsPage } from './pages/EnvironmentsPage'; import { EnvironmentsPage } from './pages/EnvironmentsPage';
import { EnvironmentDetailPage } from './pages/EnvironmentDetailPage';
import { KeysPage } from './pages/KeysPage'; import { KeysPage } from './pages/KeysPage';
import { SessionsPage } from './pages/SessionsPage'; import { SessionsPage } from './pages/SessionsPage';
import { ScenariosPage } from './pages/ScenariosPage'; import { ScenariosPage } from './pages/ScenariosPage';
@@ -50,6 +51,7 @@ export default function App() {
<Routes> <Routes>
<Route index element={<Navigate to="/scenarios" replace />} /> <Route index element={<Navigate to="/scenarios" replace />} />
<Route path="/environments" element={<EnvironmentsPage />} /> <Route path="/environments" element={<EnvironmentsPage />} />
<Route path="/environments/:id" element={<EnvironmentDetailPage />} />
<Route path="/keys" element={<KeysPage />} /> <Route path="/keys" element={<KeysPage />} />
<Route path="/sessions" element={<SessionsPage />} /> <Route path="/sessions" element={<SessionsPage />} />
<Route path="/scenarios" element={<ScenariosPage />} /> <Route path="/scenarios" element={<ScenariosPage />} />
+11 -1
View File
@@ -11,7 +11,17 @@
"col_name": "Name", "col_name": "Name",
"col_urls": "URLs", "col_urls": "URLs",
"col_updated": "Updated", "col_updated": "Updated",
"empty": "No environments yet." "empty": "No environments yet.",
"loading": "Loading…",
"no_urls": "No URLs configured.",
"menu_label": "Environment options",
"action_view": "View details",
"action_delete": "Delete",
"back": "Environments",
"field_id": "ID",
"field_created": "Created",
"field_updated": "Updated",
"section_urls": "URLs"
}, },
"keys": { "keys": {
"title": "Keys", "title": "Keys",
@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Trash2 } from 'lucide-react';
import { environments } from '../api';
import type { Environment } from '../api';
import { Breadcrumbs, Button, Card, Timestamp } from '../ui';
import styles from './Page.module.css';
export function EnvironmentDetailPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [env, setEnv] = useState<Environment | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
environments
.get(Number(id))
.then(setEnv)
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
const handleDelete = async () => {
if (!env) return;
await environments.remove(env.id);
navigate('/environments');
};
return (
<div>
<div className={styles.pageToolbar}>
<Breadcrumbs
items={[
{ label: t('environments.title'), onClick: () => navigate('/environments') },
{ label: env?.name ?? `#${id}` },
]}
/>
{env && (
<Button variant="danger" size="sm" onClick={handleDelete}>
<Trash2 size={14} />
{t('environments.action_delete')}
</Button>
)}
</div>
{error && <p className={styles.error}>{error}</p>}
{loading && <p className={styles.muted}>{t('environments.loading')}</p>}
{env && (
<>
<div className={styles.detailMeta}>
<span className={styles.metaLabel}>{t('environments.field_id')}</span>
<span>{env.id}</span>
<span className={styles.metaLabel}>{t('environments.field_created')}</span>
<Timestamp value={env.createdAt} />
<span className={styles.metaLabel}>{t('environments.field_updated')}</span>
<Timestamp value={env.updatedAt} />
</div>
<h2 className={styles.sectionHeading}>{t('environments.section_urls')}</h2>
<Card>
{Object.entries(env.urls).filter(([, v]) => v).length === 0 ? (
<p className={styles.muted}>{t('environments.no_urls')}</p>
) : (
<dl className={styles.envDetailUrls}>
{Object.entries(env.urls)
.filter(([, v]) => v)
.map(([key, value]) => (
<div key={key} className={styles.envDetailUrlRow}>
<dt className={styles.envCardUrlKey}>{key}</dt>
<dd className={styles.envDetailUrlVal}>
<a href={value} target="_blank" rel="noopener noreferrer">
{value}
</a>
</dd>
</div>
))}
</dl>
)}
</Card>
</>
)}
</div>
);
}
+75 -32
View File
@@ -1,57 +1,100 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Settings, ExternalLink, Trash2 } from 'lucide-react';
import { environments } from '../api'; import { environments } from '../api';
import type { Environment } from '../api'; import type { Environment } from '../api';
import { Table, type TableColumn, Timestamp } from '../ui'; import { Breadcrumbs, Button, Card, ContextMenu, Timestamp } from '../ui';
import styles from './Page.module.css'; import styles from './Page.module.css';
function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: number) => void }) {
const { t } = useTranslation();
const navigate = useNavigate();
const urlEntries = Object.entries(env.urls).filter(([, v]) => v);
return (
<Card className={styles.envCard}>
<div className={styles.envCardHeader}>
<span className={styles.envCardId}>#{env.id}</span>
<span className={styles.envCardName}>{env.name}</span>
<Timestamp value={env.updatedAt} />
<ContextMenu
align="right"
trigger={
<Button variant="ghost" size="sm" aria-label={t('environments.menu_label')}>
<Settings size={14} />
</Button>
}
items={[
{
label: t('environments.action_view'),
icon: <ExternalLink size={14} />,
onClick: () => navigate(`/environments/${env.id}`),
},
{
label: t('environments.action_delete'),
icon: <Trash2 size={14} />,
variant: 'danger',
onClick: () => onDelete(env.id),
},
]}
/>
</div>
{urlEntries.length > 0 && (
<dl className={styles.envCardUrls}>
{urlEntries.map(([key, value]) => (
<div key={key} className={styles.envCardUrlRow}>
<dt className={styles.envCardUrlKey}>{key}</dt>
<dd className={styles.envCardUrlVal}>{value}</dd>
</div>
))}
</dl>
)}
{urlEntries.length === 0 && (
<p className={styles.envCardEmpty}>{t('environments.no_urls')}</p>
)}
</Card>
);
}
export function EnvironmentsPage() { export function EnvironmentsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [items, setItems] = useState<Environment[]>([]); const [items, setItems] = useState<Environment[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const columns: TableColumn<Environment>[] = [ const load = () => {
{ key: 'id', header: t('environments.col_id'), render: (e) => e.id, width: 60 },
{ key: 'name', header: t('environments.col_name'), render: (e) => e.name },
{
key: 'urls',
header: t('environments.col_urls'),
render: (e) =>
Object.entries(e.urls)
.filter(([, v]) => v)
.map(([k, v]) => `${k}: ${v}`)
.join(' · ') || '—',
},
{
key: 'updated',
header: t('environments.col_updated'),
width: 140,
render: (e) => <Timestamp value={e.updatedAt} />,
},
];
useEffect(() => {
environments environments
.list() .list()
.then((res) => setItems(res.data)) .then((res) => setItems(res.data))
.catch((err: Error) => setError(err.message)) .catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
};
useEffect(() => {
load();
}, []); }, []);
const handleDelete = async (id: number) => {
await environments.remove(id);
setItems((prev) => prev.filter((e) => e.id !== id));
};
return ( return (
<div> <div>
<h1 className={styles.heading}>{t('environments.title')}</h1> <Breadcrumbs items={[{ label: t('environments.title') }]} className={styles.breadcrumbs} />
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
<Table {loading ? (
columns={columns} <p className={styles.muted}>{t('environments.loading')}</p>
data={items} ) : items.length === 0 ? (
rowKey={(e) => e.id} <p className={styles.muted}>{t('environments.empty')}</p>
loading={loading} ) : (
emptyMessage={t('environments.empty')} <div className={styles.envGrid}>
pageSize={10} {items.map((env) => (
pageSizeOptions={[10, 25, 50]} <EnvironmentCard key={env.id} env={env} onDelete={handleDelete} />
/> ))}
</div>
)}
</div> </div>
); );
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { keys } from '../api'; import { keys } from '../api';
import { Table, type TableColumn } from '../ui'; import { Breadcrumbs, Table, type TableColumn } from '../ui';
import styles from './Page.module.css'; import styles from './Page.module.css';
interface KeyRow { interface KeyRow {
@@ -28,7 +28,7 @@ export function KeysPage() {
return ( return (
<div> <div>
<h1 className={styles.heading}>{t('keys.title')}</h1> <Breadcrumbs items={[{ label: t('keys.title') }]} className={styles.breadcrumbs} />
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
<Table <Table
columns={columns} columns={columns}
+154
View File
@@ -5,6 +5,17 @@
color: var(--color-text); color: var(--color-text);
} }
.breadcrumbs {
margin-bottom: var(--space-4);
}
.pageToolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
}
.error { .error {
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
@@ -13,3 +24,146 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.muted {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
/* ── Environment cards ──────────────────────────────────────── */
.envGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-4);
}
.envCard {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.envCardHeader {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.envCardId {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
flex-shrink: 0;
}
.envCardName {
font-weight: 700;
font-size: var(--font-size-sm);
flex: 1;
}
.envCardUrls {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.envCardUrlRow {
display: flex;
gap: var(--space-2);
align-items: baseline;
font-size: var(--font-size-xs);
}
.envCardUrlKey {
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.envCardUrlVal {
margin: 0;
color: var(--color-text);
word-break: break-all;
}
.envCardEmpty {
margin: 0;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
/* ── Environment detail page ────────────────────────────────────────────── */
.detailToolbar {
margin-bottom: var(--space-4);
}
.detailHeader {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.detailHeader .heading {
margin: 0;
flex: 1;
}
.detailMeta {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--space-1) var(--space-4);
margin-bottom: var(--space-6);
font-size: var(--font-size-sm);
}
.metaLabel {
color: var(--color-text-muted);
font-weight: 600;
text-transform: uppercase;
font-size: var(--font-size-xs);
letter-spacing: 0.04em;
align-self: center;
}
.sectionHeading {
margin: 0 0 var(--space-3);
font-size: var(--font-size-md);
font-weight: 700;
}
.envDetailUrls {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.envDetailUrlRow {
display: grid;
grid-template-columns: 140px 1fr;
gap: var(--space-2);
align-items: baseline;
font-size: var(--font-size-sm);
}
.envDetailUrlVal {
margin: 0;
word-break: break-all;
}
.envDetailUrlVal a {
color: var(--color-text-muted);
text-decoration: none;
}
.envDetailUrlVal a:hover {
text-decoration: underline;
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { scenarios } from '../api'; import { scenarios } from '../api';
import type { Scenario } from '../api'; import type { Scenario } from '../api';
import { Button, Table, type TableColumn, Timestamp } from '../ui'; import { Breadcrumbs, Button, Table, type TableColumn, Timestamp } from '../ui';
import styles from './Page.module.css'; import styles from './Page.module.css';
export function ScenariosPage() { export function ScenariosPage() {
@@ -62,7 +62,7 @@ export function ScenariosPage() {
return ( return (
<div> <div>
<h1 className={styles.heading}>{t('scenarios.title')}</h1> <Breadcrumbs items={[{ label: t('scenarios.title') }]} className={styles.breadcrumbs} />
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
<Table <Table
columns={columns} columns={columns}
+2 -2
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { sessions } from '../api'; import { sessions } from '../api';
import type { Session } from '../api'; import type { Session } from '../api';
import { Badge, Button, Table, type TableColumn, Timestamp } from '../ui'; import { Badge, Breadcrumbs, Button, Table, type TableColumn, Timestamp } from '../ui';
import styles from './Page.module.css'; import styles from './Page.module.css';
export function SessionsPage() { export function SessionsPage() {
@@ -61,7 +61,7 @@ export function SessionsPage() {
return ( return (
<div> <div>
<h1 className={styles.heading}>{t('sessions.title')}</h1> <Breadcrumbs items={[{ label: t('sessions.title') }]} className={styles.breadcrumbs} />
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
<Table <Table
columns={columns} columns={columns}
@@ -0,0 +1,60 @@
.root {
position: relative;
display: inline-flex;
}
.triggerWrap {
display: inline-flex;
}
.menu {
position: absolute;
top: calc(100% + var(--space-1));
z-index: 100;
min-width: 160px;
margin: 0;
padding: var(--space-1) 0;
list-style: none;
background: var(--color-bg-subtle);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.alignRight { right: 0; }
.alignLeft { left: 0; }
.item {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-4);
background: transparent;
border: none;
text-align: left;
font-family: var(--font-family);
font-size: var(--font-size-sm);
color: var(--color-text);
cursor: pointer;
transition: background var(--transition), color var(--transition);
white-space: nowrap;
}
.item:hover {
background: var(--color-secondary);
}
.item.danger {
color: var(--color-danger);
}
.item.danger:hover {
background: var(--color-error-bg);
}
.icon {
display: inline-flex;
align-items: center;
opacity: 0.75;
}
+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>
);
}
+3
View File
@@ -27,5 +27,8 @@ export type { TimestampProps } from './Timestamp/Timestamp';
export { Pagination } from './Pagination/Pagination'; export { Pagination } from './Pagination/Pagination';
export type { PaginationProps } from './Pagination/Pagination'; export type { PaginationProps } from './Pagination/Pagination';
export { ContextMenu } from './ContextMenu/ContextMenu';
export type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMenu';
export { Table } from './Table/Table'; export { Table } from './Table/Table';
export type { TableProps, TableColumn } from './Table/Table'; export type { TableProps, TableColumn } from './Table/Table';