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,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>
);
}