mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-01 22:29:44 +08:00
feat: improve admin panel (#14180)
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@affine/admin/components/ui/avatar';
|
||||
import { AccountIcon, LinkIcon } from '@blocksuite/icons/rc';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { WorkspaceListItem } from '../schema';
|
||||
import { formatBytes } from '../utils';
|
||||
import { DataTableRowActions } from './data-table-row-actions';
|
||||
|
||||
export const useColumns = () => {
|
||||
const columns: ColumnDef<WorkspaceListItem>[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'workspace',
|
||||
header: () => <div className="text-xs font-medium">Workspace</div>,
|
||||
cell: ({ row }) => {
|
||||
const workspace = row.original;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 max-w-[40vw] min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-2 text-sm font-medium overflow-hidden">
|
||||
<span className="truncate">
|
||||
{workspace.name || workspace.id}
|
||||
</span>
|
||||
{workspace.public ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded border"
|
||||
style={{
|
||||
backgroundColor: cssVarV2('chip/label/white'),
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
}}
|
||||
>
|
||||
<LinkIcon fontSize={14} />
|
||||
Public
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs font-mono truncate w-full"
|
||||
style={{ color: cssVarV2('text/secondary') }}
|
||||
>
|
||||
{workspace.id}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-[11px]">
|
||||
{workspace.features.length ? (
|
||||
workspace.features.map(feature => (
|
||||
<span
|
||||
key={feature}
|
||||
className="px-2 py-0.5 rounded border"
|
||||
style={{
|
||||
backgroundColor: cssVarV2('chip/label/white'),
|
||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
||||
}}
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: cssVarV2('text/secondary') }}>
|
||||
No features
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'owner',
|
||||
header: () => <div className="text-xs font-medium">Owner</div>,
|
||||
cell: ({ row }) => {
|
||||
const owner = row.original.owner;
|
||||
if (!owner) {
|
||||
return (
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: cssVarV2('text/secondary') }}
|
||||
>
|
||||
Unknown
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-3 min-w-[180px] min-w-0">
|
||||
<Avatar className="w-9 h-9">
|
||||
<AvatarImage src={owner.avatarUrl ?? undefined} />
|
||||
<AvatarFallback>
|
||||
<AccountIcon fontSize={16} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col overflow-hidden min-w-0">
|
||||
<div className="text-sm font-medium truncate">{owner.name}</div>
|
||||
<div
|
||||
className="text-xs truncate"
|
||||
style={{ color: cssVarV2('text/secondary') }}
|
||||
>
|
||||
{owner.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'usage',
|
||||
header: () => <div className="text-xs font-medium">Usage</div>,
|
||||
cell: ({ row }) => {
|
||||
const ws = row.original;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<div className="flex gap-3">
|
||||
<span>Snapshot {formatBytes(ws.snapshotSize)}</span>
|
||||
<span style={{ color: cssVarV2('text/secondary') }}>
|
||||
({ws.snapshotCount})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span>Blobs {formatBytes(ws.blobSize)}</span>
|
||||
<span style={{ color: cssVarV2('text/secondary') }}>
|
||||
({ws.blobCount})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'members',
|
||||
header: () => <div className="text-xs font-medium">Members</div>,
|
||||
cell: ({ row }) => {
|
||||
const ws = row.original;
|
||||
return (
|
||||
<div className="flex flex-col text-xs gap-1">
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">{ws.memberCount}</span>
|
||||
<span style={{ color: cssVarV2('text/secondary') }}>
|
||||
members
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">{ws.publicPageCount}</span>
|
||||
<span style={{ color: cssVarV2('text/secondary') }}>
|
||||
shared pages
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
meta: {
|
||||
className: 'w-[80px] justify-end',
|
||||
},
|
||||
header: () => (
|
||||
<div className="text-xs font-medium text-right">Actions</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end w-full">
|
||||
<DataTableRowActions workspace={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
return columns;
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { EditIcon } from '@blocksuite/icons/rc';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { DiscardChanges } from '../../../components/shared/discard-changes';
|
||||
import { useRightPanel } from '../../panel/context';
|
||||
import type { WorkspaceListItem } from '../schema';
|
||||
import { WorkspacePanel } from './workspace-panel';
|
||||
|
||||
export function DataTableRowActions({
|
||||
workspace,
|
||||
}: {
|
||||
workspace: WorkspaceListItem;
|
||||
}) {
|
||||
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
|
||||
const {
|
||||
setPanelContent,
|
||||
openPanel,
|
||||
isOpen,
|
||||
closePanel,
|
||||
hasDirtyChanges,
|
||||
setHasDirtyChanges,
|
||||
} = useRightPanel();
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setHasDirtyChanges(false);
|
||||
setPanelContent(
|
||||
<WorkspacePanel workspaceId={workspace.id} onClose={closePanel} />
|
||||
);
|
||||
if (!isOpen) {
|
||||
openPanel();
|
||||
}
|
||||
}, [
|
||||
closePanel,
|
||||
isOpen,
|
||||
openPanel,
|
||||
setHasDirtyChanges,
|
||||
setPanelContent,
|
||||
workspace.id,
|
||||
]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
if (hasDirtyChanges) {
|
||||
setDiscardDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
handleConfirm();
|
||||
}, [handleConfirm, hasDirtyChanges]);
|
||||
|
||||
const handleDiscardConfirm = useCallback(() => {
|
||||
setDiscardDialogOpen(false);
|
||||
setHasDirtyChanges(false);
|
||||
handleConfirm();
|
||||
}, [handleConfirm, setHasDirtyChanges]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-2 h-8 flex items-center gap-2"
|
||||
onClick={handleEdit}
|
||||
>
|
||||
<EditIcon fontSize={18} />
|
||||
<span>Edit</span>
|
||||
</Button>
|
||||
<DiscardChanges
|
||||
open={discardDialogOpen}
|
||||
onOpenChange={setDiscardDialogOpen}
|
||||
onClose={() => setDiscardDialogOpen(false)}
|
||||
onConfirm={handleDiscardConfirm}
|
||||
description="Changes to this workspace will not be saved."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { AdminWorkspaceSort, FeatureType } from '@affine/graphql';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { FeatureFilterPopover } from '../../../components/shared/feature-filter-popover';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '../../../components/ui/popover';
|
||||
import { useDebouncedValue } from '../../../hooks/use-debounced-value';
|
||||
import { useServerConfig } from '../../common';
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
table?: Table<TData>;
|
||||
keyword: string;
|
||||
onKeywordChange: (keyword: string) => void;
|
||||
selectedFeatures: FeatureType[];
|
||||
onFeaturesChange: (features: FeatureType[]) => void;
|
||||
sort: AdminWorkspaceSort | undefined;
|
||||
onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
|
||||
}
|
||||
|
||||
const sortOptions: { value: AdminWorkspaceSort; label: string }[] = [
|
||||
{ value: AdminWorkspaceSort.SnapshotSize, label: 'Snapshot size' },
|
||||
{ value: AdminWorkspaceSort.BlobCount, label: 'Blob count' },
|
||||
{ value: AdminWorkspaceSort.BlobSize, label: 'Blob size' },
|
||||
{ value: AdminWorkspaceSort.CreatedAt, label: 'Created time' },
|
||||
];
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
selectedFeatures,
|
||||
onFeaturesChange,
|
||||
sort,
|
||||
onSortChange,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const [value, setValue] = useState(keyword);
|
||||
const debouncedValue = useDebouncedValue(value, 400);
|
||||
const serverConfig = useServerConfig();
|
||||
const availableFeatures = serverConfig.availableWorkspaceFeatures ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
setValue(keyword);
|
||||
}, [keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
onKeywordChange(debouncedValue.trim());
|
||||
}, [debouncedValue, onKeywordChange]);
|
||||
|
||||
const onValueChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.currentTarget.value);
|
||||
}, []);
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(value: AdminWorkspaceSort) => {
|
||||
onSortChange(value);
|
||||
},
|
||||
[onSortChange]
|
||||
);
|
||||
|
||||
const selectedSortLabel = useMemo(
|
||||
() =>
|
||||
sortOptions.find(option => option.value === sort)?.label ??
|
||||
'Created time',
|
||||
[sort]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-y-2 gap-x-4 flex-wrap">
|
||||
<FeatureFilterPopover
|
||||
selectedFeatures={selectedFeatures}
|
||||
availableFeatures={availableFeatures}
|
||||
onChange={onFeaturesChange}
|
||||
align="start"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 px-2 lg:px-3">
|
||||
Sort: {selectedSortLabel}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[220px] p-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
{sortOptions.map(option => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant="ghost"
|
||||
className="justify-start"
|
||||
size="sm"
|
||||
onClick={() => handleSortChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="flex">
|
||||
<Input
|
||||
placeholder="Search Workspace / Owner"
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
className="h-8 w-[150px] lg:w-[250px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { AdminWorkspaceSort, FeatureType } from '@affine/graphql';
|
||||
import type { ColumnDef, PaginationState } from '@tanstack/react-table';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { SharedDataTable } from '../../../components/shared/data-table';
|
||||
import { DataTableToolbar } from './data-table-toolbar';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pagination: PaginationState;
|
||||
workspacesCount: number;
|
||||
keyword: string;
|
||||
onKeywordChange: (value: string) => void;
|
||||
selectedFeatures: FeatureType[];
|
||||
onFeaturesChange: (features: FeatureType[]) => void;
|
||||
sort: AdminWorkspaceSort | undefined;
|
||||
onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
|
||||
onPaginationChange: Dispatch<
|
||||
SetStateAction<{
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export function DataTable<TData extends { id: string }, TValue>({
|
||||
columns,
|
||||
data,
|
||||
pagination,
|
||||
workspacesCount,
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
selectedFeatures,
|
||||
onFeaturesChange,
|
||||
sort,
|
||||
onSortChange,
|
||||
onPaginationChange,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
return (
|
||||
<SharedDataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
totalCount={workspacesCount}
|
||||
pagination={pagination}
|
||||
onPaginationChange={onPaginationChange}
|
||||
resetFiltersDeps={[keyword, selectedFeatures, sort]}
|
||||
renderToolbar={table => (
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
keyword={keyword}
|
||||
onKeywordChange={onKeywordChange}
|
||||
selectedFeatures={selectedFeatures}
|
||||
onFeaturesChange={onFeaturesChange}
|
||||
sort={sort}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@affine/admin/components/ui/avatar';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
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 {
|
||||
adminUpdateWorkspaceMutation,
|
||||
adminWorkspaceQuery,
|
||||
adminWorkspacesQuery,
|
||||
FeatureType,
|
||||
} from '@affine/graphql';
|
||||
import { AccountIcon } from '@blocksuite/icons/rc';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { FeatureToggleList } from '../../../components/shared/feature-toggle-list';
|
||||
import { useMutateQueryResource, useMutation } from '../../../use-mutation';
|
||||
import { useQuery } from '../../../use-query';
|
||||
import { useServerConfig } from '../../common';
|
||||
import { RightPanelHeader } from '../../header';
|
||||
import { useRightPanel } from '../../panel/context';
|
||||
import type { WorkspaceDetail } from '../schema';
|
||||
import { formatBytes } from '../utils';
|
||||
|
||||
export function WorkspacePanel({
|
||||
workspaceId,
|
||||
onClose,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data } = useQuery({
|
||||
query: adminWorkspaceQuery,
|
||||
variables: {
|
||||
id: workspaceId,
|
||||
memberSkip: 0,
|
||||
memberTake: 20,
|
||||
},
|
||||
});
|
||||
const workspace = data.adminWorkspace;
|
||||
|
||||
if (!workspace) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<RightPanelHeader
|
||||
title="Workspace"
|
||||
handleClose={onClose}
|
||||
handleConfirm={onClose}
|
||||
canSave={false}
|
||||
/>
|
||||
<div
|
||||
className="p-6 text-sm"
|
||||
style={{ color: cssVarV2('text/secondary') }}
|
||||
>
|
||||
Workspace not found.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <WorkspacePanelContent workspace={workspace} onClose={onClose} />;
|
||||
}
|
||||
|
||||
function WorkspacePanelContent({
|
||||
workspace,
|
||||
onClose,
|
||||
}: {
|
||||
workspace: WorkspaceDetail;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const serverConfig = useServerConfig();
|
||||
const { setHasDirtyChanges } = useRightPanel();
|
||||
const revalidate = useMutateQueryResource();
|
||||
const { trigger: updateWorkspace, isMutating } = useMutation({
|
||||
mutation: adminUpdateWorkspaceMutation,
|
||||
});
|
||||
|
||||
const normalizedWorkspace = useMemo(
|
||||
() => ({
|
||||
features: [...workspace.features],
|
||||
flags: {
|
||||
public: workspace.public,
|
||||
enableAi: workspace.enableAi,
|
||||
enableUrlPreview: workspace.enableUrlPreview,
|
||||
enableDocEmbedding: workspace.enableDocEmbedding,
|
||||
name: workspace.name ?? '',
|
||||
},
|
||||
}),
|
||||
[workspace]
|
||||
);
|
||||
|
||||
const [featureSelection, setFeatureSelection] = useState<FeatureType[]>(
|
||||
normalizedWorkspace.features
|
||||
);
|
||||
const [flags, setFlags] = useState(normalizedWorkspace.flags);
|
||||
const [baseline, setBaseline] = useState(normalizedWorkspace);
|
||||
|
||||
useEffect(() => {
|
||||
setFeatureSelection(normalizedWorkspace.features);
|
||||
setFlags(normalizedWorkspace.flags);
|
||||
setBaseline(normalizedWorkspace);
|
||||
}, [normalizedWorkspace]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
return (
|
||||
flags.public !== baseline.flags.public ||
|
||||
flags.enableAi !== baseline.flags.enableAi ||
|
||||
flags.enableUrlPreview !== baseline.flags.enableUrlPreview ||
|
||||
flags.enableDocEmbedding !== baseline.flags.enableDocEmbedding ||
|
||||
flags.name !== baseline.flags.name ||
|
||||
featureSelection.length !== baseline.features.length ||
|
||||
featureSelection.some(f => !baseline.features.includes(f))
|
||||
);
|
||||
}, [baseline, featureSelection, flags]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasDirtyChanges(hasChanges);
|
||||
}, [hasChanges, setHasDirtyChanges]);
|
||||
|
||||
const handleFeaturesChange = useCallback((features: FeatureType[]) => {
|
||||
setFeatureSelection(features);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const update = async () => {
|
||||
try {
|
||||
await updateWorkspace({
|
||||
input: {
|
||||
id: workspace.id,
|
||||
public: flags.public,
|
||||
enableAi: flags.enableAi,
|
||||
enableUrlPreview: flags.enableUrlPreview,
|
||||
enableDocEmbedding: flags.enableDocEmbedding,
|
||||
name: flags.name || null,
|
||||
features: featureSelection,
|
||||
},
|
||||
});
|
||||
await Promise.all([
|
||||
revalidate(adminWorkspacesQuery),
|
||||
revalidate(adminWorkspaceQuery, vars => vars?.id === workspace.id),
|
||||
]);
|
||||
toast.success('Workspace updated successfully');
|
||||
setBaseline({
|
||||
flags: { ...flags },
|
||||
features: [...featureSelection],
|
||||
});
|
||||
setHasDirtyChanges(false);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast.error(`Failed to update workspace: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
update().catch(() => {});
|
||||
}, [
|
||||
featureSelection,
|
||||
flags,
|
||||
onClose,
|
||||
revalidate,
|
||||
setBaseline,
|
||||
setHasDirtyChanges,
|
||||
updateWorkspace,
|
||||
workspace.id,
|
||||
]);
|
||||
|
||||
const memberList = workspace.members ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<RightPanelHeader
|
||||
title="Update Workspace"
|
||||
handleClose={onClose}
|
||||
handleConfirm={handleSave}
|
||||
canSave={hasChanges && !isMutating}
|
||||
/>
|
||||
<div className="p-4 flex flex-col gap-4 overflow-y-auto">
|
||||
<div className="border rounded-md p-3 space-y-2">
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: cssVarV2('text/secondary') }}
|
||||
>
|
||||
Workspace ID
|
||||
</div>
|
||||
<div className="text-sm font-mono break-all">{workspace.id}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label
|
||||
className="text-xs"
|
||||
style={{ color: cssVarV2('text/secondary') }}
|
||||
>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
value={flags.name}
|
||||
onChange={e =>
|
||||
setFlags(prev => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="Workspace name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
<FlagItem
|
||||
label="Public"
|
||||
description="Allow public access to workspace pages"
|
||||
checked={flags.public}
|
||||
onCheckedChange={value =>
|
||||
setFlags(prev => ({ ...prev, public: value }))
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
<FlagItem
|
||||
label="Enable AI"
|
||||
description="Allow AI features in this workspace"
|
||||
checked={flags.enableAi}
|
||||
onCheckedChange={value =>
|
||||
setFlags(prev => ({ ...prev, enableAi: value }))
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
<FlagItem
|
||||
label="Enable URL Preview"
|
||||
description="Allow URL previews in shared pages"
|
||||
checked={flags.enableUrlPreview}
|
||||
onCheckedChange={value =>
|
||||
setFlags(prev => ({ ...prev, enableUrlPreview: value }))
|
||||
}
|
||||
/>
|
||||
<Separator />
|
||||
<FlagItem
|
||||
label="Enable Doc Embedding"
|
||||
description="Allow document embedding for search"
|
||||
checked={flags.enableDocEmbedding}
|
||||
onCheckedChange={value =>
|
||||
setFlags(prev => ({ ...prev, enableDocEmbedding: value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md p-3 space-y-3">
|
||||
<div className="text-sm font-medium">Features</div>
|
||||
<FeatureToggleList
|
||||
features={serverConfig.availableWorkspaceFeatures ?? []}
|
||||
selected={featureSelection}
|
||||
onChange={handleFeaturesChange}
|
||||
className="grid grid-cols-1 gap-2"
|
||||
control="checkbox"
|
||||
controlPosition="left"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<MetricCard
|
||||
label="Snapshot Size"
|
||||
value={formatBytes(workspace.snapshotSize)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Snapshot Count"
|
||||
value={`${workspace.snapshotCount}`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Blob Size"
|
||||
value={formatBytes(workspace.blobSize)}
|
||||
/>
|
||||
<MetricCard label="Blob Count" value={`${workspace.blobCount}`} />
|
||||
<MetricCard label="Members" value={`${workspace.memberCount}`} />
|
||||
<MetricCard
|
||||
label="Shared Pages"
|
||||
value={`${workspace.publicPageCount}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
<div className="px-3 py-2 text-sm font-medium">Members</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col divide-y">
|
||||
{memberList.length === 0 ? (
|
||||
<div
|
||||
className="px-3 py-3 text-xs"
|
||||
style={{ color: cssVarV2('text/secondary') }}
|
||||
>
|
||||
No members.
|
||||
</div>
|
||||
) : (
|
||||
memberList.map(member => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center gap-3 px-3 py-2"
|
||||
>
|
||||
<Avatar className="w-9 h-9">
|
||||
<AvatarImage src={member.avatarUrl ?? undefined} />
|
||||
<AvatarFallback>
|
||||
<AccountIcon fontSize={16} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{member.name || member.email}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs truncate"
|
||||
style={{ color: cssVarV2('text/secondary') }}
|
||||
>
|
||||
{member.email}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto text-xs px-2 py-1 rounded border">
|
||||
{member.role}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlagItem({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-2 p-3">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<div className="text-xs" style={{ color: cssVarV2('text/secondary') }}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={checked} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="border rounded-md p-3 flex flex-col gap-1">
|
||||
<div className="text-xs" style={{ color: cssVarV2('text/secondary') }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-sm font-semibold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AdminWorkspaceSort, FeatureType } from '@affine/graphql';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Header } from '../header';
|
||||
import { useColumns } from './components/columns';
|
||||
import { DataTable } from './components/data-table';
|
||||
import { useWorkspaceList } from './use-workspace-list';
|
||||
|
||||
export function WorkspacePage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [featureFilters, setFeatureFilters] = useState<FeatureType[]>([]);
|
||||
const [sort, setSort] = useState<AdminWorkspaceSort | undefined>(
|
||||
AdminWorkspaceSort.CreatedAt
|
||||
);
|
||||
|
||||
const { workspaces, pagination, setPagination, workspacesCount } =
|
||||
useWorkspaceList({
|
||||
keyword,
|
||||
features: featureFilters,
|
||||
orderBy: sort,
|
||||
});
|
||||
|
||||
const columns = useColumns();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1 flex-col flex">
|
||||
<Header title="Workspaces" />
|
||||
|
||||
<DataTable
|
||||
data={workspaces}
|
||||
columns={columns}
|
||||
pagination={pagination}
|
||||
workspacesCount={workspacesCount}
|
||||
onPaginationChange={setPagination}
|
||||
keyword={keyword}
|
||||
onKeywordChange={setKeyword}
|
||||
selectedFeatures={featureFilters}
|
||||
onFeaturesChange={setFeatureFilters}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkspacePage as Component };
|
||||
@@ -0,0 +1,17 @@
|
||||
import type {
|
||||
AdminUpdateWorkspaceMutation,
|
||||
AdminWorkspaceQuery,
|
||||
AdminWorkspacesQuery,
|
||||
FeatureType,
|
||||
} from '@affine/graphql';
|
||||
|
||||
export type WorkspaceListItem = AdminWorkspacesQuery['adminWorkspaces'][0];
|
||||
export type WorkspaceDetail = NonNullable<
|
||||
AdminWorkspaceQuery['adminWorkspace']
|
||||
>;
|
||||
export type WorkspaceMember = WorkspaceDetail['members'][0];
|
||||
|
||||
export type WorkspaceUpdateInput =
|
||||
AdminUpdateWorkspaceMutation['adminUpdateWorkspace'];
|
||||
|
||||
export type WorkspaceFeatureFilter = FeatureType[];
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useQuery } from '@affine/admin/use-query';
|
||||
import {
|
||||
adminWorkspacesCountQuery,
|
||||
AdminWorkspaceSort,
|
||||
adminWorkspacesQuery,
|
||||
FeatureType,
|
||||
} from '@affine/graphql';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export const useWorkspaceList = (filter?: {
|
||||
keyword?: string;
|
||||
features?: FeatureType[];
|
||||
orderBy?: AdminWorkspaceSort;
|
||||
}) => {
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const filterKey = useMemo(
|
||||
() =>
|
||||
`${filter?.keyword ?? ''}-${[...(filter?.features ?? [])]
|
||||
.sort()
|
||||
.join(',')}-${filter?.orderBy ?? ''}`,
|
||||
[filter?.features, filter?.keyword, filter?.orderBy]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination(prev => ({ ...prev, pageIndex: 0 }));
|
||||
}, [filterKey]);
|
||||
|
||||
const variables = useMemo(
|
||||
() => ({
|
||||
filter: {
|
||||
first: pagination.pageSize,
|
||||
skip: pagination.pageIndex * pagination.pageSize,
|
||||
keyword: filter?.keyword || undefined,
|
||||
features:
|
||||
filter?.features && filter.features.length > 0
|
||||
? filter.features
|
||||
: undefined,
|
||||
orderBy: filter?.orderBy,
|
||||
},
|
||||
}),
|
||||
[
|
||||
filter?.features,
|
||||
filter?.keyword,
|
||||
filter?.orderBy,
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
]
|
||||
);
|
||||
|
||||
const { data: listData } = useQuery(
|
||||
{
|
||||
query: adminWorkspacesQuery,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: countData } = useQuery(
|
||||
{
|
||||
query: adminWorkspacesCountQuery,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
workspaces: listData?.adminWorkspaces ?? [],
|
||||
workspacesCount: countData?.adminWorkspacesCount ?? 0,
|
||||
pagination,
|
||||
setPagination,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export function formatBytes(bytes: number) {
|
||||
if (!bytes) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
const digits = value >= 10 ? 0 : 1;
|
||||
return `${value.toFixed(digits)} ${units[unitIndex]}`;
|
||||
}
|
||||
Reference in New Issue
Block a user