feat: refactor admin panel style

This commit is contained in:
DarkSky
2026-02-17 10:43:46 +08:00
parent 2cb3e08b55
commit 1ecfed91ff
56 changed files with 869 additions and 685 deletions

View File

@@ -13,6 +13,7 @@ import {
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';
@@ -94,53 +95,55 @@ function RootRoutes() {
export const App = () => {
return (
<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.queue}/*`} element={<Queue />} />
<Route path={ROUTES.admin.ai} element={<AI />} />
<Route path={ROUTES.admin.about} element={<About />} />
<Route
path={ROUTES.admin.settings.index}
element={<Settings />}
/>
<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.queue}/*`} element={<Queue />} />
<Route path={ROUTES.admin.ai} element={<AI />} />
<Route path={ROUTES.admin.about} element={<About />} />
<Route
path={ROUTES.admin.settings.index}
element={<Settings />}
/>
</Route>
</Route>
</Route>
</Routes>
</BrowserRouter>
</SWRConfig>
<Toaster />
</TooltipProvider>
</Routes>
</BrowserRouter>
</SWRConfig>
<Toaster />
</TooltipProvider>
</ThemeProvider>
);
};

View File

@@ -39,7 +39,7 @@ export const ConfirmDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle className="leading-7">{title}</DialogTitle>
<DialogDescription className="leading-6">
@@ -48,13 +48,19 @@ export const ConfirmDialog = ({
</DialogHeader>
<DialogFooter className="mt-6">
<div className="flex justify-end gap-2 items-center w-full">
<Button type="button" onClick={handleClose} variant="outline">
<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>

View File

@@ -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();
});
});

View File

@@ -21,6 +21,8 @@ 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[];
@@ -58,7 +60,7 @@ export function SharedDataTable<TData extends { id: string }, TValue>({
rowSelection,
onRowSelectionChange,
renderToolbar,
resetFiltersDeps = [],
resetFiltersDeps = DEFAULT_RESET_FILTERS_DEPS,
}: DataTableProps<TData, TValue>) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -88,13 +90,13 @@ export function SharedDataTable<TData extends { id: string }, TValue>({
});
return (
<div className="flex flex-col gap-4 py-5 px-6 h-full overflow-auto relative">
<div className="relative flex h-full flex-col gap-4 overflow-auto px-6 py-5">
{renderToolbar?.(table)}
<div className="rounded-md border h-full flex flex-col overflow-auto relative">
<div className="relative flex h-full flex-col overflow-auto rounded-xl border border-border bg-card shadow-1">
{loading ? (
<div className="absolute inset-0 z-10 bg-gray-50/70 backdrop-blur-[1px] flex flex-col items-center justify-center gap-2 text-sm text-gray-600">
<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-gray-500"
className="h-5 w-5 animate-spin text-primary"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@@ -119,7 +121,10 @@ export function SharedDataTable<TData extends { id: string }, TValue>({
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id} className="flex items-center">
<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
@@ -154,7 +159,7 @@ export function SharedDataTable<TData extends { id: string }, TValue>({
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className="flex items-center"
className="flex items-center bg-card"
>
{row.getVisibleCells().map(cell => {
const meta = cell.column.columnDef.meta as

View File

@@ -3,7 +3,6 @@ import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import type { FeatureType } from '@affine/graphql';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback } from 'react';
import { cn } from '../../utils';
@@ -42,10 +41,7 @@ export const FeatureToggleList = ({
if (!features.length) {
return (
<div
className={cn(className, 'px-3 py-2 text-xs')}
style={{ color: cssVarV2('text/secondary') }}
>
<div className={cn(className, 'px-3 py-2 text-xs text-muted-foreground')}>
No configurable features.
</div>
);

View File

@@ -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');
});
});

View 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>
);
};

View 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-md');
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);
});
});

View File

@@ -4,25 +4,25 @@ 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-md text-sm font-medium 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',
'inline-flex items-center justify-center whitespace-nowrap rounded-md 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 hover:bg-primary/90',
default:
'bg-primary text-primary-foreground shadow-1 hover:bg-primary/90 active:translate-y-px',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
'bg-destructive text-destructive-foreground shadow-1 hover:bg-destructive/90 active:translate-y-px',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
'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-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
default: 'h-9 px-4',
sm: 'h-8 px-3 text-xs',
lg: 'h-10 px-6',
icon: 'h-9 w-9',
},
},
defaultVariants: {

View File

@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
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',
'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}

View File

@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-9 w-full rounded-md 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}

View File

@@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
'flex h-9 w-full items-center justify-between rounded-md 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}
@@ -72,7 +72,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover 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',
'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
@@ -115,7 +115,7 @@ const SelectItem = React.forwardRef<
<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 focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'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}

View File

@@ -18,7 +18,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.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',
'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}

View File

@@ -13,7 +13,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
'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',

View File

@@ -8,7 +8,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
'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}
@@ -16,7 +16,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
'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>

View File

@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'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}

View File

@@ -15,7 +15,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md 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',
'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}

View File

@@ -6,35 +6,63 @@
@plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *));
@custom-variant dark (&:is(.dark *, [data-theme='dark'] *));
@theme {
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-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);
@@ -79,78 +107,47 @@
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(--color-gray-200, currentColor);
border-color: var(--border, currentColor);
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--background: var(--affine-v2-layer-background-primary);
--foreground: var(--affine-v2-text-primary);
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--card: var(--affine-v2-layer-background-secondary);
--card-foreground: var(--affine-v2-text-primary);
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--popover: var(--affine-v2-layer-background-overlayPanel);
--popover-foreground: var(--affine-v2-text-primary);
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--primary: var(--affine-v2-button-primary);
--primary-foreground: var(--affine-v2-button-pureWhiteText);
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--secondary: var(--affine-v2-layer-background-secondary);
--secondary-foreground: var(--affine-v2-text-primary);
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--muted: var(--affine-v2-layer-background-tertiary);
--muted-foreground: var(--affine-v2-text-secondary);
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--accent: var(--affine-v2-layer-background-hoverOverlay);
--accent-foreground: var(--affine-v2-text-primary);
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--destructive: var(--affine-v2-button-error);
--destructive-foreground: var(--affine-v2-button-pureWhiteText);
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--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);
}
}
@@ -158,7 +155,9 @@
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground antialiased;
font-family: var(--affine-font-family);
}
}

View 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']");
});
});

View File

@@ -74,7 +74,7 @@ export function AboutAFFiNE() {
))}
</div>
</div>
<div className="space-y-3 text-sm font-normal text-gray-500">
<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>

View File

@@ -5,7 +5,7 @@ import { AboutAFFiNE } from './about';
export function ConfigPage() {
return (
<div className=" h-screen flex-1 space-y-1 flex-col flex">
<div className="h-dvh flex-1 space-y-1 flex-col flex">
<Header title="Server" />
<ScrollArea>
<AboutAFFiNE />

View File

@@ -3,6 +3,7 @@ import {
AvatarFallback,
AvatarImage,
} from '@affine/admin/components/ui/avatar';
import { cn } from '@affine/admin/utils';
import { FeatureType } from '@affine/graphql';
import {
AccountIcon,
@@ -12,7 +13,6 @@ import {
UnlockIcon,
} from '@blocksuite/icons/rc';
import type { ColumnDef } from '@tanstack/react-table';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
type Dispatch,
type ReactNode,
@@ -39,10 +39,10 @@ const StatusItem = ({
textFalse: string;
}) => (
<div
className="flex gap-1 items-center"
style={{
color: condition ? cssVarV2('text/secondary') : cssVarV2('status/error'),
}}
className={cn(
'flex items-center gap-1',
condition ? 'text-muted-foreground' : 'text-destructive'
)}
>
{condition ? (
<>
@@ -152,36 +152,17 @@ export const useColumns = ({
<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 rounded px-2 py-0.5 text-xs h-5 border text-center inline-flex items-center font-normal"
style={{
borderRadius: '4px',
backgroundColor: cssVarV2('chip/label/blue'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
<span className="ml-2 inline-flex h-5 items-center rounded border border-border bg-chip-blue px-2 py-0.5 text-xs font-normal text-chip-text">
Admin
</span>
)}
{row.original.disabled && (
<span
className="ml-2 rounded px-2 py-0.5 text-xs h-5 border"
style={{
borderRadius: '4px',
backgroundColor: cssVarV2('chip/label/white'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
<span className="ml-2 inline-flex h-5 items-center rounded border border-border bg-chip-white px-2 py-0.5 text-xs">
Disabled
</span>
)}
</div>
<div
className="text-xs font-medium max-w-full overflow-hidden"
style={{
color: cssVarV2('text/secondary'),
}}
>
<div className="max-w-full overflow-hidden text-xs font-medium text-muted-foreground">
{row.original.email}
</div>
</div>
@@ -207,16 +188,10 @@ export const useColumns = ({
<StatusItem
condition={user.hasPassword}
IconTrue={
<LockIcon
fontSize={16}
color={cssVarV2('selfhost/icon/tertiary')}
/>
<LockIcon fontSize={16} className="text-muted-foreground" />
}
IconFalse={
<UnlockIcon
fontSize={16}
color={cssVarV2('toast/iconState/error')}
/>
<UnlockIcon fontSize={16} className="text-destructive" />
}
textTrue="Password Set"
textFalse="No Password"
@@ -226,13 +201,13 @@ export const useColumns = ({
IconTrue={
<EmailIcon
fontSize={16}
color={cssVarV2('selfhost/icon/tertiary')}
className="text-muted-foreground"
/>
}
IconFalse={
<EmailWarningIcon
fontSize={16}
color={cssVarV2('toast/iconState/error')}
className="text-destructive"
/>
}
textTrue="Email Verified"
@@ -244,24 +219,13 @@ export const useColumns = ({
user.features.map(feature => (
<span
key={feature}
className="rounded px-2 py-0.5 text-xs h-5 border inline-flex items-center"
style={{
borderRadius: '4px',
backgroundColor: cssVarV2('chip/label/white'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
className="inline-flex h-5 items-center rounded border border-border bg-chip-white px-2 py-0.5 text-xs"
>
{feature}
</span>
))
) : (
<span
style={{
color: cssVarV2('text/secondary'),
}}
>
No features
</span>
<span className="text-muted-foreground">No features</span>
)}
</div>
</div>

View File

@@ -168,13 +168,14 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
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-[5px] gap-2">
<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"
@@ -201,7 +202,7 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
<DropdownMenuSeparator />
{!user.disabled && (
<DropdownMenuItem
className="px-2 py-[6px] text-sm font-normal gap-2 text-red-500 cursor-pointer focus:text-red-500"
className="cursor-pointer gap-2 px-2 py-[6px] text-sm font-normal text-destructive focus:text-destructive"
onSelect={openDisableDialog}
>
<AccountBanIcon fontSize={20} />
@@ -209,7 +210,7 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
</DropdownMenuItem>
)}
<DropdownMenuItem
className="px-2 py-[6px] text-sm font-normal gap-2 text-red-500 cursor-pointer focus:text-red-500"
className="cursor-pointer gap-2 px-2 py-[6px] text-sm font-normal text-destructive focus:text-destructive"
onSelect={openDeleteDialog}
>
<DeleteIcon fontSize={20} />

View File

@@ -1,6 +1,4 @@
import { WarningIcon } from '@blocksuite/icons/rc';
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import type { FC } from 'react';
interface CsvFormatGuidanceProps {
@@ -17,16 +15,9 @@ export const CsvFormatGuidance: FC<CsvFormatGuidanceProps> = ({
passwordLimits,
}) => {
return (
<div
className="flex p-1.5 gap-1 rounded-[6px]"
style={{
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
backgroundColor: cssVarV2('layer/background/secondary'),
}}
>
<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} color={cssVarV2('icon/primary')} />
<WarningIcon fontSize={16} className="text-foreground" />
</div>
<div>
<p>CSV file includes username, email, and password.</p>

View File

@@ -1,6 +1,5 @@
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { UploadIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
type ChangeEvent,
type DragEvent,
@@ -86,37 +85,28 @@ export const FileUploadArea = forwardRef<
return (
<div
className={`flex justify-center p-8 border-2 border-dashed rounded-[6px] ${
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
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}
style={{
borderColor: isDragging
? cssVarV2('button/primary')
: cssVarV2('layer/insideBorder/blackBorder'),
}}
>
<div className="text-center">
<UploadIcon
fontSize={24}
className="mx-auto mb-3"
style={{
color: cssVarV2('selfhost/icon/secondary'),
}}
className="mx-auto mb-3 text-muted-foreground"
/>
<div
className="text-xs font-medium"
style={{ color: cssVarV2('text/secondary') }}
>
<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-gray-500">
<p className="mt-1 text-xs text-muted-foreground">
{isDragging ? 'Preparing to upload...' : ''}
</p>
</div>

View File

@@ -1,4 +1,3 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import type { FC, RefObject } from 'react';
import type { ParsedUser } from '../../utils/csv-utils';
@@ -21,7 +20,7 @@ export const ImportPreviewContent: FC<ImportPreviewContentProps> = ({
return (
<div className="grid gap-3">
{!isImported && (
<p style={{ color: cssVarV2('text/secondary') }}>
<p className="text-sm text-muted-foreground">
{parsedUsers.length} users detected from the CSV file. Please confirm
the user list below and import.
</p>
@@ -50,7 +49,7 @@ export const ImportInitialContent: FC<ImportInitialContentProps> = ({
}) => {
return (
<div className="grid gap-3">
<p style={{ color: cssVarV2('text/secondary') }}>
<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>

View File

@@ -9,7 +9,7 @@ export const Logo = () => {
>
<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="black"
fill="currentColor"
/>
</svg>
);

View File

@@ -3,7 +3,6 @@ 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 { cssVarV2 } from '@toeverything/theme/v2';
import { ChevronRightIcon } from 'lucide-react';
import type { ChangeEvent } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
@@ -114,15 +113,15 @@ function UserForm({
}, [defaultUser]);
return (
<div className="flex flex-col h-full gap-1">
<div className="flex h-full flex-col gap-2 bg-background">
<RightPanelHeader
title={title}
handleClose={handleClose}
handleConfirm={handleConfirm}
canSave={canSave}
/>
<div className="p-4 flex-grow overflow-y-auto space-y-[8px]">
<div className="flex flex-col rounded-md border">
<div className="flex-grow space-y-2 overflow-y-auto p-4">
<div className="flex flex-col rounded-lg border border-border bg-card">
<InputItem
label="User name"
field="name"
@@ -154,7 +153,7 @@ function UserForm({
</div>
<FeatureToggleList
className="border rounded-md"
className="rounded-lg border border-border bg-card"
features={serverConfig.availableUserFeatures}
selected={changes.features ?? []}
onChange={handleFeaturesChange}
@@ -192,23 +191,17 @@ function InputItem({
return (
<div className="flex flex-col gap-1.5 p-3">
<Label
className="text-[15px] font-medium flex-wrap flex"
style={{ lineHeight: '1.6rem' }}
>
<Label className="flex flex-wrap text-sm font-medium leading-6">
{label}
{optional && (
<span
className="font-normal ml-1"
style={{ color: cssVarV2('text/secondary') }}
>
<span className="ml-1 font-normal text-muted-foreground">
(optional)
</span>
)}
</Label>
<Input
type="text"
className="py-2 px-3 text-[15px] font-normal h-9"
className="py-2 px-3 text-sm font-normal h-9"
value={value}
onChange={onValueChange}
placeholder={placeholder}
@@ -318,7 +311,7 @@ export function UpdateUserForm({
actions={
<>
<Button
className="w-full flex items-center justify-between text-sm font-medium px-4 py-3"
className="h-9 w-full justify-between px-4 text-sm font-medium"
variant="outline"
onClick={onResetPassword}
>
@@ -326,7 +319,7 @@ export function UpdateUserForm({
<ChevronRightIcon size={16} />
</Button>
<Button
className="w-full text-red-500 px-4 py-3 rounded-md flex items-center justify-between text-sm font-medium hover:text-red-500"
className="h-9 w-full justify-between px-4 text-sm font-medium text-destructive hover:text-destructive"
variant="outline"
onClick={onDeleteAccount}
>

View File

@@ -9,34 +9,34 @@ interface UserTableProps {
*/
export const UserTable: React.FC<UserTableProps> = ({ users }) => {
return (
<div className="max-h-[300px] overflow-y-auto border rounded-md">
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border bg-card">
<table className="w-full border-collapse">
<thead className="bg-white sticky top-0">
<thead className="sticky top-0 bg-muted/40">
<tr>
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 tracking-wider ">
<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="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 tracking-wider">
<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="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 tracking-wider">
<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="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 tracking-wider">
<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-gray-200">
<tbody className="divide-y divide-border">
{users.map((user, index) => (
<tr
key={`${user.email}-${index}`}
className={`${user.valid === false ? 'bg-red-50' : ''}
${user.importStatus === ImportStatus.Failed ? 'bg-red-50' : ''}
${user.importStatus === ImportStatus.Success ? 'bg-green-50' : ''}
${user.importStatus === ImportStatus.Processing ? 'bg-yellow-50' : ''}`}
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="py-2 px-4 text-sm text-gray-900 truncate max-w-[150px]">
<td className="max-w-[150px] truncate px-4 py-2 text-sm text-foreground">
{user.name || '-'}
</td>
<td
@@ -44,8 +44,8 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
user.valid === false &&
(user.error?.toLowerCase().includes('email') ||
!user.error?.toLowerCase().includes('password'))
? 'text-red-500'
: 'text-gray-900'
? 'text-destructive'
: 'text-foreground'
}`}
>
{user.email}
@@ -54,36 +54,36 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
className={`py-2 px-4 text-sm truncate max-w-[150px] ${
user.valid === false &&
user.error?.toLowerCase().includes('password')
? 'text-red-500'
: 'text-gray-900'
? 'text-destructive'
: 'text-foreground'
}`}
>
{user.password || '-'}
</td>
<td className="py-2 px-4 text-sm">
{user.importStatus === ImportStatus.Success ? (
<span className="text-gray-900">
<span className="h-2 w-2 bg-gray-900 rounded-full inline-block mr-2" />
<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-red-500" title={user.importError}>
<span className="h-2 w-2 bg-red-500 rounded-full inline-block mr-2" />
<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-yellow-500">
<span className="h-2 w-2 bg-yellow-500 rounded-full inline-block mr-2" />
<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-red-500" title={user.error}>
<span className="h-2 w-2 bg-red-500 rounded-full inline-block mr-2" />
<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-gray-900">
<span className="h-2 w-2 bg-gray-900 rounded-full inline-block mr-2" />
<span className="text-foreground">
<span className="mr-2 inline-block h-2 w-2 rounded-full bg-foreground" />
Valid
</span>
)}

View File

@@ -42,7 +42,7 @@ export function AccountPage() {
}, [selectedUserIds, memoUsers]);
return (
<div className=" h-screen flex-1 flex-col flex">
<div className="h-dvh flex-1 flex-col flex">
<Header title="Accounts" />
<DataTable

View File

@@ -66,21 +66,21 @@ export function EditPrompt({
<div className="px-5 py-4 overflow-y-auto space-y-[10px] flex flex-col gap-5">
<div className="flex flex-col">
<div className="text-sm font-medium">Name</div>
<div className="text-sm font-normal text-zinc-500">
<div className="text-sm font-normal text-muted-foreground">
{item.name}
</div>
</div>
{item.action ? (
<div className="flex flex-col">
<div className="text-sm font-medium">Action</div>
<div className="text-sm font-normal text-zinc-500">
<div className="text-sm font-normal text-muted-foreground">
{item.action}
</div>
</div>
) : null}
<div className="flex flex-col">
<div className="text-sm font-medium">Model</div>
<div className="text-sm font-normal text-zinc-500">
<div className="text-sm font-normal text-muted-foreground">
{item.model}
</div>
</div>
@@ -91,7 +91,7 @@ export function EditPrompt({
<div key={key} className="flex flex-col">
{index !== 0 && <Separator />}
<span className="text-sm font-normal">{key}</span>
<span className="text-sm font-normal text-zinc-500">
<span className="text-sm font-normal text-muted-foreground">
{value?.toString()}
</span>
</div>
@@ -106,7 +106,7 @@ export function EditPrompt({
{index !== 0 && <Separator />}
<div>
<div className="text-sm font-normal">Role</div>
<div className="text-sm font-normal text-zinc-500">
<div className="text-sm font-normal text-muted-foreground">
{message.role}
</div>
</div>
@@ -120,7 +120,7 @@ export function EditPrompt({
{index !== 0 && <Separator />}
<span className="text-sm font-normal">{key}</span>
<span
className="text-sm font-normal text-zinc-500"
className="text-sm font-normal text-muted-foreground"
style={{ overflowWrap: 'break-word' }}
>
{value.toString()}

View File

@@ -9,7 +9,7 @@ function AiPage() {
const [enableAi, setEnableAi] = useState(false);
return (
<div className="h-screen flex-1 flex-col flex">
<div className="h-dvh flex-1 flex-col flex">
<Header title="AI" />
<ScrollAreaPrimitive.Root
className={cn('relative overflow-hidden w-full')}

View File

@@ -58,7 +58,7 @@ export function Keys() {
</div>
</div>
<Separator />
<div className="px-5 space-y-3 text-sm font-normal text-gray-500">
<div className="px-5 space-y-3 text-sm font-normal text-muted-foreground">
Custom API keys may not perform as expected. AFFiNE does not
guarantee results when using custom API keys.
</div>

View File

@@ -78,7 +78,7 @@ export function Auth() {
}
return (
<div className="w-full lg:grid lg:min-h-[600px] lg:grid-cols-2 xl:min-h-[800px] h-screen">
<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">

View File

@@ -0,0 +1,125 @@
/**
* @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', () => ({
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,
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',
},
};
describe('DashboardPage', () => {
beforeEach(() => {
(globalThis as any).environment = {
isSelfHosted: true,
};
useQueryMock.mockReset();
useQueryMock.mockReturnValue({
data: 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))');
});
});

View File

@@ -210,13 +210,13 @@ function TrendChart({
const config: ChartConfig = {
primary: {
label: primaryLabel,
color: 'hsl(var(--primary))',
color: 'var(--primary)',
},
...(hasSecondary
? {
secondary: {
label: secondaryLabel,
color: 'hsl(var(--foreground) / 0.6)',
color: 'var(--muted-foreground)',
},
}
: {}),
@@ -236,7 +236,7 @@ function TrendChart({
>
<CartesianGrid
vertical={false}
stroke="hsl(var(--border) / 0.6)"
stroke="var(--border)"
strokeDasharray="3 4"
/>
<XAxis
@@ -260,7 +260,7 @@ function TrendChart({
/>
<ChartTooltip
cursor={{
stroke: 'hsl(var(--border))',
stroke: 'var(--border)',
strokeDasharray: '4 4',
strokeWidth: 1,
}}
@@ -314,7 +314,7 @@ function TrendChart({
</LineChart>
</ChartContainer>
<div className="flex justify-between text-[11px] text-muted-foreground tabular-nums">
<div className="flex justify-between text-xxs text-muted-foreground tabular-nums">
<span>{points[0]?.label}</span>
<span>{points[points.length - 1]?.label}</span>
</div>
@@ -420,7 +420,7 @@ function WindowSelect({
function DashboardPageSkeleton() {
return (
<div className="h-screen flex-1 flex-col flex overflow-hidden">
<div className="h-dvh flex-1 flex-col flex overflow-hidden">
<Header
title="Dashboard"
endFix={
@@ -436,22 +436,22 @@ function DashboardPageSkeleton() {
<Skeleton className="h-5 w-36" />
<Skeleton className="h-4 w-80" />
</CardHeader>
<CardContent className="grid gap-3 grid-cols-1 min-[1024px]:grid-cols-3 items-end">
<CardContent className="grid grid-cols-1 items-end gap-3 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
<div className="grid gap-5 grid-cols-1 min-[1024px]:grid-cols-12">
<Skeleton className="h-28 w-full min-[1024px]:col-span-5" />
<Skeleton className="h-28 w-full min-[1024px]:col-span-3" />
<Skeleton className="h-28 w-full min-[1024px]:col-span-4" />
<div className="grid grid-cols-1 gap-5 lg:grid-cols-12">
<Skeleton className="h-28 w-full lg:col-span-5" />
<Skeleton className="h-28 w-full lg:col-span-3" />
<Skeleton className="h-28 w-full lg:col-span-4" />
</div>
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
<Skeleton className="h-72 w-full xl:col-span-1" />
<Skeleton className="h-72 w-full xl:col-span-2" />
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
<Skeleton className="h-72 w-full lg:col-span-1" />
<Skeleton className="h-72 w-full lg:col-span-2" />
</div>
<Skeleton className="h-64 w-full" />
@@ -661,7 +661,7 @@ function DashboardPageContent() {
dashboard.workspaceStorageBytes + dashboard.blobStorageBytes;
return (
<div className="h-screen flex-1 flex-col flex overflow-hidden">
<div className="h-dvh flex-1 flex-col flex overflow-hidden">
<Header
title="Dashboard"
endFix={
@@ -696,7 +696,7 @@ function DashboardPageContent() {
automatically.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 grid-cols-1 min-[1024px]:grid-cols-3 items-end">
<CardContent className="grid grid-cols-1 items-end gap-3 md:grid-cols-2 lg:grid-cols-3">
<WindowSelect
id="storage-history-window"
label="Storage History"
@@ -724,14 +724,14 @@ function DashboardPageContent() {
</CardContent>
</Card>
<div className="grid gap-5 grid-cols-1 min-[1024px]:grid-cols-12">
<div className="min-w-0 h-full min-[1024px]:col-span-5">
<div className="grid grid-cols-1 gap-5 lg:grid-cols-12">
<div className="h-full min-w-0 lg:col-span-5">
<PrimaryMetricCard
value={intFormatter.format(dashboard.syncActiveUsers)}
description={`${dashboard.syncWindow.effectiveSize}h active window`}
/>
</div>
<div className="min-w-0 h-full min-[1024px]:col-span-3">
<div className="h-full min-w-0 lg:col-span-3">
<SecondaryMetricCard
title="Copilot Conversations"
value={intFormatter.format(dashboard.copilotConversations)}
@@ -741,7 +741,7 @@ function DashboardPageContent() {
}
/>
</div>
<div className="min-w-0 h-full min-[1024px]:col-span-4">
<div className="h-full min-w-0 lg:col-span-4">
<Card className="h-full border-border/70 bg-gradient-to-br from-card via-card to-muted/15 shadow-sm">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
@@ -762,8 +762,8 @@ function DashboardPageContent() {
</div>
</div>
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
<Card className="xl:col-span-1 border-border/70 bg-card/95 shadow-sm">
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
<Card className="border-border/70 bg-card/95 shadow-sm lg:col-span-1">
<CardHeader>
<CardTitle className="text-base">
Sync Active Users Trend
@@ -782,7 +782,7 @@ function DashboardPageContent() {
</CardContent>
</Card>
<Card className="xl:col-span-2 border-border/70 bg-gradient-to-br from-primary/5 via-card to-card shadow-sm">
<Card className="border-border/70 bg-gradient-to-br from-primary/5 via-card to-card shadow-sm lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">
Storage Trend (Workspace + Blob)

View File

@@ -18,21 +18,21 @@ export const Header = ({
return (
<div>
<div className="flex items-center px-6 gap-4 h-[56px]">
<div className="flex h-14 items-center gap-4 px-6">
{isSmallScreen ? (
<div className="h-7 w-7 p-1" />
) : (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 p-1 hover:bg-gray-200 cursor-pointer"
className="h-7 w-7 cursor-pointer p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={togglePanel}
>
<SidebarIcon width={20} height={20} />
</Button>
)}
<Separator orientation="vertical" className="h-5" />
<div className="text-[15px] font-semibold">{title}</div>
<div className="text-sm font-semibold">{title}</div>
{endFix && <div className="ml-auto">{endFix}</div>}
</div>
<Separator />
@@ -53,11 +53,11 @@ export const RightPanelHeader = ({
}) => {
return (
<div>
<div className=" flex justify-between items-center h-[56px] px-6">
<div className="flex h-14 items-center justify-between px-6">
<Button
type="button"
size="icon"
className="w-7 h-7"
className="h-7 w-7"
variant="ghost"
onClick={handleClose}
>
@@ -67,7 +67,7 @@ export const RightPanelHeader = ({
<Button
type="submit"
size="icon"
className="w-7 h-7"
className="h-7 w-7"
variant="ghost"
onClick={handleConfirm}
disabled={!canSave}

View File

@@ -5,7 +5,6 @@ import {
import { Separator } from '@affine/admin/components/ui/separator';
import { TooltipProvider } from '@affine/admin/components/ui/tooltip';
import { cn } from '@affine/admin/utils';
import { cssVarV2 } from '@toeverything/theme/v2';
import { AlignJustifyIcon } from 'lucide-react';
import type { PropsWithChildren, ReactNode, RefObject } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -147,7 +146,7 @@ export function Layout({ children }: PropsWithChildren) {
}}
>
<TooltipProvider delayDuration={0}>
<div className="flex h-screen w-full overflow-hidden">
<div className="flex h-dvh w-full overflow-hidden">
<ResizablePanelGroup direction="horizontal">
<LeftPanel
panelRef={leftPanelRef as RefObject<ImperativePanelHandle>}
@@ -181,7 +180,11 @@ export const LeftPanel = ({
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" className="fixed top-5 left-6 p-0 h-5 w-5">
<Button
variant="ghost"
className="fixed left-4 top-4 z-20 h-8 w-8 rounded-lg border border-border bg-background/95 p-0 shadow-1 backdrop-blur"
size="icon"
>
<AlignJustifyIcon size={20} />
</Button>
</SheetTrigger>
@@ -191,11 +194,15 @@ export const LeftPanel = ({
Admin panel for managing accounts, AI, config, and settings
</SheetDescription>
</SheetHeader>
<SheetContent side="left" className="p-0" withoutCloseButton>
<SheetContent
side="left"
className="w-64 border-r border-border bg-sidebar-bg p-0"
withoutCloseButton
>
<div className="flex flex-col w-full h-full">
<div
className={cn(
'flex h-[52px] items-center gap-2 px-4 text-base font-medium'
'flex h-14 items-center gap-2 border-b border-border px-4 text-base font-semibold text-sidebar-foreground'
)}
>
<Logo />
@@ -223,21 +230,13 @@ export const LeftPanel = ({
onCollapse={onCollapse}
className={cn(
isCollapsed ? 'min-w-[57px] max-w-[57px]' : 'min-w-56 max-w-56',
'border-r h-dvh'
'h-dvh overflow-visible border-r border-border bg-sidebar-bg'
)}
style={{ overflow: 'visible' }}
>
<div
className="flex flex-col max-w-56 h-full "
style={{
backgroundColor: cssVarV2(
'selfhost/layer/background/sidebarBg/sidebarBg'
),
}}
>
<div className="flex h-full max-w-56 flex-col">
<div
className={cn(
'flex h-[56px] items-center px-4 text-base font-medium',
'flex h-14 items-center px-4 text-base font-semibold text-sidebar-foreground',
isCollapsed && 'justify-center px-2'
)}
>
@@ -283,7 +282,11 @@ export const RightPanel = ({
For displaying additional information
</SheetDescription>
</SheetHeader>
<SheetContent side="right" className="p-0" withoutCloseButton>
<SheetContent
side="right"
className="border-l border-border bg-background p-0"
withoutCloseButton
>
<div className="h-full overflow-y-auto">{panelContent}</div>
</SheetContent>
</Sheet>
@@ -301,7 +304,7 @@ export const RightPanel = ({
collapsedSize={0}
onExpand={onExpand}
onCollapse={onCollapse}
className="border-l max-w-96"
className="max-w-96 border-l border-border bg-card"
>
<div className="h-full overflow-y-auto">{panelContent}</div>
</ResizablePanel>

View File

@@ -0,0 +1,42 @@
/**
* @vitest-environment happy-dom
*/
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, describe, expect, test } from 'vitest';
import { NavItem } from './nav-item';
describe('NavItem', () => {
afterEach(() => {
cleanup();
});
test('applies selected style when route matches', () => {
render(
<MemoryRouter initialEntries={['/admin/accounts']}>
<NavItem to="/admin/accounts" label="Accounts" icon={<span>i</span>} />
</MemoryRouter>
);
const link = screen.getByRole('link');
expect(link.className).toContain('bg-sidebar-active');
expect(link.className).toContain('text-sidebar-foreground');
});
test('keeps label hidden in collapsed mode', () => {
render(
<MemoryRouter initialEntries={['/admin/accounts']}>
<NavItem
to="/admin/dashboard"
label="Dashboard"
isCollapsed={true}
icon={<span>i</span>}
/>
</MemoryRouter>
);
expect(screen.queryByText('Dashboard')).toBeNull();
expect(screen.getByRole('link').className).toContain('w-9');
});
});

View File

@@ -1,6 +1,4 @@
import { buttonVariants } from '@affine/admin/components/ui/button';
import { cn } from '@affine/admin/utils';
import { cssVarV2 } from '@toeverything/theme/v2';
import { NavLink } from 'react-router-dom';
interface NavItemProps {
@@ -11,52 +9,33 @@ interface NavItemProps {
isCollapsed?: boolean;
}
const navItemBaseClass =
'group inline-flex h-9 items-center gap-2 rounded-lg text-sm font-medium transition-colors duration-150';
const navItemStateClass =
'text-sidebar-foreground-secondary hover:bg-sidebar-hover hover:text-sidebar-foreground';
const navItemActiveClass = 'bg-sidebar-active text-sidebar-foreground';
export const NavItem = ({ icon, label, to, isCollapsed }: NavItemProps) => {
const className = ({ isActive }: { isActive: boolean }) =>
cn(
navItemBaseClass,
navItemStateClass,
isCollapsed ? 'w-9 justify-center px-0' : 'w-full justify-start px-2',
isActive && navItemActiveClass
);
if (isCollapsed) {
return (
<NavLink
to={to}
className={cn(
buttonVariants({
variant: 'ghost',
className: 'w-10 h-10',
size: 'icon',
})
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
'&:hover': {
backgroundColor: cssVarV2('selfhost/button/sidebarButton/bg/hover'),
},
})}
>
<NavLink to={to} className={className}>
{icon}
</NavLink>
);
}
return (
<NavLink
to={to}
className={cn(
buttonVariants({
variant: 'ghost',
}),
'justify-start flex-none text-sm font-medium px-2'
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
'&:hover': {
backgroundColor: cssVarV2('selfhost/button/sidebarButton/bg/hover'),
},
})}
>
{icon}
{label}
<NavLink to={to} className={className}>
<span className="flex items-center p-0.5">{icon}</span>
<span className="truncate">{label}</span>
</NavLink>
);
};

View File

@@ -1,77 +1,17 @@
import { buttonVariants } from '@affine/admin/components/ui/button';
import { cn } from '@affine/admin/utils';
import { ROUTES } from '@affine/routes';
import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
BarChart3Icon,
LayoutDashboardIcon,
ListChecksIcon,
} from 'lucide-react';
import { NavLink } from 'react-router-dom';
import { NavItem } from './nav-item';
import { ServerVersion } from './server-version';
import { SettingsItem } from './settings-item';
import { UserDropdown } from './user-dropdown';
interface NavItemProps {
icon: React.ReactNode;
label: string;
to: string;
isActive?: boolean;
isCollapsed?: boolean;
}
const NavItem = ({ icon, label, to, isCollapsed }: NavItemProps) => {
if (isCollapsed) {
return (
<NavLink
to={to}
className={cn(
buttonVariants({
variant: 'ghost',
className: 'w-10 h-10',
size: 'icon',
})
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
'&:hover': {
backgroundColor: cssVarV2('selfhost/button/sidebarButton/bg/hover'),
},
})}
>
{icon}
</NavLink>
);
}
return (
<NavLink
to={to}
className={cn(
buttonVariants({
variant: 'ghost',
}),
'justify-start flex-none text-sm font-medium px-2'
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
'&:hover': {
backgroundColor: cssVarV2('selfhost/button/sidebarButton/bg/hover'),
},
})}
>
<span className="flex items-center p-0.5 mr-2">{icon}</span>
{label}
</NavLink>
);
};
interface NavProps {
isCollapsed?: boolean;
}
@@ -80,13 +20,13 @@ export function Nav({ isCollapsed = false }: NavProps) {
return (
<div
className={cn(
'flex flex-col gap-4 py-2 justify-between flex-grow h-full overflow-hidden',
'flex h-full flex-grow flex-col justify-between gap-4 py-2',
isCollapsed && 'overflow-visible'
)}
>
<nav
className={cn(
'flex flex-1 flex-col gap-1 px-2 flex-grow overflow-y-auto overflow-x-hidden',
'flex flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto px-2',
isCollapsed && 'items-center px-0 gap-1 overflow-visible'
)}
>
@@ -134,7 +74,7 @@ export function Nav({ isCollapsed = false }: NavProps) {
</nav>
<div
className={cn(
'flex gap-2 px-2 flex-col overflow-hidden',
'flex flex-col gap-2 overflow-hidden px-2',
isCollapsed && 'items-center px-0 gap-1'
)}
>

View File

@@ -1,4 +1,3 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback } from 'react';
import { Button } from '../../components/ui/button';
@@ -19,7 +18,7 @@ export const ServerVersion = () => {
return (
<Button
variant="outline"
className="flex items-center justify-center gap-1 text-xs p-2 font-medium w-full overflow-hidden"
className="flex w-full items-center justify-center gap-1 overflow-hidden px-2 py-1.5 text-xs font-medium"
onClick={handleClick}
title={`New Version ${availableUpgrade.version} Available`}
>
@@ -32,12 +31,7 @@ export const ServerVersion = () => {
);
}
return (
<div
className="inline-flex items-center justify-between pt-2 border-t px-2 text-xs flex-nowrap gap-1"
style={{
color: cssVarV2('text/tertiary'),
}}
>
<div className="inline-flex flex-nowrap items-center justify-between gap-1 border-t border-border px-2 pt-2 text-xs text-muted-foreground">
<span>ServerVersion</span>
<span
className="overflow-hidden text-ellipsis whitespace-nowrap"

View File

@@ -1,54 +1,15 @@
import { buttonVariants } from '@affine/admin/components/ui/button';
import { cn } from '@affine/admin/utils';
import { ROUTES } from '@affine/routes';
import { SettingsIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import { NavLink } from 'react-router-dom';
import { NavItem } from './nav-item';
export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
if (isCollapsed) {
return (
<NavLink
to="/admin/settings"
className={cn(
buttonVariants({
variant: 'ghost',
className: 'w-10 h-10',
size: 'icon',
})
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
})}
>
<SettingsIcon fontSize={20} />
</NavLink>
);
}
return (
<NavLink
to="/admin/settings"
className={cn(
buttonVariants({
variant: 'ghost',
}),
'justify-start flex-none text-sm font-medium px-2'
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
'&:hover': {
backgroundColor: cssVarV2('selfhost/button/sidebarButton/bg/hover'),
},
})}
>
<span className="flex items-center p-0.5 mr-2">
<SettingsIcon fontSize={20} />
</span>
Settings
</NavLink>
<NavItem
to={ROUTES.admin.settings.index}
icon={<SettingsIcon fontSize={20} />}
label="Settings"
isCollapsed={isCollapsed}
/>
);
};

View File

@@ -13,7 +13,6 @@ import {
DropdownMenuTrigger,
} from '@affine/admin/components/ui/dropdown-menu';
import { MoreVerticalIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import { CircleUser } from 'lucide-react';
import { useCallback } from 'react';
import { toast } from 'sonner';
@@ -25,6 +24,9 @@ interface UserDropdownProps {
isCollapsed: boolean;
}
const adminBadgeClass =
'inline-flex h-5 items-center rounded border border-border bg-chip-blue px-2 py-0.5 text-xs font-normal text-chip-text';
const UserInfo = ({
name,
email,
@@ -44,21 +46,41 @@ const UserInfo = ({
</Avatar>
<div className="flex flex-col font-medium gap-1">
{name ?? email.split('@')[0]}
<span
className="w-fit rounded px-2 py-0.5 text-xs h-5 border text-center inline-flex items-center font-normal"
style={{
borderRadius: '4px',
backgroundColor: cssVarV2('chip/label/blue'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
Admin
</span>
<span className={adminBadgeClass}>Admin</span>
</div>
</>
);
};
const UserName = ({
name,
email,
}: {
name?: string | null;
email?: string;
}) => {
if (name) {
return (
<span
className="max-w-[120px] overflow-hidden text-ellipsis text-sm whitespace-nowrap"
title={name}
>
{name}
</span>
);
}
const fallback = email?.split('@')[0] ?? '';
return (
<span
className="max-w-[120px] overflow-hidden text-ellipsis text-sm whitespace-nowrap"
title={fallback}
>
{fallback}
</span>
);
};
export function UserDropdown({ isCollapsed }: UserDropdownProps) {
const currentUser = useCurrentUser();
const relative = useRevalidateCurrentUser();
@@ -78,8 +100,8 @@ export function UserDropdown({ isCollapsed }: UserDropdownProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-10 h-10" size="icon">
<Avatar className="w-5 h-5">
<Button variant="ghost" className="h-9 w-9 rounded-lg" size="icon">
<Avatar className="h-5 w-5">
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />
<AvatarFallback>
<CircleUser size={24} />
@@ -105,43 +127,24 @@ export function UserDropdown({ isCollapsed }: UserDropdownProps) {
}
return (
<div
className={`flex flex-none items-center ${isCollapsed ? 'justify-center' : 'justify-between'} px-1 py-3 flex-nowrap`}
>
<div className="flex items-center gap-2 font-medium text-ellipsis break-words overflow-hidden">
<Avatar className="w-5 h-5">
<div className="flex items-center justify-between px-1 py-3">
<div className="flex min-w-0 items-center gap-2 font-medium">
<Avatar className="h-5 w-5">
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />
<AvatarFallback>
<CircleUser size={24} />
</AvatarFallback>
</Avatar>
{currentUser?.name ? (
<span
className="text-sm text-nowrap text-ellipsis break-words overflow-hidden"
title={currentUser?.name}
>
{currentUser?.name}
</span>
) : (
// Fallback to email prefix if name is not available
<span className="text-sm" title={currentUser?.email.split('@')[0]}>
{currentUser?.email.split('@')[0]}
</span>
)}
<span
className="ml-2 rounded px-2 py-0.5 text-xs h-5 border text-center inline-flex items-center font-normal"
style={{
borderRadius: '4px',
backgroundColor: cssVarV2('chip/label/blue'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
Admin
</span>
<UserName name={currentUser?.name} email={currentUser?.email} />
<span className={adminBadgeClass}>Admin</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="ml-2 p-1 h-6">
<Button
variant="ghost"
className="ml-2 h-7 w-7 rounded-lg p-0"
size="icon"
>
<MoreVerticalIcon fontSize={20} />
</Button>
</DropdownMenuTrigger>

View File

@@ -1,4 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import '@queuedash/ui/dist/styles.css';
import './queue.css';
@@ -8,7 +8,7 @@ import { Header } from '../header';
export function QueuePage() {
return (
<div className="h-screen flex-1 flex-col flex overflow-hidden">
<div className="h-dvh flex-1 flex-col flex overflow-hidden">
<Header title="Queue" />
<div className="flex-1 overflow-hidden">
<QueueDashApp

View File

@@ -92,14 +92,14 @@ describe('ConfigRow', () => {
});
expect(screen.queryByText('Invalid JSON format')).not.toBeNull();
expect(textarea.className).toContain('border-red-500');
expect(textarea.className).toContain('border-destructive');
fireEvent.change(textarea, {
target: { value: '["localhost"]' },
});
expect(screen.queryByText('Invalid JSON format')).toBeNull();
expect(textarea.className).not.toContain('border-red-500');
expect(textarea.className).not.toContain('border-destructive');
expect(handleChange).toHaveBeenLastCalledWith('server/hosts', [
'localhost',
]);

View File

@@ -120,7 +120,7 @@ const Inputs: Record<
className={cn(
'w-full',
error
? 'border-red-500 hover:border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500'
? 'border-destructive hover:border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20'
: undefined
)}
/>
@@ -182,14 +182,21 @@ export const ConfigRow = ({
return (
<div
className={`flex justify-between flex-grow space-y-[10px]
${type === 'Boolean' ? 'flex-row' : 'flex-col'}`}
className={cn(
'flex flex-grow gap-3',
type === 'Boolean' ? 'items-start justify-between' : 'flex-col'
)}
>
<div
className="text-base font-bold flex-3"
className="flex-3 text-sm font-semibold leading-6 text-foreground"
dangerouslySetInnerHTML={{ __html: desc }}
/>
<div className="flex flex-col items-end relative flex-1">
<div
className={cn(
'relative flex flex-1 flex-col',
type === 'Boolean' ? 'items-end' : 'items-stretch'
)}
>
<Input
defaultValue={defaultValue}
onChange={onValueChange}
@@ -198,7 +205,7 @@ export const ConfigRow = ({
{...props}
/>
{mergedError && (
<div className="mt-1 w-full text-sm break-words text-red-500">
<div className="mt-1 w-full break-words text-sm text-destructive">
{mergedError}
</div>
)}

View File

@@ -32,7 +32,7 @@ export function SettingsPage() {
const [expandedModules, setExpandedModules] = useState<string[]>([]);
return (
<div className="h-screen flex-1 flex-col flex">
<div className="flex h-dvh flex-1 flex-col bg-background">
<Header title="Settings" />
<AdminPanel
expandedModules={expandedModules}
@@ -133,7 +133,7 @@ const AdminPanel = ({
return (
<ScrollArea className="h-full">
<div className="flex flex-col gap-4 py-5 px-6 w-full max-w-[900px] mx-auto">
<div className="mx-auto flex w-full max-w-[900px] flex-col gap-4 px-6 py-5">
<Accordion
type="multiple"
className="w-full"
@@ -156,12 +156,12 @@ const AdminPanel = ({
key={module}
value={module}
id={`config-module-${module}`}
className="border border-border rounded-xl px-5 mb-4"
className="mb-4 rounded-xl border border-border bg-card px-5 shadow-1"
>
<AccordionTrigger className="hover:no-underline py-4">
<div className="flex flex-col items-start text-left gap-1">
<div className="text-lg font-semibold">{name}</div>
<div className="text-sm text-muted-foreground">
<div className="text-base font-semibold">{name}</div>
<div className="text-xs text-muted-foreground">
Manage {name.toLowerCase()} settings
</div>
</div>
@@ -223,6 +223,7 @@ const AdminPanel = ({
<Button
type="button"
variant="outline"
className="h-9 min-w-[88px]"
onClick={() => {
onResetGroup(module);
clearModuleErrors(module);
@@ -234,6 +235,7 @@ const AdminPanel = ({
) : null}
<Button
type="button"
className="h-9 min-w-[88px]"
onClick={() => {
onSaveGroup(module).catch(err => {
console.error(err);

View File

@@ -80,7 +80,7 @@ export const CreateAdmin = ({
required
/>
<p
className={`absolute text-sm text-red-500 -bottom-6 ${invalidEmail ? '' : 'opacity-0 pointer-events-none'}`}
className={`absolute text-sm text-destructive -bottom-6 ${invalidEmail ? '' : 'opacity-0 pointer-events-none'}`}
>
Invalid email address.
</p>
@@ -99,7 +99,7 @@ export const CreateAdmin = ({
required
/>
<p
className={`text-sm text-muted-foreground ${invalidPassword && 'text-red-500'}`}
className={`text-sm text-muted-foreground ${invalidPassword && 'text-destructive'}`}
>
{invalidPassword ? 'Invalid password. ' : ''}Please enter{' '}
{String(passwordLimits.minLength)}-

View File

@@ -12,7 +12,7 @@ export function Setup() {
}
return (
<div className="w-full lg:grid lg:grid-cols-2 h-screen">
<div className="w-full lg:grid lg:grid-cols-2 h-dvh">
<div className="flex items-center justify-center py-12 h-full">
<Form />
</div>

View File

@@ -1,5 +1,5 @@
<svg width="730" height="703" viewBox="0 0 730 703" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M778.049 644.705C764.467 620.365 741.846 579.955 719.499 540.04C712.719 527.929 705.985 515.888 699.526 504.342C686.24 480.591 674.142 458.937 665.514 443.48C605.64 336.671 487.764 125.197 428.872 20.6265C410.725 -8.5204 368.678 -6.21127 353.065 23.9724C335.352 55.664 315.858 90.5131 295.268 127.341C288.74 139.028 282.075 150.928 275.341 162.968C189.559 316.313 89.1678 495.813 17.6524 623.663C14.0002 630.497 7.19787 641.642 3.79672 649.017C-2.161 662.33 -0.99684 678.918 6.51308 691.217C14.9817 705.779 30.8233 713.767 47.0073 712.942C66.4783 712.942 107.771 712.918 161.163 712.942C173.831 712.942 187.185 712.942 201.086 712.942C386.734 712.942 670.33 713.06 739.928 712.942C773.734 713.013 794.94 674.747 778.094 644.681L778.049 644.705ZM378.425 508.89L344.254 447.792C338.251 437.048 345.76 423.617 357.767 423.617H426.11C438.139 423.617 445.649 437.048 439.623 447.792L405.452 508.89C399.448 519.635 384.429 519.635 378.402 508.89H378.425ZM321.176 395.106C318.346 387.661 315.903 380.097 313.895 372.416L426.954 395.106H321.176ZM372.81 555.85C367.993 562.118 362.858 568.079 357.425 573.734L319.921 461.317L372.787 555.85H372.81ZM481.875 429.319C489.522 430.497 497.1 432.123 504.542 434.15L428.986 523.876L481.875 429.319ZM306.933 334.126C305.723 322.769 305.198 311.294 305.243 299.796L454.552 374.984L306.91 334.15L306.933 334.126ZM289.219 446.685L328.755 599.017C319.83 605.779 310.471 612 300.792 617.702L289.196 446.685H289.219ZM540.151 447.085C550.286 451.68 560.17 456.958 569.803 462.755L432.068 558.654L540.151 447.085ZM308.667 251.304C311.407 230.428 315.63 209.857 320.743 190.136L547.57 393.669L308.69 251.304H308.667ZM258.403 638.932C239.526 646.92 220.169 653.423 201.063 658.701L258.403 354.202V638.932ZM608.767 490.04C624.906 502.929 640.062 516.996 654.055 531.416L369.888 632.405L608.767 490.04ZM401.868 75.6922C443.686 150.598 504.405 259.221 562.247 362.614L346.285 139.688C358.977 117.021 371.007 95.5083 382.1 75.6451C386.506 67.7988 397.463 67.7988 401.868 75.6451V75.6922ZM64.5609 643.362C76.4535 622.179 92.7972 593.126 96.4267 586.434C130.849 524.889 177.21 442.019 225.1 356.393L146.006 661.057C117.678 661.057 93.2538 661.057 74.4447 661.057C65.6565 661.057 60.1553 651.232 64.5609 643.385V643.362ZM709.501 661.104C643.555 661.104 515.521 661.104 388.4 661.104L683.57 579.46C699.046 607.122 711.441 629.271 719.385 643.456C723.79 651.302 718.289 661.104 709.523 661.104H709.501Z"
fill="black" fill-opacity="0.05" />
fill="currentColor" fill-opacity="0.05" />
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -5,7 +5,6 @@ import {
} from '@affine/admin/components/ui/avatar';
import { AccountIcon, LinkIcon } from '@blocksuite/icons/rc';
import type { ColumnDef } from '@tanstack/react-table';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useMemo } from 'react';
import type { WorkspaceListItem } from '../schema';
@@ -27,42 +26,27 @@ export const useColumns = () => {
{workspace.name || workspace.id}
</span>
{workspace.public ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded border"
style={{
backgroundColor: cssVarV2('chip/label/white'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
>
<span className="inline-flex items-center gap-1 rounded border border-border bg-chip-white px-2 py-0.5 text-xxs">
<LinkIcon fontSize={14} />
Public
</span>
) : null}
</div>
<div
className="text-xs font-mono truncate w-full"
style={{ color: cssVarV2('text/secondary') }}
>
<div className="w-full truncate font-mono text-xs text-muted-foreground">
{workspace.id}
</div>
<div className="flex flex-wrap gap-2 text-[11px]">
<div className="flex flex-wrap gap-2 text-xxs">
{workspace.features.length ? (
workspace.features.map(feature => (
<span
key={feature}
className="px-2 py-0.5 rounded border"
style={{
backgroundColor: cssVarV2('chip/label/white'),
borderColor: cssVarV2('layer/insideBorder/border'),
}}
className="rounded border border-border bg-chip-white px-2 py-0.5"
>
{feature}
</span>
))
) : (
<span style={{ color: cssVarV2('text/secondary') }}>
No features
</span>
<span className="text-muted-foreground">No features</span>
)}
</div>
</div>
@@ -75,14 +59,7 @@ export const useColumns = () => {
cell: ({ row }) => {
const owner = row.original.owner;
if (!owner) {
return (
<div
className="text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
Unknown
</div>
);
return <div className="text-xs text-muted-foreground">Unknown</div>;
}
return (
<div className="flex items-center gap-3 min-w-[180px] min-w-0">
@@ -94,10 +71,7 @@ export const useColumns = () => {
</Avatar>
<div className="flex flex-col overflow-hidden min-w-0">
<div className="text-sm font-medium truncate">{owner.name}</div>
<div
className="text-xs truncate"
style={{ color: cssVarV2('text/secondary') }}
>
<div className="truncate text-xs text-muted-foreground">
{owner.email}
</div>
</div>
@@ -114,15 +88,13 @@ export const useColumns = () => {
<div className="flex flex-col gap-1 text-xs">
<div className="flex gap-3">
<span>Snapshot {formatBytes(ws.snapshotSize)}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
<span className="text-muted-foreground">
({ws.snapshotCount})
</span>
</div>
<div className="flex gap-3">
<span>Blobs {formatBytes(ws.blobSize)}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
({ws.blobCount})
</span>
<span className="text-muted-foreground">({ws.blobCount})</span>
</div>
</div>
);
@@ -137,15 +109,11 @@ export const useColumns = () => {
<div className="flex flex-col text-xs gap-1">
<div className="flex gap-2">
<span className="font-medium">{ws.memberCount}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
members
</span>
<span className="text-muted-foreground">members</span>
</div>
<div className="flex gap-2">
<span className="font-medium">{ws.publicPageCount}</span>
<span style={{ color: cssVarV2('text/secondary') }}>
shared pages
</span>
<span className="text-muted-foreground">shared pages</span>
</div>
</div>
);

View File

@@ -14,7 +14,6 @@ import {
adminWorkspacesQuery,
} from '@affine/graphql';
import { AccountIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
@@ -53,10 +52,7 @@ export function WorkspacePanel({
handleConfirm={onClose}
canSave={false}
/>
<div
className="p-6 text-sm"
style={{ color: cssVarV2('text/secondary') }}
>
<div className="p-6 text-sm text-muted-foreground">
Workspace not found.
</div>
</div>
@@ -173,29 +169,19 @@ function WorkspacePanelContent({
const memberList = workspace.members ?? [];
return (
<div className="flex flex-col h-full">
<div className="flex h-full flex-col bg-background">
<RightPanelHeader
title="Update Workspace"
handleClose={onClose}
handleConfirm={handleSave}
canSave={hasChanges && !isMutating}
/>
<div className="p-4 flex flex-col gap-4 overflow-y-auto">
<div className="border rounded-md p-3 space-y-2">
<div
className="text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
Workspace ID
</div>
<div className="flex flex-col gap-4 overflow-y-auto p-4">
<div className="space-y-2 rounded-lg border border-border bg-card p-3">
<div className="text-xs text-muted-foreground">Workspace ID</div>
<div className="text-sm font-mono break-all">{workspace.id}</div>
<div className="flex flex-col gap-1">
<Label
className="text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
Name
</Label>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
value={flags.name}
onChange={e =>
@@ -206,7 +192,7 @@ function WorkspacePanelContent({
</div>
</div>
<div className="border rounded-md">
<div className="rounded-lg border border-border bg-card">
<FlagItem
label="Public"
description="Allow public access to workspace pages"
@@ -253,7 +239,7 @@ function WorkspacePanelContent({
/>
</div>
<div className="border rounded-md p-3 space-y-3">
<div className="space-y-3 rounded-lg border border-border bg-card p-3">
<div className="text-sm font-medium">Features</div>
<FeatureToggleList
features={serverConfig.availableWorkspaceFeatures ?? []}
@@ -286,15 +272,12 @@ function WorkspacePanelContent({
/>
</div>
<div className="border rounded-md">
<div className="rounded-lg border border-border bg-card">
<div className="px-3 py-2 text-sm font-medium">Members</div>
<Separator />
<div className="flex flex-col divide-y">
{memberList.length === 0 ? (
<div
className="px-3 py-3 text-xs"
style={{ color: cssVarV2('text/secondary') }}
>
<div className="px-3 py-3 text-xs text-muted-foreground">
No members.
</div>
) : (
@@ -313,10 +296,7 @@ function WorkspacePanelContent({
<div className="text-sm font-medium truncate">
{member.name || member.email}
</div>
<div
className="text-xs truncate"
style={{ color: cssVarV2('text/secondary') }}
>
<div className="truncate text-xs text-muted-foreground">
{member.email}
</div>
</div>
@@ -348,9 +328,7 @@ function FlagItem({
<div className="flex items-start justify-between gap-2 p-3">
<div className="flex flex-col">
<div className="text-sm font-medium">{label}</div>
<div className="text-xs" style={{ color: cssVarV2('text/secondary') }}>
{description}
</div>
<div className="text-xs text-muted-foreground">{description}</div>
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
</div>
@@ -359,10 +337,8 @@ function FlagItem({
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="border rounded-md p-3 flex flex-col gap-1">
<div className="text-xs" style={{ color: cssVarV2('text/secondary') }}>
{label}
</div>
<div className="flex flex-col gap-1 rounded-lg border border-border bg-card p-3">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-sm font-semibold">{value}</div>
</div>
);

View File

@@ -1,6 +1,5 @@
import { Separator } from '@affine/admin/components/ui/separator';
import { adminWorkspaceQuery } from '@affine/graphql';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useMemo } from 'react';
import { useQuery } from '../../../use-query';
@@ -44,10 +43,7 @@ export function WorkspaceSharedLinksPanel({
handleConfirm={onClose}
canSave={false}
/>
<div
className="p-6 text-sm"
style={{ color: cssVarV2('text/secondary') }}
>
<div className="p-6 text-sm text-muted-foreground">
Workspace not found.
</div>
</div>
@@ -55,23 +51,18 @@ export function WorkspaceSharedLinksPanel({
}
return (
<div className="flex flex-col h-full">
<div className="flex h-full flex-col bg-background">
<RightPanelHeader
title="Shared Links"
handleClose={onClose}
handleConfirm={onClose}
canSave={false}
/>
<div className="p-4 flex flex-col gap-3 overflow-y-auto">
<div className="flex flex-col gap-3 overflow-y-auto p-4">
{sharedLinks.length === 0 ? (
<div
className="text-sm"
style={{ color: cssVarV2('text/secondary') }}
>
No shared links.
</div>
<div className="text-sm text-muted-foreground">No shared links.</div>
) : (
<div className="flex flex-col divide-y rounded-md border">
<div className="flex flex-col divide-y rounded-lg border border-border bg-card">
{sharedLinks.map(link => (
<SharedLinkItem key={link.docId} link={link} />
))}
@@ -91,9 +82,7 @@ function SharedLinkItem({ link }: { link: WorkspaceSharedLink }) {
<div className="text-sm font-medium truncate">{title}</div>
<div className="flex items-center gap-2 text-xs">
<Separator className="h-3" orientation="vertical" />
<span style={{ color: cssVarV2('text/secondary') }}>
Shared on {sharedDate}
</span>
<span className="text-muted-foreground">Shared on {sharedDate}</span>
</div>
</div>
);

View File

@@ -27,7 +27,7 @@ export function WorkspacePage() {
const columns = useColumns();
return (
<div className="h-screen flex-1 flex-col flex">
<div className="h-dvh flex-1 flex-col flex">
<Header title="Workspaces" />
<DataTable

View File

@@ -1,8 +1,16 @@
/** @type {import('tailwindcss').Config} */
const { baseTheme, themeToVar } = require('@toeverything/theme');
const themeVar = (key, fallback) =>
`var(${themeToVar(key)}${fallback ? `, ${fallback}` : ''})`;
module.exports = {
darkMode: ['class'],
// TODO(@forehalo): we are not running webpack in admin dir
content: ['./packages/frontend/admin/src/**/*.{ts,tsx}'],
// Keep both roots so class scanning works in monorepo-root and package-root runs.
content: [
'./src/**/*.{ts,tsx}',
'./packages/frontend/admin/src/**/*.{ts,tsx}',
],
prefix: '',
theme: {
container: {
@@ -13,46 +21,87 @@ module.exports = {
},
},
extend: {
fontFamily: {
sans: themeVar('fontFamily', baseTheme.fontFamily),
mono: themeVar('fontCodeFamily', baseTheme.fontCodeFamily),
},
fontSize: {
xxs: '11px',
base: themeVar('fontBase', baseTheme.fontBase),
sm: themeVar('fontSm', baseTheme.fontSm),
xs: themeVar('fontXs', baseTheme.fontXs),
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
border: 'var(--border)',
input: 'var(--input)',
ring: 'var(--ring)',
background: 'var(--background)',
foreground: 'var(--foreground)',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
DEFAULT: 'var(--primary)',
foreground: 'var(--primary-foreground)',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
DEFAULT: 'var(--secondary)',
foreground: 'var(--secondary-foreground)',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
DEFAULT: 'var(--destructive)',
foreground: 'var(--destructive-foreground)',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
DEFAULT: 'var(--muted)',
foreground: 'var(--muted-foreground)',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
DEFAULT: 'var(--accent)',
foreground: 'var(--accent-foreground)',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
DEFAULT: 'var(--popover)',
foreground: 'var(--popover-foreground)',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
DEFAULT: 'var(--card)',
foreground: 'var(--card-foreground)',
},
// Selfhost sidebar tokens
sidebar: {
bg: 'var(--affine-v2-selfhost-layer-background-sidebarBg-sidebarBg)',
foreground: 'var(--affine-v2-selfhost-text-sidebar-primary)',
'foreground-secondary':
'var(--affine-v2-selfhost-text-sidebar-secondary)',
hover: 'var(--affine-v2-selfhost-button-sidebarButton-bg-hover)',
active: 'var(--affine-v2-selfhost-button-sidebarButton-bg-select)',
},
// Chip / badge tokens
chip: {
blue: 'var(--affine-v2-chip-label-blue)',
white: 'var(--affine-v2-chip-label-white)',
text: 'var(--affine-v2-chip-label-text)',
},
// Toggle tokens
toggle: {
on: 'var(--affine-v2-selfhost-toggle-backgroundOn)',
off: 'var(--affine-v2-selfhost-toggle-backgroundOff)',
thumb: 'var(--affine-v2-selfhost-toggle-foreground)',
},
},
borderRadius: {
lg: 'var(--radius)',
lg: `var(--radius, ${themeVar('popoverRadius')})`,
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
spacing: {
paragraph: themeVar('paragraphSpace', baseTheme.paragraphSpace),
},
boxShadow: {
menu: themeVar('menuShadow'),
overlay: themeVar('overlayShadow'),
1: themeVar('shadow1'),
2: themeVar('shadow2'),
3: themeVar('shadow3'),
},
keyframes: {
'accordion-down': {
from: { height: '0' },