feat: improve admin panel (#14180)

This commit is contained in:
DarkSky
2025-12-30 05:22:54 +08:00
committed by GitHub
parent d6b380aee5
commit 95a5e941e7
94 changed files with 3146 additions and 1114 deletions
@@ -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>
);
};