CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 38s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m1s
- Refactored module entry dialogs to reset forms based on edit state and improve state management. - Updated selection logic in various components to utilize derived states for active selections, enhancing bulk operations. - Improved dialog bindings for better state synchronization and user experience. - Streamlined component structure for maintainability and clarity.
238 lines
7.2 KiB
Svelte
238 lines
7.2 KiB
Svelte
<script lang="ts">
|
||
import { apiMutate } from '$lib/api/client.js';
|
||
import type { BgpCommunity, CdnSource } from '$lib/api/types.js';
|
||
import { formatDateTime } from '$lib/modules/display.js';
|
||
import {
|
||
communityLabel,
|
||
normalizeCdnSourceKind
|
||
} from '$lib/components/modules/module-helpers.js';
|
||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||
import { Button } from '$lib/ui/core/button/index.js';
|
||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
CardTitle,
|
||
CardDescription
|
||
} from '$lib/ui/core/card/index.js';
|
||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||
import ModuleCdnSourceDialog from '$lib/components/modules/ModuleCdnSourceDialog.svelte';
|
||
import Plus from '@lucide/svelte/icons/plus';
|
||
import Pencil from '@lucide/svelte/icons/pencil';
|
||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||
|
||
type Props = {
|
||
moduleId: string;
|
||
sources: CdnSource[];
|
||
communities: BgpCommunity[];
|
||
loading?: boolean;
|
||
onChanged: () => void | Promise<void>;
|
||
};
|
||
|
||
let { moduleId, sources, communities, loading = false, onChanged }: Props = $props();
|
||
|
||
let dialogOpen = $state(false);
|
||
let editTarget = $state<CdnSource | null>(null);
|
||
let selectedIds = $state(new Set<string>());
|
||
let deletingBulk = $state(false);
|
||
|
||
const activeSelected = $derived.by(() => {
|
||
const allowed = new Set(sources.map((s) => s.id));
|
||
return [...selectedIds].filter((id) => allowed.has(id));
|
||
});
|
||
const selectedCount = $derived(activeSelected.length);
|
||
const allSelected = $derived(sources.length > 0 && sources.every((s) => selectedIds.has(s.id)));
|
||
|
||
const columns = [
|
||
{ id: 'select', label: '', class: 'w-10' },
|
||
{ id: 'url', label: 'URL', sortable: true, sortValue: (s: CdnSource) => s.url },
|
||
{ id: 'kind', label: 'Тип', sortable: true, sortValue: (s: CdnSource) => s.source_kind },
|
||
{ id: 'community', label: 'Community' },
|
||
{
|
||
id: 'interval',
|
||
label: 'Интервал',
|
||
sortable: true,
|
||
sortValue: (s: CdnSource) => s.refresh_interval_sec ?? 0
|
||
},
|
||
{
|
||
id: 'refreshed',
|
||
label: 'Последнее обновление',
|
||
sortable: true,
|
||
sortValue: (s: CdnSource) => s.last_refreshed_at ?? ''
|
||
},
|
||
{ id: 'actions', label: '', class: 'w-20' }
|
||
] as const;
|
||
|
||
function toggleSelection(id: string) {
|
||
const next = new Set(selectedIds);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
selectedIds = next;
|
||
}
|
||
|
||
function toggleAll(checked: boolean) {
|
||
selectedIds = checked ? new Set(sources.map((s) => s.id)) : new Set<string>();
|
||
}
|
||
|
||
function openCreate() {
|
||
editTarget = null;
|
||
dialogOpen = true;
|
||
}
|
||
|
||
function openEdit(src: CdnSource) {
|
||
editTarget = src;
|
||
dialogOpen = true;
|
||
}
|
||
|
||
function requestDelete(src: CdnSource) {
|
||
void confirm({
|
||
title: 'Удалить CDN-источник?',
|
||
description: src.url,
|
||
confirmLabel: 'Удалить',
|
||
destructive: true,
|
||
onConfirm: async () => {
|
||
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${src.id}`, 'DELETE', undefined, {
|
||
idempotent: false
|
||
});
|
||
notify.success('Удалено');
|
||
await onChanged();
|
||
}
|
||
});
|
||
}
|
||
|
||
function requestBulkDelete() {
|
||
if (selectedCount === 0) return;
|
||
void confirm({
|
||
title: 'Удалить выбранные CDN-источники?',
|
||
description: `Будет удалено: ${selectedCount}`,
|
||
confirmLabel: 'Удалить',
|
||
destructive: true,
|
||
onConfirm: bulkDelete
|
||
});
|
||
}
|
||
|
||
async function bulkDelete() {
|
||
if (selectedCount === 0) return;
|
||
deletingBulk = true;
|
||
let deleted = 0;
|
||
try {
|
||
for (const id of activeSelected) {
|
||
try {
|
||
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, {
|
||
idempotent: false
|
||
});
|
||
deleted += 1;
|
||
} catch (e) {
|
||
notifyApiError(e);
|
||
}
|
||
}
|
||
if (deleted > 0) notify.success(`Удалено CDN-источников: ${deleted}`);
|
||
await onChanged();
|
||
} finally {
|
||
deletingBulk = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<Card>
|
||
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<div class="min-w-0 flex-1">
|
||
<CardTitle class="text-base">CDN-источники</CardTitle>
|
||
<CardDescription>URL источников для скачивания списков CIDR.</CardDescription>
|
||
</div>
|
||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||
{#if selectedCount > 0}
|
||
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
|
||
<Trash2 />
|
||
Удалить ({selectedCount})
|
||
</Button>
|
||
{/if}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent class="p-4 pt-0">
|
||
<AppDataTable
|
||
columns={[...columns]}
|
||
rows={sources}
|
||
rowKey={(s) => s.id}
|
||
{loading}
|
||
emptyTitle="Нет CDN-источников"
|
||
emptyDescription="Добавьте URL для загрузки списков CIDR."
|
||
>
|
||
{#snippet toolbar()}
|
||
{#if sources.length > 0}
|
||
<div class="flex items-center gap-2">
|
||
<Checkbox
|
||
checked={allSelected}
|
||
onCheckedChange={(v) => toggleAll(v === true)}
|
||
aria-label="Выбрать все CDN-источники"
|
||
/>
|
||
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||
</div>
|
||
{/if}
|
||
{/snippet}
|
||
{#snippet cell({ row: src, column })}
|
||
{#if column.id === 'select'}
|
||
<Checkbox
|
||
checked={selectedIds.has(src.id)}
|
||
aria-label="Выбрать CDN-источник"
|
||
onCheckedChange={() => toggleSelection(src.id)}
|
||
/>
|
||
{:else if column.id === 'url'}
|
||
<span class="max-w-xs truncate font-mono text-xs" title={src.url}>{src.url}</span>
|
||
{:else if column.id === 'kind'}
|
||
<div class="flex flex-col gap-0.5">
|
||
<Badge variant="outline">{normalizeCdnSourceKind(src.source_kind)}</Badge>
|
||
{#if src.prefix_path?.trim()}
|
||
<span
|
||
class="font-mono text-xs break-all text-muted-foreground"
|
||
title={src.prefix_path}>{src.prefix_path}</span
|
||
>
|
||
{/if}
|
||
</div>
|
||
{:else if column.id === 'community'}
|
||
<span class="text-sm text-muted-foreground">
|
||
{communityLabel(src.community_id, communities)}
|
||
</span>
|
||
{:else if column.id === 'interval'}
|
||
<span class="text-sm text-muted-foreground">
|
||
{src.refresh_interval_sec != null ? `${src.refresh_interval_sec}с` : '—'}
|
||
</span>
|
||
{:else if column.id === 'refreshed'}
|
||
<span class="text-sm whitespace-nowrap text-muted-foreground">
|
||
{formatDateTime(src.last_refreshed_at)}
|
||
</span>
|
||
{:else if column.id === 'actions'}
|
||
<div class="flex gap-1">
|
||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(src)}>
|
||
<Pencil class="size-3.5" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
class="text-destructive"
|
||
onclick={() => requestDelete(src)}
|
||
>
|
||
<Trash2 class="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
{/if}
|
||
{/snippet}
|
||
</AppDataTable>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<ModuleCdnSourceDialog
|
||
bind:open={dialogOpen}
|
||
{moduleId}
|
||
edit={editTarget}
|
||
{communities}
|
||
onSaved={onChanged}
|
||
onClose={() => {
|
||
editTarget = null;
|
||
}}
|
||
/>
|