mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 19:46:32 +08:00
feat: improve admin panel (#14180)
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { Button, type ButtonProps } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
cancelText?: string;
|
||||
confirmText?: string;
|
||||
confirmButtonVariant?: ButtonProps['variant'];
|
||||
onConfirm: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
cancelText = 'Cancel',
|
||||
confirmText = 'Confirm',
|
||||
confirmButtonVariant = 'default',
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ConfirmDialogProps) => {
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="leading-7">{title}</DialogTitle>
|
||||
<DialogDescription className="leading-6">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-6">
|
||||
<div className="flex justify-end gap-2 items-center w-full">
|
||||
<Button type="button" onClick={handleClose} variant="outline">
|
||||
<span>{cancelText}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
variant={confirmButtonVariant}
|
||||
>
|
||||
<span>{confirmText}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@affine/admin/components/ui/select';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useTransition } from 'react';
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
// to handle the error: a component suspended while responding to synchronous input.
|
||||
// This will cause the UI to be replaced with a loading indicator.
|
||||
// To fix, updates that suspend should be wrapped with startTransition.
|
||||
const onPageSizeChange = useCallback(
|
||||
(value: string) => {
|
||||
startTransition(() => {
|
||||
table.setPageSize(Number(value));
|
||||
});
|
||||
},
|
||||
[table]
|
||||
);
|
||||
const handleFirstPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.setPageIndex(0);
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
const handlePreviousPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.previousPage();
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
const handleNextPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.nextPage();
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
const handleLastPage = useCallback(() => {
|
||||
startTransition(() => {
|
||||
table.setPageIndex(table.getPageCount() - 1);
|
||||
});
|
||||
}, [startTransition, table]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between md:px-2">
|
||||
<div className="flex items-center md:space-x-2">
|
||||
<p className="text-sm font-medium max-md:hidden">Rows per page</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={onPageSizeChange}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 40, 80].map(pageSize => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{' '}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={handleFirstPage}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronsLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleNextPage}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleLastPage}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronsRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@affine/admin/components/ui/table';
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type Table as TableInstance,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import { DataTablePagination } from './data-table-pagination';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
totalCount: number;
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
|
||||
// Row Selection
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
||||
|
||||
// Toolbar
|
||||
renderToolbar?: (table: TableInstance<TData>) => ReactNode;
|
||||
|
||||
// External State Sync (for filters etc to reset internal state if needed)
|
||||
// In the original code, columnFilters was reset when keyword/features changed.
|
||||
// We can expose onColumnFiltersChange or just handle it internally if we don't need to lift it.
|
||||
// The original code reset columnFilters on keyword change.
|
||||
// We can pass a dependency array to reset column filters?
|
||||
// Or just let the parent handle it if they want to control column filters.
|
||||
// For now, let's keep columnFilters internal but allow resetting.
|
||||
resetFiltersDeps?: any[];
|
||||
}
|
||||
|
||||
export function SharedDataTable<TData extends { id: string }, TValue>({
|
||||
columns,
|
||||
data,
|
||||
totalCount,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
renderToolbar,
|
||||
resetFiltersDeps = [],
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnFilters([]);
|
||||
}, [resetFiltersDeps]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: row => row.id,
|
||||
manualPagination: true,
|
||||
rowCount: totalCount,
|
||||
enableFilters: true,
|
||||
onPaginationChange: onPaginationChange,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
// Row Selection
|
||||
enableRowSelection: !!onRowSelectionChange,
|
||||
onRowSelectionChange: onRowSelectionChange,
|
||||
state: {
|
||||
pagination,
|
||||
columnFilters,
|
||||
rowSelection: rowSelection ?? {},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-5 px-6 h-full overflow-auto">
|
||||
{renderToolbar?.(table)}
|
||||
<div className="rounded-md border h-full flex flex-col overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<TableRow key={headerGroup.id} className="flex items-center">
|
||||
{headerGroup.headers.map(header => {
|
||||
// Use meta.className if available, otherwise default to flex-1
|
||||
const meta = header.column.columnDef.meta as
|
||||
| { className?: string }
|
||||
| undefined;
|
||||
const className = meta?.className ?? 'flex-1 min-w-0';
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={`${className} py-2 text-xs flex items-center h-9`}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
</Table>
|
||||
<div className="overflow-auto flex-1">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map(row => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className="flex items-center"
|
||||
>
|
||||
{row.getVisibleCells().map(cell => {
|
||||
const meta = cell.column.columnDef.meta as
|
||||
| { className?: string }
|
||||
| undefined;
|
||||
const className = meta?.className ?? 'flex-1 min-w-0';
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`${className} py-2`}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center flex-1"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ConfirmDialog } from './confirm-dialog';
|
||||
|
||||
export const DiscardChanges = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
description = 'Changes will not be saved.',
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
description?: string;
|
||||
}) => {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Discard Changes"
|
||||
description={description}
|
||||
confirmText="Discard"
|
||||
confirmButtonVariant="destructive"
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Checkbox } from '@affine/admin/components/ui/checkbox';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@affine/admin/components/ui/popover';
|
||||
import type { FeatureType } from '@affine/graphql';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
type FeatureFilterPopoverProps = {
|
||||
selectedFeatures: FeatureType[];
|
||||
availableFeatures: FeatureType[];
|
||||
onChange: (features: FeatureType[]) => void;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
export const FeatureFilterPopover = ({
|
||||
selectedFeatures,
|
||||
availableFeatures,
|
||||
onChange,
|
||||
align = 'start',
|
||||
buttonLabel = 'Features',
|
||||
}: FeatureFilterPopoverProps) => {
|
||||
const handleFeatureToggle = useCallback(
|
||||
(feature: FeatureType, checked: boolean) => {
|
||||
if (checked) {
|
||||
onChange([...new Set([...selectedFeatures, feature])]);
|
||||
} else {
|
||||
onChange(selectedFeatures.filter(enabled => enabled !== feature));
|
||||
}
|
||||
},
|
||||
[onChange, selectedFeatures]
|
||||
);
|
||||
|
||||
const handleClearFeatures = useCallback(() => {
|
||||
onChange([]);
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 lg:px-3 space-x-1"
|
||||
>
|
||||
<span>{buttonLabel}</span>
|
||||
{selectedFeatures.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({selectedFeatures.length})
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={align}
|
||||
className="w-[240px] p-2 flex flex-col gap-2"
|
||||
>
|
||||
<div className="text-xs font-medium px-1">Filter by feature</div>
|
||||
<div className="flex flex-col gap-1 max-h-64 overflow-auto">
|
||||
{availableFeatures.map(feature => (
|
||||
<label
|
||||
key={feature}
|
||||
className="flex items-center gap-2 px-1 py-1.5 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedFeatures.includes(feature)}
|
||||
onCheckedChange={checked =>
|
||||
handleFeatureToggle(feature, !!checked)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm truncate">{feature}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearFeatures}
|
||||
disabled={selectedFeatures.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Checkbox } from '@affine/admin/components/ui/checkbox';
|
||||
import { Label } from '@affine/admin/components/ui/label';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { Switch } from '@affine/admin/components/ui/switch';
|
||||
import type { FeatureType } from '@affine/graphql';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { cn } from '../../utils';
|
||||
|
||||
type FeatureToggleListProps = {
|
||||
features: FeatureType[];
|
||||
selected: FeatureType[];
|
||||
onChange: (features: FeatureType[]) => void;
|
||||
control?: 'checkbox' | 'switch';
|
||||
controlPosition?: 'left' | 'right';
|
||||
showSeparators?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const FeatureToggleList = ({
|
||||
features,
|
||||
selected,
|
||||
onChange,
|
||||
control = 'checkbox',
|
||||
controlPosition = 'left',
|
||||
showSeparators = false,
|
||||
className,
|
||||
}: FeatureToggleListProps) => {
|
||||
const Control = control === 'switch' ? Switch : Checkbox;
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(feature: FeatureType, checked: boolean) => {
|
||||
if (checked) {
|
||||
onChange([...new Set([...selected, feature])]);
|
||||
} else {
|
||||
onChange(selected.filter(item => item !== feature));
|
||||
}
|
||||
},
|
||||
[onChange, selected]
|
||||
);
|
||||
|
||||
if (!features.length) {
|
||||
return (
|
||||
<div
|
||||
className={cn(className, 'px-3 py-2 text-xs')}
|
||||
style={{ color: cssVarV2('text/secondary') }}
|
||||
>
|
||||
No configurable features.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{features.map((feature, index) => (
|
||||
<div key={feature}>
|
||||
<Label
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
controlPosition === 'right'
|
||||
? 'flex items-center justify-between p-3 text-[15px] gap-2 font-medium leading-6 overflow-hidden'
|
||||
: 'flex items-center gap-2 px-3 py-2 text-sm'
|
||||
)}
|
||||
>
|
||||
{controlPosition === 'left' ? (
|
||||
<>
|
||||
<Control
|
||||
checked={selected.includes(feature)}
|
||||
onCheckedChange={checked => handleToggle(feature, !!checked)}
|
||||
/>
|
||||
<span className="truncate">{feature}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="overflow-hidden text-ellipsis" title={feature}>
|
||||
{feature}
|
||||
</span>
|
||||
<Control
|
||||
checked={selected.includes(feature)}
|
||||
onCheckedChange={checked => handleToggle(feature, !!checked)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Label>
|
||||
{showSeparators && index < features.length - 1 && <Separator />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Button, type ButtonProps } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface TypeConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
targetText: string;
|
||||
inputPlaceholder?: string;
|
||||
cancelText?: string;
|
||||
confirmText?: string;
|
||||
confirmButtonVariant?: ButtonProps['variant'];
|
||||
onConfirm: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const TypeConfirmDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
targetText,
|
||||
inputPlaceholder = 'Please type to confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmText = 'Confirm',
|
||||
confirmButtonVariant = 'destructive',
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: TypeConfirmDialogProps) => {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleInput = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(event.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setInput('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
placeholder={inputPlaceholder}
|
||||
className="placeholder:opacity-50 mt-4 h-9"
|
||||
/>
|
||||
<DialogFooter className="mt-6">
|
||||
<div className="flex justify-end gap-2 items-center w-full">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={input !== targetText}
|
||||
size="sm"
|
||||
variant={confirmButtonVariant}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user