feat(client): add Card header/footer, DescriptionList, and portal ContextMenu

- add Card header prop (primary/secondary variants) and footer prop (sticks to bottom)
- add Breadcrumbs to all 5 pages; remove duplicate h1 headings
- add pageToolbar layout for breadcrumbs + danger button aligned right
- add DescriptionList component replacing raw dl/dt/dd markup; supports truncate+tooltip
- rewrite ContextMenu to render dropdown via React portal to avoid clip/overflow issues
- update EnvironmentCard to use Card header (primary), footer (Timestamp), and truncated DescriptionList
- fix cog button contrast, square sizing, and right-edge alignment on colored header
- add border to Timestamp pill for dark-mode visibility
- add id badge pill styling to environment card header
This commit is contained in:
2026-04-09 14:29:16 +03:00
parent 35997f8bcb
commit 89016b01d2
13 changed files with 359 additions and 97 deletions
+25 -20
View File
@@ -4,7 +4,7 @@ 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 { Breadcrumbs, Button, Card, DescriptionList, Timestamp } from '../ui';
import styles from './Page.module.css';
export function EnvironmentDetailPage() {
@@ -54,12 +54,19 @@ export function EnvironmentDetailPage() {
{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} />
<DescriptionList
items={[
{ term: t('environments.field_id'), detail: env.id },
{
term: t('environments.field_created'),
detail: <Timestamp value={env.createdAt} />,
},
{
term: t('environments.field_updated'),
detail: <Timestamp value={env.updatedAt} />,
},
]}
/>
</div>
<h2 className={styles.sectionHeading}>{t('environments.section_urls')}</h2>
@@ -67,20 +74,18 @@ export function EnvironmentDetailPage() {
{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)
<DescriptionList
items={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>
.map(([key, value]) => ({
term: key,
detail: (
<a href={value} target="_blank" rel="noopener noreferrer">
{value}
</a>
),
}))}
/>
)}
</Card>
</>
+36 -36
View File
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Settings, ExternalLink, Trash2 } from 'lucide-react';
import { environments } from '../api';
import type { Environment } from '../api';
import { Breadcrumbs, Button, Card, ContextMenu, Timestamp } from '../ui';
import { Breadcrumbs, Button, Card, ContextMenu, DescriptionList, Timestamp } from '../ui';
import styles from './Page.module.css';
function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: number) => void }) {
@@ -12,43 +12,43 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n
const navigate = useNavigate();
const urlEntries = Object.entries(env.urls).filter(([, v]) => v);
const header = (
<div className={styles.envCardHeader}>
<span className={styles.envCardId}>#{env.id}</span>
<span className={styles.envCardName}>{env.name}</span>
<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>
);
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>
<Card
className={styles.envCard}
header={header}
headerVariant="primary"
footer={<Timestamp value={env.updatedAt} />}
>
{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>
<DescriptionList truncate items={urlEntries.map(([key, value]) => ({ term: key, detail: value }))} />
)}
{urlEntries.length === 0 && (
<p className={styles.envCardEmpty}>{t('environments.no_urls')}</p>
+18 -1
View File
@@ -53,8 +53,25 @@
.envCardId {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-weight: 700;
color: inherit;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(0, 0, 0, 0.35);
border-radius: 9999px;
padding: 1px var(--space-2);
line-height: 1.6;
}
.envCardHeader button {
color: inherit;
padding: var(--space-1);
aspect-ratio: 1;
margin-right: calc(-1 * var(--space-1));
}
.envCardHeader button:hover {
background: color-mix(in srgb, currentColor 15%, transparent) !important;
}
.envCardName {