feat(web): уплотнить UI списков по ReUI PRO (list-9 + stats-7)
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m53s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

Каталог слева — Frame/Item вместо тяжёлого ResourcePage; KPI детали — одна полоса stats-7; Sheet с ScrollArea и sticky footer.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 23:41:45 +07:00
co-authored by Cursor
parent c505ab82e8
commit 35051babb5
3 changed files with 409 additions and 281 deletions
@@ -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[] }) {
return (
<div className="@container w-full">
<div className="grid gap-4 @2xl:grid-cols-3">
<Frame dense spacing="sm" className="w-full">
<FramePanel className="grid grid-cols-1 divide-y p-0 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
{cards.map((card) => (
<Frame key={card.id} spacing="sm">
<FrameHeader className="px-1! py-1!">
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
{card.icon}
<span className="text-foreground text-sm font-medium">
{card.label}
</span>
</div>
</FrameHeader>
<FramePanel className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs leading-relaxed">
{card.description}
</p>
{card.footer}
</FramePanel>
</Frame>
<div key={card.id} className="flex flex-col gap-1.5 px-4 py-3">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium [&_svg]:size-3.5">
{card.icon}
<span>{card.label}</span>
</div>
<p className="text-foreground text-xl font-semibold tracking-tight tabular-nums">
{card.description}
</p>
{card.footer}
</div>
))}
</div>
</div>
</FramePanel>
</Frame>
)
}