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:
@@ -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<Filter[]>([])
|
||||
const [entryFilters, setEntryFilters] = useState<Filter[]>([])
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [deleteListId, setDeleteListId] = useState<string | null>(null)
|
||||
const [deleteValue, setDeleteValue] = useState<string | null>(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<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(
|
||||
() => [
|
||||
{
|
||||
@@ -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() {
|
||||
}
|
||||
/>
|
||||
|
||||
<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
|
||||
className={cn(
|
||||
'min-w-0',
|
||||
showCatalogOnMobile ? 'block' : 'hidden lg:block',
|
||||
)}
|
||||
>
|
||||
<ResourcePage
|
||||
title="Каталог"
|
||||
hideHeader
|
||||
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)}
|
||||
<ListsCatalog
|
||||
items={items}
|
||||
selectedId={listId}
|
||||
isLoading={listsQ.isLoading}
|
||||
isError={listsQ.isError}
|
||||
error={listsQ.error}
|
||||
onRetry={() => void listsQ.refetch()}
|
||||
pageSize={8}
|
||||
emptyState={{
|
||||
title: 'Нет списков',
|
||||
description: 'Создайте первый список.',
|
||||
action: (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
Новый список
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
onSelect={selectList}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onRefresh={(id) => refresh.mutate(id)}
|
||||
onDelete={setDeleteListId}
|
||||
refreshPending={refresh.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -531,6 +393,7 @@ function ListsPage() {
|
||||
) : listQ.isLoading ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
) : !detail ? (
|
||||
@@ -687,6 +550,7 @@ function ListsPage() {
|
||||
disabled={removeEntry.isPending}
|
||||
/>
|
||||
|
||||
{/* sheet-1 / sheet-8: inset form + sticky footer */}
|
||||
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
@@ -695,63 +559,65 @@ function ListsPage() {
|
||||
После создания заполните содержимое в таблице справа.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
||||
<Field>
|
||||
<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' ? (
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="grid auto-rows-min gap-4 py-2 pb-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
|
||||
<FieldLabel htmlFor="list-name">Имя</FieldLabel>
|
||||
<Input
|
||||
id="list-url"
|
||||
value={extra}
|
||||
placeholder="https://…"
|
||||
onChange={(e) => {
|
||||
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)}
|
||||
/>
|
||||
</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>
|
||||
) : 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>
|
||||
{source === 'json_url' ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
|
||||
<Input
|
||||
id="list-url"
|
||||
value={extra}
|
||||
placeholder="https://…"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setExtra(v)
|
||||
if (guessListSourceFromInput(v) === 'json_url') {
|
||||
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">
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
Отмена
|
||||
@@ -775,66 +641,68 @@ function ListsPage() {
|
||||
несколько строк сразу.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
||||
<Field>
|
||||
<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' ? (
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="grid auto-rows-min gap-4 py-2 pb-4">
|
||||
<Field>
|
||||
<FieldLabel>Список</FieldLabel>
|
||||
<FieldLabel>Вид</FieldLabel>
|
||||
<Select
|
||||
value={addListRef || null}
|
||||
value={addKind}
|
||||
onValueChange={(v) => {
|
||||
if (v) setAddListRef(v)
|
||||
if (v) setAddKind(v as ListEntryKind)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите список" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{nestedCandidates.map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="ip">IP</SelectItem>
|
||||
<SelectItem value="cidr">CIDR / диапазон</SelectItem>
|
||||
<SelectItem value="hostname">Домен</SelectItem>
|
||||
<SelectItem value="list">Список</SelectItem>
|
||||
</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>
|
||||
{addKind === 'list' ? (
|
||||
<Field>
|
||||
<FieldLabel>Список</FieldLabel>
|
||||
<Select
|
||||
value={addListRef || null}
|
||||
onValueChange={(v) => {
|
||||
if (v) setAddListRef(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите список" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{nestedCandidates.map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</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">
|
||||
<Button variant="outline" onClick={() => setAddOpen(false)}>
|
||||
Отмена
|
||||
|
||||
Reference in New Issue
Block a user