diff --git a/client/.storybook/stories/ContextMenu.stories.tsx b/client/.storybook/stories/ContextMenu.stories.tsx new file mode 100644 index 0000000..158f47f --- /dev/null +++ b/client/.storybook/stories/ContextMenu.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Settings, Trash2, ExternalLink } from 'lucide-react'; +import { Button } from '../../src/ui/Button/Button'; +import { ContextMenu } from '../../src/ui/ContextMenu/ContextMenu'; + +const meta: Meta = { + title: 'UI/ContextMenu', + component: ContextMenu, + parameters: { layout: 'centered' }, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +const trigger = ( + +); + +export const Default: Story = { + args: { + trigger, + items: [ + { label: 'View details', icon: , onClick: () => {} }, + { label: 'Delete', icon: , variant: 'danger', onClick: () => {} }, + ], + }, +}; + +export const AlignLeft: Story = { + args: { + trigger, + align: 'left', + items: [ + { label: 'View details', icon: , onClick: () => {} }, + { label: 'Delete', icon: , variant: 'danger', onClick: () => {} }, + ], + }, +}; + +export const ManyItems: Story = { + args: { + trigger, + items: [ + { label: 'View details', icon: , onClick: () => {} }, + { label: 'Edit', icon: , onClick: () => {} }, + { label: 'Duplicate', onClick: () => {} }, + { label: 'Delete', icon: , variant: 'danger', onClick: () => {} }, + ], + }, +}; diff --git a/client/.storybook/stories/Table.stories.tsx b/client/.storybook/stories/Table.stories.tsx index f45ecd6..5dd85f1 100644 --- a/client/.storybook/stories/Table.stories.tsx +++ b/client/.storybook/stories/Table.stories.tsx @@ -22,9 +22,27 @@ interface User { const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString(); const users: User[] = [ - { id: 1, name: 'Alice Müller', email: 'alice@example.com', status: 'active', updatedAt: daysAgo(1) }, - { id: 2, name: 'Bob Nguyen', email: 'bob@example.com', status: 'inactive', updatedAt: daysAgo(5) }, - { id: 3, name: 'Carol Santos', email: 'carol@example.com', status: 'active', updatedAt: daysAgo(30) }, + { + id: 1, + name: 'Alice Müller', + email: 'alice@example.com', + status: 'active', + updatedAt: daysAgo(1), + }, + { + id: 2, + name: 'Bob Nguyen', + email: 'bob@example.com', + status: 'inactive', + updatedAt: daysAgo(5), + }, + { + id: 3, + name: 'Carol Santos', + email: 'carol@example.com', + status: 'active', + updatedAt: daysAgo(30), + }, ]; const manyUsers: User[] = Array.from({ length: 47 }, (_, i) => ({ diff --git a/client/src/App.tsx b/client/src/App.tsx index 2d63bd5..e91f271 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,7 @@ import styles from './App.module.css'; import { SidePanel, ThemeSwitcher } from './ui'; import { EnvironmentsPage } from './pages/EnvironmentsPage'; +import { EnvironmentDetailPage } from './pages/EnvironmentDetailPage'; import { KeysPage } from './pages/KeysPage'; import { SessionsPage } from './pages/SessionsPage'; import { ScenariosPage } from './pages/ScenariosPage'; @@ -50,6 +51,7 @@ export default function App() { } /> } /> + } /> } /> } /> } /> diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 93fe7f5..51fd16d 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -11,7 +11,17 @@ "col_name": "Name", "col_urls": "URLs", "col_updated": "Updated", - "empty": "No environments yet." + "empty": "No environments yet.", + "loading": "Loading…", + "no_urls": "No URLs configured.", + "menu_label": "Environment options", + "action_view": "View details", + "action_delete": "Delete", + "back": "Environments", + "field_id": "ID", + "field_created": "Created", + "field_updated": "Updated", + "section_urls": "URLs" }, "keys": { "title": "Keys", diff --git a/client/src/pages/EnvironmentDetailPage.tsx b/client/src/pages/EnvironmentDetailPage.tsx new file mode 100644 index 0000000..b36da30 --- /dev/null +++ b/client/src/pages/EnvironmentDetailPage.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +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 styles from './Page.module.css'; + +export function EnvironmentDetailPage() { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [env, setEnv] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) return; + environments + .get(Number(id)) + .then(setEnv) + .catch((err: Error) => setError(err.message)) + .finally(() => setLoading(false)); + }, [id]); + + const handleDelete = async () => { + if (!env) return; + await environments.remove(env.id); + navigate('/environments'); + }; + + return ( +
+
+ navigate('/environments') }, + { label: env?.name ?? `#${id}` }, + ]} + /> + {env && ( + + )} +
+ + {error &&

{error}

} + + {loading &&

{t('environments.loading')}

} + + {env && ( + <> +
+ {t('environments.field_id')} + {env.id} + {t('environments.field_created')} + + {t('environments.field_updated')} + +
+ +

{t('environments.section_urls')}

+ + {Object.entries(env.urls).filter(([, v]) => v).length === 0 ? ( +

{t('environments.no_urls')}

+ ) : ( +
+ {Object.entries(env.urls) + .filter(([, v]) => v) + .map(([key, value]) => ( +
+
{key}
+
+ + {value} + +
+
+ ))} +
+ )} +
+ + )} +
+ ); +} diff --git a/client/src/pages/EnvironmentsPage.tsx b/client/src/pages/EnvironmentsPage.tsx index e8a30f6..51bd141 100644 --- a/client/src/pages/EnvironmentsPage.tsx +++ b/client/src/pages/EnvironmentsPage.tsx @@ -1,57 +1,100 @@ import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { Settings, ExternalLink, Trash2 } from 'lucide-react'; import { environments } from '../api'; import type { Environment } from '../api'; -import { Table, type TableColumn, Timestamp } from '../ui'; +import { Breadcrumbs, Button, Card, ContextMenu, Timestamp } from '../ui'; import styles from './Page.module.css'; +function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: number) => void }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const urlEntries = Object.entries(env.urls).filter(([, v]) => v); + + return ( + +
+ #{env.id} + {env.name} + + + + + } + items={[ + { + label: t('environments.action_view'), + icon: , + onClick: () => navigate(`/environments/${env.id}`), + }, + { + label: t('environments.action_delete'), + icon: , + variant: 'danger', + onClick: () => onDelete(env.id), + }, + ]} + /> +
+ {urlEntries.length > 0 && ( +
+ {urlEntries.map(([key, value]) => ( +
+
{key}
+
{value}
+
+ ))} +
+ )} + {urlEntries.length === 0 && ( +

{t('environments.no_urls')}

+ )} +
+ ); +} + export function EnvironmentsPage() { const { t } = useTranslation(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const columns: TableColumn[] = [ - { key: 'id', header: t('environments.col_id'), render: (e) => e.id, width: 60 }, - { key: 'name', header: t('environments.col_name'), render: (e) => e.name }, - { - key: 'urls', - header: t('environments.col_urls'), - render: (e) => - Object.entries(e.urls) - .filter(([, v]) => v) - .map(([k, v]) => `${k}: ${v}`) - .join(' · ') || '—', - }, - { - key: 'updated', - header: t('environments.col_updated'), - width: 140, - render: (e) => , - }, - ]; - - useEffect(() => { + const load = () => { environments .list() .then((res) => setItems(res.data)) .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); + }; + + useEffect(() => { + load(); }, []); + const handleDelete = async (id: number) => { + await environments.remove(id); + setItems((prev) => prev.filter((e) => e.id !== id)); + }; + return (
-

{t('environments.title')}

+ {error &&

{error}

} - e.id} - loading={loading} - emptyMessage={t('environments.empty')} - pageSize={10} - pageSizeOptions={[10, 25, 50]} - /> + {loading ? ( +

{t('environments.loading')}

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

{t('environments.empty')}

+ ) : ( +
+ {items.map((env) => ( + + ))} +
+ )} ); } diff --git a/client/src/pages/KeysPage.tsx b/client/src/pages/KeysPage.tsx index c45cce3..d628055 100644 --- a/client/src/pages/KeysPage.tsx +++ b/client/src/pages/KeysPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { keys } from '../api'; -import { Table, type TableColumn } from '../ui'; +import { Breadcrumbs, Table, type TableColumn } from '../ui'; import styles from './Page.module.css'; interface KeyRow { @@ -28,7 +28,7 @@ export function KeysPage() { return (
-

{t('keys.title')}

+ {error &&

{error}

}
-

{t('scenarios.title')}

+ {error &&

{error}

}
-

{t('sessions.title')}

+ {error &&

{error}

}
void; + variant?: 'default' | 'danger'; + icon?: React.ReactNode; +} + +export interface ContextMenuProps { + items: ContextMenuItem[]; + /** The trigger element — a button rendered by the component */ + trigger: React.ReactNode; + align?: 'left' | 'right'; +} + +export function ContextMenu({ items, trigger, align = 'right' }: ContextMenuProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) return; + function handleOutside(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener('mousedown', handleOutside); + return () => document.removeEventListener('mousedown', handleOutside); + }, [open]); + + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [open]); + + return ( +
+
{ + e.stopPropagation(); + setOpen((v) => !v); + }} + > + {trigger} +
+ {open && ( +
    + {items.map((item, i) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/client/src/ui/index.ts b/client/src/ui/index.ts index aaa5196..75e107d 100644 --- a/client/src/ui/index.ts +++ b/client/src/ui/index.ts @@ -27,5 +27,8 @@ export type { TimestampProps } from './Timestamp/Timestamp'; export { Pagination } from './Pagination/Pagination'; export type { PaginationProps } from './Pagination/Pagination'; +export { ContextMenu } from './ContextMenu/ContextMenu'; +export type { ContextMenuProps, ContextMenuItem } from './ContextMenu/ContextMenu'; + export { Table } from './Table/Table'; export type { TableProps, TableColumn } from './Table/Table';