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,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 { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, ExternalLink, Trash2 } from 'lucide-react';
|
||||
import { environments } 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';
|
||||
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const [items, setItems] = useState<Environment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const columns: TableColumn<Environment>[] = [
|
||||
{ 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(() => {
|
||||
const load = () => {
|
||||
environments
|
||||
.list()
|
||||
.then((res) => setItems(res.data))
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await environments.remove(id);
|
||||
setItems((prev) => prev.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<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>}
|
||||
<Table
|
||||
columns={columns}
|
||||
data={items}
|
||||
rowKey={(e) => e.id}
|
||||
loading={loading}
|
||||
emptyMessage={t('environments.empty')}
|
||||
pageSize={10}
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
/>
|
||||
{loading ? (
|
||||
<p className={styles.muted}>{t('environments.loading')}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className={styles.muted}>{t('environments.empty')}</p>
|
||||
) : (
|
||||
<div className={styles.envGrid}>
|
||||
{items.map((env) => (
|
||||
<EnvironmentCard key={env.id} env={env} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { keys } from '../api';
|
||||
import { Table, type TableColumn } from '../ui';
|
||||
import { Breadcrumbs, Table, type TableColumn } from '../ui';
|
||||
import styles from './Page.module.css';
|
||||
|
||||
interface KeyRow {
|
||||
@@ -28,7 +28,7 @@ export function KeysPage() {
|
||||
|
||||
return (
|
||||
<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>}
|
||||
<Table
|
||||
columns={columns}
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
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 {
|
||||
margin-bottom: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
@@ -13,3 +24,146 @@
|
||||
border-radius: var(--radius-md);
|
||||
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 { scenarios } 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';
|
||||
|
||||
export function ScenariosPage() {
|
||||
@@ -62,7 +62,7 @@ export function ScenariosPage() {
|
||||
|
||||
return (
|
||||
<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>}
|
||||
<Table
|
||||
columns={columns}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { sessions } 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';
|
||||
|
||||
export function SessionsPage() {
|
||||
@@ -61,7 +61,7 @@ export function SessionsPage() {
|
||||
|
||||
return (
|
||||
<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>}
|
||||
<Table
|
||||
columns={columns}
|
||||
|
||||
Reference in New Issue
Block a user