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:
@@ -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 />} />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user