feat(web): уплотнить UI списков по ReUI PRO (list-9 + stats-7)
Каталог слева — Frame/Item вместо тяжёлого ResourcePage; KPI детали — одна полоса stats-7; Sheet с ScrollArea и sticky footer. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,262 @@
|
|||||||
|
import {
|
||||||
|
GlobeIcon,
|
||||||
|
LinkIcon,
|
||||||
|
ListIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
SearchIcon,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { Button } from '@evofw/ui/components/button'
|
||||||
|
import { Input } from '@evofw/ui/components/input'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@evofw/ui/components/item'
|
||||||
|
import { Separator } from '@evofw/ui/components/separator'
|
||||||
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
|
import { cn } from '@evofw/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
ipListSourceLabel,
|
||||||
|
isManualListType,
|
||||||
|
type IpList,
|
||||||
|
} from '@evofw/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists catalog — list-9 pattern (Frame + Item rows), not a full DataGrid.
|
||||||
|
* Preview: https://reui.io/preview/base/list-9 · list-5 tabs
|
||||||
|
*/
|
||||||
|
export function ListsCatalog({
|
||||||
|
items,
|
||||||
|
selectedId,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
onSelect,
|
||||||
|
onCreate,
|
||||||
|
onRefresh,
|
||||||
|
onDelete,
|
||||||
|
refreshPending,
|
||||||
|
}: {
|
||||||
|
items: IpList[]
|
||||||
|
selectedId?: string
|
||||||
|
isLoading?: boolean
|
||||||
|
isError?: boolean
|
||||||
|
error?: Error | null
|
||||||
|
onRetry?: () => void
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
onCreate: () => void
|
||||||
|
onRefresh: (id: string) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
refreshPending?: boolean
|
||||||
|
}) {
|
||||||
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (activeTab === 'manual' && !isManualListType(item.type)) return false
|
||||||
|
if (
|
||||||
|
activeTab !== 'all' &&
|
||||||
|
activeTab !== 'manual' &&
|
||||||
|
item.type !== activeTab
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!q) return true
|
||||||
|
return item.name.toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
}, [items, activeTab, query])
|
||||||
|
|
||||||
|
const tabCounts = useMemo(() => {
|
||||||
|
const base = query.trim()
|
||||||
|
? items.filter((i) =>
|
||||||
|
i.name.toLowerCase().includes(query.trim().toLowerCase()),
|
||||||
|
)
|
||||||
|
: items
|
||||||
|
return {
|
||||||
|
all: base.length,
|
||||||
|
manual: base.filter((i) => isManualListType(i.type)).length,
|
||||||
|
json_url: base.filter((i) => i.type === 'json_url').length,
|
||||||
|
evobgp_community: base.filter((i) => i.type === 'evobgp_community')
|
||||||
|
.length,
|
||||||
|
}
|
||||||
|
}, [items, query])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-2 p-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FramePanel className="flex flex-col gap-2 p-4">
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
{error?.message ?? 'Не удалось загрузить списки'}
|
||||||
|
</p>
|
||||||
|
{onRetry ? (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader className="flex-row items-center justify-between gap-2">
|
||||||
|
<FrameTitle>Каталог</FrameTitle>
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
{filtered.length}
|
||||||
|
</span>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-0 p-0">
|
||||||
|
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||||
|
<CountedLineTabs
|
||||||
|
tabs={[
|
||||||
|
{ id: 'all', label: 'Все', count: tabCounts.all },
|
||||||
|
{ id: 'manual', label: 'Ручной', count: tabCounts.manual },
|
||||||
|
{ id: 'json_url', label: 'JSON', count: tabCounts.json_url },
|
||||||
|
{
|
||||||
|
id: 'evobgp_community',
|
||||||
|
label: 'EvoBGP',
|
||||||
|
count: tabCounts.evobgp_community,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="px-(--frame-panel-header-px) py-2">
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Поиск…"
|
||||||
|
className="h-8 pl-8"
|
||||||
|
aria-label="Поиск списков"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<EmptyState
|
||||||
|
title="Нет списков"
|
||||||
|
description="Создайте первый список."
|
||||||
|
centered={false}
|
||||||
|
action={
|
||||||
|
<Button size="sm" onClick={onCreate}>
|
||||||
|
Новый список
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground px-4 py-6 text-center text-sm">
|
||||||
|
Нет совпадений
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ItemGroup className="max-h-[min(28rem,55svh)] gap-0 overflow-y-auto p-1">
|
||||||
|
{filtered.map((list) => {
|
||||||
|
const selected = selectedId === list.id
|
||||||
|
const Icon = isManualListType(list.type)
|
||||||
|
? ListIcon
|
||||||
|
: list.type === 'json_url'
|
||||||
|
? LinkIcon
|
||||||
|
: GlobeIcon
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
key={list.id}
|
||||||
|
size="sm"
|
||||||
|
variant={selected ? 'muted' : 'default'}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer border-transparent',
|
||||||
|
selected && 'bg-muted ring-border ring-1',
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect(list.id)}
|
||||||
|
>
|
||||||
|
<ItemMedia
|
||||||
|
variant="icon"
|
||||||
|
className="bg-background border-border size-8 rounded-md border"
|
||||||
|
>
|
||||||
|
<Icon aria-hidden />
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle className="gap-2">
|
||||||
|
<span className="truncate">{list.name}</span>
|
||||||
|
<StatusBadge status={list.type} />
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription>
|
||||||
|
{ipListSourceLabel(list.type)} ·{' '}
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{list.entry_count ?? 0}
|
||||||
|
</span>{' '}
|
||||||
|
CIDR
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="gap-0.5"
|
||||||
|
>
|
||||||
|
{!isManualListType(list.type) ? (
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label="Refresh"
|
||||||
|
disabled={refreshPending}
|
||||||
|
onClick={() => onRefresh(list.id)}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
aria-label="Удалить"
|
||||||
|
onClick={() => onDelete(list.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ItemGroup>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -58,30 +58,28 @@ function DetailPanelHeader({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact KPI strip — stats-7 pattern (single Frame, divided cells).
|
||||||
|
* Preview: https://reui.io/preview/base/stats-7
|
||||||
|
*/
|
||||||
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
|
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="@container w-full">
|
<Frame dense spacing="sm" className="w-full">
|
||||||
<div className="grid gap-4 @2xl:grid-cols-3">
|
<FramePanel className="grid grid-cols-1 divide-y p-0 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<Frame key={card.id} spacing="sm">
|
<div key={card.id} className="flex flex-col gap-1.5 px-4 py-3">
|
||||||
<FrameHeader className="px-1! py-1!">
|
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium [&_svg]:size-3.5">
|
||||||
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
{card.icon}
|
||||||
{card.icon}
|
<span>{card.label}</span>
|
||||||
<span className="text-foreground text-sm font-medium">
|
</div>
|
||||||
{card.label}
|
<p className="text-foreground text-xl font-semibold tracking-tight tabular-nums">
|
||||||
</span>
|
{card.description}
|
||||||
</div>
|
</p>
|
||||||
</FrameHeader>
|
{card.footer}
|
||||||
<FramePanel className="flex flex-col gap-2">
|
</div>
|
||||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
|
||||||
{card.description}
|
|
||||||
</p>
|
|
||||||
{card.footer}
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</FramePanel>
|
||||||
</div>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
PageShell,
|
PageShell,
|
||||||
ResourcePage,
|
ResourcePage,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
|
import { ListsCatalog } from '@/components/lists/lists-catalog'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import {
|
import {
|
||||||
DataGridMutedCell,
|
DataGridMutedCell,
|
||||||
@@ -33,6 +34,7 @@ import { apiFetch } from '@/lib/api'
|
|||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
import { Input } from '@evofw/ui/components/input'
|
||||||
|
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||||
import { Textarea } from '@evofw/ui/components/textarea'
|
import { Textarea } from '@evofw/ui/components/textarea'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -54,7 +56,6 @@ import { cn } from '@evofw/ui/lib/utils'
|
|||||||
import {
|
import {
|
||||||
guessListSourceFromInput,
|
guessListSourceFromInput,
|
||||||
isManualListType,
|
isManualListType,
|
||||||
type IpList,
|
|
||||||
type ListEntryKind,
|
type ListEntryKind,
|
||||||
} from '@evofw/shared'
|
} from '@evofw/shared'
|
||||||
|
|
||||||
@@ -78,9 +79,11 @@ type ListItem = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists — master-detail.
|
* Lists — master-detail (ReUI PRO composition).
|
||||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
* Catalog: https://reui.io/preview/base/list-9
|
||||||
* Sheet: https://reui.io/preview/base/sheet-8 · form-7
|
* Entries: https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
* KPI: https://reui.io/preview/base/stats-7
|
||||||
|
* Sheet: https://reui.io/preview/base/sheet-1 · sheet-8
|
||||||
* Empty: https://reui.io/preview/base/empty-state-12
|
* Empty: https://reui.io/preview/base/empty-state-12
|
||||||
*/
|
*/
|
||||||
function ListsPage() {
|
function ListsPage() {
|
||||||
@@ -103,9 +106,7 @@ function ListsPage() {
|
|||||||
const [addValue, setAddValue] = useState('')
|
const [addValue, setAddValue] = useState('')
|
||||||
const [addListRef, setAddListRef] = useState('')
|
const [addListRef, setAddListRef] = useState('')
|
||||||
|
|
||||||
const [catalogFilters, setCatalogFilters] = useState<Filter[]>([])
|
|
||||||
const [entryFilters, setEntryFilters] = useState<Filter[]>([])
|
const [entryFilters, setEntryFilters] = useState<Filter[]>([])
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
|
||||||
const [deleteListId, setDeleteListId] = useState<string | null>(null)
|
const [deleteListId, setDeleteListId] = useState<string | null>(null)
|
||||||
const [deleteValue, setDeleteValue] = useState<string | null>(null)
|
const [deleteValue, setDeleteValue] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -180,8 +181,10 @@ function ListsPage() {
|
|||||||
}
|
}
|
||||||
const text = addValue.trim()
|
const text = addValue.trim()
|
||||||
if (!text) throw new Error('Введите значение')
|
if (!text) throw new Error('Введите значение')
|
||||||
// Multi-line paste for ip/cidr/hostname; structured kind when single line
|
const lines = text
|
||||||
const lines = text.split(/[\n,;]+/).map((s) => s.trim()).filter(Boolean)
|
.split(/[\n,;]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
if (lines.length === 1) {
|
if (lines.length === 1) {
|
||||||
return apiFetch(`/api/v1/lists/${listId}/entries`, {
|
return apiFetch(`/api/v1/lists/${listId}/entries`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -227,24 +230,6 @@ function ListsPage() {
|
|||||||
const manual = detail ? isManualListType(detail.type) : false
|
const manual = detail ? isManualListType(detail.type) : false
|
||||||
const entryItems: ListItem[] = detail?.items ?? []
|
const entryItems: ListItem[] = detail?.items ?? []
|
||||||
|
|
||||||
const catalogFilterFields: FilterFieldConfig[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{ key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' },
|
|
||||||
{
|
|
||||||
key: 'type',
|
|
||||||
label: 'Источник',
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{ value: 'static', label: 'Ручной' },
|
|
||||||
{ value: 'json_url', label: 'JSON по URL' },
|
|
||||||
{ value: 'evobgp_community', label: 'EvoBGP community' },
|
|
||||||
{ value: 'domains', label: 'Ручной' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const entryFilterFields: FilterFieldConfig[] = useMemo(
|
const entryFilterFields: FilterFieldConfig[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -268,12 +253,6 @@ function ListsPage() {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const getCatalogFilterValue = useCallback((item: IpList, field: string) => {
|
|
||||||
if (field === 'name') return item.name
|
|
||||||
if (field === 'type') return item.type
|
|
||||||
return undefined
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const getEntryFilterValue = useCallback((item: ListItem, field: string) => {
|
const getEntryFilterValue = useCallback((item: ListItem, field: string) => {
|
||||||
if (field === 'value') {
|
if (field === 'value') {
|
||||||
return item.kind === 'list'
|
return item.kind === 'list'
|
||||||
@@ -284,98 +263,6 @@ function ListsPage() {
|
|||||||
return undefined
|
return undefined
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const tabFilter = useCallback((item: IpList, tabId: string) => {
|
|
||||||
if (tabId === 'all') return true
|
|
||||||
if (tabId === 'manual') return isManualListType(item.type)
|
|
||||||
return item.type === tabId
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const catalogColumns: ColumnDef<IpList>[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'name',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Имя" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'min-w-0 text-left',
|
|
||||||
listId === row.original.id && 'font-medium',
|
|
||||||
)}
|
|
||||||
onClick={() => selectList(row.original.id)}
|
|
||||||
>
|
|
||||||
<DataGridPrimaryCell
|
|
||||||
accent={listId === row.original.id ? 'primary' : undefined}
|
|
||||||
title={row.original.name}
|
|
||||||
subtitle={
|
|
||||||
listId === row.original.id ? 'выбран' : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'type',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Тип" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.type} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'entry_count',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="#" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="tabular-nums text-muted-foreground">
|
|
||||||
{row.original.entry_count ?? 0}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
enableSorting: false,
|
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const l = row.original
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-0.5">
|
|
||||||
{!isManualListType(l.type) ? (
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label="Refresh"
|
|
||||||
disabled={refresh.isPending}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
refresh.mutate(l.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-destructive"
|
|
||||||
aria-label="Удалить"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeleteListId(l.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[listId, refresh, selectList],
|
|
||||||
)
|
|
||||||
|
|
||||||
const entryColumns: ColumnDef<ListItem>[] = useMemo(
|
const entryColumns: ColumnDef<ListItem>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -451,9 +338,7 @@ function ListsPage() {
|
|||||||
const canAdd =
|
const canAdd =
|
||||||
addKind === 'list' ? Boolean(addListRef) : Boolean(addValue.trim())
|
addKind === 'list' ? Boolean(addListRef) : Boolean(addValue.trim())
|
||||||
|
|
||||||
const nestedCandidates = items.filter(
|
const nestedCandidates = items.filter((l) => l.id !== listId)
|
||||||
(l) => l.id !== listId,
|
|
||||||
)
|
|
||||||
|
|
||||||
const showCatalogOnMobile = !listId
|
const showCatalogOnMobile = !listId
|
||||||
const showDetailOnMobile = Boolean(listId)
|
const showDetailOnMobile = Boolean(listId)
|
||||||
@@ -470,48 +355,25 @@ function ListsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(280px,360px)_1fr] lg:items-start">
|
<div className="grid gap-4 lg:grid-cols-[minmax(280px,340px)_1fr] lg:items-start">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-0',
|
'min-w-0',
|
||||||
showCatalogOnMobile ? 'block' : 'hidden lg:block',
|
showCatalogOnMobile ? 'block' : 'hidden lg:block',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ResourcePage
|
<ListsCatalog
|
||||||
title="Каталог"
|
items={items}
|
||||||
hideHeader
|
selectedId={listId}
|
||||||
data={items}
|
|
||||||
columns={catalogColumns}
|
|
||||||
getRowId={(r) => r.id}
|
|
||||||
filterFields={catalogFilterFields}
|
|
||||||
filters={catalogFilters}
|
|
||||||
onFiltersChange={setCatalogFilters}
|
|
||||||
onClearFilters={() => setCatalogFilters([])}
|
|
||||||
getFilterFieldValue={getCatalogFilterValue}
|
|
||||||
tabs={[
|
|
||||||
{ id: 'all', label: 'Все' },
|
|
||||||
{ id: 'manual', label: 'Ручной' },
|
|
||||||
{ id: 'json_url', label: 'JSON' },
|
|
||||||
{ id: 'evobgp_community', label: 'EvoBGP' },
|
|
||||||
]}
|
|
||||||
activeTab={activeTab}
|
|
||||||
onTabChange={setActiveTab}
|
|
||||||
tabFilter={tabFilter}
|
|
||||||
onRowClick={(row) => selectList(row.id)}
|
|
||||||
isLoading={listsQ.isLoading}
|
isLoading={listsQ.isLoading}
|
||||||
isError={listsQ.isError}
|
isError={listsQ.isError}
|
||||||
error={listsQ.error}
|
error={listsQ.error}
|
||||||
onRetry={() => void listsQ.refetch()}
|
onRetry={() => void listsQ.refetch()}
|
||||||
pageSize={8}
|
onSelect={selectList}
|
||||||
emptyState={{
|
onCreate={() => setCreateOpen(true)}
|
||||||
title: 'Нет списков',
|
onRefresh={(id) => refresh.mutate(id)}
|
||||||
description: 'Создайте первый список.',
|
onDelete={setDeleteListId}
|
||||||
action: (
|
refreshPending={refresh.isPending}
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
||||||
Новый список
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -531,6 +393,7 @@ function ListsPage() {
|
|||||||
) : listQ.isLoading ? (
|
) : listQ.isLoading ? (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Skeleton className="h-10 w-64" />
|
<Skeleton className="h-10 w-64" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
<Skeleton className="h-48 w-full" />
|
<Skeleton className="h-48 w-full" />
|
||||||
</div>
|
</div>
|
||||||
) : !detail ? (
|
) : !detail ? (
|
||||||
@@ -687,6 +550,7 @@ function ListsPage() {
|
|||||||
disabled={removeEntry.isPending}
|
disabled={removeEntry.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* sheet-1 / sheet-8: inset form + sticky footer */}
|
||||||
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
|
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||||
<SheetHeader className="shrink-0">
|
<SheetHeader className="shrink-0">
|
||||||
@@ -695,63 +559,65 @@ function ListsPage() {
|
|||||||
После создания заполните содержимое в таблице справа.
|
После создания заполните содержимое в таблице справа.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
<ScrollArea className="flex-1 px-4">
|
||||||
<Field>
|
<div className="grid auto-rows-min gap-4 py-2 pb-4">
|
||||||
<FieldLabel htmlFor="list-name">Имя</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="list-name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Источник</FieldLabel>
|
|
||||||
<Select
|
|
||||||
value={source}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (v) setSource(v as CreateSource)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="static">Ручной</SelectItem>
|
|
||||||
<SelectItem value="json_url">JSON по URL</SelectItem>
|
|
||||||
<SelectItem value="evobgp_community">
|
|
||||||
EvoBGP community
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
{source === 'json_url' ? (
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
|
<FieldLabel htmlFor="list-name">Имя</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="list-url"
|
id="list-name"
|
||||||
value={extra}
|
value={name}
|
||||||
placeholder="https://…"
|
onChange={(e) => setName(e.target.value)}
|
||||||
onChange={(e) => {
|
/>
|
||||||
const v = e.target.value
|
</Field>
|
||||||
setExtra(v)
|
<Field>
|
||||||
if (guessListSourceFromInput(v) === 'json_url') {
|
<FieldLabel>Источник</FieldLabel>
|
||||||
setSource('json_url')
|
<Select
|
||||||
}
|
value={source}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) setSource(v as CreateSource)
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="static">Ручной</SelectItem>
|
||||||
|
<SelectItem value="json_url">JSON по URL</SelectItem>
|
||||||
|
<SelectItem value="evobgp_community">
|
||||||
|
EvoBGP community
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
) : null}
|
{source === 'json_url' ? (
|
||||||
{source === 'evobgp_community' ? (
|
<Field>
|
||||||
<Field>
|
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
|
||||||
<FieldLabel htmlFor="list-comm">Community ID</FieldLabel>
|
<Input
|
||||||
<Input
|
id="list-url"
|
||||||
id="list-comm"
|
value={extra}
|
||||||
value={extra}
|
placeholder="https://…"
|
||||||
onChange={(e) => setExtra(e.target.value)}
|
onChange={(e) => {
|
||||||
/>
|
const v = e.target.value
|
||||||
</Field>
|
setExtra(v)
|
||||||
) : null}
|
if (guessListSourceFromInput(v) === 'json_url') {
|
||||||
</div>
|
setSource('json_url')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
{source === 'evobgp_community' ? (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="list-comm">Community ID</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="list-comm"
|
||||||
|
value={extra}
|
||||||
|
onChange={(e) => setExtra(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||||
Отмена
|
Отмена
|
||||||
@@ -775,66 +641,68 @@ function ListsPage() {
|
|||||||
несколько строк сразу.
|
несколько строк сразу.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
<ScrollArea className="flex-1 px-4">
|
||||||
<Field>
|
<div className="grid auto-rows-min gap-4 py-2 pb-4">
|
||||||
<FieldLabel>Вид</FieldLabel>
|
|
||||||
<Select
|
|
||||||
value={addKind}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (v) setAddKind(v as ListEntryKind)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="ip">IP</SelectItem>
|
|
||||||
<SelectItem value="cidr">CIDR / диапазон</SelectItem>
|
|
||||||
<SelectItem value="hostname">Домен</SelectItem>
|
|
||||||
<SelectItem value="list">Список</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
{addKind === 'list' ? (
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Список</FieldLabel>
|
<FieldLabel>Вид</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
value={addListRef || null}
|
value={addKind}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (v) setAddListRef(v)
|
if (v) setAddKind(v as ListEntryKind)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Выберите список" />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{nestedCandidates.map((l) => (
|
<SelectItem value="ip">IP</SelectItem>
|
||||||
<SelectItem key={l.id} value={l.id}>
|
<SelectItem value="cidr">CIDR / диапазон</SelectItem>
|
||||||
{l.name}
|
<SelectItem value="hostname">Домен</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="list">Список</SelectItem>
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
) : (
|
{addKind === 'list' ? (
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="entry-value">Значение</FieldLabel>
|
<FieldLabel>Список</FieldLabel>
|
||||||
<Textarea
|
<Select
|
||||||
id="entry-value"
|
value={addListRef || null}
|
||||||
rows={5}
|
onValueChange={(v) => {
|
||||||
value={addValue}
|
if (v) setAddListRef(v)
|
||||||
placeholder={
|
}}
|
||||||
addKind === 'ip'
|
>
|
||||||
? '8.8.8.8'
|
<SelectTrigger>
|
||||||
: addKind === 'cidr'
|
<SelectValue placeholder="Выберите список" />
|
||||||
? '10.0.0.0/8'
|
</SelectTrigger>
|
||||||
: 'bad.example.com'
|
<SelectContent>
|
||||||
}
|
{nestedCandidates.map((l) => (
|
||||||
onChange={(e) => setAddValue(e.target.value)}
|
<SelectItem key={l.id} value={l.id}>
|
||||||
/>
|
{l.name}
|
||||||
</Field>
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
</div>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="entry-value">Значение</FieldLabel>
|
||||||
|
<Textarea
|
||||||
|
id="entry-value"
|
||||||
|
rows={5}
|
||||||
|
value={addValue}
|
||||||
|
placeholder={
|
||||||
|
addKind === 'ip'
|
||||||
|
? '8.8.8.8'
|
||||||
|
: addKind === 'cidr'
|
||||||
|
? '10.0.0.0/8'
|
||||||
|
: 'bad.example.com'
|
||||||
|
}
|
||||||
|
onChange={(e) => setAddValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||||
<Button variant="outline" onClick={() => setAddOpen(false)}>
|
<Button variant="outline" onClick={() => setAddOpen(false)}>
|
||||||
Отмена
|
Отмена
|
||||||
|
|||||||
Reference in New Issue
Block a user