feat:Update pnpm-lock.yaml to link shared package; modify API routes to utilize new schemas for domain and service management; enhance DNS record handling with CNAME support; refactor service and subdomain routes for improved functionality; implement confirm dialog for domain deletion in the frontend; clean up unused components and improve UI consistency.
Build, Test, and Push CFDM Docker Image / test (push) Failing after 51s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Failing after 51s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
groupColumnId,
|
||||
UNGROUPED_COLUMN_ID,
|
||||
} from '@/components/groups-board/column-ids'
|
||||
import type { BoardColumn, BoardState } from '@/components/groups-board/types'
|
||||
import type { DomainListItem, Group } from '@/lib/schemas'
|
||||
|
||||
export function mapGroupsDomainsToBoard(
|
||||
groups: Group[],
|
||||
domains: DomainListItem[],
|
||||
): BoardState {
|
||||
const columns: BoardColumn[] = groups.map((group) => ({
|
||||
id: groupColumnId(group.id),
|
||||
groupId: group.id,
|
||||
title: group.name,
|
||||
slug: group.slug,
|
||||
items: domains.filter((domain) => domain.group_id === group.id),
|
||||
group,
|
||||
}))
|
||||
|
||||
const ungrouped = domains.filter((domain) => domain.group_id === null)
|
||||
columns.push({
|
||||
id: UNGROUPED_COLUMN_ID,
|
||||
groupId: null,
|
||||
title: 'Без группы',
|
||||
slug: null,
|
||||
items: ungrouped,
|
||||
})
|
||||
|
||||
return { columns }
|
||||
}
|
||||
|
||||
export function findColumnId(
|
||||
columns: BoardColumn[],
|
||||
itemId: string,
|
||||
): string | undefined {
|
||||
if (columns.some((column) => column.id === itemId)) return itemId
|
||||
return columns.find((column) =>
|
||||
column.items.some((domain) => String(domain.id) === itemId),
|
||||
)?.id
|
||||
}
|
||||
|
||||
export function moveDomainBetweenColumns(
|
||||
board: BoardState,
|
||||
domainId: number,
|
||||
fromColumnId: string,
|
||||
toColumnId: string,
|
||||
): BoardState {
|
||||
if (fromColumnId === toColumnId) return board
|
||||
|
||||
const fromColumn = board.columns.find((column) => column.id === fromColumnId)
|
||||
const domain = fromColumn?.items.find((item) => item.id === domainId)
|
||||
if (!domain || !fromColumn) return board
|
||||
|
||||
const targetColumn = board.columns.find((column) => column.id === toColumnId)
|
||||
if (!targetColumn) return board
|
||||
|
||||
const updatedDomain: DomainListItem = {
|
||||
...domain,
|
||||
group_id: targetColumn.groupId,
|
||||
group_name: targetColumn.group?.name ?? null,
|
||||
}
|
||||
|
||||
const columns = board.columns.map((column) => {
|
||||
if (column.id === fromColumnId) {
|
||||
return {
|
||||
...column,
|
||||
items: column.items.filter((item) => item.id !== domainId),
|
||||
}
|
||||
}
|
||||
if (column.id === toColumnId) {
|
||||
return {
|
||||
...column,
|
||||
items: [...column.items, updatedDomain],
|
||||
}
|
||||
}
|
||||
return column
|
||||
})
|
||||
|
||||
return { columns }
|
||||
}
|
||||
|
||||
export function boardToDomainsList(
|
||||
board: BoardState,
|
||||
previous: DomainListItem[],
|
||||
): DomainListItem[] {
|
||||
const byId = new Map(previous.map((domain) => [domain.id, domain]))
|
||||
|
||||
for (const column of board.columns) {
|
||||
for (const domain of column.items) {
|
||||
byId.set(domain.id, domain)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byId.values())
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
UNGROUPED_COLUMN_ID,
|
||||
groupColumnId,
|
||||
parseGroupColumnId,
|
||||
} from '@/components/board/column-ids'
|
||||
@@ -0,0 +1,100 @@
|
||||
import { FolderTreeIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||
import type { BoardColumn } from '@/components/groups-board/types'
|
||||
import type { Group } from '@/lib/schemas'
|
||||
import { AccordionTrigger } from '@cfdm/ui/components/accordion'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
|
||||
interface DomainGroupHeaderProps {
|
||||
column: BoardColumn
|
||||
isOpen?: boolean
|
||||
isDragging?: boolean
|
||||
dragDisabled?: boolean
|
||||
onEditGroup?: (group: Group) => void
|
||||
onDeleteGroup?: (group: Group) => void
|
||||
}
|
||||
|
||||
export function DomainGroupHeader({
|
||||
column,
|
||||
isOpen = true,
|
||||
isDragging = false,
|
||||
dragDisabled = false,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
}: DomainGroupHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-1">
|
||||
<AccordionTrigger className="min-h-10 flex-1 items-center gap-2 rounded-md py-2 hover:bg-muted/40 hover:no-underline">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{column.groupId !== null ? (
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-background/80 text-muted-foreground">
|
||||
<FolderTreeIcon />
|
||||
</span>
|
||||
) : null}
|
||||
<span className="font-medium">{column.title}</span>
|
||||
<Badge variant="secondary">{column.items.length}</Badge>
|
||||
{column.slug ? (
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{column.slug}
|
||||
</Badge>
|
||||
) : null}
|
||||
{!isOpen && isDragging && !dragDisabled ? (
|
||||
<Badge variant="default" className="font-normal">
|
||||
Отпустите для переноса
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
{column.groupId !== null && column.group ? (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1 pr-1"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Действия для группы ${column.title}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onEditGroup ? (
|
||||
<DropdownMenuItem onClick={() => onEditGroup(column.group!)}>
|
||||
<PencilIcon data-icon="inline-start" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onDeleteGroup ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDeleteGroup(column.group!)}
|
||||
>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
Удалить группу
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { DomainGroupHeader } from '@/components/groups-board/domain-group-header'
|
||||
import { DomainRow } from '@/components/groups-board/domain-row'
|
||||
import type { BoardColumn } from '@/components/groups-board/types'
|
||||
import type { Group } from '@/lib/schemas'
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
} from '@cfdm/ui/components/accordion'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
import { ItemGroup, ItemSeparator } from '@cfdm/ui/components/item'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface DomainGroupItemProps {
|
||||
column: BoardColumn
|
||||
isOpen?: boolean
|
||||
isDragging?: boolean
|
||||
onExpandColumn?: (columnId: string) => void
|
||||
onEditGroup?: (group: Group) => void
|
||||
onDeleteGroup?: (group: Group) => void
|
||||
dragDisabled?: boolean
|
||||
serviceLabelsByDomain?: Map<number, string[]>
|
||||
}
|
||||
|
||||
export function DomainGroupItem({
|
||||
column,
|
||||
isOpen = true,
|
||||
isDragging = false,
|
||||
onExpandColumn,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
dragDisabled = false,
|
||||
serviceLabelsByDomain,
|
||||
}: DomainGroupItemProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: column.id,
|
||||
disabled: dragDisabled,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isOver && isDragging && !dragDisabled) {
|
||||
onExpandColumn?.(column.id)
|
||||
}
|
||||
}, [isOver, isDragging, dragDisabled, column.id, onExpandColumn])
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
value={column.id}
|
||||
className={cn(
|
||||
'not-last:border-b-0 overflow-hidden rounded-lg border border-border transition-colors',
|
||||
!isOpen && 'bg-muted/20',
|
||||
isOpen && 'bg-muted/30',
|
||||
isOver && !dragDisabled && 'ring-2 ring-primary/30',
|
||||
)}
|
||||
>
|
||||
<DomainGroupHeader
|
||||
column={column}
|
||||
isOpen={isOpen}
|
||||
isDragging={isDragging}
|
||||
dragDisabled={dragDisabled}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
/>
|
||||
|
||||
<AccordionContent className="px-1 pb-2">
|
||||
<div ref={setNodeRef}>
|
||||
{column.items.length > 0 ? (
|
||||
<ItemGroup className="gap-0 py-1">
|
||||
{column.items.map((domain, index) => (
|
||||
<div key={domain.id}>
|
||||
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||
<DomainRow
|
||||
domain={domain}
|
||||
serviceLabels={serviceLabelsByDomain?.get(domain.id)}
|
||||
dragDisabled={dragDisabled}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ItemGroup>
|
||||
) : (
|
||||
<Empty
|
||||
className={cn(
|
||||
'border border-dashed py-2',
|
||||
isOver && !dragDisabled && 'border-primary bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle className="text-sm">Нет доменов в группе</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Перетащите домен сюда
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
GripVerticalIcon,
|
||||
MoreHorizontalIcon,
|
||||
ServerIcon,
|
||||
} from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DomainListItem } from '@/lib/schemas'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Card, CardContent } from '@cfdm/ui/components/card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export interface DomainRowProps {
|
||||
domain: DomainListItem
|
||||
serviceLabels?: string[]
|
||||
dragDisabled?: boolean
|
||||
overlay?: boolean
|
||||
}
|
||||
|
||||
function DomainServiceList({
|
||||
labels,
|
||||
serviceCount,
|
||||
}: {
|
||||
labels: string[]
|
||||
serviceCount: number
|
||||
}) {
|
||||
if (labels.length === 0) {
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{serviceCount > 0 ? `${serviceCount} сервис(ов)` : 'Нет сервисов'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (labels.length === 1) {
|
||||
return (
|
||||
<Badge variant="secondary" className="max-w-full truncate font-normal">
|
||||
{labels[0]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="sm" className="w-full shadow-none">
|
||||
<CardContent className="flex flex-col gap-1 py-0">
|
||||
{labels.map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<ServerIcon className="size-3 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function DomainRow({
|
||||
domain,
|
||||
serviceLabels = [],
|
||||
dragDisabled = false,
|
||||
overlay = false,
|
||||
}: DomainRowProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: String(domain.id),
|
||||
disabled: dragDisabled || overlay,
|
||||
})
|
||||
|
||||
const style = transform
|
||||
? { transform: CSS.Translate.toString(transform) }
|
||||
: undefined
|
||||
|
||||
const hasServiceList = serviceLabels.length > 1
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlay ? undefined : setNodeRef}
|
||||
style={overlay ? undefined : style}
|
||||
className={cn(
|
||||
'flex gap-3 rounded-md px-3 transition-colors hover:bg-muted/50',
|
||||
hasServiceList ? 'items-start py-2' : 'h-10 items-center',
|
||||
(isDragging || overlay) && 'opacity-90 shadow-md',
|
||||
isDragging && !overlay && 'z-10',
|
||||
)}
|
||||
>
|
||||
{!dragDisabled && !overlay ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
'touch-none shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing',
|
||||
hasServiceList && 'mt-0.5',
|
||||
)}
|
||||
aria-label={`Перетащить ${domain.zone_name}`}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<GripVerticalIcon />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 shrink-0',
|
||||
hasServiceList ? 'w-28 pt-0.5' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-sm font-medium">{domain.zone_name}</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<DomainServiceList
|
||||
labels={serviceLabels}
|
||||
serviceCount={domain.service_count}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-2',
|
||||
hasServiceList && 'self-center',
|
||||
)}
|
||||
>
|
||||
<StatusBadge status={domain.status} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Действия для ${domain.zone_name}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(domain.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ExternalLinkIcon data-icon="inline-start" />
|
||||
Обзор
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link to="/services" search={{ domainId: domain.id }} />
|
||||
}
|
||||
>
|
||||
<ServerIcon data-icon="inline-start" />
|
||||
Сервисы
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
|
||||
export function GroupsBoardSkeleton() {
|
||||
return (
|
||||
<div className="grid w-full grid-cols-1 gap-2 lg:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, groupIndex) => (
|
||||
<div
|
||||
key={groupIndex}
|
||||
className="flex flex-col gap-2 rounded-lg border border-border px-2 py-2"
|
||||
>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 3 }).map((__, rowIndex) => (
|
||||
<Skeleton key={rowIndex} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { DragContextProvider } from '@/components/board/drag-context-provider'
|
||||
import { DomainGroupItem } from '@/components/groups-board/domain-group-item'
|
||||
import { DomainRow } from '@/components/groups-board/domain-row'
|
||||
import type { BoardState } from '@/components/groups-board/types'
|
||||
import type { DomainListItem, Group } from '@/lib/schemas'
|
||||
import { Accordion } from '@cfdm/ui/components/accordion'
|
||||
|
||||
interface GroupsBoardProps {
|
||||
board: BoardState
|
||||
activeDomain?: DomainListItem
|
||||
dragDisabled?: boolean
|
||||
onDragStart: Parameters<typeof DragContextProvider>[0]['onDragStart']
|
||||
onDragEnd: Parameters<typeof DragContextProvider>[0]['onDragEnd']
|
||||
onEditGroup?: (group: Group) => void
|
||||
onDeleteGroup?: (group: Group) => void
|
||||
serviceLabelsByDomain?: Map<number, string[]>
|
||||
}
|
||||
|
||||
export function GroupsBoard({
|
||||
board,
|
||||
activeDomain,
|
||||
dragDisabled = false,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
serviceLabelsByDomain,
|
||||
}: GroupsBoardProps) {
|
||||
const columnIds = useMemo(
|
||||
() => board.columns.map((column) => column.id),
|
||||
[board.columns],
|
||||
)
|
||||
const columnIdsKey = columnIds.join(',')
|
||||
|
||||
const [openColumns, setOpenColumns] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setOpenColumns((prev) => {
|
||||
const preserved = prev.filter((id) => columnIds.includes(id))
|
||||
const added = columnIds.filter((id) => !preserved.includes(id))
|
||||
if (preserved.length === 0 && added.length > 0) {
|
||||
return columnIds
|
||||
}
|
||||
return [...preserved, ...added]
|
||||
})
|
||||
}, [columnIdsKey, columnIds])
|
||||
|
||||
const isDragging = activeDomain != null
|
||||
|
||||
function handleExpandColumn(columnId: string) {
|
||||
setOpenColumns((prev) =>
|
||||
prev.includes(columnId) ? prev : [...prev, columnId],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DragContextProvider
|
||||
disabled={dragDisabled}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
overlay={
|
||||
activeDomain ? (
|
||||
<DomainRow
|
||||
domain={activeDomain}
|
||||
serviceLabels={serviceLabelsByDomain?.get(activeDomain.id)}
|
||||
dragDisabled
|
||||
overlay
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Accordion
|
||||
multiple
|
||||
value={openColumns}
|
||||
onValueChange={setOpenColumns}
|
||||
className="grid w-full grid-cols-1 gap-2 lg:grid-cols-2"
|
||||
>
|
||||
{board.columns.map((column) => (
|
||||
<DomainGroupItem
|
||||
key={column.id}
|
||||
column={column}
|
||||
isOpen={openColumns.includes(column.id)}
|
||||
isDragging={isDragging}
|
||||
onExpandColumn={handleExpandColumn}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
dragDisabled={dragDisabled}
|
||||
serviceLabelsByDomain={serviceLabelsByDomain}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
</DragContextProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { DomainListItem, Group } from '@/lib/schemas'
|
||||
|
||||
export interface BoardColumn {
|
||||
id: string
|
||||
groupId: number | null
|
||||
title: string
|
||||
slug: string | null
|
||||
items: DomainListItem[]
|
||||
group?: Group
|
||||
}
|
||||
|
||||
export interface BoardState {
|
||||
columns: BoardColumn[]
|
||||
}
|
||||
Reference in New Issue
Block a user