feat(client): add create/edit environment pages and hoverable cards

- add CreateEnvironmentPage and EditEnvironmentPage with pre-filled form
- add /environments/new and /environments/:id/edit routes
- add Edit option to card context menu and detail page toolbar
- move env id pill to card footer (left), timestamp stays right
- make env cards clickable (navigate to detail); cog stops propagation
- add hoverable Card variant with primary border+shadow on hover
- add link color tokens and global anchor styles for both themes
- fix Timestamp pill visibility in dark mode with border token
- add DescriptionList truncate prop with ellipsis and title tooltip
- stack DescriptionList dd below dt with 8px gap between items
This commit is contained in:
2026-04-09 17:22:01 +03:00
parent 89016b01d2
commit 18177cac52
11 changed files with 397 additions and 16 deletions
+4
View File
@@ -2,6 +2,8 @@ import styles from './App.module.css';
import { SidePanel, ThemeSwitcher } from './ui';
import { EnvironmentsPage } from './pages/EnvironmentsPage';
import { EnvironmentDetailPage } from './pages/EnvironmentDetailPage';
import { CreateEnvironmentPage } from './pages/CreateEnvironmentPage';
import { EditEnvironmentPage } from './pages/EditEnvironmentPage';
import { KeysPage } from './pages/KeysPage';
import { SessionsPage } from './pages/SessionsPage';
import { ScenariosPage } from './pages/ScenariosPage';
@@ -51,6 +53,8 @@ export default function App() {
<Routes>
<Route index element={<Navigate to="/scenarios" replace />} />
<Route path="/environments" element={<EnvironmentsPage />} />
<Route path="/environments/new" element={<CreateEnvironmentPage />} />
<Route path="/environments/:id/edit" element={<EditEnvironmentPage />} />
<Route path="/environments/:id" element={<EnvironmentDetailPage />} />
<Route path="/keys" element={<KeysPage />} />
<Route path="/sessions" element={<SessionsPage />} />
+13
View File
@@ -17,6 +17,19 @@
"menu_label": "Environment options",
"action_view": "View details",
"action_delete": "Delete",
"action_add": "Add environment",
"action_edit": "Edit",
"action_save": "Create",
"action_update": "Save changes",
"action_cancel": "Cancel",
"create_title": "New Environment",
"edit_title": "Edit Environment",
"form_name": "Name",
"form_name_placeholder": "e.g. staging, production",
"form_name_required": "Name is required",
"form_id_url": "ID URL",
"form_cabinet_url": "Cabinet URL",
"form_admin_url": "Admin URL",
"back": "Environments",
"field_id": "ID",
"field_created": "Created",
+109
View File
@@ -0,0 +1,109 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { environments } from '../api';
import { Breadcrumbs, Button, Card, Input } from '../ui';
import styles from './Page.module.css';
export function CreateEnvironmentPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [name, setName] = useState('');
const [idUrl, setIdUrl] = useState('');
const [cabinetUrl, setCabinetUrl] = useState('');
const [adminUrl, setAdminUrl] = useState('');
const [nameError, setNameError] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setNameError(t('environments.form_name_required'));
return;
}
setSaving(true);
setError(null);
try {
const env = await environments.create(name.trim(), {
id_url: idUrl.trim() || undefined,
cabinet_url: cabinetUrl.trim() || undefined,
admin_url: adminUrl.trim() || undefined,
});
navigate(`/environments/${env.id}`);
} catch (err) {
setError((err as Error).message);
} finally {
setSaving(false);
}
};
return (
<div>
<div className={styles.pageToolbar}>
<Breadcrumbs
items={[
{ label: t('environments.title'), onClick: () => navigate('/environments') },
{ label: t('environments.create_title') },
]}
/>
</div>
{error && <p className={styles.error}>{error}</p>}
<form onSubmit={handleSubmit} noValidate>
<Card className={styles.formCard}>
<div className={styles.formFields}>
<Input
label={t('environments.form_name')}
placeholder={t('environments.form_name_placeholder')}
value={name}
onChange={(e) => {
setName(e.target.value);
setNameError('');
}}
error={nameError || undefined}
required
autoFocus
/>
<Input
label={t('environments.form_id_url')}
type="url"
placeholder="https://…"
value={idUrl}
onChange={(e) => setIdUrl(e.target.value)}
/>
<Input
label={t('environments.form_cabinet_url')}
type="url"
placeholder="https://…"
value={cabinetUrl}
onChange={(e) => setCabinetUrl(e.target.value)}
/>
<Input
label={t('environments.form_admin_url')}
type="url"
placeholder="https://…"
value={adminUrl}
onChange={(e) => setAdminUrl(e.target.value)}
/>
</div>
<div className={styles.formActions}>
<Button
type="button"
variant="secondary"
onClick={() => navigate('/environments')}
>
{t('environments.action_cancel')}
</Button>
<Button type="submit" loading={saving}>
{t('environments.action_save')}
</Button>
</div>
</Card>
</form>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { environments } from '../api';
import type { Environment } from '../api';
import { Breadcrumbs, Button, Card, Input } from '../ui';
import styles from './Page.module.css';
export function EditEnvironmentPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [env, setEnv] = useState<Environment | null>(null);
const [name, setName] = useState('');
const [idUrl, setIdUrl] = useState('');
const [cabinetUrl, setCabinetUrl] = useState('');
const [adminUrl, setAdminUrl] = useState('');
const [nameError, setNameError] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
environments
.get(Number(id))
.then((data) => {
setEnv(data);
setName(data.name);
setIdUrl(data.urls.id_url ?? '');
setCabinetUrl(data.urls.cabinet_url ?? '');
setAdminUrl(data.urls.admin_url ?? '');
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setNameError(t('environments.form_name_required'));
return;
}
setSaving(true);
setError(null);
try {
await environments.update(Number(id), {
name: name.trim(),
urls: {
id_url: idUrl.trim() || undefined,
cabinet_url: cabinetUrl.trim() || undefined,
admin_url: adminUrl.trim() || undefined,
},
});
navigate(`/environments/${id}`);
} catch (err) {
setError((err as Error).message);
} finally {
setSaving(false);
}
};
return (
<div>
<div className={styles.pageToolbar}>
<Breadcrumbs
items={[
{ label: t('environments.title'), onClick: () => navigate('/environments') },
{
label: env?.name ?? `#${id}`,
onClick: () => navigate(`/environments/${id}`),
},
{ label: t('environments.edit_title') },
]}
/>
</div>
{error && <p className={styles.error}>{error}</p>}
{loading && <p className={styles.muted}>{t('environments.loading')}</p>}
{!loading && env && (
<form onSubmit={handleSubmit} noValidate>
<Card className={styles.formCard}>
<div className={styles.formFields}>
<Input
label={t('environments.form_name')}
placeholder={t('environments.form_name_placeholder')}
value={name}
onChange={(e) => {
setName(e.target.value);
setNameError('');
}}
error={nameError || undefined}
required
autoFocus
/>
<Input
label={t('environments.form_id_url')}
type="url"
placeholder="https://…"
value={idUrl}
onChange={(e) => setIdUrl(e.target.value)}
/>
<Input
label={t('environments.form_cabinet_url')}
type="url"
placeholder="https://…"
value={cabinetUrl}
onChange={(e) => setCabinetUrl(e.target.value)}
/>
<Input
label={t('environments.form_admin_url')}
type="url"
placeholder="https://…"
value={adminUrl}
onChange={(e) => setAdminUrl(e.target.value)}
/>
</div>
<div className={styles.formActions}>
<Button
type="button"
variant="secondary"
onClick={() => navigate(`/environments/${id}`)}
>
{t('environments.action_cancel')}
</Button>
<Button type="submit" loading={saving}>
{t('environments.action_update')}
</Button>
</div>
</Card>
</form>
)}
</div>
);
}
+11 -5
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Trash2 } from 'lucide-react';
import { Pencil, Trash2 } from 'lucide-react';
import { environments } from '../api';
import type { Environment } from '../api';
import { Breadcrumbs, Button, Card, DescriptionList, Timestamp } from '../ui';
@@ -40,10 +40,16 @@ export function EnvironmentDetailPage() {
]}
/>
{env && (
<Button variant="danger" size="sm" onClick={handleDelete}>
<Trash2 size={14} />
{t('environments.action_delete')}
</Button>
<div className={styles.toolbarActions}>
<Button variant="secondary" size="sm" onClick={() => navigate(`/environments/${env.id}/edit`)}>
<Pencil size={14} />
{t('environments.action_edit')}
</Button>
<Button variant="danger" size="sm" onClick={handleDelete}>
<Trash2 size={14} />
{t('environments.action_delete')}
</Button>
</div>
)}
</div>
+31 -7
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Settings, ExternalLink, Trash2 } from 'lucide-react';
import { Settings, ExternalLink, Pencil, Trash2, Plus } from 'lucide-react';
import { environments } from '../api';
import type { Environment } from '../api';
import { Breadcrumbs, Button, Card, ContextMenu, DescriptionList, Timestamp } from '../ui';
@@ -14,9 +14,8 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n
const header = (
<div className={styles.envCardHeader}>
<span className={styles.envCardId}>#{env.id}</span>
<span className={styles.envCardName}>{env.name}</span>
<ContextMenu
<div onClick={(e) => e.stopPropagation()}><ContextMenu
align="right"
trigger={
<Button variant="ghost" size="sm" aria-label={t('environments.menu_label')}>
@@ -29,6 +28,11 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n
icon: <ExternalLink size={14} />,
onClick: () => navigate(`/environments/${env.id}`),
},
{
label: t('environments.action_edit'),
icon: <Pencil size={14} />,
onClick: () => navigate(`/environments/${env.id}/edit`),
},
{
label: t('environments.action_delete'),
icon: <Trash2 size={14} />,
@@ -36,7 +40,14 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n
onClick: () => onDelete(env.id),
},
]}
/>
/></div>
</div>
);
const footer = (
<div className={styles.envCardFooter}>
<span className={styles.envCardId}>#{env.id}</span>
<Timestamp value={env.updatedAt} />
</div>
);
@@ -45,7 +56,8 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n
className={styles.envCard}
header={header}
headerVariant="primary"
footer={<Timestamp value={env.updatedAt} />}
footer={footer}
onClick={() => navigate(`/environments/${env.id}`)}
>
{urlEntries.length > 0 && (
<DescriptionList truncate items={urlEntries.map(([key, value]) => ({ term: key, detail: value }))} />
@@ -57,6 +69,19 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n
);
}
function AddEnvironmentCard() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<div className={styles.envCardAdd}>
<Button variant="ghost" onClick={() => navigate('/environments/new')}>
<Plus size={16} />
{t('environments.action_add')}
</Button>
</div>
);
}
export function EnvironmentsPage() {
const { t } = useTranslation();
const [items, setItems] = useState<Environment[]>([]);
@@ -86,13 +111,12 @@ export function EnvironmentsPage() {
{error && <p className={styles.error}>{error}</p>}
{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} />
))}
<AddEnvironmentCard />
</div>
)}
</div>
+49 -3
View File
@@ -16,6 +16,12 @@
margin-bottom: var(--space-4);
}
.toolbarActions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.error {
margin-bottom: var(--space-4);
padding: var(--space-3) var(--space-4);
@@ -44,6 +50,21 @@
gap: var(--space-3);
}
.envCardAdd {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
transition: border-color var(--transition), background var(--transition);
}
.envCardAdd:hover {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
}
.envCardHeader {
display: flex;
align-items: center;
@@ -54,15 +75,21 @@
.envCardId {
font-size: var(--font-size-xs);
font-weight: 700;
color: inherit;
color: var(--color-text-muted);
flex-shrink: 0;
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(0, 0, 0, 0.35);
border: var(--border-width) solid var(--color-border);
border-radius: 9999px;
padding: 1px var(--space-2);
line-height: 1.6;
}
.envCardFooter {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.envCardHeader button {
color: inherit;
padding: var(--space-1);
@@ -115,6 +142,25 @@
color: var(--color-text-muted);
}
/* ── Environment form ───────────────────────────────────────────────────── */
.formCard {
max-width: 540px;
}
.formFields {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.formActions {
display: flex;
gap: var(--space-3);
justify-content: flex-end;
}
/* ── Environment detail page ────────────────────────────────────────────── */
.detailToolbar {
+9
View File
@@ -47,3 +47,12 @@
.sm { padding: var(--space-3) var(--space-4); }
.md { padding: var(--space-4) var(--space-6); }
.lg { padding: var(--space-6) var(--space-8); }
.hoverable {
cursor: pointer;
transition: border-color var(--transition), box-shadow var(--transition);
}
.hoverable:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-sm), 0 0 0 1px var(--color-primary);
}
+9 -1
View File
@@ -8,6 +8,7 @@ export interface CardProps {
header?: React.ReactNode;
headerVariant?: 'primary' | 'secondary';
footer?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}
export function Card({
@@ -17,10 +18,17 @@ export function Card({
header,
headerVariant = 'primary',
footer,
onClick,
}: CardProps) {
return (
<div
className={[styles.card, footer !== undefined ? styles.withFooter : '', className]
onClick={onClick}
className={[
styles.card,
footer !== undefined ? styles.withFooter : '',
onClick ? styles.hoverable : '',
className,
]
.filter(Boolean)
.join(' ')}
>
@@ -29,6 +29,14 @@
word-break: break-all;
}
.detail a {
color: var(--color-link);
}
.detail a:hover {
color: var(--color-link-hover);
}
.truncate {
overflow: hidden;
white-space: nowrap;
+16
View File
@@ -39,6 +39,9 @@
--color-running-fg: #6B4A00;
/* Text & Surface — Outer Space */
--color-link: #5240A8; /* Kimberly, darkened for contrast on light bg */
--color-link-hover: #3D2F80;
--color-text: #2D3339;
--color-text-muted: #6650C0; /* Kimberly */
--color-border: #AFBFB9; /* Powder Ash */
@@ -91,6 +94,16 @@ body {
margin: 0;
}
a {
color: var(--color-link);
text-decoration: underline;
text-underline-offset: 2px;
}
a:hover {
color: var(--color-link-hover);
}
/* ── Dark theme ─────────────────────────────────────────────────────────── */
[data-theme="dark"] {
/* Primary — Atlantis unchanged, pops well on dark */
@@ -126,6 +139,9 @@ body {
--color-running-fg: #F0D060;
/* Text & Surface */
--color-link: #B0AAD6; /* lightened Kimberly, readable on dark bg */
--color-link-hover: #CCC8E8;
--color-text: #E2EAE6;
--color-text-muted: #A9A3C9; /* lightened Kimberly */
--color-border: #3D4850;