diff --git a/client/src/App.tsx b/client/src/App.tsx index e91f271..c3aa32c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { } /> } /> + } /> + } /> } /> } /> } /> diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 51fd16d..73ba3db 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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", diff --git a/client/src/pages/CreateEnvironmentPage.tsx b/client/src/pages/CreateEnvironmentPage.tsx new file mode 100644 index 0000000..faad86e --- /dev/null +++ b/client/src/pages/CreateEnvironmentPage.tsx @@ -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(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 ( +
+
+ navigate('/environments') }, + { label: t('environments.create_title') }, + ]} + /> +
+ + {error &&

{error}

} + +
+ +
+ { + setName(e.target.value); + setNameError(''); + }} + error={nameError || undefined} + required + autoFocus + /> + setIdUrl(e.target.value)} + /> + setCabinetUrl(e.target.value)} + /> + setAdminUrl(e.target.value)} + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/client/src/pages/EditEnvironmentPage.tsx b/client/src/pages/EditEnvironmentPage.tsx new file mode 100644 index 0000000..eee8eb9 --- /dev/null +++ b/client/src/pages/EditEnvironmentPage.tsx @@ -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(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(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 ( +
+
+ navigate('/environments') }, + { + label: env?.name ?? `#${id}`, + onClick: () => navigate(`/environments/${id}`), + }, + { label: t('environments.edit_title') }, + ]} + /> +
+ + {error &&

{error}

} + {loading &&

{t('environments.loading')}

} + + {!loading && env && ( +
+ +
+ { + setName(e.target.value); + setNameError(''); + }} + error={nameError || undefined} + required + autoFocus + /> + setIdUrl(e.target.value)} + /> + setCabinetUrl(e.target.value)} + /> + setAdminUrl(e.target.value)} + /> +
+ +
+ + +
+
+
+ )} +
+ ); +} diff --git a/client/src/pages/EnvironmentDetailPage.tsx b/client/src/pages/EnvironmentDetailPage.tsx index d85cca2..b0effb2 100644 --- a/client/src/pages/EnvironmentDetailPage.tsx +++ b/client/src/pages/EnvironmentDetailPage.tsx @@ -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 && ( - +
+ + +
)} diff --git a/client/src/pages/EnvironmentsPage.tsx b/client/src/pages/EnvironmentsPage.tsx index 909da79..1f701cc 100644 --- a/client/src/pages/EnvironmentsPage.tsx +++ b/client/src/pages/EnvironmentsPage.tsx @@ -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 = (
- #{env.id} {env.name} - e.stopPropagation()}> @@ -29,6 +28,11 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n icon: , onClick: () => navigate(`/environments/${env.id}`), }, + { + label: t('environments.action_edit'), + icon: , + onClick: () => navigate(`/environments/${env.id}/edit`), + }, { label: t('environments.action_delete'), icon: , @@ -36,7 +40,14 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n onClick: () => onDelete(env.id), }, ]} - /> + />
+ + ); + + const footer = ( +
+ #{env.id} +
); @@ -45,7 +56,8 @@ function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: n className={styles.envCard} header={header} headerVariant="primary" - footer={} + footer={footer} + onClick={() => navigate(`/environments/${env.id}`)} > {urlEntries.length > 0 && ( ({ 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 ( +
+ +
+ ); +} + export function EnvironmentsPage() { const { t } = useTranslation(); const [items, setItems] = useState([]); @@ -86,13 +111,12 @@ export function EnvironmentsPage() { {error &&

{error}

} {loading ? (

{t('environments.loading')}

- ) : items.length === 0 ? ( -

{t('environments.empty')}

) : (
{items.map((env) => ( ))} +
)} diff --git a/client/src/pages/Page.module.css b/client/src/pages/Page.module.css index 4673833..42a53d6 100644 --- a/client/src/pages/Page.module.css +++ b/client/src/pages/Page.module.css @@ -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 { diff --git a/client/src/ui/Card/Card.module.css b/client/src/ui/Card/Card.module.css index a1a0756..69d0a47 100644 --- a/client/src/ui/Card/Card.module.css +++ b/client/src/ui/Card/Card.module.css @@ -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); +} diff --git a/client/src/ui/Card/Card.tsx b/client/src/ui/Card/Card.tsx index f954857..85fa0b2 100644 --- a/client/src/ui/Card/Card.tsx +++ b/client/src/ui/Card/Card.tsx @@ -8,6 +8,7 @@ export interface CardProps { header?: React.ReactNode; headerVariant?: 'primary' | 'secondary'; footer?: React.ReactNode; + onClick?: React.MouseEventHandler; } export function Card({ @@ -17,10 +18,17 @@ export function Card({ header, headerVariant = 'primary', footer, + onClick, }: CardProps) { return (
diff --git a/client/src/ui/DescriptionList/DescriptionList.module.css b/client/src/ui/DescriptionList/DescriptionList.module.css index 265ebf4..0aedb33 100644 --- a/client/src/ui/DescriptionList/DescriptionList.module.css +++ b/client/src/ui/DescriptionList/DescriptionList.module.css @@ -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; diff --git a/client/src/ui/tokens.css b/client/src/ui/tokens.css index 2bfa8ea..6a9aeba 100644 --- a/client/src/ui/tokens.css +++ b/client/src/ui/tokens.css @@ -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;