diff --git a/apps/web/src/components/lists/lists-catalog.tsx b/apps/web/src/components/lists/lists-catalog.tsx new file mode 100644 index 0000000..229e866 --- /dev/null +++ b/apps/web/src/components/lists/lists-catalog.tsx @@ -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 ( + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + + ) + } + + if (isError) { + return ( + + +

+ {error?.message ?? 'Не удалось загрузить списки'} +

+ {onRetry ? ( + + ) : null} +
+ + ) + } + + return ( + + + Каталог + + {filtered.length} + + + +
+ +
+ +
+
+ + setQuery(e.target.value)} + placeholder="Поиск…" + className="h-8 pl-8" + aria-label="Поиск списков" + /> +
+
+ + {items.length === 0 ? ( +
+ + Новый список + + } + /> +
+ ) : filtered.length === 0 ? ( +

+ Нет совпадений +

+ ) : ( + + {filtered.map((list) => { + const selected = selectedId === list.id + const Icon = isManualListType(list.type) + ? ListIcon + : list.type === 'json_url' + ? LinkIcon + : GlobeIcon + return ( + onSelect(list.id)} + > + + + + + + {list.name} + + + + {ipListSourceLabel(list.type)} ·{' '} + + {list.entry_count ?? 0} + {' '} + CIDR + + + e.stopPropagation()} + className="gap-0.5" + > + {!isManualListType(list.type) ? ( + + ) : null} + + + + ) + })} + + )} +
+ + ) +} diff --git a/apps/web/src/components/reui-kit/detail-panel.tsx b/apps/web/src/components/reui-kit/detail-panel.tsx index 3975740..5079a9f 100644 --- a/apps/web/src/components/reui-kit/detail-panel.tsx +++ b/apps/web/src/components/reui-kit/detail-panel.tsx @@ -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[] }) { return ( -
-
+ + {cards.map((card) => ( - - -
- {card.icon} - - {card.label} - -
-
- -

- {card.description} -

- {card.footer} -
- +
+
+ {card.icon} + {card.label} +
+

+ {card.description} +

+ {card.footer} +
))} -
-
+ + ) } diff --git a/apps/web/src/routes/_auth/lists/index.tsx b/apps/web/src/routes/_auth/lists/index.tsx index b288d3e..e1f336b 100644 --- a/apps/web/src/routes/_auth/lists/index.tsx +++ b/apps/web/src/routes/_auth/lists/index.tsx @@ -21,6 +21,7 @@ import { PageShell, ResourcePage, } from '@/components/reui-kit' +import { ListsCatalog } from '@/components/lists/lists-catalog' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' import { DataGridMutedCell, @@ -33,6 +34,7 @@ import { apiFetch } from '@/lib/api' import { Button } from '@evofw/ui/components/button' import { Field, FieldLabel } from '@evofw/ui/components/field' import { Input } from '@evofw/ui/components/input' +import { ScrollArea } from '@evofw/ui/components/scroll-area' import { Textarea } from '@evofw/ui/components/textarea' import { Select, @@ -54,7 +56,6 @@ import { cn } from '@evofw/ui/lib/utils' import { guessListSourceFromInput, isManualListType, - type IpList, type ListEntryKind, } from '@evofw/shared' @@ -78,9 +79,11 @@ type ListItem = { } /** - * Lists — master-detail. - * Preview: https://reui.io/preview/base/data-grid-filtering-2 - * Sheet: https://reui.io/preview/base/sheet-8 · form-7 + * Lists — master-detail (ReUI PRO composition). + * Catalog: https://reui.io/preview/base/list-9 + * 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 */ function ListsPage() { @@ -103,9 +106,7 @@ function ListsPage() { const [addValue, setAddValue] = useState('') const [addListRef, setAddListRef] = useState('') - const [catalogFilters, setCatalogFilters] = useState([]) const [entryFilters, setEntryFilters] = useState([]) - const [activeTab, setActiveTab] = useState('all') const [deleteListId, setDeleteListId] = useState(null) const [deleteValue, setDeleteValue] = useState(null) @@ -180,8 +181,10 @@ function ListsPage() { } const text = addValue.trim() if (!text) throw new Error('Введите значение') - // Multi-line paste for ip/cidr/hostname; structured kind when single line - const lines = text.split(/[\n,;]+/).map((s) => s.trim()).filter(Boolean) + const lines = text + .split(/[\n,;]+/) + .map((s) => s.trim()) + .filter(Boolean) if (lines.length === 1) { return apiFetch(`/api/v1/lists/${listId}/entries`, { method: 'POST', @@ -227,24 +230,6 @@ function ListsPage() { const manual = detail ? isManualListType(detail.type) : false 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( () => [ { @@ -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) => { if (field === 'value') { return item.kind === 'list' @@ -284,98 +263,6 @@ function ListsPage() { 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[] = useMemo( - () => [ - { - accessorKey: 'name', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - ), - }, - { - accessorKey: 'type', - header: ({ column }) => ( - - ), - cell: ({ row }) => , - }, - { - accessorKey: 'entry_count', - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.entry_count ?? 0} - - ), - }, - { - id: 'actions', - enableSorting: false, - header: () => Действия, - cell: ({ row }) => { - const l = row.original - return ( -
- {!isManualListType(l.type) ? ( - - ) : null} - -
- ) - }, - }, - ], - [listId, refresh, selectList], - ) - const entryColumns: ColumnDef[] = useMemo( () => [ { @@ -451,9 +338,7 @@ function ListsPage() { const canAdd = addKind === 'list' ? Boolean(addListRef) : Boolean(addValue.trim()) - const nestedCandidates = items.filter( - (l) => l.id !== listId, - ) + const nestedCandidates = items.filter((l) => l.id !== listId) const showCatalogOnMobile = !listId const showDetailOnMobile = Boolean(listId) @@ -470,48 +355,25 @@ function ListsPage() { } /> -
+
- 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)} + void listsQ.refetch()} - pageSize={8} - emptyState={{ - title: 'Нет списков', - description: 'Создайте первый список.', - action: ( - - ), - }} + onSelect={selectList} + onCreate={() => setCreateOpen(true)} + onRefresh={(id) => refresh.mutate(id)} + onDelete={setDeleteListId} + refreshPending={refresh.isPending} />
@@ -531,6 +393,7 @@ function ListsPage() { ) : listQ.isLoading ? (
+
) : !detail ? ( @@ -687,6 +550,7 @@ function ListsPage() { disabled={removeEntry.isPending} /> + {/* sheet-1 / sheet-8: inset form + sticky footer */} @@ -695,63 +559,65 @@ function ListsPage() { После создания заполните содержимое в таблице справа. -
- - Имя - setName(e.target.value)} - /> - - - Источник - - - {source === 'json_url' ? ( + +
- URL JSON + Имя { - const v = e.target.value - setExtra(v) - if (guessListSourceFromInput(v) === 'json_url') { - setSource('json_url') - } + id="list-name" + value={name} + onChange={(e) => setName(e.target.value)} + /> + + + Источник + - ) : null} - {source === 'evobgp_community' ? ( - - Community ID - setExtra(e.target.value)} - /> - - ) : null} -
+ {source === 'json_url' ? ( + + URL JSON + { + const v = e.target.value + setExtra(v) + if (guessListSourceFromInput(v) === 'json_url') { + setSource('json_url') + } + }} + /> + + ) : null} + {source === 'evobgp_community' ? ( + + Community ID + setExtra(e.target.value)} + /> + + ) : null} +
+