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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user