snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
17
packages/frontend/admin/components.json
Normal file
17
packages/frontend/admin/components.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/global.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@affine/admin/components",
|
||||
"utils": "@affine/admin/utils"
|
||||
}
|
||||
}
|
||||
78
packages/frontend/admin/package.json
Normal file
78
packages/frontend/admin/package.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "@affine/admin",
|
||||
"version": "0.27.5",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@affine/component": "workspace:*",
|
||||
"@affine/core": "workspace:*",
|
||||
"@affine/error": "workspace:*",
|
||||
"@affine/graphql": "workspace:*",
|
||||
"@affine/routes": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.3",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.2",
|
||||
"@radix-ui/react-context-menu": "^2.2.3",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.3",
|
||||
"@radix-ui/react-hover-card": "^1.1.3",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-menubar": "^1.1.3",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.2",
|
||||
"@radix-ui/react-popover": "^1.1.3",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.3",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slider": "^1.2.2",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.3",
|
||||
"@radix-ui/react-toggle": "^1.1.1",
|
||||
"@radix-ui/react-toggle-group": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.5",
|
||||
"@sentry/react": "^10.53.1",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"cmdk": "^1.0.4",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"input-otp": "^1.4.1",
|
||||
"lodash-es": "^4.17.23",
|
||||
"lucide-react": "^0.508.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^19.2.1",
|
||||
"react-day-picker": "^9.4.3",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.7",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "affine bundle",
|
||||
"dev": "affine bundle --dev",
|
||||
"update-shadcn": "yarn dlx shadcn@latest add -p src/components/ui"
|
||||
},
|
||||
"exports": {
|
||||
"./*": "./src/*.ts",
|
||||
"./components/ui/*": "./src/components/ui/*.tsx"
|
||||
}
|
||||
}
|
||||
147
packages/frontend/admin/src/app.tsx
Normal file
147
packages/frontend/admin/src/app.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Toaster } from '@affine/admin/components/ui/sonner';
|
||||
import { lazy, ROUTES } from '@affine/routes';
|
||||
import { withSentryReactRouterV7Routing } from '@sentry/react';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
BrowserRouter,
|
||||
Navigate,
|
||||
Outlet,
|
||||
Route,
|
||||
Routes as ReactRouterRoutes,
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
import { ThemeProvider } from './components/theme-provider';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { isAdmin, useCurrentUser, useServerConfig } from './modules/common';
|
||||
import { Layout } from './modules/layout';
|
||||
|
||||
export const Setup = lazy(
|
||||
() => import(/* webpackChunkName: "setup" */ './modules/setup')
|
||||
);
|
||||
export const Accounts = lazy(
|
||||
() => import(/* webpackChunkName: "accounts" */ './modules/accounts')
|
||||
);
|
||||
export const Dashboard = lazy(
|
||||
() => import(/* webpackChunkName: "dashboard" */ './modules/dashboard')
|
||||
);
|
||||
export const Workspaces = lazy(
|
||||
() => import(/* webpackChunkName: "workspaces" */ './modules/workspaces')
|
||||
);
|
||||
export const About = lazy(
|
||||
() => import(/* webpackChunkName: "about" */ './modules/about')
|
||||
);
|
||||
export const Settings = lazy(
|
||||
() => import(/* webpackChunkName: "settings" */ './modules/settings')
|
||||
);
|
||||
export const Auth = lazy(
|
||||
() => import(/* webpackChunkName: "auth" */ './modules/auth')
|
||||
);
|
||||
|
||||
const Routes = window.SENTRY_RELEASE
|
||||
? withSentryReactRouterV7Routing(ReactRouterRoutes)
|
||||
: ReactRouterRoutes;
|
||||
|
||||
function AuthenticatedRoutes() {
|
||||
const user = useCurrentUser();
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !isAdmin(user)) {
|
||||
toast.error('You are not an admin, please login the admin account.');
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
if (!user || !isAdmin(user)) {
|
||||
return <Navigate to="/admin/auth" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function RootRoutes() {
|
||||
const config = useServerConfig();
|
||||
const location = useLocation();
|
||||
|
||||
if (!config.initialized && location.pathname !== '/admin/setup') {
|
||||
return <Navigate to="/admin/setup" />;
|
||||
}
|
||||
|
||||
if (/^\/admin\/?$/.test(location.pathname)) {
|
||||
return (
|
||||
<Navigate
|
||||
to={
|
||||
environment.isSelfHosted
|
||||
? ROUTES.admin.accounts
|
||||
: ROUTES.admin.dashboard
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export const App = () => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<SWRConfig
|
||||
value={{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnMount: false,
|
||||
}}
|
||||
>
|
||||
<BrowserRouter basename={environment.subPath}>
|
||||
<Routes>
|
||||
<Route path={ROUTES.admin.index} element={<RootRoutes />}>
|
||||
<Route path={ROUTES.admin.auth} element={<Auth />} />
|
||||
<Route path={ROUTES.admin.setup} element={<Setup />} />
|
||||
<Route element={<AuthenticatedRoutes />}>
|
||||
<Route
|
||||
path={ROUTES.admin.dashboard}
|
||||
element={
|
||||
environment.isSelfHosted ? (
|
||||
<Navigate to={ROUTES.admin.accounts} replace />
|
||||
) : (
|
||||
<Dashboard />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path={ROUTES.admin.accounts} element={<Accounts />} />
|
||||
<Route
|
||||
path={ROUTES.admin.workspaces}
|
||||
element={
|
||||
environment.isSelfHosted ? (
|
||||
<Navigate to={ROUTES.admin.accounts} replace />
|
||||
) : (
|
||||
<Workspaces />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.admin.ai}
|
||||
element={
|
||||
<Navigate to={ROUTES.admin.settings.index} replace />
|
||||
}
|
||||
/>
|
||||
<Route path={ROUTES.admin.about} element={<About />} />
|
||||
<Route
|
||||
path={ROUTES.admin.settings.index}
|
||||
element={<Settings />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</SWRConfig>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
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:max-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"
|
||||
size="sm"
|
||||
>
|
||||
<span>{cancelText}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
variant={confirmButtonVariant}
|
||||
size="sm"
|
||||
>
|
||||
<span>{confirmText}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
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>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
disabled = false,
|
||||
}: 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}
|
||||
disabled={disabled}
|
||||
>
|
||||
<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={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={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={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={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,85 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { SharedDataTable } from './data-table';
|
||||
|
||||
const { DataTablePaginationMock } = vi.hoisted(() => ({
|
||||
DataTablePaginationMock: vi.fn(({ disabled }: { disabled?: boolean }) => (
|
||||
<div data-disabled={disabled ? 'true' : 'false'} data-testid="pagination" />
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock('./data-table-pagination', () => ({
|
||||
DataTablePagination: DataTablePaginationMock,
|
||||
}));
|
||||
|
||||
type Row = { id: string; name: string };
|
||||
|
||||
const columns: ColumnDef<Row>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => row.original.name,
|
||||
},
|
||||
];
|
||||
|
||||
describe('SharedDataTable', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
DataTablePaginationMock.mockClear();
|
||||
});
|
||||
|
||||
test('renders token-aligned table shell and row data', () => {
|
||||
const { container } = render(
|
||||
<SharedDataTable
|
||||
columns={columns}
|
||||
data={[{ id: '1', name: 'Alice' }]}
|
||||
totalCount={1}
|
||||
pagination={{ pageIndex: 0, pageSize: 10 }}
|
||||
onPaginationChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Alice')).not.toBeNull();
|
||||
|
||||
const shell = container.querySelector('.rounded-xl');
|
||||
expect(shell).not.toBeNull();
|
||||
expect(shell?.className).toContain('border-border');
|
||||
expect(shell?.className).toContain('bg-card');
|
||||
expect(shell?.className).toContain('shadow-1');
|
||||
});
|
||||
|
||||
test('shows loading overlay and disables pagination while loading', () => {
|
||||
render(
|
||||
<SharedDataTable
|
||||
columns={columns}
|
||||
data={[{ id: '1', name: 'Alice' }]}
|
||||
totalCount={1}
|
||||
pagination={{ pageIndex: 0, pageSize: 10 }}
|
||||
onPaginationChange={vi.fn()}
|
||||
loading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Loading...')).not.toBeNull();
|
||||
expect(screen.getByTestId('pagination').dataset.disabled).toBe('true');
|
||||
});
|
||||
|
||||
test('renders empty state when there is no data', () => {
|
||||
render(
|
||||
<SharedDataTable
|
||||
columns={columns}
|
||||
data={[]}
|
||||
totalCount={0}
|
||||
pagination={{ pageIndex: 0, pageSize: 10 }}
|
||||
onPaginationChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('No results.')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
204
packages/frontend/admin/src/components/shared/data-table.tsx
Normal file
204
packages/frontend/admin/src/components/shared/data-table.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
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';
|
||||
|
||||
const DEFAULT_RESET_FILTERS_DEPS: unknown[] = [];
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
totalCount: number;
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
loading?: boolean;
|
||||
disablePagination?: boolean;
|
||||
|
||||
// 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,
|
||||
loading = false,
|
||||
disablePagination = false,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
renderToolbar,
|
||||
resetFiltersDeps = DEFAULT_RESET_FILTERS_DEPS,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnFilters([]);
|
||||
}, [resetFiltersDeps]);
|
||||
|
||||
// oxlint-disable-next-line react-hooks-js/incompatible-library
|
||||
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="relative flex h-full flex-col gap-4 overflow-auto px-6 py-5">
|
||||
{renderToolbar?.(table)}
|
||||
<div className="relative flex h-full flex-col overflow-auto rounded-xl border border-border/60 bg-card shadow-1">
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-background/75 text-sm text-muted-foreground backdrop-blur-[1px]">
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
) : null}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="flex items-center bg-muted/40"
|
||||
>
|
||||
{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 bg-card"
|
||||
>
|
||||
{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}
|
||||
disabled={disablePagination || loading}
|
||||
/>
|
||||
</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,101 @@
|
||||
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;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const FeatureFilterPopover = ({
|
||||
selectedFeatures,
|
||||
availableFeatures,
|
||||
onChange,
|
||||
align = 'start',
|
||||
buttonLabel = 'Features',
|
||||
disabled = false,
|
||||
}: FeatureFilterPopoverProps) => {
|
||||
const handleFeatureToggle = useCallback(
|
||||
(feature: FeatureType, checked: boolean) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (checked) {
|
||||
onChange([...new Set([...selectedFeatures, feature])]);
|
||||
} else {
|
||||
onChange(selectedFeatures.filter(enabled => enabled !== feature));
|
||||
}
|
||||
},
|
||||
[disabled, onChange, selectedFeatures]
|
||||
);
|
||||
|
||||
const handleClearFeatures = useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onChange([]);
|
||||
}, [disabled, onChange]);
|
||||
|
||||
return (
|
||||
<Popover open={disabled ? false : undefined}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 lg:px-3 space-x-1"
|
||||
disabled={disabled}
|
||||
>
|
||||
<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)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<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={disabled || selectedFeatures.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
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 { 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 text-muted-foreground')}>
|
||||
No configurable features.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{features.map((feature, index) => (
|
||||
<div key={feature}>
|
||||
<Label
|
||||
className={cn(
|
||||
'cursor-pointer transition-colors duration-100',
|
||||
controlPosition === 'right'
|
||||
? 'flex items-center justify-between p-3 text-sm gap-2 font-medium leading-6 overflow-hidden hover:bg-muted/40'
|
||||
: 'flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const { nextThemeProviderMock } = vi.hoisted(() => ({
|
||||
nextThemeProviderMock: vi.fn(({ children }: { children?: any }) => (
|
||||
<div data-testid="next-theme-provider">{children}</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock('next-themes', () => ({
|
||||
ThemeProvider: nextThemeProviderMock,
|
||||
}));
|
||||
|
||||
import { ThemeProvider } from './theme-provider';
|
||||
|
||||
describe('Admin ThemeProvider', () => {
|
||||
test('uses the same dark/light/system behavior as main frontend', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<div>content</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('content')).not.toBeNull();
|
||||
expect(nextThemeProviderMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const props = nextThemeProviderMock.mock.calls[0]?.[0] as any;
|
||||
expect(props?.themes).toEqual(['dark', 'light']);
|
||||
expect(props?.enableSystem).toBe(true);
|
||||
expect(props?.defaultTheme).toBe('system');
|
||||
});
|
||||
});
|
||||
16
packages/frontend/admin/src/components/theme-provider.tsx
Normal file
16
packages/frontend/admin/src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ThemeProvider as NextThemeProvider } from 'next-themes';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
const themes = ['dark', 'light'];
|
||||
|
||||
export const ThemeProvider = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<NextThemeProvider
|
||||
themes={themes}
|
||||
enableSystem={true}
|
||||
defaultTheme="system"
|
||||
>
|
||||
{children}
|
||||
</NextThemeProvider>
|
||||
);
|
||||
};
|
||||
55
packages/frontend/admin/src/components/ui/accordion.tsx
Normal file
55
packages/frontend/admin/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn('border-b', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AccordionItem.displayName = 'AccordionItem';
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex w-full">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
138
packages/frontend/admin/src/components/ui/alert-dialog.tsx
Normal file
138
packages/frontend/admin/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import * as React from 'react';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
||||
58
packages/frontend/admin/src/components/ui/alert.tsx
Normal file
58
packages/frontend/admin/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = 'Alert';
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-sm [&_p]:leading-relaxed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
|
||||
export { Alert, AlertDescription, AlertTitle };
|
||||
@@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root;
|
||||
|
||||
export { AspectRatio };
|
||||
48
packages/frontend/admin/src/components/ui/avatar.tsx
Normal file
48
packages/frontend/admin/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
import * as React from 'react';
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
crossOrigin="anonymous"
|
||||
className={cn('aspect-square h-full w-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarFallback, AvatarImage };
|
||||
36
packages/frontend/admin/src/components/ui/badge.tsx
Normal file
36
packages/frontend/admin/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import type * as React from 'react';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
114
packages/frontend/admin/src/components/ui/breadcrumb.tsx
Normal file
114
packages/frontend/admin/src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<'nav'> & {
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||
Breadcrumb.displayName = 'Breadcrumb';
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<'ol'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbList.displayName = 'BreadcrumbList';
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<'li'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn('inline-flex items-center gap-1.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbItem.displayName = 'BreadcrumbItem';
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<'a'> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn('transition-colors hover:text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = 'BreadcrumbLink';
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<'span'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('font-normal text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbPage.displayName = 'BreadcrumbPage';
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
};
|
||||
36
packages/frontend/admin/src/components/ui/button.spec.tsx
Normal file
36
packages/frontend/admin/src/components/ui/button.spec.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { Button } from './button';
|
||||
|
||||
describe('Button', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('uses token-aligned default styles', () => {
|
||||
render(<Button>Save</Button>);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Save' });
|
||||
expect(button.className).toContain('rounded-lg');
|
||||
expect(button.className).toContain('bg-primary');
|
||||
expect(button.className).toContain('focus-visible:ring-ring/30');
|
||||
});
|
||||
|
||||
test('supports outline/sm variant with disabled state', () => {
|
||||
render(
|
||||
<Button variant="outline" size="sm" disabled={true}>
|
||||
Cancel
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Cancel' });
|
||||
expect(button.className).toContain('border-border');
|
||||
expect(button.className).toContain('h-8');
|
||||
expect(button.className).toContain('text-xs');
|
||||
expect(button.hasAttribute('disabled')).toBe(true);
|
||||
});
|
||||
});
|
||||
56
packages/frontend/admin/src/components/ui/button.tsx
Normal file
56
packages/frontend/admin/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-[background-color,color,border-color,box-shadow,transform] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-1 hover:bg-primary/90 active:translate-y-px',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-1 hover:bg-destructive/90 active:translate-y-px',
|
||||
outline:
|
||||
'border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-muted',
|
||||
ghost: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4',
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
lg: 'h-10 px-6',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
70
packages/frontend/admin/src/components/ui/calendar.tsx
Normal file
70
packages/frontend/admin/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import type * as React from 'react';
|
||||
import { Chevron, DayPicker } from 'react-day-picker';
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-3', className)}
|
||||
classNames={{
|
||||
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||
month: 'space-y-4',
|
||||
caption: 'flex justify-center pt-1 relative items-center',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'space-x-1 flex items-center',
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
|
||||
),
|
||||
nav_button_previous: 'absolute left-1',
|
||||
nav_button_next: 'absolute right-1',
|
||||
table: 'w-full border-collapse space-y-1',
|
||||
head_row: 'flex',
|
||||
head_cell:
|
||||
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
|
||||
row: 'flex w-full mt-2',
|
||||
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
|
||||
day: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
|
||||
),
|
||||
day_range_end: 'day-range-end',
|
||||
day_selected:
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
day_today: 'bg-accent text-accent-foreground',
|
||||
day_outside:
|
||||
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
|
||||
day_disabled: 'text-muted-foreground opacity-50',
|
||||
day_range_middle:
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
day_hidden: 'invisible',
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: props => {
|
||||
if (props.orientation === 'left') {
|
||||
return <ChevronLeft className="w-4 h-4" />;
|
||||
}
|
||||
if (props.orientation === 'right') {
|
||||
return <ChevronRight className="w-4 h-4" />;
|
||||
}
|
||||
return <Chevron {...props} />;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = 'Calendar';
|
||||
|
||||
export { Calendar };
|
||||
85
packages/frontend/admin/src/components/ui/card.tsx
Normal file
85
packages/frontend/admin/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as React from 'react';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border bg-card text-card-foreground shadow-sm transition-shadow duration-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
280
packages/frontend/admin/src/components/ui/carousel.tsx
Normal file
280
packages/frontend/admin/src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from 'embla-carousel-react';
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCarousel must be used within a <Carousel />');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const Carousel = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = 'horizontal',
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === 'horizontal' ? 'x' : 'y',
|
||||
},
|
||||
plugins
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = useState(false);
|
||||
|
||||
const onSelect = useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(api);
|
||||
api.on('reInit', onSelect);
|
||||
api.on('select', onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off('select', onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
const resolvedOrientation =
|
||||
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal');
|
||||
const carouselContextValue = useMemo(
|
||||
() => ({
|
||||
carouselRef,
|
||||
api,
|
||||
opts,
|
||||
orientation: resolvedOrientation,
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}),
|
||||
[
|
||||
api,
|
||||
canScrollNext,
|
||||
canScrollPrev,
|
||||
carouselRef,
|
||||
opts,
|
||||
resolvedOrientation,
|
||||
scrollNext,
|
||||
scrollPrev,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider value={carouselContextValue}>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn('relative', className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
Carousel.displayName = 'Carousel';
|
||||
|
||||
const CarouselContent = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex',
|
||||
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CarouselContent.displayName = 'CarouselContent';
|
||||
|
||||
const CarouselItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
'min-w-0 shrink-0 grow-0 basis-full',
|
||||
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
CarouselItem.displayName = 'CarouselItem';
|
||||
|
||||
const CarouselPrevious = forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? '-left-12 top-1/2 -translate-y-1/2'
|
||||
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselPrevious.displayName = 'CarouselPrevious';
|
||||
|
||||
const CarouselNext = forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? '-right-12 top-1/2 -translate-y-1/2'
|
||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselNext.displayName = 'CarouselNext';
|
||||
|
||||
export {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
};
|
||||
173
packages/frontend/admin/src/components/ui/chart.tsx
Normal file
173
packages/frontend/admin/src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { createContext, forwardRef, useContext, useId, useMemo } from 'react';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import { ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
|
||||
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode;
|
||||
color?: string;
|
||||
theme?: Partial<Record<keyof typeof THEMES, string>>;
|
||||
}
|
||||
>;
|
||||
|
||||
type ChartContextValue = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = createContext<ChartContextValue | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const value = useContext(ChartContext);
|
||||
if (!value) {
|
||||
throw new Error('useChart must be used within <ChartContainer />');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ChartStyle({
|
||||
chartId,
|
||||
config,
|
||||
}: {
|
||||
chartId: string;
|
||||
config: ChartConfig;
|
||||
}) {
|
||||
const colorEntries = Object.entries(config).filter(
|
||||
([, item]) => item.color || item.theme
|
||||
);
|
||||
|
||||
if (!colorEntries.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const css = Object.entries(THEMES)
|
||||
.map(([themeKey, prefix]) => {
|
||||
const declarations = colorEntries
|
||||
.map(([key, item]) => {
|
||||
const color =
|
||||
item.theme?.[themeKey as keyof typeof THEMES] ?? item.color;
|
||||
return color ? ` --color-${key}: ${color};` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
if (!declarations) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${prefix} [data-chart="${chartId}"] {\n${declarations}\n}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
if (!css) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <style dangerouslySetInnerHTML={{ __html: css }} />;
|
||||
}
|
||||
|
||||
type ChartContainerProps = React.ComponentProps<'div'> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof ResponsiveContainer>['children'];
|
||||
};
|
||||
|
||||
const ChartContainer = forwardRef<HTMLDivElement, ChartContainerProps>(
|
||||
({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = useId();
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
|
||||
const chartContextValue = useMemo(() => ({ config }), [config]);
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={chartContextValue}>
|
||||
<div
|
||||
ref={ref}
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
'flex min-h-0 w-full items-center justify-center text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle chartId={chartId} config={config} />
|
||||
<ResponsiveContainer>{children}</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
ChartContainer.displayName = 'ChartContainer';
|
||||
|
||||
const ChartTooltip = RechartsTooltip;
|
||||
|
||||
type TooltipContentProps = {
|
||||
active?: boolean;
|
||||
payload?: TooltipProps<number, string>['payload'];
|
||||
label?: string | number;
|
||||
labelFormatter?: (
|
||||
label: string | number,
|
||||
payload: TooltipProps<number, string>['payload']
|
||||
) => React.ReactNode;
|
||||
valueFormatter?: (value: number, key: string) => React.ReactNode;
|
||||
};
|
||||
|
||||
const ChartTooltipContent = forwardRef<HTMLDivElement, TooltipContentProps>(
|
||||
({ active, payload, label, labelFormatter, valueFormatter }, ref) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = labelFormatter ? labelFormatter(label ?? '', payload) : label;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="min-w-44 rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
|
||||
>
|
||||
{title ? (
|
||||
<div className="mb-2 font-medium text-foreground/90">{title}</div>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
{payload.map((item, index) => {
|
||||
const dataKey = String(item.dataKey ?? item.name ?? index);
|
||||
const itemConfig = config[dataKey];
|
||||
const labelText = itemConfig?.label ?? item.name ?? dataKey;
|
||||
const numericValue =
|
||||
typeof item.value === 'number'
|
||||
? item.value
|
||||
: Number(item.value ?? 0);
|
||||
const valueText = valueFormatter
|
||||
? valueFormatter(numericValue, dataKey)
|
||||
: numericValue;
|
||||
const color = item.color ?? `var(--color-${dataKey})`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${dataKey}-${index}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground">{labelText}</span>
|
||||
<span className="ml-auto font-medium tabular-nums">
|
||||
{valueText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
ChartTooltipContent.displayName = 'ChartTooltipContent';
|
||||
|
||||
export { ChartContainer, ChartTooltip, ChartTooltipContent };
|
||||
27
packages/frontend/admin/src/components/ui/checkbox.tsx
Normal file
27
packages/frontend/admin/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn('flex items-center justify-center text-current')}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
|
||||
153
packages/frontend/admin/src/components/ui/command.tsx
Normal file
153
packages/frontend/admin/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Dialog, DialogContent } from '@affine/admin/components/ui/dialog';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { type DialogProps } from '@radix-ui/react-dialog';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="p-0 overflow-hidden shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
// oxlint-disable-next-line react/no-unknown-property
|
||||
<div className="flex items-center px-3 border-b" cmdk-input-wrapper="">
|
||||
<Search className="w-4 h-4 mr-2 opacity-50 shrink-0" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-sm text-center"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CommandShortcut.displayName = 'CommandShortcut';
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
};
|
||||
197
packages/frontend/admin/src/components/ui/context-menu.tsx
Normal file
197
packages/frontend/admin/src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
));
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold text-foreground',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuPortal,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
};
|
||||
120
packages/frontend/admin/src/components/ui/dialog.tsx
Normal file
120
packages/frontend/admin/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { CloseIcon } from '@blocksuite/icons/rc';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import * as React from 'react';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-foreground/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="cursor-pointer absolute right-7 top-7 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<CloseIcon fontSize={20} color={cssVarV2('selfhost/icon/secondary')} />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-3 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold tracking-tight leading-6.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-[15px] text-muted-foreground leading-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
115
packages/frontend/admin/src/components/ui/drawer.tsx
Normal file
115
packages/frontend/admin/src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as React from 'react';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul';
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Drawer.displayName = 'Drawer';
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn('fixed inset-0 z-50 bg-black/80', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
));
|
||||
DrawerContent.displayName = 'DrawerContent';
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerHeader.displayName = 'DrawerHeader';
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerFooter.displayName = 'DrawerFooter';
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
};
|
||||
197
packages/frontend/admin/src/components/ui/dropdown-menu.tsx
Normal file
197
packages/frontend/admin/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
176
packages/frontend/admin/src/components/ui/form.tsx
Normal file
176
packages/frontend/admin/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Label } from '@affine/admin/components/ui/label';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import type * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { createContext, forwardRef, useContext, useId, useMemo } from 'react';
|
||||
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
|
||||
import { Controller, FormProvider, useFormContext } from 'react-hook-form';
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
const formFieldContextValue = useMemo(
|
||||
() => ({ name: props.name }),
|
||||
[props.name]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormFieldContext.Provider value={formFieldContextValue}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = useContext(FormFieldContext);
|
||||
const itemContext = useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
);
|
||||
|
||||
const FormItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = useId();
|
||||
const formItemContextValue = useMemo(() => ({ id }), [id]);
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={formItemContextValue}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = 'FormItem';
|
||||
|
||||
const FormLabel = forwardRef<
|
||||
React.ComponentRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && 'text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
const FormControl = forwardRef<
|
||||
React.ComponentRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = 'FormControl';
|
||||
|
||||
const FormDescription = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = 'FormDescription';
|
||||
|
||||
const FormMessage = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('text-sm font-medium text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
|
||||
export {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useFormField,
|
||||
};
|
||||
26
packages/frontend/admin/src/components/ui/hover-card.tsx
Normal file
26
packages/frontend/admin/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
|
||||
import * as React from 'react';
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger };
|
||||
68
packages/frontend/admin/src/components/ui/input-otp.tsx
Normal file
68
packages/frontend/admin/src/components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import { Dot } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
'flex items-center gap-2 has-[:disabled]:opacity-50',
|
||||
containerClassName
|
||||
)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTP.displayName = 'InputOTP';
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center', className)} {...props} />
|
||||
));
|
||||
InputOTPGroup.displayName = 'InputOTPGroup';
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
|
||||
isActive && 'z-10 ring-2 ring-ring ring-offset-background',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
InputOTPSlot.displayName = 'InputOTPSlot';
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Dot />
|
||||
</div>
|
||||
));
|
||||
InputOTPSeparator.displayName = 'InputOTPSeparator';
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot };
|
||||
23
packages/frontend/admin/src/components/ui/input.tsx
Normal file
23
packages/frontend/admin/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-lg border border-input bg-background px-3 py-1.5 text-sm text-foreground ring-offset-background transition-[border-color,box-shadow,background-color] duration-150 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
23
packages/frontend/admin/src/components/ui/label.tsx
Normal file
23
packages/frontend/admin/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
233
packages/frontend/admin/src/components/ui/menubar.tsx
Normal file
233
packages/frontend/admin/src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as MenubarPrimitive from '@radix-ui/react-menubar';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const MenubarMenu = MenubarPrimitive.Menu;
|
||||
|
||||
const MenubarGroup = MenubarPrimitive.Group;
|
||||
|
||||
const MenubarPortal = MenubarPrimitive.Portal;
|
||||
|
||||
const MenubarSub = MenubarPrimitive.Sub;
|
||||
|
||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 items-center space-x-1 rounded-md border bg-background p-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(
|
||||
(
|
||||
{ className, align = 'start', alignOffset = -4, sideOffset = 8, ...props },
|
||||
ref
|
||||
) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
));
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
));
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
MenubarShortcut.displayname = 'MenubarShortcut';
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarCheckboxItem,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarItem,
|
||||
MenubarLabel,
|
||||
MenubarMenu,
|
||||
MenubarPortal,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarSub,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarTrigger,
|
||||
};
|
||||
127
packages/frontend/admin/src/components/ui/navigation-menu.tsx
Normal file
127
packages/frontend/admin/src/components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-10 flex max-w-max flex-1 items-center justify-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
));
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group flex flex-1 list-none items-center justify-center space-x-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50'
|
||||
);
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), 'group', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{' '}
|
||||
<ChevronDown
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
));
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link;
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn('absolute left-0 top-full flex justify-center')}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
'origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
NavigationMenuViewport.displayName =
|
||||
NavigationMenuPrimitive.Viewport.displayName;
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
));
|
||||
NavigationMenuIndicator.displayName =
|
||||
NavigationMenuPrimitive.Indicator.displayName;
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenuViewport,
|
||||
};
|
||||
117
packages/frontend/admin/src/components/ui/pagination.tsx
Normal file
117
packages/frontend/admin/src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { ButtonProps } from '@affine/admin/components/ui/button';
|
||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Pagination.displayName = 'Pagination';
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<'ul'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn('flex flex-row items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PaginationContent.displayName = 'PaginationContent';
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<'li'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn('', className)} {...props} />
|
||||
));
|
||||
PaginationItem.displayName = 'PaginationItem';
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<ButtonProps, 'size'> &
|
||||
React.ComponentProps<'a'>;
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = 'icon',
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
PaginationLink.displayName = 'PaginationLink';
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn('gap-1 pl-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = 'PaginationPrevious';
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn('gap-1 pr-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = 'PaginationNext';
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
PaginationEllipsis.displayName = 'PaginationEllipsis';
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
};
|
||||
28
packages/frontend/admin/src/components/ui/popover.tsx
Normal file
28
packages/frontend/admin/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import * as React from 'react';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverContent, PopoverTrigger };
|
||||
25
packages/frontend/admin/src/components/ui/progress.tsx
Normal file
25
packages/frontend/admin/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import * as React from 'react';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
41
packages/frontend/admin/src/components/ui/radio-group.tsx
Normal file
41
packages/frontend/admin/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||
import { Circle } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
42
packages/frontend/admin/src/components/ui/resizable.tsx
Normal file
42
packages/frontend/admin/src/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import * as ResizablePrimitive from 'react-resizable-panels';
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel;
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
|
||||
45
packages/frontend/admin/src/components/ui/scroll-area.tsx
Normal file
45
packages/frontend/admin/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import * as React from 'react';
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>*:first-child]:!block">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
157
packages/frontend/admin/src/components/ui/select.tsx
Normal file
157
packages/frontend/admin/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-lg border border-input bg-background px-3 py-1.5 text-sm ring-offset-background transition-[border-color,box-shadow,background-color] duration-150 placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-[calc(var(--radius)-1px)] border border-border bg-popover text-popover-foreground shadow-menu data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
28
packages/frontend/admin/src/components/ui/separator.tsx
Normal file
28
packages/frontend/admin/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import * as React from 'react';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
145
packages/frontend/admin/src/components/ui/sheet.tsx
Normal file
145
packages/frontend/admin/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-foreground/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom:
|
||||
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
right:
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: 'right',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps & { withoutCloseButton?: boolean }
|
||||
>(
|
||||
(
|
||||
{ side = 'right', className, children, withoutCloseButton, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!withoutCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = 'SheetHeader';
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = 'SheetFooter';
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetOverlay,
|
||||
SheetPortal,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
};
|
||||
15
packages/frontend/admin/src/components/ui/skeleton.tsx
Normal file
15
packages/frontend/admin/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-pulse rounded-lg bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
25
packages/frontend/admin/src/components/ui/slider.tsx
Normal file
25
packages/frontend/admin/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
import * as React from 'react';
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none select-none items-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
29
packages/frontend/admin/src/components/ui/sonner.tsx
Normal file
29
packages/frontend/admin/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'group toast rounded-md group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-menu',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton:
|
||||
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton:
|
||||
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
26
packages/frontend/admin/src/components/ui/switch.tsx
Normal file
26
packages/frontend/admin/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
import * as React from 'react';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-toggle-on data-[state=unchecked]:bg-toggle-off',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-toggle-thumb shadow-sm ring-0 transition-transform duration-150 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
116
packages/frontend/admin/src/components/ui/table.tsx
Normal file
116
packages/frontend/admin/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as React from 'react';
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
Table.displayName = 'Table';
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = 'TableHeader';
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = 'TableFooter';
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors duration-100 hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = 'TableRow';
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = 'TableHead';
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
};
|
||||
52
packages/frontend/admin/src/components/ui/tabs.tsx
Normal file
52
packages/frontend/admin/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import * as React from 'react';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
22
packages/frontend/admin/src/components/ui/textarea.tsx
Normal file
22
packages/frontend/admin/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[96px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background transition-[border-color,box-shadow,background-color] duration-150 placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
126
packages/frontend/admin/src/components/ui/toast.tsx
Normal file
126
packages/frontend/admin/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive:
|
||||
'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
Toast,
|
||||
ToastAction,
|
||||
type ToastActionElement,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
type ToastProps,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
};
|
||||
34
packages/frontend/admin/src/components/ui/toaster.tsx
Normal file
34
packages/frontend/admin/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from '@affine/admin/components/ui/toast';
|
||||
|
||||
import { useToast } from './use-toast';
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
63
packages/frontend/admin/src/components/ui/toggle-group.tsx
Normal file
63
packages/frontend/admin/src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { toggleVariants } from '@affine/admin/components/ui/toggle';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import { createContext, forwardRef, useContext, useMemo } from 'react';
|
||||
|
||||
const ToggleGroupContext = createContext<VariantProps<typeof toggleVariants>>({
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
const ToggleGroup = forwardRef<
|
||||
React.ComponentRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => {
|
||||
const toggleGroupContextValue = useMemo(
|
||||
() => ({ variant, size }),
|
||||
[size, variant]
|
||||
);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('flex items-center justify-center gap-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={toggleGroupContextValue}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
});
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
|
||||
|
||||
const ToggleGroupItem = forwardRef<
|
||||
React.ComponentRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
42
packages/frontend/admin/src/components/ui/toggle.tsx
Normal file
42
packages/frontend/admin/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as TogglePrimitive from '@radix-ui/react-toggle';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
|
||||
const toggleVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline:
|
||||
'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-3',
|
||||
sm: 'h-9 px-2.5',
|
||||
lg: 'h-11 px-5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName;
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
26
packages/frontend/admin/src/components/ui/tooltip.tsx
Normal file
26
packages/frontend/admin/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import {
|
||||
Content,
|
||||
Provider as TooltipProvider,
|
||||
Root as Tooltip,
|
||||
Trigger as TooltipTrigger,
|
||||
} from '@radix-ui/react-tooltip';
|
||||
import * as React from 'react';
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof Content>,
|
||||
React.ComponentPropsWithoutRef<typeof Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border border-border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-menu animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
189
packages/frontend/admin/src/components/ui/use-toast.ts
Normal file
189
packages/frontend/admin/src/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// Inspired by react-hot-toast library
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from '@affine/admin/components/ui/toast';
|
||||
import * as React from 'react';
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = {
|
||||
ADD_TOAST: 'ADD_TOAST';
|
||||
UPDATE_TOAST: 'UPDATE_TOAST';
|
||||
DISMISS_TOAST: 'DISMISS_TOAST';
|
||||
REMOVE_TOAST: 'REMOVE_TOAST';
|
||||
};
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType['ADD_TOAST'];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType['UPDATE_TOAST'];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType['DISMISS_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
}
|
||||
| {
|
||||
type: ActionType['REMOVE_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map(t =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
};
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach(toast => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map(t =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
};
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter(t => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach(listener => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: open => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { toast, useToast };
|
||||
422
packages/frontend/admin/src/config.json
Normal file
422
packages/frontend/admin/src/config.json
Normal file
@@ -0,0 +1,422 @@
|
||||
{
|
||||
"metrics": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Enable metric and tracing collection"
|
||||
}
|
||||
},
|
||||
"crypto": {
|
||||
"privateKey": {
|
||||
"type": "String",
|
||||
"desc": "The private key for used by the crypto module to create signed tokens or encrypt data.",
|
||||
"env": "AFFINE_PRIVATE_KEY"
|
||||
}
|
||||
},
|
||||
"throttle": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether the throttler is enabled."
|
||||
},
|
||||
"throttlers.default": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the default throttler."
|
||||
},
|
||||
"throttlers.strict": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the strict throttler."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"allowSignup": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether allow new registrations."
|
||||
},
|
||||
"allowSignupForOauth": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether allow new registrations via configured oauth."
|
||||
},
|
||||
"requireEmailDomainVerification": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether require email domain record verification before accessing restricted resources."
|
||||
},
|
||||
"requireEmailVerification": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether require email verification before accessing restricted resources(not implemented)."
|
||||
},
|
||||
"newAccountActionDelay": {
|
||||
"type": "Number",
|
||||
"desc": "Minimum account age in seconds before new accounts can invite members, create invite links, or publish documents. Set to 0 to disable."
|
||||
},
|
||||
"trustedCloudflareHeaders": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether request abuse source facts should trust Cloudflare headers from the origin edge."
|
||||
},
|
||||
"signInRateLimit": {
|
||||
"type": "Object",
|
||||
"desc": "Limits for sign-in attempts shared through Redis by source IP and email. ttl is measured in milliseconds."
|
||||
},
|
||||
"passwordRequirements": {
|
||||
"type": "Object",
|
||||
"desc": "The password strength requirements when set new password."
|
||||
},
|
||||
"session.ttl": {
|
||||
"type": "Number",
|
||||
"desc": "Application auth expiration time in seconds."
|
||||
},
|
||||
"session.ttr": {
|
||||
"type": "Number",
|
||||
"desc": "Application auth time to refresh in seconds."
|
||||
},
|
||||
"token.accessTokenTtl": {
|
||||
"type": "Number",
|
||||
"desc": "Access JWT expiration time in seconds."
|
||||
},
|
||||
"token.refreshIdleTtl": {
|
||||
"type": "Number",
|
||||
"desc": "Auth refresh session inactivity expiration in seconds."
|
||||
},
|
||||
"token.refreshAbsoluteTtl": {
|
||||
"type": "Number",
|
||||
"desc": "Auth refresh session absolute expiration in seconds."
|
||||
},
|
||||
"token.refreshGracePeriod": {
|
||||
"type": "Number",
|
||||
"desc": "One-use refresh rotation concurrency grace period in seconds."
|
||||
},
|
||||
"token.refreshRetention": {
|
||||
"type": "Number",
|
||||
"desc": "Retention for expired auth refresh generations in seconds."
|
||||
}
|
||||
},
|
||||
"mailer": {
|
||||
"SMTP.name": {
|
||||
"type": "String",
|
||||
"desc": "Hostname used for SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.",
|
||||
"env": "MAILER_SERVERNAME"
|
||||
},
|
||||
"SMTP.host": {
|
||||
"type": "String",
|
||||
"desc": "Host of the email server (e.g. smtp.gmail.com)",
|
||||
"env": "MAILER_HOST"
|
||||
},
|
||||
"SMTP.port": {
|
||||
"type": "Number",
|
||||
"desc": "Port of the email server (they commonly are 25, 465 or 587)",
|
||||
"env": "MAILER_PORT"
|
||||
},
|
||||
"SMTP.username": {
|
||||
"type": "String",
|
||||
"desc": "Username used to authenticate the email server",
|
||||
"env": "MAILER_USER"
|
||||
},
|
||||
"SMTP.password": {
|
||||
"type": "String",
|
||||
"desc": "Password used to authenticate the email server",
|
||||
"env": "MAILER_PASSWORD"
|
||||
},
|
||||
"SMTP.sender": {
|
||||
"type": "String",
|
||||
"desc": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")",
|
||||
"env": "MAILER_SENDER"
|
||||
},
|
||||
"SMTP.ignoreTLS": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether ignore email server's TLS certificate verification. Enable it for self-signed certificates.",
|
||||
"env": "MAILER_IGNORE_TLS"
|
||||
},
|
||||
"fallbackDomains": {
|
||||
"type": "Array",
|
||||
"desc": "The emails from these domains are always sent using the fallback SMTP server."
|
||||
},
|
||||
"deliveryWorker.batchSize": {
|
||||
"type": "Number",
|
||||
"desc": "Number of mail delivery rows claimed by each worker tick."
|
||||
},
|
||||
"deliveryWorker.leaseMs": {
|
||||
"type": "Number",
|
||||
"desc": "Mail delivery worker lease duration in milliseconds."
|
||||
},
|
||||
"deliveryWorker.retentionDays": {
|
||||
"type": "Number",
|
||||
"desc": "Days to retain anonymized terminal mail delivery ledger rows."
|
||||
},
|
||||
"fallbackSMTP.name": {
|
||||
"type": "String",
|
||||
"desc": "Hostname used for fallback SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname."
|
||||
},
|
||||
"fallbackSMTP.host": {
|
||||
"type": "String",
|
||||
"desc": "Host of the email server (e.g. smtp.gmail.com)"
|
||||
},
|
||||
"fallbackSMTP.port": {
|
||||
"type": "Number",
|
||||
"desc": "Port of the email server (they commonly are 25, 465 or 587)"
|
||||
},
|
||||
"fallbackSMTP.username": {
|
||||
"type": "String",
|
||||
"desc": "Username used to authenticate the email server"
|
||||
},
|
||||
"fallbackSMTP.password": {
|
||||
"type": "String",
|
||||
"desc": "Password used to authenticate the email server"
|
||||
},
|
||||
"fallbackSMTP.sender": {
|
||||
"type": "String",
|
||||
"desc": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")"
|
||||
},
|
||||
"fallbackSMTP.ignoreTLS": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether ignore email server's TLS certificate verification. Enable it for self-signed certificates."
|
||||
}
|
||||
},
|
||||
"doc": {
|
||||
"experimental.yocto": {
|
||||
"type": "Boolean",
|
||||
"desc": "Use `y-octo` to merge updates at the same time when merging using Yjs."
|
||||
},
|
||||
"history.interval": {
|
||||
"type": "Number",
|
||||
"desc": "The minimum time interval in milliseconds of creating a new history snapshot when doc get updated."
|
||||
}
|
||||
},
|
||||
"storages": {
|
||||
"avatar.publicPath": {
|
||||
"type": "String",
|
||||
"desc": "The public accessible path prefix for user avatars."
|
||||
},
|
||||
"avatar.storage": {
|
||||
"type": "Object",
|
||||
"desc": "The config of storage for user avatars."
|
||||
},
|
||||
"blob.storage": {
|
||||
"type": "Object",
|
||||
"desc": "The config of storage for all uploaded blobs(images, videos, etc.)."
|
||||
}
|
||||
},
|
||||
"websocket": {
|
||||
"transports": {
|
||||
"type": "Array",
|
||||
"desc": "The enabled transports for accepting websocket traffics.",
|
||||
"link": "https://docs.nestjs.com/websockets/gateways#transports"
|
||||
},
|
||||
"maxHttpBufferSize": {
|
||||
"type": "Number",
|
||||
"desc": "How many bytes or characters a message can be, before closing the session (to avoid DoS)."
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"name": {
|
||||
"type": "String",
|
||||
"desc": "A recognizable name for the server. Will be shown when connected with AFFiNE Desktop."
|
||||
},
|
||||
"externalUrl": {
|
||||
"type": "String",
|
||||
"desc": "Base url of AFFiNE server, used for generating external urls.\nDefault to be `[server.protocol]://[server.host][:server.port]` if not specified.\n ",
|
||||
"env": "AFFINE_SERVER_EXTERNAL_URL"
|
||||
},
|
||||
"https": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether the server is hosted on a ssl enabled domain (https://).",
|
||||
"env": "AFFINE_SERVER_HTTPS"
|
||||
},
|
||||
"host": {
|
||||
"type": "String",
|
||||
"desc": "Where the server get deployed(FQDN).",
|
||||
"env": "AFFINE_SERVER_HOST"
|
||||
},
|
||||
"hosts": {
|
||||
"type": "Array",
|
||||
"desc": "Multiple hosts the server will accept requests from."
|
||||
},
|
||||
"listenAddr": {
|
||||
"type": "String",
|
||||
"desc": "The address to listen on (e.g., 0.0.0.0 for IPv4, :: for IPv6).",
|
||||
"env": "LISTEN_ADDR"
|
||||
},
|
||||
"port": {
|
||||
"type": "Number",
|
||||
"desc": "Which port the server will listen on.",
|
||||
"env": "AFFINE_SERVER_PORT"
|
||||
},
|
||||
"path": {
|
||||
"type": "String",
|
||||
"desc": "Subpath where the server get deployed if there is one.(e.g. /affine)",
|
||||
"env": "AFFINE_SERVER_SUB_PATH"
|
||||
}
|
||||
},
|
||||
"flags": {
|
||||
"allowGuestDemoWorkspace": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether allow guest users to create demo workspaces."
|
||||
}
|
||||
},
|
||||
"telemetry": {
|
||||
"allowedOrigin": {
|
||||
"type": "Array",
|
||||
"desc": "Allowed origins for telemetry collection."
|
||||
},
|
||||
"ga4.measurementId": {
|
||||
"type": "String",
|
||||
"desc": "GA4 Measurement ID for Measurement Protocol.",
|
||||
"env": "GA4_MEASUREMENT_ID"
|
||||
},
|
||||
"ga4.apiSecret": {
|
||||
"type": "String",
|
||||
"desc": "GA4 API secret for Measurement Protocol.",
|
||||
"env": "GA4_API_SECRET"
|
||||
},
|
||||
"dedupe.ttlHours": {
|
||||
"type": "Number",
|
||||
"desc": "Telemetry dedupe TTL in hours."
|
||||
},
|
||||
"dedupe.maxEntries": {
|
||||
"type": "Number",
|
||||
"desc": "Telemetry dedupe max entries."
|
||||
},
|
||||
"batch.maxEvents": {
|
||||
"type": "Number",
|
||||
"desc": "Max events per telemetry batch."
|
||||
}
|
||||
},
|
||||
"client": {
|
||||
"versionControl.enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether check version of client before accessing the server."
|
||||
},
|
||||
"versionControl.requiredVersion": {
|
||||
"type": "String",
|
||||
"desc": "Allowed version range of the app that allowed to access the server. Requires 'client/versionControl.enabled' to be true to take effect."
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"google": {
|
||||
"type": "Object",
|
||||
"desc": "Google Calendar integration config",
|
||||
"link": "https://developers.google.com/calendar/api/guides/push"
|
||||
},
|
||||
"caldav": {
|
||||
"type": "Object",
|
||||
"desc": "CalDAV integration config"
|
||||
}
|
||||
},
|
||||
"captcha": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Check captcha challenge when user authenticating the app."
|
||||
},
|
||||
"config": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the captcha plugin."
|
||||
}
|
||||
},
|
||||
"copilot": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Enable AI features. Workspace owners configure provider keys in Workspace Settings → Integrations → AI BYOK."
|
||||
},
|
||||
"unsplash": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the unsplash key."
|
||||
},
|
||||
"exa": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the exa web search key."
|
||||
},
|
||||
"storage": {
|
||||
"type": "Object",
|
||||
"desc": "The config for the storage provider."
|
||||
},
|
||||
"byok.enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Allow workspace owners and admins to configure AI provider keys through AI BYOK."
|
||||
},
|
||||
"byok.allowedProviders": {
|
||||
"type": "Array",
|
||||
"desc": "AI providers that workspace owners and admins may add through AI BYOK."
|
||||
},
|
||||
"byok.allowCustomEndpoint": {
|
||||
"type": "Boolean",
|
||||
"desc": "Allow AI BYOK keys to use a custom provider endpoint."
|
||||
},
|
||||
"byok.allowPrivateEndpoint": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether workspace BYOK custom endpoints may resolve to private network targets. Enabling this allows workspace owners and admins to send provider probe requests to the private network."
|
||||
}
|
||||
},
|
||||
"indexer": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Enable indexer plugin"
|
||||
},
|
||||
"provider.type": {
|
||||
"type": "String",
|
||||
"desc": "Indexer search provider. Self-hosted uses the embedded provider by default; remote providers require an endpoint."
|
||||
},
|
||||
"provider.endpoint": {
|
||||
"type": "String",
|
||||
"desc": "Remote indexer endpoint. Not used by the embedded provider."
|
||||
},
|
||||
"provider.apiKey": {
|
||||
"type": "String",
|
||||
"desc": "Indexer search service api key. Optional for remote providers",
|
||||
"link": "https://www.elastic.co/guide/server/current/api-key.html"
|
||||
},
|
||||
"provider.username": {
|
||||
"type": "String",
|
||||
"desc": "Indexer search service auth username, if not set, basic auth will be disabled. Optional for remote providers",
|
||||
"link": "https://www.elastic.co/guide/en/elasticsearch/reference/current/http-clients.html"
|
||||
},
|
||||
"provider.password": {
|
||||
"type": "String",
|
||||
"desc": "Indexer search service auth password, if not set, basic auth will be disabled. Optional for remote providers"
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"providers.google": {
|
||||
"type": "Object",
|
||||
"desc": "Google OAuth provider config",
|
||||
"link": "https://developers.google.com/identity/protocols/oauth2/web-server"
|
||||
},
|
||||
"providers.github": {
|
||||
"type": "Object",
|
||||
"desc": "GitHub OAuth provider config",
|
||||
"link": "https://docs.github.com/en/apps/oauth-apps"
|
||||
},
|
||||
"providers.oidc": {
|
||||
"type": "Object",
|
||||
"desc": "OIDC OAuth provider config. Private network access requires allowPrivateNetwork: true",
|
||||
"link": "https://openid.net/specs/openid-connect-core-1_0.html"
|
||||
},
|
||||
"providers.apple": {
|
||||
"type": "Object",
|
||||
"desc": "Apple OAuth provider config",
|
||||
"link": "https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/implementing_sign_in_with_apple_in_your_app"
|
||||
}
|
||||
},
|
||||
"payment": {
|
||||
"enabled": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether enable payment plugin"
|
||||
},
|
||||
"showLifetimePrice": {
|
||||
"type": "Boolean",
|
||||
"desc": "Whether enable lifetime price and allow user to pay for it."
|
||||
},
|
||||
"stripe": {
|
||||
"type": "Object",
|
||||
"desc": "Stripe sdk options and credentials",
|
||||
"link": "https://docs.stripe.com/api"
|
||||
},
|
||||
"revenuecat": {
|
||||
"type": "Object",
|
||||
"desc": "RevenueCat integration configs",
|
||||
"link": "https://www.revenuecat.com/docs/"
|
||||
}
|
||||
},
|
||||
"worker": {
|
||||
"allowedOrigin": {
|
||||
"type": "Array",
|
||||
"desc": "Allowed origin"
|
||||
}
|
||||
}
|
||||
}
|
||||
50
packages/frontend/admin/src/fetch-utils.ts
Normal file
50
packages/frontend/admin/src/fetch-utils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Custom fetch utility with AFFiNE version header
|
||||
* Automatically adds the x-affine-version header to all fetch requests
|
||||
*/
|
||||
|
||||
// BUILD_CONFIG is defined globally in the AFFiNE project
|
||||
|
||||
/**
|
||||
* Wrapper around fetch that automatically adds the x-affine-version header
|
||||
* @param input Request URL
|
||||
* @param init Request initialization options
|
||||
* @returns Promise with the fetch Response
|
||||
*/
|
||||
const CSRF_COOKIE_NAME = 'affine_csrf_token';
|
||||
|
||||
function getCookieValue(name: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookies = document.cookie ? document.cookie.split('; ') : [];
|
||||
for (const cookie of cookies) {
|
||||
const idx = cookie.indexOf('=');
|
||||
const key = idx === -1 ? cookie : cookie.slice(0, idx);
|
||||
if (key === name) {
|
||||
return idx === -1 ? '' : cookie.slice(idx + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const affineFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const csrfToken =
|
||||
method !== 'GET' && method !== 'HEAD'
|
||||
? getCookieValue(CSRF_COOKIE_NAME)
|
||||
: null;
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
...(csrfToken ? { 'x-affine-csrf-token': csrfToken } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
187
packages/frontend/admin/src/global.css
Normal file
187
packages/frontend/admin/src/global.css
Normal file
@@ -0,0 +1,187 @@
|
||||
@config '../tailwind.config.js';
|
||||
|
||||
@layer properties, theme, base, components, utilities;
|
||||
|
||||
@import 'tailwindcss';
|
||||
@import 'tailwindcss/utilities';
|
||||
@import '@toeverything/theme/style.css';
|
||||
|
||||
@plugin 'tailwindcss-animate';
|
||||
|
||||
@custom-variant dark (&:is(.dark *, [data-theme='dark'] *));
|
||||
|
||||
@theme {
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
/* Selfhost sidebar tokens */
|
||||
--color-sidebar-bg: var(
|
||||
--affine-v2-selfhost-layer-background-sidebarBg-sidebarBg
|
||||
);
|
||||
--color-sidebar-foreground: var(--affine-v2-selfhost-text-sidebar-primary);
|
||||
--color-sidebar-foreground-secondary: var(
|
||||
--affine-v2-selfhost-text-sidebar-secondary
|
||||
);
|
||||
--color-sidebar-hover: var(
|
||||
--affine-v2-selfhost-button-sidebarButton-bg-hover
|
||||
);
|
||||
--color-sidebar-active: var(
|
||||
--affine-v2-selfhost-button-sidebarButton-bg-select
|
||||
);
|
||||
|
||||
/* Chip / badge tokens */
|
||||
--color-chip-blue: var(--affine-v2-chip-label-blue);
|
||||
--color-chip-white: var(--affine-v2-chip-label-white);
|
||||
--color-chip-text: var(--affine-v2-chip-label-text);
|
||||
|
||||
/* Toggle tokens */
|
||||
--color-toggle-on: var(--affine-v2-selfhost-toggle-backgroundOn);
|
||||
--color-toggle-off: var(--affine-v2-selfhost-toggle-backgroundOff);
|
||||
--color-toggle-thumb: var(--affine-v2-selfhost-toggle-foreground);
|
||||
|
||||
/* Custom font size */
|
||||
--text-xxs: 11px;
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility container {
|
||||
margin-inline: auto;
|
||||
padding-inline: 2rem;
|
||||
@media (width >= --theme(--breakpoint-sm)) {
|
||||
max-width: none;
|
||||
}
|
||||
@media (width >= 1400px) {
|
||||
max-width: 1400px;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--border, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: var(--affine-v2-layer-background-primary);
|
||||
--foreground: var(--affine-v2-text-primary);
|
||||
|
||||
--card: var(--affine-v2-layer-background-secondary);
|
||||
--card-foreground: var(--affine-v2-text-primary);
|
||||
|
||||
--popover: var(--affine-v2-layer-background-overlayPanel);
|
||||
--popover-foreground: var(--affine-v2-text-primary);
|
||||
|
||||
--primary: var(--affine-v2-button-primary);
|
||||
--primary-foreground: var(--affine-v2-button-pureWhiteText);
|
||||
|
||||
--secondary: var(--affine-v2-layer-background-secondary);
|
||||
--secondary-foreground: var(--affine-v2-text-primary);
|
||||
|
||||
--muted: var(--affine-v2-layer-background-tertiary);
|
||||
--muted-foreground: var(--affine-v2-text-secondary);
|
||||
|
||||
--accent: var(--affine-v2-layer-background-hoverOverlay);
|
||||
--accent-foreground: var(--affine-v2-text-primary);
|
||||
|
||||
--destructive: var(--affine-v2-button-error);
|
||||
--destructive-foreground: var(--affine-v2-button-pureWhiteText);
|
||||
|
||||
--border: var(--affine-v2-layer-insideBorder-border);
|
||||
--input: var(--affine-v2-input-border-default);
|
||||
--ring: var(--affine-v2-input-border-active);
|
||||
--radius: var(--affine-popover-radius);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: var(--affine-font-family);
|
||||
}
|
||||
|
||||
/* Smooth scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Text selection */
|
||||
::selection {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
}
|
||||
4
packages/frontend/admin/src/global.d.ts
vendored
Normal file
4
packages/frontend/admin/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
27
packages/frontend/admin/src/global.spec.ts
Normal file
27
packages/frontend/admin/src/global.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
const css = readFileSync(new URL('./global.css', import.meta.url), 'utf8');
|
||||
|
||||
describe('admin global token mapping', () => {
|
||||
test('maps semantic colors to affine tokens', () => {
|
||||
expect(css).toContain(
|
||||
'--background: var(--affine-v2-layer-background-primary);'
|
||||
);
|
||||
expect(css).toContain('--foreground: var(--affine-v2-text-primary);');
|
||||
expect(css).toContain('--primary: var(--affine-v2-button-primary);');
|
||||
expect(css).toContain('--ring: var(--affine-v2-input-border-active);');
|
||||
expect(css).toContain('--radius: var(--affine-popover-radius);');
|
||||
});
|
||||
|
||||
test('does not keep hardcoded shadcn light/dark values', () => {
|
||||
expect(css).not.toContain('--background: 0 0% 100%;');
|
||||
expect(css).not.toContain('--foreground: 240 10% 3.9%;');
|
||||
expect(css).not.toContain('--background: 240 10% 3.9%;');
|
||||
});
|
||||
|
||||
test('supports data-theme based dark variant', () => {
|
||||
expect(css).toContain("[data-theme='dark']");
|
||||
});
|
||||
});
|
||||
17
packages/frontend/admin/src/hooks/use-debounced-value.ts
Normal file
17
packages/frontend/admin/src/hooks/use-debounced-value.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
9
packages/frontend/admin/src/index.tsx
Normal file
9
packages/frontend/admin/src/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import './global.css';
|
||||
import './setup';
|
||||
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { App } from './app';
|
||||
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
createRoot(document.getElementById('app')!).render(<App />);
|
||||
83
packages/frontend/admin/src/modules/about/about.tsx
Normal file
83
packages/frontend/admin/src/modules/about/about.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import {
|
||||
AlbumIcon,
|
||||
ChevronRightIcon,
|
||||
GithubIcon,
|
||||
MailWarningIcon,
|
||||
UploadCloudIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
type Channel = 'stable' | 'canary' | 'beta' | 'internal';
|
||||
|
||||
const appNames = {
|
||||
stable: 'AFFiNE',
|
||||
canary: 'AFFiNE Canary',
|
||||
beta: 'AFFiNE Beta',
|
||||
internal: 'AFFiNE Internal',
|
||||
} satisfies Record<Channel, string>;
|
||||
const appName = appNames[BUILD_CONFIG.appBuildType];
|
||||
|
||||
const links = [
|
||||
{
|
||||
href: BUILD_CONFIG.githubUrl,
|
||||
icon: <GithubIcon size={20} />,
|
||||
label: 'Star AFFiNE on GitHub',
|
||||
},
|
||||
{
|
||||
href: BUILD_CONFIG.githubUrl,
|
||||
icon: <MailWarningIcon size={20} />,
|
||||
label: 'Report an Issue',
|
||||
},
|
||||
{
|
||||
href: 'https://docs.affine.pro/docs/self-host-affine',
|
||||
icon: <AlbumIcon size={20} />,
|
||||
label: 'Self-host Document',
|
||||
},
|
||||
{
|
||||
href: 'https://affine.pro/pricing/?type=selfhost#table',
|
||||
icon: <UploadCloudIcon size={20} />,
|
||||
label: 'Upgrade to Team',
|
||||
},
|
||||
];
|
||||
|
||||
export function AboutAFFiNE() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl font-semibold">About AFFiNE</span>
|
||||
</div>
|
||||
<div className="overflow-y-auto space-y-[10px]">
|
||||
<div className="flex flex-col rounded-md border">
|
||||
{links.map(({ href, icon, label }, index) => (
|
||||
<div key={label + index}>
|
||||
<a
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'justify-between cursor-pointer w-full'
|
||||
)}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div>
|
||||
<ChevronRightIcon size={20} />
|
||||
</div>
|
||||
</a>
|
||||
{index < links.length - 1 && <Separator />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm font-normal text-muted-foreground">
|
||||
<div>{`App Version: ${appName} ${BUILD_CONFIG.appVersion}`}</div>
|
||||
<div>{`Editor Version: ${BUILD_CONFIG.editorVersion}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
packages/frontend/admin/src/modules/about/index.tsx
Normal file
17
packages/frontend/admin/src/modules/about/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||
|
||||
import { Header } from '../header';
|
||||
import { AboutAFFiNE } from './about';
|
||||
|
||||
export function ConfigPage() {
|
||||
return (
|
||||
<div className="h-dvh flex-1 space-y-1 flex-col flex">
|
||||
<Header title="Server" />
|
||||
<ScrollArea>
|
||||
<AboutAFFiNE />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ConfigPage as Component };
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@affine/admin/components/ui/avatar';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { FeatureType } from '@affine/graphql';
|
||||
import {
|
||||
AccountIcon,
|
||||
EmailIcon,
|
||||
EmailWarningIcon,
|
||||
LockIcon,
|
||||
UnlockIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
import { Checkbox } from '../../../components/ui/checkbox';
|
||||
import type { UserType } from '../schema';
|
||||
import { DataTableColumnHeader } from './data-table-column-header';
|
||||
import { DataTableRowActions } from './data-table-row-actions';
|
||||
|
||||
const StatusItem = ({
|
||||
condition,
|
||||
IconTrue,
|
||||
IconFalse,
|
||||
textTrue,
|
||||
textFalse,
|
||||
}: {
|
||||
condition: boolean | null;
|
||||
IconTrue: ReactNode;
|
||||
IconFalse: ReactNode;
|
||||
textTrue: string;
|
||||
textFalse: string;
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1',
|
||||
condition ? 'text-muted-foreground' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{condition ? (
|
||||
<>
|
||||
{IconTrue}
|
||||
{textTrue}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{IconFalse}
|
||||
{textFalse}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
export const useColumns = ({
|
||||
setSelectedUserIds,
|
||||
}: {
|
||||
setSelectedUserIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
}) => {
|
||||
const columns: ColumnDef<UserType>[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
meta: {
|
||||
className: 'w-[40px] flex-shrink-0',
|
||||
},
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
onCheckedChange={value => {
|
||||
if (value) {
|
||||
setSelectedUserIds(
|
||||
prev =>
|
||||
new Set([
|
||||
...prev,
|
||||
...table
|
||||
.getFilteredRowModel()
|
||||
.rows.map(row => row.original.id),
|
||||
])
|
||||
);
|
||||
} else {
|
||||
// remove selected users in the current page
|
||||
setSelectedUserIds(
|
||||
prev =>
|
||||
new Set(
|
||||
[...prev].filter(
|
||||
id =>
|
||||
!table
|
||||
.getFilteredRowModel()
|
||||
.rows.some(row => row.original.id === id)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
table.toggleAllPageRowsSelected(!!value);
|
||||
}}
|
||||
aria-label="Select all"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={value => {
|
||||
if (value) {
|
||||
setSelectedUserIds(prev => new Set([...prev, row.original.id]));
|
||||
} else {
|
||||
setSelectedUserIds(
|
||||
prev =>
|
||||
new Set([...prev].filter(id => id !== row.original.id))
|
||||
);
|
||||
}
|
||||
row.toggleSelected(!!value);
|
||||
}}
|
||||
aria-label="Select row"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'info',
|
||||
meta: {
|
||||
className: 'w-[250px] flex-shrink-0',
|
||||
},
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-xs"
|
||||
column={column}
|
||||
title="Name"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-4 items-center max-w-[50vw] overflow-hidden">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={row.original.avatarUrl ?? undefined} />
|
||||
<AvatarFallback>
|
||||
<AccountIcon fontSize={20} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1 max-w-full overflow-hidden">
|
||||
<div className="text-sm font-medium max-w-full overflow-hidden gap-[6px]">
|
||||
<span>{row.original.name}</span>
|
||||
{row.original.features.includes(FeatureType.Admin) && (
|
||||
<span className="ml-2 inline-flex h-5 items-center rounded-md border border-border/60 bg-chip-blue px-2 py-0.5 text-xxs font-medium text-chip-text">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
{row.original.disabled && (
|
||||
<span className="ml-2 inline-flex h-5 items-center rounded-md border border-border/60 bg-chip-white px-2 py-0.5 text-xxs font-medium">
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-w-full overflow-hidden text-xs font-medium text-muted-foreground">
|
||||
{row.original.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'property',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-xs max-md:hidden"
|
||||
column={column}
|
||||
title="User Detail"
|
||||
/>
|
||||
),
|
||||
cell: ({ row: { original: user } }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2 text-xs max-md:hidden">
|
||||
<div className="flex justify-start">{user.id}</div>
|
||||
<div className="flex gap-3 items-center justify-start">
|
||||
<StatusItem
|
||||
condition={user.hasPassword}
|
||||
IconTrue={
|
||||
<LockIcon fontSize={16} className="text-muted-foreground" />
|
||||
}
|
||||
IconFalse={
|
||||
<UnlockIcon fontSize={16} className="text-destructive" />
|
||||
}
|
||||
textTrue="Password Set"
|
||||
textFalse="No Password"
|
||||
/>
|
||||
<StatusItem
|
||||
condition={user.emailVerified}
|
||||
IconTrue={
|
||||
<EmailIcon
|
||||
fontSize={16}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
IconFalse={
|
||||
<EmailWarningIcon
|
||||
fontSize={16}
|
||||
className="text-destructive"
|
||||
/>
|
||||
}
|
||||
textTrue="Email Verified"
|
||||
textFalse="Email Not Verified"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{user.features.length ? (
|
||||
user.features.map(feature => (
|
||||
<span
|
||||
key={feature}
|
||||
className="inline-flex h-5 items-center rounded-md border border-border/60 bg-chip-white px-2 py-0.5 text-xxs font-medium"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">No features</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
meta: {
|
||||
className: 'w-[80px]',
|
||||
},
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
className="text-xs"
|
||||
column={column}
|
||||
title="Actions"
|
||||
/>
|
||||
),
|
||||
cell: ({ row: { original: user } }) => (
|
||||
<DataTableRowActions user={user} />
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [setSelectedUserIds]);
|
||||
return columns;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
|
||||
import { cn } from '../../../utils';
|
||||
|
||||
interface DataTableColumnHeaderProps<
|
||||
TData,
|
||||
TValue,
|
||||
> extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>;
|
||||
}
|
||||
|
||||
// TODO(@Jimmfly): add sort
|
||||
return <div className={cn(className)}>{title}</div>;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@affine/admin/components/ui/dropdown-menu';
|
||||
import {
|
||||
AccountBanIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
LockIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { DiscardChanges } from '../../../components/shared/discard-changes';
|
||||
import { useRightPanel } from '../../panel/context';
|
||||
import type { UserType } from '../schema';
|
||||
import { DeleteAccountDialog } from './delete-account';
|
||||
import { DisableAccountDialog } from './disable-account';
|
||||
import { EnableAccountDialog } from './enable-account';
|
||||
import { ResetPasswordDialog } from './reset-password';
|
||||
import {
|
||||
useDeleteUser,
|
||||
useDisableUser,
|
||||
useEnableUser,
|
||||
useResetUserPassword,
|
||||
} from './use-user-management';
|
||||
import { UpdateUserForm } from './user-form';
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
user: UserType;
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
|
||||
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
|
||||
const [enableDialogOpen, setEnableDialogOpen] = useState(false);
|
||||
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
|
||||
const {
|
||||
openPanel,
|
||||
isOpen,
|
||||
closePanel,
|
||||
setPanelContent,
|
||||
hasDirtyChanges,
|
||||
setHasDirtyChanges,
|
||||
} = useRightPanel();
|
||||
|
||||
const deleteUser = useDeleteUser();
|
||||
const disableUser = useDisableUser();
|
||||
const enableUser = useEnableUser();
|
||||
const { resetPasswordLink, onResetPassword } = useResetUserPassword();
|
||||
|
||||
const openResetPasswordDialog = useCallback(() => {
|
||||
onResetPassword(user.id, () => setResetPasswordDialogOpen(true)).catch(
|
||||
e => {
|
||||
console.error(e);
|
||||
}
|
||||
);
|
||||
}, [onResetPassword, user.id]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard
|
||||
.writeText(resetPasswordLink)
|
||||
.then(() => {
|
||||
toast('Reset password link copied to clipboard');
|
||||
setResetPasswordDialogOpen(false);
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to copy reset password link: ' + e.message);
|
||||
});
|
||||
}, [resetPasswordLink]);
|
||||
|
||||
const handleDeleting = useCallback(() => {
|
||||
if (isOpen) {
|
||||
closePanel();
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
}, [closePanel, isOpen]);
|
||||
const handleDisabling = useCallback(() => {
|
||||
if (isOpen) {
|
||||
closePanel();
|
||||
}
|
||||
setDisableDialogOpen(false);
|
||||
}, [closePanel, isOpen]);
|
||||
const handleEnabling = useCallback(() => {
|
||||
if (isOpen) {
|
||||
closePanel();
|
||||
}
|
||||
setEnableDialogOpen(false);
|
||||
}, [closePanel, isOpen]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
deleteUser(user.id, handleDeleting);
|
||||
}, [deleteUser, handleDeleting, user.id]);
|
||||
const handleDisable = useCallback(() => {
|
||||
disableUser(user.id, handleDisabling);
|
||||
}, [disableUser, handleDisabling, user.id]);
|
||||
const handleEnable = useCallback(() => {
|
||||
enableUser(user.id, handleEnabling);
|
||||
}, [enableUser, handleEnabling, user.id]);
|
||||
|
||||
const openDeleteDialog = useCallback(() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
const closeDeleteDialog = useCallback(() => {
|
||||
setDeleteDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const openDisableDialog = useCallback(() => {
|
||||
setDisableDialogOpen(true);
|
||||
}, []);
|
||||
const closeDisableDialog = useCallback(() => {
|
||||
setDisableDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const openEnableDialog = useCallback(() => {
|
||||
setEnableDialogOpen(true);
|
||||
}, []);
|
||||
const closeEnableDialog = useCallback(() => {
|
||||
setEnableDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setHasDirtyChanges(false);
|
||||
setPanelContent(
|
||||
<UpdateUserForm
|
||||
user={user}
|
||||
onComplete={closePanel}
|
||||
onResetPassword={openResetPasswordDialog}
|
||||
onDeleteAccount={openDeleteDialog}
|
||||
onDirtyChange={setHasDirtyChanges}
|
||||
/>
|
||||
);
|
||||
openPanel();
|
||||
}, [
|
||||
closePanel,
|
||||
openDeleteDialog,
|
||||
openPanel,
|
||||
openResetPasswordDialog,
|
||||
setPanelContent,
|
||||
user,
|
||||
setHasDirtyChanges,
|
||||
]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
if (hasDirtyChanges) {
|
||||
setDiscardDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
setHasDirtyChanges(false);
|
||||
handleConfirm();
|
||||
}, [handleConfirm, hasDirtyChanges, setHasDirtyChanges]);
|
||||
|
||||
const handleDiscardConfirm = useCallback(() => {
|
||||
setDiscardDialogOpen(false);
|
||||
setHasDirtyChanges(false);
|
||||
handleConfirm();
|
||||
}, [handleConfirm, setHasDirtyChanges]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex h-8 w-8 p-0 data-[state=open]:bg-accent"
|
||||
size="icon"
|
||||
>
|
||||
<MoreHorizontalIcon fontSize={20} />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[214px] p-1.5">
|
||||
<DropdownMenuItem
|
||||
onSelect={handleEdit}
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
||||
>
|
||||
<EditIcon fontSize={20} />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
||||
onSelect={openResetPasswordDialog}
|
||||
>
|
||||
<LockIcon fontSize={20} />
|
||||
{user.hasPassword ? 'Reset Password' : 'Setup Account'}
|
||||
</DropdownMenuItem>
|
||||
{user.disabled && (
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
||||
onSelect={openEnableDialog}
|
||||
>
|
||||
<AccountBanIcon fontSize={20} />
|
||||
Enable Email
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{!user.disabled && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-2 px-2 py-[6px] text-sm font-normal text-destructive focus:text-destructive"
|
||||
onSelect={openDisableDialog}
|
||||
>
|
||||
<AccountBanIcon fontSize={20} />
|
||||
Disable & Delete data
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer gap-2 px-2 py-[6px] text-sm font-normal text-destructive focus:text-destructive"
|
||||
onSelect={openDeleteDialog}
|
||||
>
|
||||
<DeleteIcon fontSize={20} />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteAccountDialog
|
||||
email={user.email}
|
||||
open={deleteDialogOpen}
|
||||
onClose={closeDeleteDialog}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
<DisableAccountDialog
|
||||
email={user.email}
|
||||
open={disableDialogOpen}
|
||||
onClose={closeDisableDialog}
|
||||
onOpenChange={setDisableDialogOpen}
|
||||
onDisable={handleDisable}
|
||||
/>
|
||||
<EnableAccountDialog
|
||||
email={user.email}
|
||||
open={enableDialogOpen}
|
||||
onClose={closeEnableDialog}
|
||||
onOpenChange={setEnableDialogOpen}
|
||||
onConfirm={handleEnable}
|
||||
/>
|
||||
<ResetPasswordDialog
|
||||
link={resetPasswordLink}
|
||||
open={resetPasswordDialogOpen}
|
||||
onOpenChange={setResetPasswordDialogOpen}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
<DiscardChanges
|
||||
open={discardDialogOpen}
|
||||
onOpenChange={setDiscardDialogOpen}
|
||||
onClose={() => setDiscardDialogOpen(false)}
|
||||
onConfirm={handleDiscardConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import type { FeatureType } from '@affine/graphql';
|
||||
import { ExportIcon, ImportIcon, PlusIcon } from '@blocksuite/icons/rc';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { DiscardChanges } from '../../../components/shared/discard-changes';
|
||||
import { FeatureFilterPopover } from '../../../components/shared/feature-filter-popover';
|
||||
import { useDebouncedValue } from '../../../hooks/use-debounced-value';
|
||||
import { useServerConfig } from '../../common';
|
||||
import { useRightPanel } from '../../panel/context';
|
||||
import type { UserType } from '../schema';
|
||||
import { ExportUsersDialog } from './export-users-dialog';
|
||||
import { ImportUsersDialog } from './import-users';
|
||||
import { CreateUserForm } from './user-form';
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
selectedUsers: UserType[];
|
||||
table?: Table<TData>;
|
||||
keyword: string;
|
||||
onKeywordChange: Dispatch<SetStateAction<string>>;
|
||||
selectedFeatures: FeatureType[];
|
||||
onFeaturesChange: Dispatch<SetStateAction<FeatureType[]>>;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
selectedUsers,
|
||||
table,
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
selectedFeatures,
|
||||
onFeaturesChange,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const [value, setValue] = useState(keyword);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const debouncedValue = useDebouncedValue(value, 500);
|
||||
const {
|
||||
setPanelContent,
|
||||
openPanel,
|
||||
closePanel,
|
||||
isOpen,
|
||||
hasDirtyChanges,
|
||||
setHasDirtyChanges,
|
||||
} = useRightPanel();
|
||||
const serverConfig = useServerConfig();
|
||||
const availableFeatures = serverConfig.availableUserFeatures ?? [];
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setPanelContent(
|
||||
<CreateUserForm
|
||||
onComplete={closePanel}
|
||||
onDirtyChange={setHasDirtyChanges}
|
||||
/>
|
||||
);
|
||||
if (dialogOpen) {
|
||||
setDialogOpen(false);
|
||||
}
|
||||
if (!isOpen) {
|
||||
openPanel();
|
||||
}
|
||||
}, [
|
||||
setPanelContent,
|
||||
closePanel,
|
||||
dialogOpen,
|
||||
isOpen,
|
||||
openPanel,
|
||||
setHasDirtyChanges,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(keyword);
|
||||
}, [keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
onKeywordChange(debouncedValue.trim());
|
||||
}, [debouncedValue, onKeywordChange]);
|
||||
|
||||
const onValueChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.currentTarget.value);
|
||||
}, []);
|
||||
|
||||
const handleFeatureToggle = useCallback(
|
||||
(features: FeatureType[]) => {
|
||||
onFeaturesChange(features);
|
||||
},
|
||||
[onFeaturesChange]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenConfirm = useCallback(() => {
|
||||
if (hasDirtyChanges) {
|
||||
return setDialogOpen(true);
|
||||
}
|
||||
return handleConfirm();
|
||||
}, [handleConfirm, hasDirtyChanges]);
|
||||
|
||||
const handleExportUsers = useCallback(() => {
|
||||
if (!table) return;
|
||||
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
|
||||
if (selectedRows.length === 0) {
|
||||
alert('Please select at least one user to export');
|
||||
return;
|
||||
}
|
||||
|
||||
setExportDialogOpen(true);
|
||||
}, [table]);
|
||||
|
||||
const handleImportUsers = useCallback(() => {
|
||||
setImportDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-y-2 gap-x-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 lg:px-3"
|
||||
onClick={handleImportUsers}
|
||||
>
|
||||
<ImportIcon fontSize={20} />
|
||||
<span className="ml-2 hidden md:inline-block">Import</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 lg:px-3"
|
||||
onClick={handleExportUsers}
|
||||
disabled={
|
||||
!table || table.getFilteredSelectedRowModel().rows.length === 0
|
||||
}
|
||||
>
|
||||
<ExportIcon fontSize={20} />
|
||||
<span className="ml-2 hidden md:inline-block">Export</span>
|
||||
</Button>
|
||||
|
||||
{table && (
|
||||
<ExportUsersDialog
|
||||
users={selectedUsers}
|
||||
open={exportDialogOpen}
|
||||
onOpenChange={setExportDialogOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ImportUsersDialog
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
|
||||
<FeatureFilterPopover
|
||||
selectedFeatures={selectedFeatures}
|
||||
availableFeatures={availableFeatures}
|
||||
onChange={handleFeatureToggle}
|
||||
align="end"
|
||||
/>
|
||||
<div className="flex">
|
||||
<Input
|
||||
placeholder="Search Email / UUID"
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
className="h-8 w-[150px] lg:w-[250px]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 px-2 lg:px-3 space-x-[6px] text-sm font-medium"
|
||||
onClick={handleOpenConfirm}
|
||||
>
|
||||
<PlusIcon fontSize={20} /> <span>Add User</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DiscardChanges
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onClose={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { FeatureType } from '@affine/graphql';
|
||||
import type {
|
||||
ColumnDef,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
} from '@tanstack/react-table';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
import { SharedDataTable } from '../../../components/shared/data-table';
|
||||
import type { UserType } from '../schema';
|
||||
import { DataTableToolbar } from './data-table-toolbar';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pagination: PaginationState;
|
||||
usersCount: number;
|
||||
selectedUsers: UserType[];
|
||||
keyword: string;
|
||||
onKeywordChange: Dispatch<SetStateAction<string>>;
|
||||
selectedFeatures: FeatureType[];
|
||||
onFeaturesChange: Dispatch<SetStateAction<FeatureType[]>>;
|
||||
onPaginationChange: Dispatch<
|
||||
SetStateAction<{
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export function DataTable<TData extends { id: string }, TValue>({
|
||||
columns,
|
||||
data,
|
||||
pagination,
|
||||
usersCount,
|
||||
selectedUsers,
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
selectedFeatures,
|
||||
onFeaturesChange,
|
||||
onPaginationChange,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [keyword, selectedFeatures]);
|
||||
|
||||
useEffect(() => {
|
||||
const selection: Record<string, boolean> = {};
|
||||
selectedUsers.forEach(user => {
|
||||
selection[user.id] = true;
|
||||
});
|
||||
setRowSelection(selection);
|
||||
}, [selectedUsers]);
|
||||
|
||||
return (
|
||||
<SharedDataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
totalCount={usersCount}
|
||||
pagination={pagination}
|
||||
onPaginationChange={onPaginationChange}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
resetFiltersDeps={[keyword, selectedFeatures]}
|
||||
renderToolbar={table => (
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
selectedUsers={selectedUsers}
|
||||
keyword={keyword}
|
||||
onKeywordChange={onKeywordChange}
|
||||
selectedFeatures={selectedFeatures}
|
||||
onFeaturesChange={onFeaturesChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { TypeConfirmDialog } from '../../../components/shared/type-confirm-dialog';
|
||||
|
||||
export const DeleteAccountDialog = ({
|
||||
email,
|
||||
open,
|
||||
onClose,
|
||||
onDelete,
|
||||
onOpenChange,
|
||||
}: {
|
||||
email: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<TypeConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Delete Account ?"
|
||||
description={
|
||||
<>
|
||||
<span className="font-bold">{email}</span> will be permanently
|
||||
deleted. This operation is irreversible. Please proceed with caution.
|
||||
</>
|
||||
}
|
||||
targetText={email}
|
||||
inputPlaceholder="Please type email to confirm"
|
||||
confirmText="Delete"
|
||||
confirmButtonVariant="destructive"
|
||||
onConfirm={onDelete}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { TypeConfirmDialog } from '../../../components/shared/type-confirm-dialog';
|
||||
|
||||
export const DisableAccountDialog = ({
|
||||
email,
|
||||
open,
|
||||
onClose,
|
||||
onDisable,
|
||||
onOpenChange,
|
||||
}: {
|
||||
email: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDisable: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<TypeConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Disable Account ?"
|
||||
description={
|
||||
<>
|
||||
The data associated with <span className="font-bold">{email}</span>{' '}
|
||||
will be deleted and cannot be used for logging in. This operation is
|
||||
irreversible. Please proceed with caution.
|
||||
</>
|
||||
}
|
||||
targetText={email}
|
||||
inputPlaceholder="Please type email to confirm"
|
||||
confirmText="Disable"
|
||||
confirmButtonVariant="destructive"
|
||||
onConfirm={onDisable}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ConfirmDialog } from '../../../components/shared/confirm-dialog';
|
||||
|
||||
export const EnableAccountDialog = ({
|
||||
open,
|
||||
email,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
email: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Enable Account"
|
||||
description={
|
||||
<>
|
||||
Are you sure you want to enable the account? After enabling the
|
||||
account, the <span className="font-bold">{email}</span> email can be
|
||||
used to log in.
|
||||
</>
|
||||
}
|
||||
confirmText="Enable"
|
||||
confirmButtonVariant="default"
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Checkbox } from '@affine/admin/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import { Label } from '@affine/admin/components/ui/label';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { CopyIcon } from '@blocksuite/icons/rc';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { UserType } from '../schema';
|
||||
import { type ExportField, useExportUsers } from './use-user-management';
|
||||
|
||||
interface ExportUsersDialogProps {
|
||||
users: UserType[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ExportUsersDialog({
|
||||
users,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ExportUsersDialogProps) {
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [fields, setFields] = useState<ExportField[]>([
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Username',
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'Email',
|
||||
checked: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(id: string, checked: boolean) => {
|
||||
setFields(
|
||||
fields.map(field => (field.id === id ? { ...field, checked } : field))
|
||||
);
|
||||
},
|
||||
[fields]
|
||||
);
|
||||
|
||||
const { exportCSV, copyToClipboard } = useExportUsers();
|
||||
|
||||
const handleExport = useAsyncCallback(async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
await exportCSV(users, fields, () => {
|
||||
setIsExporting(false);
|
||||
onOpenChange(false);
|
||||
toast('Users exported successfully');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to export users', error);
|
||||
toast.error('Failed to export users');
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [exportCSV, fields, onOpenChange, users]);
|
||||
|
||||
const handleCopy = useAsyncCallback(async () => {
|
||||
setIsCopying(true);
|
||||
try {
|
||||
await copyToClipboard(users, fields, () => {
|
||||
setIsCopying(false);
|
||||
onOpenChange(false);
|
||||
toast('Users copied successfully');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to copy users', error);
|
||||
toast.error('Failed to copy users');
|
||||
setIsCopying(false);
|
||||
}
|
||||
}, [copyToClipboard, fields, onOpenChange, users]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{fields.map(field => (
|
||||
<div key={field.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`export-${field.id}`}
|
||||
checked={field.checked}
|
||||
onCheckedChange={checked =>
|
||||
handleFieldChange(field.id, !!checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`export-${field.id}`}>{field.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
className="w-full text-[15px] px-4 py-2 h-10"
|
||||
disabled={isExporting || isCopying}
|
||||
>
|
||||
{isExporting ? 'Exporting...' : 'Download account information'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="p-5"
|
||||
onClick={handleCopy}
|
||||
disabled={isExporting || isCopying}
|
||||
>
|
||||
<CopyIcon fontSize={20} />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { WarningIcon } from '@blocksuite/icons/rc';
|
||||
import type { FC } from 'react';
|
||||
|
||||
interface CsvFormatGuidanceProps {
|
||||
passwordLimits: {
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays CSV format guidelines
|
||||
*/
|
||||
export const CsvFormatGuidance: FC<CsvFormatGuidanceProps> = ({
|
||||
passwordLimits,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex gap-1 rounded-[6px] bg-secondary p-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex justify-center py-0.5">
|
||||
<WarningIcon fontSize={16} className="text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p>CSV file includes username, email, and password.</p>
|
||||
<ul>
|
||||
{[
|
||||
`Username (optional): any text.`,
|
||||
`Email (required): e.g., user@example.com.`,
|
||||
`Password (optional): ${passwordLimits.minLength}–${passwordLimits.maxLength} characters.`,
|
||||
].map((text, index) => (
|
||||
<li
|
||||
key={`guidance-${index}`}
|
||||
className="relative pl-2 leading-normal"
|
||||
>
|
||||
<span className="absolute left-0 top-2 w-1 h-1 rounded-full bg-current" />
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { UploadIcon } from '@blocksuite/icons/rc';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type DragEvent,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface FileUploadAreaProps {
|
||||
onFileSelected: (file: File) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface FileUploadAreaRef {
|
||||
triggerFileUpload: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for CSV file upload with drag and drop support
|
||||
*/
|
||||
export const FileUploadArea = forwardRef<
|
||||
FileUploadAreaRef,
|
||||
FileUploadAreaProps
|
||||
>(({ onFileSelected }, ref) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileUpload = useAsyncCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
await onFileSelected(file);
|
||||
},
|
||||
[onFileSelected]
|
||||
);
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
triggerFileUpload: triggerFileInput,
|
||||
}));
|
||||
|
||||
const validateAndProcessFile = useAsyncCallback(
|
||||
async (file: File) => {
|
||||
if (file.type !== 'text/csv' && !file.name.endsWith('.csv')) {
|
||||
toast.error('Please upload a CSV file');
|
||||
return;
|
||||
}
|
||||
await onFileSelected(file);
|
||||
},
|
||||
[onFileSelected]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length === 0) return;
|
||||
|
||||
validateAndProcessFile(files[0]);
|
||||
},
|
||||
[validateAndProcessFile]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-center rounded-[6px] border-2 border-dashed p-8 transition-colors ${
|
||||
isDragging
|
||||
? 'border-ring bg-accent/40'
|
||||
: 'border-border hover:border-ring/50'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={triggerFileInput}
|
||||
>
|
||||
<div className="text-center">
|
||||
<UploadIcon
|
||||
fontSize={24}
|
||||
className="mx-auto mb-3 text-muted-foreground"
|
||||
/>
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{isDragging
|
||||
? 'Release mouse to upload file'
|
||||
: 'Upload your CSV file or drag it here'}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{isDragging ? 'Preparing to upload...' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FileUploadArea.displayName = 'FileUploadArea';
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { FC, RefObject } from 'react';
|
||||
|
||||
import type { ParsedUser } from '../../utils/csv-utils';
|
||||
import { UserTable } from '../user-table';
|
||||
import { CsvFormatGuidance } from './csv-format-guidance';
|
||||
import { FileUploadArea, type FileUploadAreaRef } from './file-upload-area';
|
||||
|
||||
interface ImportPreviewContentProps {
|
||||
parsedUsers: ParsedUser[];
|
||||
isImported: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the preview mode content
|
||||
*/
|
||||
export const ImportPreviewContent: FC<ImportPreviewContentProps> = ({
|
||||
parsedUsers,
|
||||
isImported,
|
||||
}) => {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{!isImported && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{parsedUsers.length} users detected from the CSV file. Please confirm
|
||||
the user list below and import.
|
||||
</p>
|
||||
)}
|
||||
<UserTable users={parsedUsers} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImportInitialContentProps {
|
||||
passwordLimits: {
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
};
|
||||
fileUploadRef: RefObject<FileUploadAreaRef | null>;
|
||||
onFileSelected: (file: File) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the initial import screen
|
||||
*/
|
||||
export const ImportInitialContent: FC<ImportInitialContentProps> = ({
|
||||
passwordLimits,
|
||||
fileUploadRef,
|
||||
onFileSelected,
|
||||
}) => {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You need to import the accounts by importing a CSV file in the correct
|
||||
format. Please download the CSV template.
|
||||
</p>
|
||||
<CsvFormatGuidance passwordLimits={passwordLimits} />
|
||||
<FileUploadArea ref={fileUploadRef} onFileSelected={onFileSelected} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImportErrorContentProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying import errors
|
||||
*/
|
||||
export const ImportErrorContent: FC<ImportErrorContentProps> = ({
|
||||
message = 'You need to import the accounts by importing a CSV file in the correct format. Please download the CSV template.',
|
||||
}) => {
|
||||
return message;
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { DialogFooter } from '@affine/admin/components/ui/dialog';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { downloadCsvTemplate, ImportStatus } from '../../utils/csv-utils';
|
||||
|
||||
interface ImportUsersFooterProps {
|
||||
isFormatError: boolean;
|
||||
isPreviewMode: boolean;
|
||||
isImporting: boolean;
|
||||
isImported: boolean;
|
||||
resetFormatError: () => void;
|
||||
cancelImport: () => void;
|
||||
handleConfirm: () => void;
|
||||
handleUpload: () => void;
|
||||
parsedUsers: {
|
||||
importStatus?: ImportStatus;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the dialog footer with appropriate buttons based on the import state
|
||||
*/
|
||||
export const ImportUsersFooter: FC<ImportUsersFooterProps> = ({
|
||||
isFormatError,
|
||||
isPreviewMode,
|
||||
isImporting,
|
||||
isImported,
|
||||
resetFormatError,
|
||||
cancelImport,
|
||||
handleConfirm,
|
||||
handleUpload,
|
||||
parsedUsers,
|
||||
}) => {
|
||||
return (
|
||||
<DialogFooter
|
||||
className={`flex-col mt-6 sm:flex-row sm:justify-between items-center ${
|
||||
isPreviewMode ? 'sm:justify-end' : 'sm:justify-between'
|
||||
}`}
|
||||
>
|
||||
{isFormatError ? (
|
||||
<FormatErrorFooter
|
||||
downloadCsvTemplate={downloadCsvTemplate}
|
||||
resetFormatError={resetFormatError}
|
||||
/>
|
||||
) : isPreviewMode ? (
|
||||
<PreviewModeFooter
|
||||
isImporting={isImporting}
|
||||
isImported={isImported}
|
||||
cancelImport={cancelImport}
|
||||
handleConfirm={handleConfirm}
|
||||
parsedUsers={parsedUsers}
|
||||
/>
|
||||
) : (
|
||||
<InitialFooter
|
||||
isImporting={isImporting}
|
||||
downloadCsvTemplate={downloadCsvTemplate}
|
||||
handleUpload={handleUpload}
|
||||
/>
|
||||
)}
|
||||
</DialogFooter>
|
||||
);
|
||||
};
|
||||
|
||||
interface FormatErrorFooterProps {
|
||||
downloadCsvTemplate: () => void;
|
||||
resetFormatError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer component when there's a format error
|
||||
*/
|
||||
const FormatErrorFooter: FC<FormatErrorFooterProps> = ({
|
||||
downloadCsvTemplate,
|
||||
resetFormatError,
|
||||
}) => (
|
||||
<>
|
||||
<div
|
||||
onClick={downloadCsvTemplate}
|
||||
className="mb-2 sm:mb-0 text-[15px] px-0 py-2 h-10 underline cursor-pointer"
|
||||
>
|
||||
CSV template
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetFormatError}
|
||||
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
interface PreviewModeFooterProps {
|
||||
isImporting: boolean;
|
||||
isImported: boolean;
|
||||
cancelImport: () => void;
|
||||
handleConfirm: () => void;
|
||||
parsedUsers: {
|
||||
importStatus?: ImportStatus;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer component for preview mode
|
||||
*/
|
||||
const PreviewModeFooter: FC<PreviewModeFooterProps> = ({
|
||||
isImporting,
|
||||
isImported,
|
||||
cancelImport,
|
||||
handleConfirm,
|
||||
parsedUsers,
|
||||
}) => (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={cancelImport}
|
||||
className="w-full mb-2 sm:mb-0 sm:w-auto"
|
||||
disabled={isImporting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
|
||||
disabled={
|
||||
isImporting ||
|
||||
parsedUsers.some(user => user.importStatus === ImportStatus.Processing)
|
||||
}
|
||||
>
|
||||
{isImporting ? 'Importing...' : isImported ? 'Export' : 'Confirm Import'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
interface InitialFooterProps {
|
||||
isImporting: boolean;
|
||||
downloadCsvTemplate: () => void;
|
||||
handleUpload: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer component for initial state
|
||||
*/
|
||||
const InitialFooter: FC<InitialFooterProps> = ({
|
||||
isImporting,
|
||||
downloadCsvTemplate,
|
||||
handleUpload,
|
||||
}) => (
|
||||
<>
|
||||
<div
|
||||
onClick={downloadCsvTemplate}
|
||||
className="mb-2 sm:mb-0 underline text-[15px] cursor-pointer"
|
||||
>
|
||||
CSV template
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
|
||||
disabled={isImporting}
|
||||
>
|
||||
{isImporting ? 'Parsing...' : 'Choose a file'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useServerConfig } from '../../../common';
|
||||
import type { FileUploadAreaRef } from './file-upload-area';
|
||||
import {
|
||||
ImportErrorContent,
|
||||
ImportInitialContent,
|
||||
ImportPreviewContent,
|
||||
} from './import-content';
|
||||
import { ImportUsersFooter } from './import-footer';
|
||||
import { useImportUsersState } from './use-import-users-state';
|
||||
|
||||
export interface ImportUsersDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for importing users from a CSV file
|
||||
*/
|
||||
export function ImportUsersDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ImportUsersDialogProps) {
|
||||
const fileUploadRef = useRef<FileUploadAreaRef>(null);
|
||||
const serverConfig = useServerConfig();
|
||||
const passwordLimits = serverConfig.credentialsRequirement.password;
|
||||
|
||||
const handleUpload = () => fileUploadRef.current?.triggerFileUpload();
|
||||
|
||||
const {
|
||||
isImporting,
|
||||
parsedUsers,
|
||||
isPreviewMode,
|
||||
isFormatError,
|
||||
isImported,
|
||||
handleFileSelected,
|
||||
cancelImport,
|
||||
resetFormatError,
|
||||
handleConfirm,
|
||||
resetState,
|
||||
} = useImportUsersState({
|
||||
passwordLimits,
|
||||
onClose: () => onOpenChange(false),
|
||||
});
|
||||
|
||||
// Reset all states when dialog is opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState();
|
||||
}
|
||||
}, [open, resetState]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={
|
||||
isPreviewMode ? 'sm:max-w-[720px] flex-col' : 'sm:max-w-[480px]'
|
||||
}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isFormatError
|
||||
? 'Incorrect import format'
|
||||
: isPreviewMode
|
||||
? isImported
|
||||
? 'Import results'
|
||||
: 'Confirm import'
|
||||
: 'Import'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-[15px] mt-3">
|
||||
{isFormatError ? (
|
||||
<ImportErrorContent />
|
||||
) : isPreviewMode ? (
|
||||
<ImportPreviewContent
|
||||
parsedUsers={parsedUsers}
|
||||
isImported={isImported}
|
||||
/>
|
||||
) : (
|
||||
<ImportInitialContent
|
||||
passwordLimits={passwordLimits}
|
||||
fileUploadRef={fileUploadRef}
|
||||
onFileSelected={handleFileSelected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ImportUsersFooter
|
||||
isFormatError={isFormatError}
|
||||
isPreviewMode={isPreviewMode}
|
||||
isImporting={isImporting}
|
||||
isImported={isImported}
|
||||
resetFormatError={resetFormatError}
|
||||
cancelImport={cancelImport}
|
||||
handleConfirm={handleConfirm}
|
||||
handleUpload={handleUpload}
|
||||
parsedUsers={parsedUsers}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
exportImportResults,
|
||||
getValidUsersToImport,
|
||||
ImportStatus,
|
||||
type ParsedUser,
|
||||
processCSVFile,
|
||||
validateUsers,
|
||||
} from '../../utils/csv-utils';
|
||||
import {
|
||||
useImportUsers,
|
||||
type UserImportReturnType,
|
||||
} from '../use-user-management';
|
||||
|
||||
export interface ImportUsersStateProps {
|
||||
passwordLimits: {
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
};
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function useImportUsersState({
|
||||
passwordLimits,
|
||||
onClose,
|
||||
}: ImportUsersStateProps) {
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([]);
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
const [isFormatError, setIsFormatError] = useState(false);
|
||||
const importUsers = useImportUsers();
|
||||
|
||||
// Reset all states when dialog is closed
|
||||
const resetState = useCallback(() => {
|
||||
setIsPreviewMode(false);
|
||||
setParsedUsers([]);
|
||||
setIsImporting(false);
|
||||
setIsFormatError(false);
|
||||
}, []);
|
||||
|
||||
const importUsersCallback = useCallback(
|
||||
(result: UserImportReturnType) => {
|
||||
const successfulUsers = result.filter(
|
||||
(user): user is Extract<typeof user, { __typename: 'UserType' }> =>
|
||||
user.__typename === 'UserType'
|
||||
);
|
||||
|
||||
const failedUsers = result.filter(
|
||||
(
|
||||
user
|
||||
): user is Extract<
|
||||
typeof user,
|
||||
{ __typename: 'UserImportFailedType' }
|
||||
> => user.__typename === 'UserImportFailedType'
|
||||
);
|
||||
|
||||
const successCount = successfulUsers.length;
|
||||
const failedCount = parsedUsers.length - successCount;
|
||||
|
||||
if (failedCount > 0) {
|
||||
toast.info(
|
||||
`Successfully imported ${successCount} users, ${failedCount} failed`
|
||||
);
|
||||
} else {
|
||||
toast.success(`Successfully imported ${successCount} users`);
|
||||
}
|
||||
|
||||
const successfulUserEmails = new Set(
|
||||
successfulUsers.map(user => user.email)
|
||||
);
|
||||
|
||||
const failedUserErrorMap = new Map(
|
||||
failedUsers.map(user => [user.email, user.error])
|
||||
);
|
||||
|
||||
setParsedUsers(prev => {
|
||||
return prev.map(user => {
|
||||
if (successfulUserEmails.has(user.email)) {
|
||||
return {
|
||||
...user,
|
||||
importStatus: ImportStatus.Success,
|
||||
};
|
||||
}
|
||||
|
||||
const errorMessage = failedUserErrorMap.get(user.email) || user.error;
|
||||
return {
|
||||
...user,
|
||||
importStatus: ImportStatus.Failed,
|
||||
importError: errorMessage,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
setIsImporting(false);
|
||||
},
|
||||
[parsedUsers.length]
|
||||
);
|
||||
|
||||
const handleFileSelected = useCallback(
|
||||
async (file: File) => {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
await processCSVFile(
|
||||
file,
|
||||
usersFromCsv => {
|
||||
const validatedUsers = validateUsers(usersFromCsv, passwordLimits);
|
||||
setParsedUsers(validatedUsers);
|
||||
setIsPreviewMode(true);
|
||||
setIsImporting(false);
|
||||
},
|
||||
() => {
|
||||
setIsImporting(false);
|
||||
setIsFormatError(true);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to process file', error);
|
||||
setIsImporting(false);
|
||||
setIsFormatError(true);
|
||||
}
|
||||
},
|
||||
[passwordLimits]
|
||||
);
|
||||
|
||||
const confirmImport = useAsyncCallback(async () => {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const validUsersToImport = getValidUsersToImport(parsedUsers);
|
||||
|
||||
setParsedUsers(prev =>
|
||||
prev.map(user => ({
|
||||
...user,
|
||||
importStatus: ImportStatus.Processing,
|
||||
}))
|
||||
);
|
||||
|
||||
await importUsers({ users: validUsersToImport }, importUsersCallback);
|
||||
} catch (error) {
|
||||
console.error('Failed to import users', error);
|
||||
toast.error('Failed to import users');
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [importUsers, importUsersCallback, parsedUsers]);
|
||||
|
||||
const cancelImport = useCallback(() => {
|
||||
setIsPreviewMode(false);
|
||||
setParsedUsers([]);
|
||||
}, []);
|
||||
|
||||
const resetFormatError = useCallback(() => {
|
||||
setIsFormatError(false);
|
||||
}, []);
|
||||
|
||||
// Handle closing the dialog after import is complete
|
||||
const handleDone = useCallback(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [onClose, resetState]);
|
||||
|
||||
// Export failed imports to CSV
|
||||
const exportResult = useCallback(() => {
|
||||
exportImportResults(parsedUsers);
|
||||
}, [parsedUsers]);
|
||||
|
||||
const isImported = !!parsedUsers.some(
|
||||
user => user.importStatus && user.importStatus !== ImportStatus.Processing
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (isImported) {
|
||||
exportResult();
|
||||
handleDone();
|
||||
} else {
|
||||
confirmImport();
|
||||
}
|
||||
}, [confirmImport, exportResult, handleDone, isImported]);
|
||||
|
||||
return {
|
||||
isImporting,
|
||||
parsedUsers,
|
||||
isPreviewMode,
|
||||
isFormatError,
|
||||
isImported,
|
||||
handleFileSelected,
|
||||
cancelImport,
|
||||
resetFormatError,
|
||||
handleConfirm,
|
||||
resetState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const Logo = () => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18.6172 16.2657C18.314 15.7224 17.8091 14.8204 17.3102 13.9295C17.1589 13.6591 17.0086 13.3904 16.8644 13.1326C16.5679 12.6025 16.2978 12.1191 16.1052 11.7741C14.7688 9.38998 12.1376 4.66958 10.823 2.33541C10.418 1.68481 9.47943 1.73636 9.13092 2.4101C8.73553 3.1175 8.3004 3.89538 7.84081 4.71744C7.69509 4.97831 7.54631 5.24392 7.396 5.51268C5.48122 8.93556 3.24035 12.9423 1.64403 15.7961C1.5625 15.9486 1.41067 16.1974 1.33475 16.362C1.20176 16.6591 1.22775 17.0294 1.39538 17.304C1.58441 17.629 1.93802 17.8073 2.29927 17.7889C2.73389 17.7889 3.65561 17.7884 4.84738 17.7889C5.13016 17.7889 5.42823 17.7889 5.73853 17.7889C9.88246 17.7889 16.2127 17.7915 17.7663 17.7889C18.5209 17.7905 18.9942 16.9363 18.6182 16.2652L18.6172 16.2657ZM9.69699 13.2342L8.93424 11.8704C8.80024 11.6305 8.96787 11.3307 9.23588 11.3307H10.7614C11.0299 11.3307 11.1975 11.6305 11.063 11.8704L10.3003 13.2342C10.1663 13.474 9.83099 13.474 9.69648 13.2342H9.69699ZM8.41912 10.6943C8.35594 10.5281 8.30142 10.3593 8.25658 10.1878L10.7802 10.6943H8.41912ZM9.57165 14.2824C9.46414 14.4223 9.3495 14.5553 9.22823 14.6816L8.39109 12.1723L9.57114 14.2824H9.57165ZM12.0061 11.458C12.1768 11.4843 12.346 11.5206 12.5121 11.5658L10.8256 13.5687L12.0061 11.458ZM8.10117 9.33318C8.07417 9.07967 8.06245 8.82353 8.06347 8.56687L11.3962 10.2452L8.10067 9.33371L8.10117 9.33318ZM7.70579 11.8456L8.58828 15.2459C8.38905 15.3969 8.18015 15.5357 7.96411 15.663L7.70528 11.8456H7.70579ZM13.3069 11.8546C13.5332 11.9571 13.7538 12.075 13.9688 12.2043L10.8944 14.345L13.3069 11.8546ZM8.1399 7.48447C8.20104 7.01847 8.2953 6.55932 8.40943 6.1191L13.4725 10.6623L8.14041 7.48447H8.1399ZM7.01793 16.1369C6.59656 16.3152 6.16449 16.4603 5.73802 16.5781L7.01793 9.78129V16.1369ZM14.8386 12.8134C15.1988 13.1011 15.5371 13.4151 15.8494 13.737L9.50643 15.9912L14.8386 12.8134ZM10.2203 3.56456C11.1537 5.23655 12.509 7.66118 13.8002 9.96905L8.97959 4.99304C9.26288 4.48707 9.5314 4.00688 9.77902 3.56351C9.87736 3.38837 10.1219 3.38837 10.2203 3.56351V3.56456ZM2.69109 16.2358C2.95655 15.7629 3.32137 15.1144 3.40238 14.9651C4.17074 13.5913 5.20557 11.7415 6.27454 9.8302L4.50906 16.6307C3.87674 16.6307 3.33156 16.6307 2.91171 16.6307C2.71555 16.6307 2.59275 16.4114 2.69109 16.2363V16.2358ZM17.0871 16.6318C15.6151 16.6318 12.7572 16.6318 9.91965 16.6318L16.5083 14.8094C16.8537 15.4268 17.1304 15.9212 17.3077 16.2379C17.406 16.413 17.2832 16.6318 17.0876 16.6318H17.0871Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Button } 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 { CopyIcon } from 'lucide-react';
|
||||
|
||||
export const ResetPasswordDialog = ({
|
||||
link,
|
||||
open,
|
||||
onCopy,
|
||||
onOpenChange,
|
||||
}: {
|
||||
link: string;
|
||||
open: boolean;
|
||||
onCopy: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="leading-7">Account Recovery Link</DialogTitle>
|
||||
<DialogDescription className="leading-6">
|
||||
Please send this recovery link to the user and instruct them to
|
||||
complete it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-4">
|
||||
<div className="flex justify-end gap-2 items-center w-full">
|
||||
<Input
|
||||
type="text"
|
||||
value={link}
|
||||
placeholder="Please type email to confirm"
|
||||
className="placeholder:opacity-50 text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
readOnly
|
||||
/>
|
||||
<Button type="button" onClick={onCopy} className="space-x-[10px]">
|
||||
<CopyIcon size={20} /> <span>Copy and Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,337 @@
|
||||
import {
|
||||
useMutateQueryResource,
|
||||
useMutation,
|
||||
} from '@affine/admin/use-mutation';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import {
|
||||
createChangePasswordUrlMutation,
|
||||
createUserMutation,
|
||||
deleteUserMutation,
|
||||
disableUserMutation,
|
||||
enableUserMutation,
|
||||
type ImportUsersInput,
|
||||
type ImportUsersMutation,
|
||||
importUsersMutation,
|
||||
listUsersQuery,
|
||||
updateAccountFeaturesMutation,
|
||||
updateAccountMutation,
|
||||
} from '@affine/graphql';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { UserInput, UserType } from '../schema';
|
||||
|
||||
export interface ExportField {
|
||||
id: string;
|
||||
label: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
export type UserImportReturnType = ImportUsersMutation['importUsers'];
|
||||
|
||||
export const useCreateUser = () => {
|
||||
const {
|
||||
trigger: createAccount,
|
||||
isMutating: creating,
|
||||
error,
|
||||
} = useMutation({
|
||||
mutation: createUserMutation,
|
||||
});
|
||||
|
||||
const { trigger: updateAccountFeatures } = useMutation({
|
||||
mutation: updateAccountFeaturesMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const create = useAsyncCallback(
|
||||
async ({ name, email, password, features }: UserInput) => {
|
||||
try {
|
||||
const account = await createAccount({
|
||||
input: {
|
||||
name,
|
||||
email,
|
||||
password: password === '' ? undefined : password,
|
||||
},
|
||||
});
|
||||
|
||||
await updateAccountFeatures({
|
||||
userId: account.createUser.id,
|
||||
features,
|
||||
});
|
||||
await revalidate(listUsersQuery);
|
||||
toast('Account updated successfully');
|
||||
} catch (e) {
|
||||
toast.error('Failed to update account: ' + (e as Error).message);
|
||||
}
|
||||
},
|
||||
[createAccount, revalidate, updateAccountFeatures]
|
||||
);
|
||||
|
||||
return { creating: creating || !!error, create };
|
||||
};
|
||||
|
||||
export const useUpdateUser = () => {
|
||||
const {
|
||||
trigger: updateAccount,
|
||||
isMutating: updating,
|
||||
error,
|
||||
} = useMutation({
|
||||
mutation: updateAccountMutation,
|
||||
});
|
||||
|
||||
const { trigger: updateAccountFeatures } = useMutation({
|
||||
mutation: updateAccountFeaturesMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const update = useAsyncCallback(
|
||||
async ({
|
||||
userId,
|
||||
name,
|
||||
email,
|
||||
features,
|
||||
}: UserInput & { userId: string }) => {
|
||||
try {
|
||||
await updateAccount({
|
||||
id: userId,
|
||||
input: {
|
||||
name,
|
||||
email,
|
||||
},
|
||||
});
|
||||
await updateAccountFeatures({
|
||||
userId,
|
||||
features,
|
||||
});
|
||||
await revalidate(listUsersQuery);
|
||||
toast('Account updated successfully');
|
||||
} catch (e) {
|
||||
toast.error('Failed to update account: ' + (e as Error).message);
|
||||
}
|
||||
},
|
||||
[revalidate, updateAccount, updateAccountFeatures]
|
||||
);
|
||||
|
||||
return { updating: updating || !!error, update };
|
||||
};
|
||||
|
||||
export const useResetUserPassword = () => {
|
||||
const [resetPasswordLink, setResetPasswordLink] = useState('');
|
||||
const { trigger: resetPassword } = useMutation({
|
||||
mutation: createChangePasswordUrlMutation,
|
||||
});
|
||||
|
||||
const onResetPassword = useCallback(
|
||||
async (id: string, callback?: () => void) => {
|
||||
setResetPasswordLink('');
|
||||
resetPassword({
|
||||
userId: id,
|
||||
callbackUrl: '/auth/changePassword',
|
||||
})
|
||||
.then(res => {
|
||||
setResetPasswordLink(res.createChangePasswordUrl);
|
||||
callback?.();
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to reset password: ' + e.message);
|
||||
});
|
||||
},
|
||||
[resetPassword]
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
resetPasswordLink,
|
||||
onResetPassword,
|
||||
};
|
||||
}, [onResetPassword, resetPasswordLink]);
|
||||
};
|
||||
|
||||
export const useDeleteUser = () => {
|
||||
const { trigger: deleteUserById } = useMutation({
|
||||
mutation: deleteUserMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const deleteById = useAsyncCallback(
|
||||
async (id: string, callback?: () => void) => {
|
||||
await deleteUserById({ id })
|
||||
.then(async () => {
|
||||
await revalidate(listUsersQuery);
|
||||
toast('User deleted successfully');
|
||||
callback?.();
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to delete user: ' + e.message);
|
||||
});
|
||||
},
|
||||
[deleteUserById, revalidate]
|
||||
);
|
||||
|
||||
return deleteById;
|
||||
};
|
||||
|
||||
export const useEnableUser = () => {
|
||||
const { trigger: enableUserById } = useMutation({
|
||||
mutation: enableUserMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const enableById = useAsyncCallback(
|
||||
async (id: string, callback?: () => void) => {
|
||||
await enableUserById({ id })
|
||||
.then(async ({ enableUser }) => {
|
||||
await revalidate(listUsersQuery);
|
||||
toast(`User ${enableUser.email} enabled successfully`);
|
||||
callback?.();
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to enable user: ' + e.message);
|
||||
});
|
||||
},
|
||||
[enableUserById, revalidate]
|
||||
);
|
||||
|
||||
return enableById;
|
||||
};
|
||||
export const useDisableUser = () => {
|
||||
const { trigger: disableUserById } = useMutation({
|
||||
mutation: disableUserMutation,
|
||||
});
|
||||
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const disableById = useAsyncCallback(
|
||||
async (id: string, callback?: () => void) => {
|
||||
await disableUserById({ id })
|
||||
.then(async ({ banUser }) => {
|
||||
await revalidate(listUsersQuery);
|
||||
toast(`User ${banUser.email} disabled successfully`);
|
||||
callback?.();
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to disable user: ' + e.message);
|
||||
});
|
||||
},
|
||||
[disableUserById, revalidate]
|
||||
);
|
||||
|
||||
return disableById;
|
||||
};
|
||||
|
||||
export const useImportUsers = () => {
|
||||
const { trigger: importUsers } = useMutation({
|
||||
mutation: importUsersMutation,
|
||||
});
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const handleImportUsers = useCallback(
|
||||
async (
|
||||
input: ImportUsersInput,
|
||||
callback?: (importUsers: UserImportReturnType) => void
|
||||
) => {
|
||||
await importUsers({ input })
|
||||
.then(async ({ importUsers }) => {
|
||||
await revalidate(listUsersQuery);
|
||||
callback?.(importUsers);
|
||||
})
|
||||
.catch(e => {
|
||||
toast.error('Failed to import users: ' + e.message);
|
||||
});
|
||||
},
|
||||
[importUsers, revalidate]
|
||||
);
|
||||
|
||||
return handleImportUsers;
|
||||
};
|
||||
|
||||
export const useExportUsers = () => {
|
||||
const exportCSV = useCallback(
|
||||
async (users: UserType[], fields: ExportField[], callback?: () => void) => {
|
||||
const selectedFields = fields
|
||||
.filter(field => field.checked)
|
||||
.map(field => field.id);
|
||||
|
||||
if (selectedFields.length === 0) {
|
||||
alert('Please select at least one field to export');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = selectedFields.map(
|
||||
fieldId => fields.find(field => field.id === fieldId)?.label || fieldId
|
||||
);
|
||||
|
||||
const csvRows = [headers.join(',')];
|
||||
|
||||
users.forEach(user => {
|
||||
const row = selectedFields.map(fieldId => {
|
||||
const value = user[fieldId as keyof UserType];
|
||||
|
||||
return typeof value === 'string'
|
||||
? `"${value.replace(/"/g, '""')}"`
|
||||
: String(value);
|
||||
});
|
||||
csvRows.push(row.join(','));
|
||||
});
|
||||
|
||||
const csvContent = csvRows.join('\n');
|
||||
|
||||
// Add BOM (Byte Order Mark) to force Excel to interpret the file as UTF-8
|
||||
const BOM = '\uFEFF';
|
||||
const csvContentWithBOM = BOM + csvContent;
|
||||
|
||||
const blob = new Blob([csvContentWithBOM], {
|
||||
type: 'text/csv;charset=utf-8;',
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', 'exported_users.csv');
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
|
||||
setTimeout(() => {
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
|
||||
callback?.();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
async (users: UserType[], fields: ExportField[], callback?: () => void) => {
|
||||
const selectedFields = fields
|
||||
.filter(field => field.checked)
|
||||
.map(field => field.id);
|
||||
|
||||
const dataToCopy: {
|
||||
[key: string]: string;
|
||||
}[] = [];
|
||||
users.forEach(user => {
|
||||
const row: { [key: string]: string } = {};
|
||||
selectedFields.forEach(fieldId => {
|
||||
const value = user[fieldId as keyof UserType];
|
||||
row[fieldId] = typeof value === 'string' ? value : String(value);
|
||||
});
|
||||
dataToCopy.push(row);
|
||||
});
|
||||
// oxlint-disable-next-line typescript/no-floating-promises
|
||||
navigator.clipboard.writeText(JSON.stringify(dataToCopy, null, 2));
|
||||
callback?.();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
exportCSV,
|
||||
copyToClipboard,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,333 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
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 type { FeatureType } from '@affine/graphql';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { FeatureToggleList } from '../../../components/shared/feature-toggle-list';
|
||||
import { useServerConfig } from '../../common';
|
||||
import { RightPanelHeader } from '../../header';
|
||||
import type { UserInput, UserType } from '../schema';
|
||||
import { validateEmails, validatePassword } from '../utils/csv-utils';
|
||||
import { useCreateUser, useUpdateUser } from './use-user-management';
|
||||
|
||||
type UserFormProps = {
|
||||
title: string;
|
||||
defaultValue?: Partial<UserInput>;
|
||||
onClose: () => void;
|
||||
onConfirm: (user: UserInput) => void;
|
||||
onValidate: (user: Partial<UserInput>) => boolean;
|
||||
actions?: React.ReactNode;
|
||||
showOption?: boolean;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
};
|
||||
|
||||
function UserForm({
|
||||
title,
|
||||
defaultValue,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onValidate,
|
||||
actions,
|
||||
showOption,
|
||||
onDirtyChange,
|
||||
}: UserFormProps) {
|
||||
const serverConfig = useServerConfig();
|
||||
|
||||
const defaultUser: Partial<UserInput> = useMemo(
|
||||
() => ({
|
||||
name: defaultValue?.name ?? '',
|
||||
email: defaultValue?.email ?? '',
|
||||
password: defaultValue?.password ?? '',
|
||||
features: defaultValue?.features ?? [],
|
||||
}),
|
||||
[defaultValue]
|
||||
);
|
||||
|
||||
const [changes, setChanges] = useState<Partial<UserInput>>(defaultUser);
|
||||
|
||||
const setField = useCallback(
|
||||
<K extends keyof UserInput>(
|
||||
field: K,
|
||||
value: UserInput[K] | ((prev: UserInput[K] | undefined) => UserInput[K])
|
||||
) => {
|
||||
setChanges(changes => ({
|
||||
...changes,
|
||||
[field]:
|
||||
typeof value === 'function' ? value(changes[field] as any) : value,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
return onValidate(changes);
|
||||
}, [onValidate, changes]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalize = (value: Partial<UserInput>) => ({
|
||||
name: value.name ?? '',
|
||||
email: value.email ?? '',
|
||||
password: value.password ?? '',
|
||||
features: [...(value.features ?? [])].sort(),
|
||||
});
|
||||
const current = normalize(changes);
|
||||
const baseline = normalize(defaultUser);
|
||||
const dirty =
|
||||
(current.name !== baseline.name ||
|
||||
current.email !== baseline.email ||
|
||||
current.password !== baseline.password ||
|
||||
current.features.join(',') !== baseline.features.join(',')) &&
|
||||
!!onDirtyChange;
|
||||
onDirtyChange?.(dirty);
|
||||
}, [changes, defaultUser, onDirtyChange]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!canSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error checked
|
||||
onConfirm(changes);
|
||||
setChanges(defaultUser);
|
||||
}, [canSave, changes, defaultUser, onConfirm]);
|
||||
|
||||
const handleFeaturesChange = useCallback(
|
||||
(features: FeatureType[]) => {
|
||||
setField('features', features);
|
||||
},
|
||||
[setField]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setChanges(defaultUser);
|
||||
onClose();
|
||||
}, [defaultUser, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
setChanges(defaultUser);
|
||||
}, [defaultUser]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<RightPanelHeader
|
||||
title={title}
|
||||
handleClose={handleClose}
|
||||
handleConfirm={handleConfirm}
|
||||
canSave={canSave}
|
||||
/>
|
||||
<div className="flex-grow space-y-3 overflow-y-auto p-4">
|
||||
<div className="flex flex-col rounded-xl border border-border bg-card shadow-sm">
|
||||
<InputItem
|
||||
label="User name"
|
||||
field="name"
|
||||
value={changes.name}
|
||||
onChange={setField}
|
||||
placeholder="Enter user name"
|
||||
/>
|
||||
<Separator />
|
||||
<InputItem
|
||||
label="Email"
|
||||
field="email"
|
||||
value={changes.email}
|
||||
onChange={setField}
|
||||
placeholder="Enter email address"
|
||||
/>
|
||||
{showOption && (
|
||||
<>
|
||||
<Separator />
|
||||
<InputItem
|
||||
label="Password"
|
||||
field="password"
|
||||
value={changes.password}
|
||||
onChange={setField}
|
||||
optional
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FeatureToggleList
|
||||
className="rounded-xl border border-border bg-card shadow-sm"
|
||||
features={serverConfig.availableUserFeatures}
|
||||
selected={changes.features ?? []}
|
||||
onChange={handleFeaturesChange}
|
||||
control="switch"
|
||||
controlPosition="right"
|
||||
showSeparators={true}
|
||||
/>
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputItem({
|
||||
label,
|
||||
field,
|
||||
optional,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
field: keyof UserInput;
|
||||
optional?: boolean;
|
||||
value?: string;
|
||||
onChange: (field: keyof UserInput, value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const onValueChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(field, e.target.value);
|
||||
},
|
||||
[field, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<Label className="flex flex-wrap text-xs font-medium leading-5 text-muted-foreground uppercase tracking-wide">
|
||||
{label}
|
||||
{optional && (
|
||||
<span className="ml-1 font-normal text-muted-foreground">
|
||||
(optional)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
className="py-2 px-3 text-sm font-normal h-9"
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const validateCreateUser = (user: Partial<UserInput>) => {
|
||||
return !!user.name && !!user.email && !!user.features;
|
||||
};
|
||||
|
||||
const validateUpdateUser = (user: Partial<UserInput>) => {
|
||||
return !!user.name || !!user.email;
|
||||
};
|
||||
|
||||
export function CreateUserForm({
|
||||
onComplete,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
onComplete: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const { create, creating } = useCreateUser();
|
||||
const serverConfig = useServerConfig();
|
||||
const passwordLimits = serverConfig.credentialsRequirement.password;
|
||||
useEffect(() => {
|
||||
if (creating) {
|
||||
return () => {
|
||||
onComplete();
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
}, [creating, onComplete]);
|
||||
|
||||
const handleCreateUser = useCallback(
|
||||
(user: UserInput) => {
|
||||
const emailValidation = validateEmails([user]);
|
||||
const passwordValidation = validatePassword(
|
||||
user.password,
|
||||
passwordLimits
|
||||
);
|
||||
if (!passwordValidation.valid || !emailValidation[0].valid) {
|
||||
toast.error(passwordValidation.error || emailValidation[0].error);
|
||||
return;
|
||||
}
|
||||
create(user);
|
||||
},
|
||||
[create, passwordLimits]
|
||||
);
|
||||
|
||||
return (
|
||||
<UserForm
|
||||
title="Create User"
|
||||
onClose={onComplete}
|
||||
onConfirm={handleCreateUser}
|
||||
onValidate={validateCreateUser}
|
||||
showOption={true}
|
||||
onDirtyChange={onDirtyChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateUserForm({
|
||||
user,
|
||||
onResetPassword,
|
||||
onDeleteAccount,
|
||||
onComplete,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
user: UserType;
|
||||
onResetPassword: () => void;
|
||||
onDeleteAccount: () => void;
|
||||
onComplete: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const { update, updating } = useUpdateUser();
|
||||
|
||||
const onUpdateUser = useCallback(
|
||||
(updates: UserInput) => {
|
||||
update({
|
||||
...updates,
|
||||
userId: user.id,
|
||||
});
|
||||
},
|
||||
[user, update]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (updating) {
|
||||
return () => {
|
||||
onComplete();
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [updating, onComplete]);
|
||||
|
||||
return (
|
||||
<UserForm
|
||||
title="Update User"
|
||||
defaultValue={user}
|
||||
onClose={onComplete}
|
||||
onConfirm={onUpdateUser}
|
||||
onValidate={validateUpdateUser}
|
||||
onDirtyChange={onDirtyChange}
|
||||
actions={
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
className="h-10 w-full justify-between rounded-xl border-border/60 px-4 text-sm font-medium hover:bg-muted/50"
|
||||
variant="outline"
|
||||
onClick={onResetPassword}
|
||||
>
|
||||
<span>Reset Password</span>
|
||||
<ChevronRightIcon size={16} className="text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-10 w-full justify-between rounded-xl border-destructive/30 px-4 text-sm font-medium text-destructive hover:bg-destructive/5 hover:text-destructive"
|
||||
variant="outline"
|
||||
onClick={onDeleteAccount}
|
||||
>
|
||||
<span>Delete Account</span>
|
||||
<ChevronRightIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ImportStatus, type ParsedUser } from '../utils/csv-utils';
|
||||
|
||||
interface UserTableProps {
|
||||
users: ParsedUser[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a table of users with their import status
|
||||
*/
|
||||
export const UserTable: React.FC<UserTableProps> = ({ users }) => {
|
||||
return (
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-xl border border-border/60 bg-card shadow-sm">
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="sticky top-0 bg-muted/40">
|
||||
<tr>
|
||||
<th className="border-b border-border px-4 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground">
|
||||
Name
|
||||
</th>
|
||||
<th className="border-b border-border px-4 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground">
|
||||
Email
|
||||
</th>
|
||||
<th className="border-b border-border px-4 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground">
|
||||
Password
|
||||
</th>
|
||||
<th className="border-b border-border px-4 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{users.map((user, index) => (
|
||||
<tr
|
||||
key={`${user.email}-${index}`}
|
||||
className={`${user.valid === false ? 'bg-destructive/10' : ''}
|
||||
${user.importStatus === ImportStatus.Failed ? 'bg-destructive/10' : ''}
|
||||
${user.importStatus === ImportStatus.Success ? 'bg-[var(--affine-v2-layer-background-success)]' : ''}
|
||||
${user.importStatus === ImportStatus.Processing ? 'bg-[var(--affine-v2-layer-background-warning)]' : ''}`}
|
||||
>
|
||||
<td className="max-w-[150px] truncate px-4 py-2 text-sm text-foreground">
|
||||
{user.name || '-'}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 px-4 text-sm truncate max-w-[200px] ${
|
||||
user.valid === false &&
|
||||
(user.error?.toLowerCase().includes('email') ||
|
||||
!user.error?.toLowerCase().includes('password'))
|
||||
? 'text-destructive'
|
||||
: 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{user.email}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 px-4 text-sm truncate max-w-[150px] ${
|
||||
user.valid === false &&
|
||||
user.error?.toLowerCase().includes('password')
|
||||
? 'text-destructive'
|
||||
: 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{user.password || '-'}
|
||||
</td>
|
||||
<td className="py-2 px-4 text-sm">
|
||||
{user.importStatus === ImportStatus.Success ? (
|
||||
<span className="text-foreground">
|
||||
<span className="mr-2 inline-block h-2 w-2 rounded-full bg-[var(--affine-v2-status-success)]" />
|
||||
Success
|
||||
</span>
|
||||
) : user.importStatus === ImportStatus.Failed ? (
|
||||
<span className="text-destructive" title={user.importError}>
|
||||
<span className="mr-2 inline-block h-2 w-2 rounded-full bg-destructive" />
|
||||
Failed ({user.importError})
|
||||
</span>
|
||||
) : user.importStatus === ImportStatus.Processing ? (
|
||||
<span className="text-primary">
|
||||
<span className="mr-2 inline-block h-2 w-2 rounded-full bg-primary" />
|
||||
Processing...
|
||||
</span>
|
||||
) : user.valid === false ? (
|
||||
<span className="text-destructive" title={user.error}>
|
||||
<span className="mr-2 inline-block h-2 w-2 rounded-full bg-destructive" />
|
||||
Invalid ({user.error})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-foreground">
|
||||
<span className="mr-2 inline-block h-2 w-2 rounded-full bg-foreground" />
|
||||
Valid
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
63
packages/frontend/admin/src/modules/accounts/index.tsx
Normal file
63
packages/frontend/admin/src/modules/accounts/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { FeatureType } from '@affine/graphql';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Header } from '../header';
|
||||
import { useColumns } from './components/columns';
|
||||
import { DataTable } from './components/data-table';
|
||||
import type { UserType } from './schema';
|
||||
import { useUserList } from './use-user-list';
|
||||
|
||||
export function AccountPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [featureFilters, setFeatureFilters] = useState<FeatureType[]>([]);
|
||||
const { users, pagination, setPagination, usersCount } = useUserList({
|
||||
keyword,
|
||||
features: featureFilters,
|
||||
});
|
||||
// Remember the user temporarily, because userList is paginated on the server side,can't get all users at once.
|
||||
const [memoUsers, setMemoUsers] = useState<UserType[]>([]);
|
||||
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
|
||||
new Set<string>()
|
||||
);
|
||||
const columns = useColumns({ setSelectedUserIds });
|
||||
|
||||
useEffect(() => {
|
||||
setMemoUsers(prev => {
|
||||
const map = new Map(prev.map(user => [user.id, user]));
|
||||
users.forEach(user => {
|
||||
map.set(user.id, user);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
});
|
||||
}, [users]);
|
||||
|
||||
useEffect(() => {
|
||||
setMemoUsers([]);
|
||||
setSelectedUserIds(new Set<string>());
|
||||
}, [featureFilters, keyword]);
|
||||
|
||||
const selectedUsers = useMemo(() => {
|
||||
return memoUsers.filter(user => selectedUserIds.has(user.id));
|
||||
}, [selectedUserIds, memoUsers]);
|
||||
|
||||
return (
|
||||
<div className="h-dvh flex-1 flex-col flex">
|
||||
<Header title="Accounts" />
|
||||
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
pagination={pagination}
|
||||
usersCount={usersCount}
|
||||
onPaginationChange={setPagination}
|
||||
selectedUsers={selectedUsers}
|
||||
keyword={keyword}
|
||||
onKeywordChange={setKeyword}
|
||||
selectedFeatures={featureFilters}
|
||||
onFeaturesChange={setFeatureFilters}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { AccountPage as Component };
|
||||
9
packages/frontend/admin/src/modules/accounts/schema.ts
Normal file
9
packages/frontend/admin/src/modules/accounts/schema.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { FeatureType, ListUsersQuery } from '@affine/graphql';
|
||||
|
||||
export type UserType = ListUsersQuery['users'][0];
|
||||
export type UserInput = {
|
||||
name: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
features: FeatureType[];
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useQuery } from '@affine/admin/use-query';
|
||||
import type { FeatureType } from '@affine/graphql';
|
||||
import { listUsersQuery } from '@affine/graphql';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export const useUserList = (filter?: {
|
||||
keyword?: string;
|
||||
features?: FeatureType[];
|
||||
}) => {
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const filterKey = useMemo(
|
||||
() =>
|
||||
`${filter?.keyword ?? ''}-${[...(filter?.features ?? [])]
|
||||
.sort()
|
||||
.join(',')}`,
|
||||
[filter?.features, filter?.keyword]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination(prev => ({ ...prev, pageIndex: 0 }));
|
||||
}, [filterKey]);
|
||||
|
||||
const {
|
||||
data: { users, usersCount },
|
||||
} = useQuery(
|
||||
{
|
||||
query: listUsersQuery,
|
||||
variables: {
|
||||
filter: {
|
||||
first: pagination.pageSize,
|
||||
skip: pagination.pageIndex * pagination.pageSize,
|
||||
keyword: filter?.keyword || undefined,
|
||||
features:
|
||||
filter?.features && filter.features.length > 0
|
||||
? filter.features
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ keepPreviousData: true }
|
||||
);
|
||||
|
||||
return {
|
||||
users,
|
||||
pagination,
|
||||
setPagination,
|
||||
usersCount,
|
||||
};
|
||||
};
|
||||
235
packages/frontend/admin/src/modules/accounts/utils/csv-utils.ts
Normal file
235
packages/frontend/admin/src/modules/accounts/utils/csv-utils.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { emailRegex } from '../../../utils';
|
||||
|
||||
export interface ParsedUser {
|
||||
name: string | null;
|
||||
email: string;
|
||||
password?: string;
|
||||
valid?: boolean;
|
||||
error?: string;
|
||||
importStatus?: ImportStatus;
|
||||
importError?: string;
|
||||
}
|
||||
|
||||
export enum ImportStatus {
|
||||
Success = 'success',
|
||||
Failed = 'failed',
|
||||
Processing = 'processing',
|
||||
}
|
||||
|
||||
export const validatePassword = (
|
||||
password: string | undefined,
|
||||
passwordLimits: { minLength: number; maxLength: number }
|
||||
): { valid: boolean; error?: string } => {
|
||||
// if password is empty, it is valid
|
||||
if (!password || password.trim() === '') {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// check password length
|
||||
if (
|
||||
password.length < passwordLimits.minLength ||
|
||||
password.length > passwordLimits.maxLength
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid password format',
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(@Jimmfly): check password contains at least one letter and one number
|
||||
|
||||
// const hasLetter = /[a-zA-Z]/.test(password);
|
||||
// const hasNumber = /[0-9]/.test(password);
|
||||
|
||||
// if (!hasLetter || !hasNumber) {
|
||||
// return {
|
||||
// valid: false,
|
||||
// error: 'Invalid password format',
|
||||
// };
|
||||
// }
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates email addresses for duplicates and format
|
||||
*/
|
||||
export const validateEmails = (users: ParsedUser[]): ParsedUser[] => {
|
||||
const emailMap = new Map<string, number>();
|
||||
|
||||
users.forEach(user => {
|
||||
const lowerCaseEmail = user.email.toLowerCase();
|
||||
emailMap.set(lowerCaseEmail, (emailMap.get(lowerCaseEmail) || 0) + 1);
|
||||
});
|
||||
|
||||
return users.map(user => {
|
||||
const lowerCaseEmail = user.email.toLowerCase();
|
||||
|
||||
if (!emailRegex.test(user.email)) {
|
||||
return { ...user, valid: false, error: 'Invalid email format' };
|
||||
}
|
||||
|
||||
const emailCount = emailMap.get(lowerCaseEmail) || 0;
|
||||
if (emailCount > 1) {
|
||||
return { ...user, valid: false, error: 'Duplicate email address' };
|
||||
}
|
||||
|
||||
return { ...user, valid: true };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters valid users for import
|
||||
*/
|
||||
export const getValidUsersToImport = (users: ParsedUser[]) => {
|
||||
return users
|
||||
.filter(user => user.valid === true)
|
||||
.map(user => ({
|
||||
name: user.name || undefined,
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Downloads a CSV template for user import
|
||||
*/
|
||||
export const downloadCsvTemplate = () => {
|
||||
const csvContent = 'Username,Email,Password\n,example@example.com,';
|
||||
downloadCsv(csvContent, 'user_import_template.csv');
|
||||
};
|
||||
|
||||
/**
|
||||
* Exports failed imports to a CSV file
|
||||
*/
|
||||
export const exportImportResults = (results: ParsedUser[]) => {
|
||||
const csvContent = [
|
||||
'Username,Email,Password,Status',
|
||||
...results.map(
|
||||
user =>
|
||||
`${user.name || ''},${user.email},${user.password || ''},${user.importStatus}${user.importError ? ` (${user.importError})` : ''}`
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
// Create and download the file
|
||||
downloadCsv(
|
||||
csvContent,
|
||||
`import_results_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
);
|
||||
|
||||
toast.success(`Exported ${results.length} import results`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function for downloading CSV content with proper UTF-8 encoding for international characters
|
||||
*/
|
||||
export const downloadCsv = (csvContent: string, filename: string) => {
|
||||
// Add BOM (Byte Order Mark) to force Excel to interpret the file as UTF-8
|
||||
const BOM = '\uFEFF';
|
||||
const csvContentWithBOM = BOM + csvContent;
|
||||
|
||||
const blob = new Blob([csvContentWithBOM], {
|
||||
type: 'text/csv;charset=utf-8;',
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', filename);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
|
||||
setTimeout(() => {
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes a CSV file to extract user data
|
||||
*/
|
||||
export const processCSVFile = async (
|
||||
file: File,
|
||||
onSuccess: (users: ParsedUser[]) => void,
|
||||
onError: () => void
|
||||
) => {
|
||||
try {
|
||||
const csvContent = await file.text();
|
||||
const rows = csvContent
|
||||
.split('\n')
|
||||
.filter(row => row.trim() !== '')
|
||||
.map(row => row.split(','));
|
||||
|
||||
if (rows.length < 2) {
|
||||
toast.error('CSV file format is incorrect or empty');
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
|
||||
const dataRows = rows.slice(1);
|
||||
|
||||
const users = dataRows.map(row => ({
|
||||
name: row[0]?.trim() || null,
|
||||
email: row[1]?.trim() || '',
|
||||
password: row[2]?.trim() || undefined,
|
||||
}));
|
||||
|
||||
const usersWithEmail = users.filter(user => user.email);
|
||||
|
||||
if (usersWithEmail.length === 0) {
|
||||
toast.error('CSV file contains no valid user data');
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedUsers = validateEmails(usersWithEmail);
|
||||
const hasValidUsers = validatedUsers.some(user => user.valid !== false);
|
||||
|
||||
if (!hasValidUsers) {
|
||||
toast.error('CSV file contains no valid user data');
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
|
||||
onSuccess(validatedUsers);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse CSV file', error);
|
||||
toast.error('Failed to parse CSV file');
|
||||
onError();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate users
|
||||
*/
|
||||
export const validateUsers = (
|
||||
users: ParsedUser[],
|
||||
passwordLimits: { minLength: number; maxLength: number }
|
||||
): ParsedUser[] => {
|
||||
// validate emails
|
||||
const emailValidatedUsers = validateEmails(users);
|
||||
|
||||
// validate password
|
||||
return emailValidatedUsers.map(user => {
|
||||
// if email is invalid, return
|
||||
if (user.valid === false) {
|
||||
return user;
|
||||
}
|
||||
|
||||
// validate password
|
||||
const passwordValidation = validatePassword(user.password, passwordLimits);
|
||||
if (!passwordValidation.valid) {
|
||||
return {
|
||||
...user,
|
||||
valid: false,
|
||||
error: passwordValidation.error,
|
||||
};
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
};
|
||||
133
packages/frontend/admin/src/modules/auth/index.tsx
Normal file
133
packages/frontend/admin/src/modules/auth/index.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { Input } from '@affine/admin/components/ui/input';
|
||||
import { Label } from '@affine/admin/components/ui/label';
|
||||
import { FeatureType, getUserFeaturesQuery } from '@affine/graphql';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { affineFetch } from '../../fetch-utils';
|
||||
import { isAdmin, useCurrentUser, useRevalidateCurrentUser } from '../common';
|
||||
import logo from './logo.svg';
|
||||
|
||||
export function Auth() {
|
||||
const currentUser = useCurrentUser();
|
||||
const revalidate = useRevalidateCurrentUser();
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const login = useCallback(
|
||||
(e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!emailRef.current || !passwordRef.current) return;
|
||||
affineFetch('/api/auth/sign-in', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: emailRef.current?.value,
|
||||
password: passwordRef.current?.value,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(async response => {
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.message || 'Failed to login');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(() =>
|
||||
affineFetch('/graphql', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
operationName: getUserFeaturesQuery.op,
|
||||
query: getUserFeaturesQuery.query,
|
||||
variables: {},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
)
|
||||
.then(res => res.json())
|
||||
.then(
|
||||
async ({
|
||||
data: {
|
||||
currentUser: { features },
|
||||
},
|
||||
}) => {
|
||||
if (features.includes(FeatureType.Admin)) {
|
||||
toast.success('Logged in successfully');
|
||||
await revalidate();
|
||||
} else {
|
||||
toast.error('You are not an admin');
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(err => {
|
||||
toast.error(`Failed to login: ${err.message}`);
|
||||
});
|
||||
},
|
||||
[revalidate]
|
||||
);
|
||||
|
||||
if (currentUser && isAdmin(currentUser)) {
|
||||
return <Navigate to="/admin" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full lg:grid lg:min-h-[600px] lg:grid-cols-2 xl:min-h-[800px] h-dvh">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="mx-auto grid w-[350px] gap-6">
|
||||
<div className="grid gap-2 text-center">
|
||||
<h1 className="text-3xl font-bold">Login</h1>
|
||||
<p className="text-balance text-muted-foreground">
|
||||
Enter your email below to login to your account
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={login} action="#">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
ref={emailRef}
|
||||
placeholder="m@example.com"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
ref={passwordRef}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={login} type="submit" className="w-full">
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden bg-muted lg:flex lg:justify-center">
|
||||
<img
|
||||
src={logo}
|
||||
alt="Image"
|
||||
className="h-1/2 object-cover dark:brightness-[0.2] dark:grayscale relative top-1/4 "
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Auth as Component };
|
||||
18
packages/frontend/admin/src/modules/auth/logo.svg
Normal file
18
packages/frontend/admin/src/modules/auth/logo.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 470 526.48">
|
||||
<g id="_6_export">
|
||||
<rect id="box_geometric" fill="none" width="470" height="526.48" />
|
||||
<path id="logo-black"
|
||||
d="m404.14,313.34c-5.95-10.33-15.86-27.48-25.65-44.42-2.97-5.14-5.92-10.25-8.75-15.15-5.82-10.08-11.12-19.27-14.9-25.83-26.23-45.33-77.87-135.08-103.67-179.46-7.95-12.37-26.37-11.39-33.21,1.42-7.76,13.45-16.3,28.24-25.32,43.87-2.86,4.96-5.78,10.01-8.73,15.12-37.58,65.08-81.56,141.26-112.89,195.52-1.6,2.9-4.58,7.63-6.07,10.76-2.61,5.65-2.1,12.69,1.19,17.91,3.71,6.18,10.65,9.57,17.74,9.22,8.53,0,26.62-.01,50.01,0,5.55,0,11.4,0,17.49,0,81.33,0,205.57.05,236.06,0,14.81.03,24.1-16.21,16.72-28.97Zm-175.07-57.64l-14.97-25.93c-2.63-4.56.66-10.26,5.92-10.26h29.94c5.27,0,8.56,5.7,5.92,10.26l-14.97,25.93c-2.63,4.56-9.21,4.56-11.85,0Zm-25.08-48.29c-1.24-3.16-2.31-6.37-3.19-9.63l49.53,9.63h-46.34Zm22.62,68.22c-2.11,2.66-4.36,5.19-6.74,7.59l-16.43-47.71,23.16,40.12Zm47.78-53.7c3.35.5,6.67,1.19,9.93,2.05l-33.1,38.08,23.17-40.13Zm-76.64-40.4c-.53-4.82-.76-9.69-.74-14.57l65.41,31.91-64.68-17.33Zm-7.76,47.77l17.32,64.65c-3.91,2.87-8.01,5.51-12.25,7.93l-5.08-72.58Zm109.93.17c4.44,1.95,8.77,4.19,12.99,6.65l-60.34,40.7,47.35-47.35Zm-101.41-83.09c1.2-8.86,3.05-17.59,5.29-25.96l99.37,86.38-104.65-60.42Zm-22.02,164.51c-8.27,3.39-16.75,6.15-25.12,8.39l25.12-129.23v120.84Zm153.49-63.19c7.07,5.47,13.71,11.44,19.84,17.56l-124.49,42.86,104.65-60.42Zm-90.64-175.85c18.32,31.79,44.92,77.89,70.26,121.77l-94.61-94.61c5.56-9.62,10.83-18.75,15.69-27.18,1.93-3.33,6.73-3.33,8.66,0Zm-147.77,240.92c5.21-8.99,12.37-21.32,13.96-24.16,15.08-26.12,35.39-61.29,56.37-97.63l-34.65,129.3c-12.41,0-23.11,0-31.35,0-3.85,0-6.26-4.17-4.33-7.5Zm282.54,7.53c-28.89,0-84.98,0-140.67,0l129.31-34.65c6.78,11.74,12.21,21.14,15.69,27.16,1.93,3.33-.48,7.49-4.32,7.49Z" />
|
||||
<path
|
||||
d="m115.94,486.75c-2.9,0-5.39-1.98-6.05-4.81l-4.72-20.29c-.23-1.01-1.12-1.71-2.16-1.71h-20.5c-1.04,0-1.92.7-2.16,1.71l-4.72,20.29c-.66,2.83-3.15,4.81-6.05,4.81h-2.26c-1.92,0-3.71-.87-4.89-2.39-1.19-1.52-1.6-3.46-1.14-5.32l22.17-89.41c.69-2.78,3.17-4.72,6.03-4.72h6.54c2.86,0,5.34,1.94,6.03,4.72l22.17,89.41c.46,1.87.05,3.81-1.14,5.33-1.19,1.52-2.97,2.38-4.89,2.38h-2.26Zm-23.18-82.26s-.08.01-.08.02l-8.17,39.3c-.16.71,0,1.39.41,1.93.42.53,1.05.84,1.73.84h12.21c.68,0,1.31-.31,1.73-.84.42-.53.58-1.22.42-1.88l-8.17-39.32-.09-.04Z" />
|
||||
<path
|
||||
d="m269.14,486.8c-3.43,0-6.21-2.79-6.21-6.21v-33.53c0-3.31-2.7-6.01-6.01-6.01h-47.88c-1.22,0-2.21.99-2.21,2.21v37.27c0,3.43-2.79,6.21-6.21,6.21h-2.56c-3.43,0-6.21-2.79-6.21-6.21v-39.49h-43.04c-1.22,0-2.21.99-2.21,2.21v37.27c0,3.43-2.79,6.21-6.21,6.21h-2.56c-3.43,0-6.21-2.79-6.21-6.21v-77.61c0-9.93,8.08-18.02,18.02-18.02h28.02c3.43,0,6.21,2.79,6.21,6.21v.95c0,3.43-2.79,6.21-6.21,6.21h-25.04c-3.31,0-6.01,2.7-6.01,6.01v21.15c0,1.22.99,2.21,2.21,2.21h43.04v-24.74c0-9.93,8.08-18.02,18.02-18.02h28.02c3.43,0,6.21,2.79,6.21,6.21v.95c0,3.43-2.79,6.21-6.21,6.21h-25.04c-3.31,0-6.01,2.7-6.01,6.01v21.15c0,1.22.99,2.21,2.21,2.21h50.59c9.93,0,18.02,8.08,18.02,18.02v34.9c0,3.43-2.79,6.21-6.21,6.21h-2.29Z" />
|
||||
<path
|
||||
d="m332.61,485.75c-2.32,0-4.39-1.56-5.02-3.8l-19.11-67.64-1.96.27-.21,65.97c0,2.87-2.35,5.2-5.21,5.2h-2.31c-2.88,0-5.21-2.34-5.21-5.21v-89.41c0-2.88,2.34-5.21,5.21-5.21h10.02c2.32,0,4.39,1.56,5.02,3.8l19.11,67.64,1.96-.27.21-65.97c0-2.87,2.35-5.2,5.21-5.2h2.31c2.88,0,5.21,2.34,5.21,5.21v89.41c0,2.88-2.34,5.21-5.21,5.21h-10.02Z" />
|
||||
<path
|
||||
d="m376.06,486.75c-9.93,0-18.02-8.08-18.02-18.02v-65.81c0-9.93,8.08-18.02,18.02-18.02h26.68c3.43,0,6.21,2.79,6.21,6.21v.95c0,3.43-2.79,6.21-6.21,6.21h-23.83c-3.31,0-6.01,2.7-6.01,6.01v19.81c0,1.22.99,2.21,2.21,2.21h26.28c3.43,0,6.21,2.79,6.21,6.21v.95c0,3.43-2.79,6.21-6.21,6.21h-26.28c-1.22,0-2.21.99-2.21,2.21v25.44c0,3.31,2.7,6.01,6.01,6.01h23.83c3.43,0,6.21,2.79,6.21,6.21v.95c0,3.43-2.79,6.21-6.21,6.21h-26.68Z" />
|
||||
<path
|
||||
d="m263.69,418.27c-1.67,0-3.28-.8-4.3-2.14-1.03-1.35-1.37-3.06-.93-4.71l3.1-11.56c.64-2.37,2.8-4.03,5.27-4.03,1.44,0,2.8.57,3.83,1.59l8.46,8.46c1.39,1.39,1.92,3.35,1.41,5.25-.51,1.9-1.95,3.34-3.84,3.84l-11.56,3.1c-.47.13-.95.19-1.42.19h0Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
64
packages/frontend/admin/src/modules/common.ts
Normal file
64
packages/frontend/admin/src/modules/common.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { GetCurrentUserFeaturesQuery } from '@affine/graphql';
|
||||
import {
|
||||
adminServerConfigQuery,
|
||||
FeatureType,
|
||||
getCurrentUserFeaturesQuery,
|
||||
} from '@affine/graphql';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useMutateQueryResource } from '../use-mutation';
|
||||
import { useQuery } from '../use-query';
|
||||
|
||||
export const useServerConfig = () => {
|
||||
const { data } = useQuery({
|
||||
query: adminServerConfigQuery,
|
||||
});
|
||||
|
||||
return data.serverConfig;
|
||||
};
|
||||
|
||||
export const useRevalidateServerConfig = () => {
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
return () => {
|
||||
return revalidate(adminServerConfigQuery);
|
||||
};
|
||||
};
|
||||
|
||||
export const useRevalidateCurrentUser = () => {
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
return () => {
|
||||
return revalidate(getCurrentUserFeaturesQuery);
|
||||
};
|
||||
};
|
||||
export const useCurrentUser = () => {
|
||||
const { data } = useQuery({
|
||||
query: getCurrentUserFeaturesQuery,
|
||||
});
|
||||
return data.currentUser;
|
||||
};
|
||||
|
||||
export function isAdmin(
|
||||
user: NonNullable<GetCurrentUserFeaturesQuery['currentUser']>
|
||||
) {
|
||||
return user.features.includes(FeatureType.Admin);
|
||||
}
|
||||
|
||||
export function useMediaQuery(query: string) {
|
||||
const [value, setValue] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onChange(event: MediaQueryListEvent) {
|
||||
setValue(event.matches);
|
||||
}
|
||||
|
||||
const result = matchMedia(query);
|
||||
result.addEventListener('change', onChange);
|
||||
setValue(result.matches);
|
||||
|
||||
return () => result.removeEventListener('change', onChange);
|
||||
}, [query]);
|
||||
|
||||
return value;
|
||||
}
|
||||
286
packages/frontend/admin/src/modules/dashboard/index.spec.tsx
Normal file
286
packages/frontend/admin/src/modules/dashboard/index.spec.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const mutateQueryResourceMock = vi.fn();
|
||||
|
||||
vi.mock('@affine/admin/use-query', () => ({
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../use-mutation', () => ({
|
||||
useMutation: () => ({
|
||||
trigger: vi.fn(),
|
||||
}),
|
||||
useMutateQueryResource: () => () => {
|
||||
mutateQueryResourceMock();
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../header', () => ({
|
||||
Header: ({ title, endFix }: { title: string; endFix?: ReactNode }) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
{endFix}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('recharts', () => ({
|
||||
ResponsiveContainer: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="responsive-container">{children}</div>
|
||||
),
|
||||
Tooltip: ({ content }: { content?: ReactNode }) => (
|
||||
<div data-testid="chart-tooltip">{content}</div>
|
||||
),
|
||||
Area: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="area">{children}</div>
|
||||
),
|
||||
CartesianGrid: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="grid">{children}</div>
|
||||
),
|
||||
Line: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="line">{children}</div>
|
||||
),
|
||||
LineChart: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="line-chart">{children}</div>
|
||||
),
|
||||
XAxis: () => <div data-testid="x-axis" />,
|
||||
YAxis: () => <div data-testid="y-axis" />,
|
||||
}));
|
||||
|
||||
import { DashboardPage } from './index';
|
||||
|
||||
const dashboardData = {
|
||||
adminDashboard: {
|
||||
syncActiveUsers: 0,
|
||||
syncActiveUsersTimeline: [
|
||||
{ minute: '2026-02-16T10:30:00.000Z', activeUsers: 0 },
|
||||
],
|
||||
syncWindow: {
|
||||
from: '2026-02-14T20:30:00.000Z',
|
||||
to: '2026-02-16T19:30:00.000Z',
|
||||
timezone: 'UTC',
|
||||
bucket: 'minute',
|
||||
requestedSize: 48,
|
||||
effectiveSize: 48,
|
||||
},
|
||||
copilotConversations: 0,
|
||||
copilotWindow: {
|
||||
from: '2026-02-10T00:00:00.000Z',
|
||||
to: '2026-02-16T00:00:00.000Z',
|
||||
timezone: 'UTC',
|
||||
bucket: 'Day',
|
||||
requestedSize: 7,
|
||||
effectiveSize: 7,
|
||||
},
|
||||
workspaceStorageBytes: 375,
|
||||
blobStorageBytes: 0,
|
||||
workspaceStorageHistory: [{ date: '2026-02-16', value: 375 }],
|
||||
blobStorageHistory: [{ date: '2026-02-16', value: 0 }],
|
||||
storageWindow: {
|
||||
from: '2026-01-18T00:00:00.000Z',
|
||||
to: '2026-02-16T00:00:00.000Z',
|
||||
timezone: 'UTC',
|
||||
bucket: 'day',
|
||||
requestedSize: 30,
|
||||
effectiveSize: 30,
|
||||
},
|
||||
generatedAt: '2026-02-16T19:30:00.000Z',
|
||||
},
|
||||
};
|
||||
|
||||
const mailDeliveryData = {
|
||||
adminMailDeliveries: {
|
||||
window: {
|
||||
from: '2026-02-15T20:00:00.000Z',
|
||||
to: '2026-02-16T20:00:00.000Z',
|
||||
timezone: 'UTC',
|
||||
bucket: 'Hour',
|
||||
requestedSize: 24,
|
||||
effectiveSize: 24,
|
||||
},
|
||||
summary: {
|
||||
total: 4,
|
||||
sent: 2,
|
||||
failed: 1,
|
||||
skipped: 0,
|
||||
canceled: 0,
|
||||
queued: 1,
|
||||
sending: 0,
|
||||
retryWait: 0,
|
||||
successRate: 2 / 3,
|
||||
},
|
||||
byStatus: [
|
||||
{
|
||||
key: 'sent',
|
||||
label: 'Sent',
|
||||
total: 2,
|
||||
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 2 }],
|
||||
},
|
||||
{
|
||||
key: 'failed',
|
||||
label: 'Failed',
|
||||
total: 1,
|
||||
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 1 }],
|
||||
},
|
||||
{
|
||||
key: 'queued',
|
||||
label: 'Queued',
|
||||
total: 1,
|
||||
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 1 }],
|
||||
},
|
||||
],
|
||||
byType: [
|
||||
{
|
||||
key: 'auth',
|
||||
label: 'auth',
|
||||
total: 2,
|
||||
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 2 }],
|
||||
},
|
||||
],
|
||||
byOutcome: [
|
||||
{
|
||||
key: 'successful',
|
||||
label: 'Successful',
|
||||
total: 2,
|
||||
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 2 }],
|
||||
},
|
||||
{
|
||||
key: 'unsuccessful',
|
||||
label: 'Unsuccessful',
|
||||
total: 1,
|
||||
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 1 }],
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: 'Pending',
|
||||
total: 1,
|
||||
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 1 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const topSharedLinksData = {
|
||||
adminDashboard: {
|
||||
topSharedLinks: [
|
||||
{
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'doc-1',
|
||||
title: 'Public doc',
|
||||
shareUrl: 'https://app.affine.pro/workspace/workspace-1/doc-1',
|
||||
publishedAt: '2026-02-16T10:00:00.000Z',
|
||||
views: 12,
|
||||
uniqueViews: 8,
|
||||
guestViews: 6,
|
||||
lastAccessedAt: '2026-02-16T19:00:00.000Z',
|
||||
},
|
||||
],
|
||||
topSharedLinksWindow: {
|
||||
from: '2026-01-20T00:00:00.000Z',
|
||||
to: '2026-02-16T00:00:00.000Z',
|
||||
timezone: 'UTC',
|
||||
bucket: 'Day',
|
||||
requestedSize: 28,
|
||||
effectiveSize: 28,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as any).environment = {
|
||||
isSelfHosted: true,
|
||||
};
|
||||
useQueryMock.mockReset();
|
||||
useQueryMock.mockImplementation(
|
||||
({ query }: { query: { id: string; query: string } }) => ({
|
||||
data:
|
||||
query.id === 'adminMailDeliveriesQuery'
|
||||
? mailDeliveryData
|
||||
: query.query.includes('topSharedLinks')
|
||||
? topSharedLinksData
|
||||
: dashboardData,
|
||||
isValidating: false,
|
||||
})
|
||||
);
|
||||
mutateQueryResourceMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('uses responsive tailwind breakpoints instead of hardcoded min-[1024px]', () => {
|
||||
const { container } = render(<DashboardPage />);
|
||||
const classes = Array.from(container.querySelectorAll('[class]'))
|
||||
.map(node => node.getAttribute('class') ?? '')
|
||||
.join(' ');
|
||||
|
||||
expect(classes).toContain('lg:grid-cols-12');
|
||||
expect(classes).toContain('lg:grid-cols-3');
|
||||
expect(classes).not.toContain('min-[1024px]');
|
||||
});
|
||||
|
||||
test('uses affine token color variables for trend chart lines', () => {
|
||||
render(<DashboardPage />);
|
||||
const styles = Array.from(document.querySelectorAll('style'))
|
||||
.map(node => node.textContent ?? '')
|
||||
.join('\n');
|
||||
|
||||
expect(styles).toContain('--color-primary: var(--primary);');
|
||||
expect(styles).toContain('--color-secondary: var(--muted-foreground);');
|
||||
expect(styles).not.toContain('hsl(var(--primary))');
|
||||
});
|
||||
|
||||
test('renders mail delivery analytics controls and summary', () => {
|
||||
const { getAllByText, getByText, queryByText } = render(<DashboardPage />);
|
||||
|
||||
expect(getByText('Email Delivery Trend')).toBeTruthy();
|
||||
expect(queryByText('Window Controls')).toBeNull();
|
||||
expect(getByText('Status')).toBeTruthy();
|
||||
expect(getAllByText('24h').length).toBeGreaterThan(0);
|
||||
expect(getByText('Success rate')).toBeTruthy();
|
||||
expect(getByText('66.7%')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('sends independent dashboard windows to the overview query', () => {
|
||||
render(<DashboardPage />);
|
||||
|
||||
const overviewCall = useQueryMock.mock.calls.find(([arg]) => {
|
||||
const query = (arg as { query: { query: string } }).query.query;
|
||||
return query.includes('syncActiveUsersTimeline');
|
||||
});
|
||||
|
||||
expect(overviewCall).toBeTruthy();
|
||||
expect(overviewCall![0]).toMatchObject({
|
||||
variables: {
|
||||
input: {
|
||||
storageHistoryDays: 30,
|
||||
syncHistoryHours: 48,
|
||||
copilotWindowDays: 7,
|
||||
sharedLinkWindowDays: 28,
|
||||
timezone: 'UTC',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('renders top shared links range control in cloud mode', () => {
|
||||
(globalThis as any).environment = {
|
||||
isSelfHosted: false,
|
||||
};
|
||||
|
||||
const { getByLabelText, getByText } = render(<DashboardPage />);
|
||||
|
||||
expect(getByText('Top Shared Links')).toBeTruthy();
|
||||
expect(getByLabelText('Top shared links range')).toBeTruthy();
|
||||
expect(getByText('28d')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
1458
packages/frontend/admin/src/modules/dashboard/index.tsx
Normal file
1458
packages/frontend/admin/src/modules/dashboard/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user