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() {
После создания заполните содержимое в таблице справа.
-
+