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:
@@ -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: () => {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,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,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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user