- add a reusable modal component and export it from the shared ui barrel - gate all delete and remove flows behind explicit confirmation dialogs - use a solid modal surface color token with fallback to avoid transparent body
219 lines
6.4 KiB
TypeScript
219 lines
6.4 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Settings, ExternalLink, Pencil, Trash2, Plus, Globe, Download } from 'lucide-react';
|
|
import { parse as yamlParse } from 'yaml';
|
|
import { stringify as yamlStringify } from 'yaml';
|
|
import { environments } from '../../api';
|
|
import type { Environment } from '../../api';
|
|
import {
|
|
Breadcrumbs,
|
|
Button,
|
|
Card,
|
|
ContextMenu,
|
|
DescriptionList,
|
|
Modal,
|
|
Timestamp,
|
|
UuidBadge,
|
|
} from '../../ui';
|
|
import styles from '../Page.module.css';
|
|
|
|
function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: string) => void }) {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const dataEntries = Object.entries(env.data ?? {}).filter(([, v]) => v);
|
|
|
|
const handleExport = async () => {
|
|
const data = await environments.exportEnvironment(env.id);
|
|
const blob = new Blob([yamlStringify(data)], { type: 'application/yaml' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `environment-${env.name}.yaml`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const header = (
|
|
<div className={styles.envCardHeader}>
|
|
<span className={styles.envCardName}>{env.name}</span>
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<ContextMenu
|
|
align="right"
|
|
trigger={
|
|
<Button variant="ghost" size="sm" aria-label={t('environments.menu_label')}>
|
|
<Settings size={14} />
|
|
</Button>
|
|
}
|
|
items={[
|
|
{
|
|
label: t('environments.action_view'),
|
|
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_export'),
|
|
icon: <Download size={14} />,
|
|
onClick: () => void handleExport(),
|
|
},
|
|
{
|
|
label: t('environments.action_delete'),
|
|
icon: <Trash2 size={14} />,
|
|
variant: 'danger',
|
|
onClick: () => onDelete(env.id),
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const footer = (
|
|
<div className={styles.envCardFooter}>
|
|
<UuidBadge id={env.id} />
|
|
<Timestamp value={env.updatedAt} />
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<Card
|
|
className={styles.envCard}
|
|
header={header}
|
|
headerVariant="primary"
|
|
footer={footer}
|
|
onClick={() => navigate(`/environments/${env.id}`)}
|
|
>
|
|
{dataEntries.length > 0 && (
|
|
<DescriptionList
|
|
layout="compact"
|
|
truncate
|
|
items={dataEntries.map(([key, value]) => ({ term: key, detail: value }))}
|
|
/>
|
|
)}
|
|
{dataEntries.length === 0 && (
|
|
<p className={styles.envCardEmpty}>{t('environments.no_data')}</p>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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 navigate = useNavigate();
|
|
const [items, setItems] = useState<Environment[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const load = () => {
|
|
environments
|
|
.list()
|
|
.then((res) => setItems(res.data))
|
|
.catch((err: Error) => setError(err.message))
|
|
.finally(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, []);
|
|
|
|
const requestDelete = (id: string) => {
|
|
setDeleteId(id);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteId) return;
|
|
setDeleting(true);
|
|
try {
|
|
await environments.remove(deleteId);
|
|
setItems((prev) => prev.filter((e) => e.id !== deleteId));
|
|
setDeleteId(null);
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
e.target.value = '';
|
|
try {
|
|
const text = await file.text();
|
|
const payload = yamlParse(text) as unknown;
|
|
const imported = await environments.importEnvironment(payload);
|
|
navigate(`/environments/${imported.id}`);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className={styles.pageToolbar}>
|
|
<Breadcrumbs items={[{ label: t('environments.title'), icon: <Globe size={14} /> }]} />
|
|
<div className={styles.toolbarActions}>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".yaml,.yml,.json"
|
|
style={{ display: 'none' }}
|
|
onChange={handleImport}
|
|
/>
|
|
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
|
<Download size={14} />
|
|
{t('environments.action_import')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{loading ? (
|
|
<p className={styles.muted}>{t('environments.loading')}</p>
|
|
) : (
|
|
<div className={styles.envGrid}>
|
|
{items.map((env) => (
|
|
<EnvironmentCard key={env.id} env={env} onDelete={requestDelete} />
|
|
))}
|
|
<AddEnvironmentCard />
|
|
</div>
|
|
)}
|
|
|
|
<Modal
|
|
open={deleteId != null}
|
|
title={t('environments.action_delete')}
|
|
onClose={() => !deleting && setDeleteId(null)}
|
|
footer={(
|
|
<>
|
|
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
|
{t('environments.action_delete')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
>
|
|
Are you sure you want to delete this environment?
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|