Files
liqa/client/src/pages/environment/EnvironmentsPage.tsx
T
ars9 aa3bae9020 feat(client): set dynamic page title based on content
- add usePageTitle hook that writes '<title> | Liqa' to document.title
- list pages use section name; detail pages use entity name once loaded
- create/edit pages use static action title, edit includes entity name
2026-04-20 16:32:16 +03:00

210 lines
6.1 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePageTitle } from '../../hooks/usePageTitle';
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,
Timestamp,
UuidBadge,
} from '../../ui';
import { DeleteConfirmationModal } from '../../components/modals';
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();
usePageTitle(t('environments.title'));
const navigate = useNavigate();
const [items, setItems] = useState<Environment[]>([]);
const [loading, setLoading] = useState(true);
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))
.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 {
// Import errors are silently ignored
}
};
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>
)}
<DeleteConfirmationModal
open={deleteId != null}
title={t('environments.action_delete')}
message={t('common.confirm_delete_environment')}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
isDeleting={deleting}
/>
</div>
);
}