web(ui): AppDataTable pattern and modules pilot
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,25 +22,11 @@
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from '$lib/components/ui/select/index.js';
|
} from '$lib/components/ui/select/index.js';
|
||||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||||
import {
|
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||||
AlertDialog,
|
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||||
AlertDialogAction,
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
AlertDialogCancel,
|
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
AlertDialogContent,
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle
|
|
||||||
} from '$lib/components/ui/alert-dialog/index.js';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow
|
|
||||||
} from '$lib/components/ui/table/index.js';
|
|
||||||
import { toast } from 'svelte-sonner';
|
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
@@ -55,7 +41,6 @@
|
|||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let selectedModuleIds = $state(new Set<string>());
|
let selectedModuleIds = $state(new Set<string>());
|
||||||
let deletingBulkModules = $state(false);
|
let deletingBulkModules = $state(false);
|
||||||
let bulkDeleteModulesDialog = $state(false);
|
|
||||||
|
|
||||||
const selectedModulesCount = $derived(selectedModuleIds.size);
|
const selectedModulesCount = $derived(selectedModuleIds.size);
|
||||||
const allModulesSelected = $derived(rows.length > 0 && selectedModuleIds.size === rows.length);
|
const allModulesSelected = $derived(rows.length > 0 && selectedModuleIds.size === rows.length);
|
||||||
@@ -75,6 +60,17 @@
|
|||||||
{ value: 'IP_RANGES', label: 'IP Ranges' }
|
{ value: 'IP_RANGES', label: 'IP Ranges' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const moduleColumns = [
|
||||||
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
|
{ id: 'name', label: 'Название', sortable: true, sortValue: (m: ModuleRow) => m.name },
|
||||||
|
{ id: 'type', label: 'Тип', sortable: true, sortValue: (m: ModuleRow) => m.type },
|
||||||
|
{ id: 'priority', label: 'Приоритет', sortable: true, sortValue: (m: ModuleRow) => m.priority ?? 0 },
|
||||||
|
{ id: 'interval', label: 'Интервал' },
|
||||||
|
{ id: 'refreshed', label: 'Последнее обновление', sortable: true, sortValue: (m: ModuleRow) => m.last_refreshed_at ?? '' },
|
||||||
|
{ id: 'status', label: 'Статус' },
|
||||||
|
{ id: 'actions', label: '', class: 'w-16' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -83,7 +79,7 @@
|
|||||||
const validIds = new Set(rows.map((item) => item.id));
|
const validIds = new Set(rows.map((item) => item.id));
|
||||||
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
|
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -93,18 +89,18 @@
|
|||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
if (!form.name.trim()) {
|
if (!form.name.trim()) {
|
||||||
toast.error('Укажите название модуля');
|
notify.error('Укажите название модуля');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
await apiMutate('/v1/modules', 'POST', form);
|
await apiMutate('/v1/modules', 'POST', form);
|
||||||
toast.success('Модуль создан');
|
notify.success('Модуль создан');
|
||||||
dialogOpen = false;
|
dialogOpen = false;
|
||||||
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
|
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -154,6 +150,17 @@
|
|||||||
selectedModuleIds = checked ? new Set(rows.map((item) => item.id)) : new Set<string>();
|
selectedModuleIds = checked ? new Set(rows.map((item) => item.id)) : new Set<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestBulkDelete() {
|
||||||
|
if (selectedModuleIds.size === 0) return;
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить выбранные модули?',
|
||||||
|
description: `Будет удалено: ${selectedModulesCount}. Это действие необратимо.`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: bulkDeleteModules
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function bulkDeleteModules() {
|
async function bulkDeleteModules() {
|
||||||
if (selectedModuleIds.size === 0) return;
|
if (selectedModuleIds.size === 0) return;
|
||||||
deletingBulkModules = true;
|
deletingBulkModules = true;
|
||||||
@@ -164,13 +171,12 @@
|
|||||||
await apiMutate(`/v1/modules/${id}`, 'DELETE', undefined, { idempotent: false });
|
await apiMutate(`/v1/modules/${id}`, 'DELETE', undefined, { idempotent: false });
|
||||||
deleted += 1;
|
deleted += 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (deleted > 0) {
|
if (deleted > 0) {
|
||||||
toast.success(`Удалено модулей: ${deleted}`);
|
notify.success(`Удалено модулей: ${deleted}`);
|
||||||
}
|
}
|
||||||
bulkDeleteModulesDialog = false;
|
|
||||||
await load();
|
await load();
|
||||||
} finally {
|
} finally {
|
||||||
deletingBulkModules = false;
|
deletingBulkModules = false;
|
||||||
@@ -178,21 +184,14 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<PageHeader
|
||||||
<div class="flex min-w-0 items-start gap-3">
|
title="Модули префиксов"
|
||||||
<div
|
description="Управление модулями — AS, CDN, домены, IP-диапазоны."
|
||||||
class="bg-chart-1/15 text-chart-1 flex size-11 shrink-0 items-center justify-center rounded-xl"
|
icon={Boxes}
|
||||||
aria-hidden="true"
|
iconClass="bg-chart-1/15 text-chart-1"
|
||||||
>
|
>
|
||||||
<Boxes class="size-6" />
|
{#snippet actions()}
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Модули префиксов</h1>
|
|
||||||
<p class="text-muted-foreground mt-1 text-sm">Управление модулями — AS, CDN, домены, IP-диапазоны.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
||||||
<RefreshCw class={loading ? 'animate-spin' : ''} />
|
<RefreshCw class={loading ? 'animate-spin' : ''} />
|
||||||
Обновить
|
Обновить
|
||||||
@@ -201,8 +200,8 @@
|
|||||||
<Plus />
|
<Plus />
|
||||||
Новый модуль
|
Новый модуль
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
{/snippet}
|
||||||
</div>
|
</PageHeader>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
||||||
@@ -214,7 +213,7 @@
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={deletingBulkModules}
|
disabled={deletingBulkModules}
|
||||||
onclick={() => (bulkDeleteModulesDialog = true)}
|
onclick={requestBulkDelete}
|
||||||
>
|
>
|
||||||
<Trash2 class="size-4" />
|
<Trash2 class="size-4" />
|
||||||
Удалить выбранные
|
Удалить выбранные
|
||||||
@@ -222,72 +221,45 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="p-0">
|
<CardContent class="p-4 pt-0">
|
||||||
<Table>
|
<AppDataTable
|
||||||
<TableHeader>
|
columns={[...moduleColumns]}
|
||||||
<TableRow>
|
rows={rows}
|
||||||
<TableHead class="w-10">
|
rowKey={(m) => m.id}
|
||||||
<input
|
{loading}
|
||||||
type="checkbox"
|
emptyTitle="Нет модулей"
|
||||||
checked={allModulesSelected}
|
emptyDescription="Создайте первый модуль."
|
||||||
indeterminate={someModulesSelected}
|
>
|
||||||
aria-label="Выбрать все модули"
|
{#snippet cell({ row: m, column })}
|
||||||
onchange={(event) => toggleAllModules((event.currentTarget as HTMLInputElement).checked)}
|
{#if column.id === 'select'}
|
||||||
/>
|
<Checkbox
|
||||||
</TableHead>
|
checked={selectedModuleIds.has(m.id)}
|
||||||
<TableHead>Название</TableHead>
|
aria-label={`Выбрать модуль ${m.name}`}
|
||||||
<TableHead>Тип</TableHead>
|
onCheckedChange={() => toggleModuleSelection(m.id)}
|
||||||
<TableHead>Приоритет</TableHead>
|
/>
|
||||||
<TableHead>Интервал</TableHead>
|
{:else if column.id === 'name'}
|
||||||
<TableHead>Последнее обновление</TableHead>
|
<span class="font-medium">{m.name}</span>
|
||||||
<TableHead>Статус</TableHead>
|
{:else if column.id === 'type'}
|
||||||
<TableHead class="w-16"></TableHead>
|
<Badge variant={typeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||||
</TableRow>
|
{:else if column.id === 'priority'}
|
||||||
</TableHeader>
|
<span class="text-muted-foreground">{m.priority}</span>
|
||||||
<TableBody>
|
{:else if column.id === 'interval'}
|
||||||
{#each rows as m (m.id)}
|
<span class="text-muted-foreground font-mono text-xs">{moduleIntervalLabel(m)}</span>
|
||||||
<TableRow>
|
{:else if column.id === 'refreshed'}
|
||||||
<TableCell class="w-10">
|
<span class="text-muted-foreground text-sm whitespace-nowrap">{formatDateTime(m.last_refreshed_at)}</span>
|
||||||
<input
|
{:else if column.id === 'status'}
|
||||||
type="checkbox"
|
{#if m.enabled}
|
||||||
checked={selectedModuleIds.has(m.id)}
|
<Badge variant="default" class="text-xs">вкл</Badge>
|
||||||
aria-label={`Выбрать модуль ${m.name}`}
|
{:else}
|
||||||
onchange={() => toggleModuleSelection(m.id)}
|
<Badge variant="secondary" class="text-xs">выкл</Badge>
|
||||||
/>
|
{/if}
|
||||||
</TableCell>
|
{:else if column.id === 'actions'}
|
||||||
<TableCell class="font-medium">{m.name}</TableCell>
|
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||||
<TableCell>
|
<ExternalLink class="size-3.5" />
|
||||||
<Badge variant={typeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
</Button>
|
||||||
</TableCell>
|
{/if}
|
||||||
<TableCell class="text-muted-foreground">{m.priority}</TableCell>
|
{/snippet}
|
||||||
<TableCell class="text-muted-foreground font-mono text-xs">
|
</AppDataTable>
|
||||||
{moduleIntervalLabel(m)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-muted-foreground text-sm whitespace-nowrap">
|
|
||||||
{formatDateTime(m.last_refreshed_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{#if m.enabled}
|
|
||||||
<Badge variant="default" class="text-xs">вкл</Badge>
|
|
||||||
{:else}
|
|
||||||
<Badge variant="secondary" class="text-xs">выкл</Badge>
|
|
||||||
{/if}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
|
||||||
<ExternalLink class="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{:else}
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colspan={8} class="text-muted-foreground text-center py-8">
|
|
||||||
{loading ? 'Загрузка…' : 'Нет модулей. Создайте первый.'}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{/each}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -353,19 +325,3 @@
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<AlertDialog bind:open={bulkDeleteModulesDialog}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Удалить выбранные модули?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Будет удалено: {selectedModulesCount}. Это действие необратимо.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onclick={bulkDeleteModules} disabled={deletingBulkModules}>
|
|
||||||
{deletingBulkModules ? 'Удаление…' : 'Удалить'}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user