- emit API/network failures through a shared toast event bridge - remove per-page inline error notifications to avoid duplicate error UI - dedupe repeated toast messages to keep feedback readable
149 lines
4.3 KiB
TypeScript
149 lines
4.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Settings, Pencil, Trash2, Plus, Download, Braces } from 'lucide-react';
|
|
import { parse as yamlParse } from 'yaml';
|
|
import { snippets } from '../../api';
|
|
import type { Snippet } from '../../api';
|
|
import { Breadcrumbs, Button, Card, ContextMenu, Timestamp, UuidBadge } from '../../ui';
|
|
import styles from '../Page.module.css';
|
|
|
|
function SnippetCard({ snippet, onDelete }: { snippet: Snippet; onDelete: (id: string) => void }) {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
|
|
const header = (
|
|
<div className={styles.envCardHeader}>
|
|
<span className={styles.envCardName}>{snippet.name}</span>
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<ContextMenu
|
|
align="right"
|
|
trigger={
|
|
<Button variant="ghost" size="sm" aria-label={t('snippets.menu_label')}>
|
|
<Settings size={14} />
|
|
</Button>
|
|
}
|
|
items={[
|
|
{
|
|
label: t('snippets.action_edit'),
|
|
icon: <Pencil size={14} />,
|
|
onClick: () => navigate(`/snippets/${snippet.id}/edit`),
|
|
},
|
|
{
|
|
label: t('snippets.action_delete'),
|
|
icon: <Trash2 size={14} />,
|
|
variant: 'danger',
|
|
onClick: () => onDelete(snippet.id),
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const footer = (
|
|
<div className={styles.envCardFooter}>
|
|
<UuidBadge id={snippet.id} />
|
|
<Timestamp value={snippet.updatedAt} />
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<Card
|
|
className={styles.envCard}
|
|
header={header}
|
|
headerVariant="primary"
|
|
footer={footer}
|
|
onClick={() => navigate(`/snippets/${snippet.id}`)}
|
|
>
|
|
{snippet.description && <p className={styles.muted}>{snippet.description}</p>}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function AddSnippetCard() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
return (
|
|
<div className={styles.envCardAdd}>
|
|
<Button variant="ghost" onClick={() => navigate('/snippets/new')}>
|
|
<Plus size={16} />
|
|
{t('snippets.action_add')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SnippetsPage() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const [items, setItems] = useState<Snippet[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const load = () => {
|
|
snippets
|
|
.list()
|
|
.then((res) => setItems(res.data))
|
|
.catch((err: Error) => setError(err.message))
|
|
.finally(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, []);
|
|
|
|
const handleDelete = async (id: string) => {
|
|
await snippets.remove(id);
|
|
load();
|
|
};
|
|
|
|
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 snippets.importSnippet(payload);
|
|
navigate(`/snippets/${imported.id}`);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className={styles.pageToolbar}>
|
|
<Breadcrumbs items={[{ label: t('snippets.title'), icon: <Braces 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('snippets.action_import')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{loading && <p className={styles.muted}>{t('snippets.loading')}</p>}
|
|
|
|
{!loading && (
|
|
<div className={styles.envGrid}>
|
|
{items.map((s) => (
|
|
<SnippetCard key={s.id} snippet={s} onDelete={handleDelete} />
|
|
))}
|
|
{items.length === 0 && <p className={styles.muted}>{t('snippets.empty')}</p>}
|
|
<AddSnippetCard />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|