feat(web): enhance lists UI and functionality
- Updated Toaster component to support rich colors and position. - Added breadcrumb navigation for list detail pages. - Removed deprecated ListsCatalog component to streamline the codebase. - Refactored list detail page to improve entry management and user experience. - Enhanced filtering and navigation features in the lists overview. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -48,6 +48,13 @@ function getBreadcrumbs(
|
||||
]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/lists\/[^/]+$/)) {
|
||||
return [
|
||||
{ label: 'Списки', href: '/lists' },
|
||||
{ label: dynamicLabels[pathname] ?? 'Список', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
const title = routeTitles[pathname]
|
||||
if (title) {
|
||||
return [{ label: title, href: pathname }]
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { ListIcon, RefreshCwIcon, Trash2 } from 'lucide-react'
|
||||
import type { FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
DataGridMutedCell,
|
||||
DataGridPrimaryCell,
|
||||
} from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { isManualListType, type IpList } from '@evofw/shared'
|
||||
|
||||
export const LIST_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'manual', label: 'Ручные' },
|
||||
{ id: 'json_url', label: 'JSON' },
|
||||
{ id: 'evobgp_community', label: 'EvoBGP' },
|
||||
] as const
|
||||
|
||||
export const listFilterFields: FilterFieldConfig[] = [
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function listFilterFieldValue(item: IpList, field: string): unknown {
|
||||
if (field === 'name') return item.name
|
||||
if (field === 'type') {
|
||||
return isManualListType(item.type) ? 'static' : item.type
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function listTabFilter(item: IpList, tabId: string): boolean {
|
||||
if (tabId === 'all') return true
|
||||
if (tabId === 'manual') return isManualListType(item.type)
|
||||
return item.type === tabId
|
||||
}
|
||||
|
||||
export function createListColumns(opts: {
|
||||
onRefresh: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
refreshPending?: boolean
|
||||
}): ColumnDef<IpList>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Список" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to="/lists/$id"
|
||||
params={{ id: row.original.id }}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<ListIcon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<DataGridPrimaryCell
|
||||
accent="primary"
|
||||
title={row.original.name}
|
||||
subtitle={
|
||||
row.original.last_error
|
||||
? 'Ошибка обновления'
|
||||
: row.original.refreshed_at
|
||||
? `Обновлён ${new Date(row.original.refreshed_at).toLocaleString('ru-RU')}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
),
|
||||
meta: { headerTitle: 'Список' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Источник" />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.type} />,
|
||||
meta: { headerTitle: 'Источник' },
|
||||
},
|
||||
{
|
||||
id: 'entries',
|
||||
accessorFn: (row) => row.entry_count ?? 0,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Записей" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.entry_count ?? 0}</span>
|
||||
),
|
||||
meta: { headerTitle: 'Записей' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Обновлено" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.updated_at
|
||||
? new Date(row.original.updated_at).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Обновить"
|
||||
disabled={opts.refreshPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
opts.onRefresh(row.original.id)
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={
|
||||
opts.refreshPending ? 'size-3.5 animate-spin' : 'size-3.5'
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
render={
|
||||
<Link to="/lists/$id" params={{ id: row.original.id }} />
|
||||
}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
aria-label="Удалить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
opts.onDelete(row.original.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user