Compare commits

...

2 Commits

Author SHA1 Message Date
Neo
35e1411407 fix: docTitle unexpectedly translated (#14467)
fix #14465

In Chinese mode, the document with the specified name may not be
displayed correctly in the sidebar, and it may be mistaken for the
translation of the content that needs to be translated.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed document title display in navigation panels on desktop and
mobile to properly render without additional processing steps.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-17 13:45:31 +00:00
DarkSky
8f833388eb feat: improve admin panel design (#14464) 2026-02-17 17:40:29 +08:00
88 changed files with 2635 additions and 1433 deletions

View File

@@ -222,7 +222,7 @@
},
"SMTP.sender": {
"type": "string",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")\n@default \"AFFiNE Self Hosted <noreply@example.com>\"\n@environment `MAILER_SENDER`",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted &lt;noreply@example.com&gt;\")\n@default \"AFFiNE Self Hosted <noreply@example.com>\"\n@environment `MAILER_SENDER`",
"default": "AFFiNE Self Hosted <noreply@example.com>"
},
"SMTP.ignoreTLS": {
@@ -262,7 +262,7 @@
},
"fallbackSMTP.sender": {
"type": "string",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")\n@default \"\"",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted &lt;noreply@example.com&gt;\")\n@default \"\"",
"default": ""
},
"fallbackSMTP.ignoreTLS": {

View File

@@ -148,7 +148,7 @@ jobs:
name: Wait for approval
with:
secret: ${{ secrets.GITHUB_TOKEN }}
approvers: darkskygit,pengx17,L-Sun,EYHN
approvers: darkskygit
minimum-approvals: 1
fail-on-denial: true
issue-title: Please confirm to release docker image

View File

@@ -56,7 +56,7 @@ defineModuleConfig('mailer', {
env: 'MAILER_PASSWORD',
},
'SMTP.sender': {
desc: 'Sender of all the emails (e.g. "AFFiNE Self Hosted <noreply@example.com>")',
desc: 'Sender of all the emails (e.g. "AFFiNE Self Hosted &lt;noreply@example.com&gt;")',
default: 'AFFiNE Self Hosted <noreply@example.com>',
env: 'MAILER_SENDER',
},
@@ -92,7 +92,7 @@ defineModuleConfig('mailer', {
default: '',
},
'fallbackSMTP.sender': {
desc: 'Sender of all the emails (e.g. "AFFiNE Self Hosted <noreply@example.com>")',
desc: 'Sender of all the emails (e.g. "AFFiNE Self Hosted &lt;noreply@example.com&gt;")',
default: '',
},
'fallbackSMTP.ignoreTLS': {

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import {
Args,
Field,
Info,
InputType,
Int,
Mutation,
@@ -14,6 +15,12 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import {
type FragmentDefinitionNode,
type GraphQLResolveInfo,
Kind,
type SelectionNode,
} from 'graphql';
import { SafeIntResolver } from 'graphql-scalars';
import { PaginationInput, URLHelper } from '../../../base';
@@ -53,6 +60,44 @@ registerEnumType(AdminSharedLinksOrder, {
name: 'AdminSharedLinksOrder',
});
function hasSelectedField(
selections: readonly SelectionNode[],
fieldName: string,
fragments: Record<string, FragmentDefinitionNode>
): boolean {
for (const selection of selections) {
if (selection.kind === Kind.FIELD) {
if (selection.name.value === fieldName) {
return true;
}
continue;
}
if (selection.kind === Kind.INLINE_FRAGMENT) {
if (
hasSelectedField(
selection.selectionSet.selections,
fieldName,
fragments
)
) {
return true;
}
continue;
}
const fragment = fragments[selection.name.value];
if (
fragment &&
hasSelectedField(fragment.selectionSet.selections, fieldName, fragments)
) {
return true;
}
}
return false;
}
@InputType()
class ListWorkspaceInput {
@Field(() => Int, { defaultValue: 20 })
@@ -471,22 +516,40 @@ export class AdminWorkspaceResolver {
})
async adminDashboard(
@Args('input', { nullable: true, type: () => AdminDashboardInput })
input?: AdminDashboardInput
input?: AdminDashboardInput,
@Info() info?: GraphQLResolveInfo
) {
this.assertCloudOnly();
const includeTopSharedLinks = Boolean(
info?.fieldNodes.some(
node =>
node.selectionSet &&
hasSelectedField(
node.selectionSet.selections,
'topSharedLinks',
info.fragments
)
)
);
const dashboard = await this.models.workspaceAnalytics.adminGetDashboard({
timezone: input?.timezone,
storageHistoryDays: input?.storageHistoryDays,
syncHistoryHours: input?.syncHistoryHours,
sharedLinkWindowDays: input?.sharedLinkWindowDays,
includeTopSharedLinks,
});
return {
...dashboard,
topSharedLinks: dashboard.topSharedLinks.map(link => ({
...link,
shareUrl: this.url.link(`/workspace/${link.workspaceId}/${link.docId}`),
})),
topSharedLinks: includeTopSharedLinks
? dashboard.topSharedLinks.map(link => ({
...link,
shareUrl: this.url.link(
`/workspace/${link.workspaceId}/${link.docId}`
),
}))
: [],
};
}

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
import pkg from '../package.json' with { type: 'json' };
declare global {
// oxlint-disable-next-line no-shadow-restricted-names
namespace globalThis {
// oxlint-disable-next-line no-var
var env: Readonly<Env>;

View File

@@ -51,6 +51,7 @@ export type AdminDashboardOptions = {
storageHistoryDays?: number;
syncHistoryHours?: number;
sharedLinkWindowDays?: number;
includeTopSharedLinks?: boolean;
};
export type AdminAllSharedLinksOptions = {
@@ -262,6 +263,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
90,
DEFAULT_SHARED_LINK_WINDOW_DAYS
);
const includeTopSharedLinks = options.includeTopSharedLinks ?? true;
const now = new Date();
@@ -274,6 +276,66 @@ export class WorkspaceAnalyticsModel extends BaseModel {
const storageFrom = addUtcDays(currentDay, -(storageHistoryDays - 1));
const sharedFrom = addUtcDays(currentDay, -(sharedLinkWindowDays - 1));
const topSharedLinksPromise = includeTopSharedLinks
? this.db.$queryRaw<
{
workspaceId: string;
docId: string;
title: string | null;
publishedAt: Date | null;
docUpdatedAt: Date | null;
workspaceOwnerId: string | null;
lastUpdaterId: string | null;
views: bigint | number;
uniqueViews: bigint | number;
guestViews: bigint | number;
lastAccessedAt: Date | null;
}[]
>`
WITH view_agg AS (
SELECT
workspace_id,
doc_id,
COALESCE(SUM(total_views), 0) AS views,
COALESCE(SUM(unique_views), 0) AS unique_views,
COALESCE(SUM(guest_views), 0) AS guest_views,
MAX(last_accessed_at) AS last_accessed_at
FROM workspace_doc_view_daily
WHERE date BETWEEN ${sharedFrom}::date AND ${currentDay}::date
GROUP BY workspace_id, doc_id
)
SELECT
wp.workspace_id AS "workspaceId",
wp.page_id AS "docId",
wp.title AS title,
wp.published_at AS "publishedAt",
sn.updated_at AS "docUpdatedAt",
owner.user_id AS "workspaceOwnerId",
sn.updated_by AS "lastUpdaterId",
COALESCE(v.views, 0) AS views,
COALESCE(v.unique_views, 0) AS "uniqueViews",
COALESCE(v.guest_views, 0) AS "guestViews",
v.last_accessed_at AS "lastAccessedAt"
FROM workspace_pages wp
LEFT JOIN snapshots sn
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
LEFT JOIN view_agg v
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
LEFT JOIN LATERAL (
SELECT user_id
FROM workspace_user_permissions
WHERE workspace_id = wp.workspace_id
AND type = ${WorkspaceRole.Owner}
AND status = 'Accepted'::"WorkspaceMemberStatus"
ORDER BY created_at ASC
LIMIT 1
) owner ON TRUE
WHERE wp.public = TRUE
ORDER BY views DESC, "uniqueViews" DESC, "workspaceId" ASC, "docId" ASC
LIMIT 10
`
: Promise.resolve([]);
const [
syncCurrent,
syncTimeline,
@@ -350,63 +412,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
AND created_at >= ${sharedFrom}
AND created_at <= ${now}
`,
this.db.$queryRaw<
{
workspaceId: string;
docId: string;
title: string | null;
publishedAt: Date | null;
docUpdatedAt: Date | null;
workspaceOwnerId: string | null;
lastUpdaterId: string | null;
views: bigint | number;
uniqueViews: bigint | number;
guestViews: bigint | number;
lastAccessedAt: Date | null;
}[]
>`
WITH view_agg AS (
SELECT
workspace_id,
doc_id,
COALESCE(SUM(total_views), 0) AS views,
COALESCE(SUM(unique_views), 0) AS unique_views,
COALESCE(SUM(guest_views), 0) AS guest_views,
MAX(last_accessed_at) AS last_accessed_at
FROM workspace_doc_view_daily
WHERE date BETWEEN ${sharedFrom}::date AND ${currentDay}::date
GROUP BY workspace_id, doc_id
)
SELECT
wp.workspace_id AS "workspaceId",
wp.page_id AS "docId",
wp.title AS title,
wp.published_at AS "publishedAt",
sn.updated_at AS "docUpdatedAt",
owner.user_id AS "workspaceOwnerId",
sn.updated_by AS "lastUpdaterId",
COALESCE(v.views, 0) AS views,
COALESCE(v.unique_views, 0) AS "uniqueViews",
COALESCE(v.guest_views, 0) AS "guestViews",
v.last_accessed_at AS "lastAccessedAt"
FROM workspace_pages wp
LEFT JOIN snapshots sn
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
LEFT JOIN view_agg v
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
LEFT JOIN LATERAL (
SELECT user_id
FROM workspace_user_permissions
WHERE workspace_id = wp.workspace_id
AND type = ${WorkspaceRole.Owner}
AND status = 'Accepted'::"WorkspaceMemberStatus"
ORDER BY created_at ASC
LIMIT 1
) owner ON TRUE
WHERE wp.public = TRUE
ORDER BY views DESC, "uniqueViews" DESC, "workspaceId" ASC, "docId" ASC
LIMIT 10
`,
topSharedLinksPromise,
]);
const storageHistorySeries = storageHistory.map(row => ({

View File

@@ -132,6 +132,10 @@ export class IndexerJob {
indexed: true,
});
}
if (!missingDocIds.length && !deletedDocIds.length) {
this.logger.verbose(`workspace ${workspaceId} is already indexed`);
return;
}
this.logger.log(
`indexed workspace ${workspaceId} with ${missingDocIds.length} missing docs and ${deletedDocIds.length} deleted docs`
);

View File

@@ -60,6 +60,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/lodash-es": "^4.17.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -67,7 +68,8 @@
"shadcn-ui": "^0.9.5",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"vitest": "^3.2.4"
},
"scripts": {
"build": "affine bundle",

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,58 +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.settings.module}
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/60 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>
);
@@ -57,10 +53,10 @@ export const FeatureToggleList = ({
<div key={feature}>
<Label
className={cn(
'cursor-pointer',
'cursor-pointer transition-colors duration-100',
controlPosition === 'right'
? 'flex items-center justify-between p-3 text-[15px] gap-2 font-medium leading-6 overflow-hidden'
: 'flex items-center gap-2 px-3 py-2 text-sm'
? 'flex items-center justify-between p-3 text-sm gap-2 font-medium leading-6 overflow-hidden hover:bg-muted/40'
: 'flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted/40'
)}
>
{controlPosition === 'left' ? (

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-lg');
expect(button.className).toContain('bg-primary');
expect(button.className).toContain('focus-visible:ring-ring/30');
});
test('supports outline/sm variant with disabled state', () => {
render(
<Button variant="outline" size="sm" disabled={true}>
Cancel
</Button>
);
const button = screen.getByRole('button', { name: 'Cancel' });
expect(button.className).toContain('border-border');
expect(button.className).toContain('h-8');
expect(button.className).toContain('text-xs');
expect(button.hasAttribute('disabled')).toBe(true);
});
});

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-lg text-sm font-medium transition-[background-color,color,border-color,box-shadow,transform] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground 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

@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
'rounded-xl border bg-card text-card-foreground shadow-sm transition-shadow duration-200',
className
)}
{...props}

View File

@@ -4,7 +4,15 @@ import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import * as React from 'react';
import {
createContext,
forwardRef,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
@@ -27,10 +35,10 @@ type CarouselContextProps = {
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
const CarouselContext = createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
const context = useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
@@ -39,7 +47,7 @@ function useCarousel() {
return context;
}
const Carousel = React.forwardRef<
const Carousel = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
@@ -62,10 +70,10 @@ const Carousel = React.forwardRef<
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const [canScrollPrev, setCanScrollPrev] = useState(false);
const [canScrollNext, setCanScrollNext] = useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
const onSelect = useCallback((api: CarouselApi) => {
if (!api) {
return;
}
@@ -74,15 +82,15 @@ const Carousel = React.forwardRef<
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
const scrollPrev = useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
const scrollNext = useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
@@ -95,7 +103,7 @@ const Carousel = React.forwardRef<
[scrollPrev, scrollNext]
);
React.useEffect(() => {
useEffect(() => {
if (!api || !setApi) {
return;
}
@@ -103,7 +111,7 @@ const Carousel = React.forwardRef<
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
useEffect(() => {
if (!api) {
return;
}
@@ -117,20 +125,33 @@ const Carousel = React.forwardRef<
};
}, [api, onSelect]);
const resolvedOrientation =
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal');
const carouselContextValue = useMemo(
() => ({
carouselRef,
api,
opts,
orientation: resolvedOrientation,
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}),
[
api,
canScrollNext,
canScrollPrev,
carouselRef,
opts,
resolvedOrientation,
scrollNext,
scrollPrev,
]
);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<CarouselContext.Provider value={carouselContextValue}>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
@@ -147,7 +168,7 @@ const Carousel = React.forwardRef<
);
Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef<
const CarouselContent = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
@@ -169,7 +190,7 @@ const CarouselContent = React.forwardRef<
});
CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef<
const CarouselItem = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
@@ -191,7 +212,7 @@ const CarouselItem = React.forwardRef<
});
CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<
const CarouselPrevious = forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
@@ -220,7 +241,7 @@ const CarouselPrevious = React.forwardRef<
});
CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<
const CarouselNext = forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {

View File

@@ -1,5 +1,5 @@
import { cn } from '@affine/admin/utils';
import * as React from 'react';
import { createContext, forwardRef, useContext, useId, useMemo } from 'react';
import type { TooltipProps } from 'recharts';
import { ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
@@ -18,10 +18,10 @@ type ChartContextValue = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextValue | null>(null);
const ChartContext = createContext<ChartContextValue | null>(null);
function useChart() {
const value = React.useContext(ChartContext);
const value = useContext(ChartContext);
if (!value) {
throw new Error('useChart must be used within <ChartContainer />');
}
@@ -75,13 +75,14 @@ type ChartContainerProps = React.ComponentProps<'div'> & {
children: React.ComponentProps<typeof ResponsiveContainer>['children'];
};
const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
const ChartContainer = forwardRef<HTMLDivElement, ChartContainerProps>(
({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const uniqueId = useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
const chartContextValue = useMemo(() => ({ config }), [config]);
return (
<ChartContext.Provider value={{ config }}>
<ChartContext.Provider value={chartContextValue}>
<div
ref={ref}
data-chart={chartId}
@@ -113,61 +114,60 @@ type TooltipContentProps = {
valueFormatter?: (value: number, key: string) => React.ReactNode;
};
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
TooltipContentProps
>(({ active, payload, label, labelFormatter, valueFormatter }, ref) => {
const { config } = useChart();
const ChartTooltipContent = forwardRef<HTMLDivElement, TooltipContentProps>(
({ active, payload, label, labelFormatter, valueFormatter }, ref) => {
const { config } = useChart();
if (!active || !payload?.length) {
return null;
}
if (!active || !payload?.length) {
return null;
}
const title = labelFormatter ? labelFormatter(label ?? '', payload) : label;
const title = labelFormatter ? labelFormatter(label ?? '', payload) : label;
return (
<div
ref={ref}
className="min-w-44 rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
>
{title ? (
<div className="mb-2 font-medium text-foreground/90">{title}</div>
) : null}
<div className="space-y-1">
{payload.map((item, index) => {
const dataKey = String(item.dataKey ?? item.name ?? index);
const itemConfig = config[dataKey];
const labelText = itemConfig?.label ?? item.name ?? dataKey;
const numericValue =
typeof item.value === 'number'
? item.value
: Number(item.value ?? 0);
const valueText = valueFormatter
? valueFormatter(numericValue, dataKey)
: numericValue;
const color = item.color ?? `var(--color-${dataKey})`;
return (
<div
ref={ref}
className="min-w-44 rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
>
{title ? (
<div className="mb-2 font-medium text-foreground/90">{title}</div>
) : null}
<div className="space-y-1">
{payload.map((item, index) => {
const dataKey = String(item.dataKey ?? item.name ?? index);
const itemConfig = config[dataKey];
const labelText = itemConfig?.label ?? item.name ?? dataKey;
const numericValue =
typeof item.value === 'number'
? item.value
: Number(item.value ?? 0);
const valueText = valueFormatter
? valueFormatter(numericValue, dataKey)
: numericValue;
const color = item.color ?? `var(--color-${dataKey})`;
return (
<div
key={`${dataKey}-${index}`}
className="flex items-center gap-2"
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<span className="text-muted-foreground">{labelText}</span>
<span className="ml-auto font-medium tabular-nums">
{valueText}
</span>
</div>
);
})}
return (
<div
key={`${dataKey}-${index}`}
className="flex items-center gap-2"
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<span className="text-muted-foreground">{labelText}</span>
<span className="ml-auto font-medium tabular-nums">
{valueText}
</span>
</div>
);
})}
</div>
</div>
</div>
);
});
);
}
);
ChartTooltipContent.displayName = 'ChartTooltipContent';
export { ChartContainer, ChartTooltip, ChartTooltipContent };

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

@@ -2,7 +2,7 @@ import { Label } from '@affine/admin/components/ui/label';
import { cn } from '@affine/admin/utils';
import type * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
import { createContext, forwardRef, useContext, useId, useMemo } from 'react';
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
import { Controller, FormProvider, useFormContext } from 'react-hook-form';
@@ -15,7 +15,7 @@ type FormFieldContextValue<
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
const FormFieldContext = createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
@@ -25,16 +25,21 @@ const FormField = <
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
const formFieldContextValue = useMemo(
() => ({ name: props.name }),
[props.name]
);
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<FormFieldContext.Provider value={formFieldContextValue}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const fieldContext = useContext(FormFieldContext);
const itemContext = useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
@@ -59,26 +64,27 @@ type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
const FormItemContext = createContext<FormItemContextValue>(
{} as FormItemContextValue
);
const FormItem = React.forwardRef<
const FormItem = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
const id = useId();
const formItemContextValue = useMemo(() => ({ id }), [id]);
return (
<FormItemContext.Provider value={{ id }}>
<FormItemContext.Provider value={formItemContextValue}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
const FormLabel = forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
@@ -94,8 +100,8 @@ const FormLabel = React.forwardRef<
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
const FormControl = forwardRef<
React.ComponentRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
@@ -117,7 +123,7 @@ const FormControl = React.forwardRef<
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<
const FormDescription = forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
@@ -134,7 +140,7 @@ const FormDescription = React.forwardRef<
});
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<
const FormMessage = forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {

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-lg border border-input bg-background px-3 py-1.5 text-sm text-foreground ring-offset-background transition-[border-color,box-shadow,background-color] duration-150 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}

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-lg border border-input bg-background px-3 py-1.5 text-sm ring-offset-background transition-[border-color,box-shadow,background-color] duration-150 placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
@@ -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

@@ -6,7 +6,7 @@ function Skeleton({
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
className={cn('animate-pulse rounded-lg bg-muted', 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

@@ -57,7 +57,7 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
'border-b transition-colors duration-100 hover:bg-muted/50 data-[state=selected]:bg-muted',
className
)}
{...props}

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

@@ -2,39 +2,44 @@ import { toggleVariants } from '@affine/admin/components/ui/toggle';
import { cn } from '@affine/admin/utils';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import type { VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { createContext, forwardRef, useContext, useMemo } from 'react';
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
const ToggleGroupContext = createContext<VariantProps<typeof toggleVariants>>({
size: 'default',
variant: 'default',
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
const ToggleGroup = forwardRef<
React.ComponentRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('flex items-center justify-center gap-1', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
>(({ className, variant, size, children, ...props }, ref) => {
const toggleGroupContextValue = useMemo(
() => ({ variant, size }),
[size, variant]
);
return (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('flex items-center justify-center gap-1', className)}
{...props}
>
<ToggleGroupContext.Provider value={toggleGroupContextValue}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
});
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
const ToggleGroupItem = forwardRef<
React.ComponentRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
const context = useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item

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

@@ -116,7 +116,7 @@
},
"SMTP.sender": {
"type": "String",
"desc": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")",
"desc": "Sender of all the emails (e.g. \"AFFiNE Self Hosted &lt;noreply@example.com&gt;\")",
"env": "MAILER_SENDER"
},
"SMTP.ignoreTLS": {
@@ -150,7 +150,7 @@
},
"fallbackSMTP.sender": {
"type": "String",
"desc": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")"
"desc": "Sender of all the emails (e.g. \"AFFiNE Self Hosted &lt;noreply@example.com&gt;\")"
},
"fallbackSMTP.ignoreTLS": {
"type": "Boolean",

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,31 @@
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground antialiased;
font-family: var(--affine-font-family);
}
/* Smooth scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
/* Text selection */
::selection {
background: var(--primary);
color: var(--primary-foreground);
}
}

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-md border border-border/60 bg-chip-blue px-2 py-0.5 text-xxs font-medium text-chip-text">
Admin
</span>
)}
{row.original.disabled && (
<span
className="ml-2 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-md border border-border/60 bg-chip-white px-2 py-0.5 text-xxs font-medium">
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-md border border-border/60 bg-chip-white px-2 py-0.5 text-xxs font-medium"
>
{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 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-3 overflow-y-auto p-4">
<div className="flex flex-col rounded-xl border border-border bg-card shadow-sm">
<InputItem
label="User name"
field="name"
@@ -154,7 +153,7 @@ function UserForm({
</div>
<FeatureToggleList
className="border rounded-md"
className="rounded-xl border border-border bg-card shadow-sm"
features={serverConfig.availableUserFeatures}
selected={changes.features ?? []}
onChange={handleFeaturesChange}
@@ -191,24 +190,18 @@ 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' }}
>
<div className="flex flex-col gap-2 p-3">
<Label className="flex flex-wrap text-xs font-medium leading-5 text-muted-foreground uppercase tracking-wide">
{label}
{optional && (
<span
className="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}
@@ -316,24 +309,24 @@ export function UpdateUserForm({
onValidate={validateUpdateUser}
onDirtyChange={onDirtyChange}
actions={
<>
<div className="space-y-2">
<Button
className="w-full flex items-center justify-between text-sm font-medium px-4 py-3"
className="h-10 w-full justify-between rounded-xl border-border/60 px-4 text-sm font-medium hover:bg-muted/50"
variant="outline"
onClick={onResetPassword}
>
<span>Reset Password</span>
<ChevronRightIcon size={16} />
<ChevronRightIcon size={16} className="text-muted-foreground" />
</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-10 w-full justify-between rounded-xl border-destructive/30 px-4 text-sm font-medium text-destructive hover:bg-destructive/5 hover:text-destructive"
variant="outline"
onClick={onDeleteAccount}
>
<span>Delete Account</span>
<ChevronRightIcon size={16} />
</Button>
</>
</div>
}
/>
);

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-xl border border-border/60 bg-card shadow-sm">
<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')}
@@ -19,7 +19,7 @@ function AiPage() {
<div className="text-[20px]">AI</div>
<div className="flex justify-between items-center">
<div>
<p className="text-[15px] font-medium mt-6">Enable AI</p>
<p className="text-sm font-medium mt-6">Enable AI</p>
<p className="text-sm text-muted-foreground mt-1">
AI functionality is not currently supported. Self-hosted AI
support is in progress.

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

@@ -21,6 +21,7 @@ import {
SelectValue,
} from '@affine/admin/components/ui/select';
import { Separator } from '@affine/admin/components/ui/separator';
import { Skeleton } from '@affine/admin/components/ui/skeleton';
import {
Table,
TableBody,
@@ -38,13 +39,82 @@ import {
RefreshCwIcon,
UsersIcon,
} from 'lucide-react';
import { type ReactNode, useMemo, useState } from 'react';
import { type ReactNode, Suspense, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
import { useMutateQueryResource } from '../../use-mutation';
import { Header } from '../header';
import { formatBytes } from '../workspaces/utils';
const adminDashboardOverviewQuery: typeof adminDashboardQuery = {
...adminDashboardQuery,
query: `query adminDashboard($input: AdminDashboardInput) {
adminDashboard(input: $input) {
syncActiveUsers
syncActiveUsersTimeline {
minute
activeUsers
}
syncWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
copilotConversations
workspaceStorageBytes
blobStorageBytes
workspaceStorageHistory {
date
value
}
blobStorageHistory {
date
value
}
storageWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
generatedAt
}
}`,
};
const adminDashboardTopSharedLinksQuery: typeof adminDashboardQuery = {
...adminDashboardQuery,
query: `query adminDashboard($input: AdminDashboardInput) {
adminDashboard(input: $input) {
topSharedLinks {
workspaceId
docId
title
shareUrl
publishedAt
views
uniqueViews
guestViews
lastAccessedAt
}
topSharedLinksWindow {
from
to
timezone
bucket
requestedSize
effectiveSize
}
}
}`,
};
const intFormatter = new Intl.NumberFormat('en-US');
const compactFormatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
@@ -140,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)',
},
}
: {}),
@@ -166,7 +236,7 @@ function TrendChart({
>
<CartesianGrid
vertical={false}
stroke="hsl(var(--border) / 0.6)"
stroke="var(--border)"
strokeDasharray="3 4"
/>
<XAxis
@@ -190,7 +260,7 @@ function TrendChart({
/>
<ChartTooltip
cursor={{
stroke: 'hsl(var(--border))',
stroke: 'var(--border)',
strokeDasharray: '4 4',
strokeWidth: 1,
}}
@@ -244,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>
@@ -260,14 +330,14 @@ function PrimaryMetricCard({
description: string;
}) {
return (
<Card className="lg:col-span-5 border-primary/30 bg-gradient-to-br from-primary/10 via-card to-card shadow-sm">
<Card className="h-full border-border/60 bg-card shadow-1">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2 text-foreground/75">
<CardDescription className="flex items-center gap-2 text-sm">
<UsersIcon className="h-4 w-4" aria-hidden="true" />
Current Sync Active Users
</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
<CardContent className="space-y-1.5">
<div className="text-4xl font-bold tracking-tight tabular-nums">
{value}
</div>
@@ -289,9 +359,9 @@ function SecondaryMetricCard({
icon: ReactNode;
}) {
return (
<Card className="lg:col-span-3 border-border/70 bg-card/95 shadow-sm">
<Card className="h-full border-border/60 bg-card shadow-1">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
<CardDescription className="flex items-center gap-2 text-sm">
<span aria-hidden="true">{icon}</span>
{title}
</CardDescription>
@@ -300,7 +370,7 @@ function SecondaryMetricCard({
<div className="text-2xl font-semibold tracking-tight tabular-nums">
{value}
</div>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
<p className="text-xs text-muted-foreground mt-1.5">{description}</p>
</CardContent>
</Card>
);
@@ -322,7 +392,7 @@ function WindowSelect({
onChange: (value: number) => void;
}) {
return (
<div className="flex flex-col gap-2 min-w-40">
<div className="flex min-w-0 flex-col gap-2">
<Label
htmlFor={id}
className="text-xs uppercase tracking-wide text-muted-foreground"
@@ -348,10 +418,192 @@ function WindowSelect({
);
}
export function DashboardPage() {
function DashboardPageSkeleton() {
return (
<div className="h-dvh flex-1 flex-col flex overflow-hidden">
<Header
title="Dashboard"
endFix={
<div className="flex items-center gap-3">
<Skeleton className="h-3 w-44" />
<Skeleton className="h-8 w-20" />
</div>
}
/>
<div className="flex-1 overflow-auto p-6 space-y-6">
<Card className="border-border/60 bg-card shadow-1">
<CardHeader className="pb-3">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-4 w-80" />
</CardHeader>
<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 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 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" />
</div>
</div>
);
}
function TopSharedLinksCardSkeleton() {
return (
<Card className="border-border/60 bg-card shadow-1">
<CardHeader>
<Skeleton className="h-5 w-36" />
<Skeleton className="h-4 w-72" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<Separator />
<div className="flex justify-between">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-20" />
</div>
</CardContent>
</Card>
);
}
function TopSharedLinksSection({
sharedLinkWindowDays,
}: {
sharedLinkWindowDays: number;
}) {
const variables = useMemo(
() => ({
input: {
sharedLinkWindowDays,
timezone: 'UTC',
},
}),
[sharedLinkWindowDays]
);
const { data } = useQuery(
{
query: adminDashboardTopSharedLinksQuery,
variables,
},
{
keepPreviousData: true,
revalidateOnFocus: false,
revalidateIfStale: true,
revalidateOnReconnect: true,
}
);
const topSharedLinks = data.adminDashboard.topSharedLinks;
const topSharedLinksWindow = data.adminDashboard.topSharedLinksWindow;
return (
<Card className="border-border/60 bg-card shadow-1">
<CardHeader>
<CardTitle className="text-base">Top Shared Links</CardTitle>
<CardDescription>
Top {topSharedLinks.length} links in the last{' '}
{topSharedLinksWindow.effectiveSize} days
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{topSharedLinks.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/60 p-8 text-center bg-muted/15">
<div className="text-sm font-medium">
No shared links in this window
</div>
<div className="text-xs text-muted-foreground mt-2">
Publish pages and collect traffic, then this table will rank links
by views.
</div>
<Button asChild variant="outline" size="sm" className="mt-4">
<Link to={ROUTES.admin.workspaces}>Go to Workspaces</Link>
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Document</TableHead>
<TableHead>Workspace</TableHead>
<TableHead className="text-right">Views</TableHead>
<TableHead className="text-right">Unique</TableHead>
<TableHead className="text-right">Guest</TableHead>
<TableHead>Last Accessed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topSharedLinks.map(link => (
<TableRow
key={`${link.workspaceId}-${link.docId}`}
className="hover:bg-muted/40"
>
<TableCell className="max-w-80 min-w-0">
<a
href={link.shareUrl}
target="_blank"
rel="noreferrer"
className="font-medium underline-offset-4 hover:underline truncate block"
>
{link.title || link.docId}
</a>
</TableCell>
<TableCell className="font-mono text-xs tabular-nums">
{link.workspaceId}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.views)}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.uniqueViews)}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.guestViews)}
</TableCell>
<TableCell className="tabular-nums">
{link.lastAccessedAt
? formatDateTime(link.lastAccessedAt)
: '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Separator />
<div className="flex justify-between text-xs text-muted-foreground tabular-nums">
<span>{formatDate(topSharedLinksWindow.from)}</span>
<span>{formatDate(topSharedLinksWindow.to)}</span>
</div>
</CardContent>
</Card>
);
}
function DashboardPageContent() {
const [storageHistoryDays, setStorageHistoryDays] = useState<number>(30);
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
const shouldShowTopSharedLinks = !environment.isSelfHosted;
const revalidateQueryResource = useMutateQueryResource();
const variables = useMemo(
() => ({
@@ -365,9 +617,9 @@ export function DashboardPage() {
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours]
);
const { data, isValidating, mutate } = useQuery(
const { data, isValidating } = useQuery(
{
query: adminDashboardQuery,
query: adminDashboardOverviewQuery,
variables,
},
{
@@ -409,7 +661,7 @@ export function DashboardPage() {
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={
@@ -421,7 +673,7 @@ export function DashboardPage() {
variant="outline"
size="sm"
onClick={() => {
mutate().catch(() => {});
revalidateQueryResource(adminDashboardQuery).catch(() => {});
}}
disabled={isValidating}
>
@@ -436,7 +688,7 @@ export function DashboardPage() {
/>
<div className="flex-1 overflow-auto p-6 space-y-6">
<Card className="border-primary/20 bg-gradient-to-r from-primary/5 via-card to-card shadow-sm">
<Card className="border-border/60 bg-card shadow-1">
<CardHeader className="pb-3">
<CardTitle className="text-base">Window Controls</CardTitle>
<CardDescription>
@@ -444,7 +696,7 @@ export function DashboardPage() {
automatically.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 grid-cols-1 md: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"
@@ -472,40 +724,46 @@ export function DashboardPage() {
</CardContent>
</Card>
<div className="grid gap-5 grid-cols-1 lg:grid-cols-12">
<PrimaryMetricCard
value={intFormatter.format(dashboard.syncActiveUsers)}
description={`${dashboard.syncWindow.effectiveSize}h active window`}
/>
<SecondaryMetricCard
title="Copilot Conversations"
value={intFormatter.format(dashboard.copilotConversations)}
description={`${dashboard.topSharedLinksWindow.effectiveSize}d aggregation`}
icon={
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
}
/>
<Card className="lg:col-span-4 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">
<DatabaseIcon className="h-4 w-4" aria-hidden="true" />
Managed Storage
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold tracking-tight tabular-nums">
{formatBytes(totalStorageBytes)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Workspace {formatBytes(dashboard.workspaceStorageBytes)} Blob{' '}
{formatBytes(dashboard.blobStorageBytes)}
</p>
</CardContent>
</Card>
<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="h-full min-w-0 lg:col-span-3">
<SecondaryMetricCard
title="Copilot Conversations"
value={intFormatter.format(dashboard.copilotConversations)}
description={`${sharedLinkWindowDays}d aggregation`}
icon={
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
}
/>
</div>
<div className="h-full min-w-0 lg:col-span-4">
<Card className="h-full border-border/60 bg-card shadow-1">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2 text-sm">
<DatabaseIcon className="h-4 w-4" aria-hidden="true" />
Managed Storage
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold tracking-tight tabular-nums">
{formatBytes(totalStorageBytes)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Workspace {formatBytes(dashboard.workspaceStorageBytes)}
Blob {formatBytes(dashboard.blobStorageBytes)}
</p>
</CardContent>
</Card>
</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/60 bg-card shadow-1 lg:col-span-1">
<CardHeader>
<CardTitle className="text-base">
Sync Active Users Trend
@@ -524,7 +782,7 @@ export function DashboardPage() {
</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/60 bg-card shadow-1 lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">
Storage Trend (Workspace + Blob)
@@ -557,89 +815,24 @@ export function DashboardPage() {
</Card>
</div>
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Top Shared Links</CardTitle>
<CardDescription>
Top {dashboard.topSharedLinks.length} links in the last{' '}
{dashboard.topSharedLinksWindow.effectiveSize} days
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{dashboard.topSharedLinks.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center bg-muted/20">
<div className="text-sm font-medium">
No shared links in this window
</div>
<div className="text-xs text-muted-foreground mt-2">
Publish pages and collect traffic, then this table will rank
links by views.
</div>
<Button asChild variant="outline" size="sm" className="mt-4">
<Link to={ROUTES.admin.workspaces}>Go to Workspaces</Link>
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Document</TableHead>
<TableHead>Workspace</TableHead>
<TableHead className="text-right">Views</TableHead>
<TableHead className="text-right">Unique</TableHead>
<TableHead className="text-right">Guest</TableHead>
<TableHead>Last Accessed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboard.topSharedLinks.map(link => (
<TableRow
key={`${link.workspaceId}-${link.docId}`}
className="hover:bg-muted/40"
>
<TableCell className="max-w-80 min-w-0">
<a
href={link.shareUrl}
target="_blank"
rel="noreferrer"
className="font-medium underline-offset-4 hover:underline truncate block"
>
{link.title || link.docId}
</a>
</TableCell>
<TableCell className="font-mono text-xs tabular-nums">
{link.workspaceId}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.views)}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.uniqueViews)}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.guestViews)}
</TableCell>
<TableCell className="tabular-nums">
{link.lastAccessedAt
? formatDateTime(link.lastAccessedAt)
: '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Separator />
<div className="flex justify-between text-xs text-muted-foreground tabular-nums">
<span>{formatDate(dashboard.topSharedLinksWindow.from)}</span>
<span>{formatDate(dashboard.topSharedLinksWindow.to)}</span>
</div>
</CardContent>
</Card>
{shouldShowTopSharedLinks ? (
<Suspense fallback={<TopSharedLinksCardSkeleton />}>
<TopSharedLinksSection
sharedLinkWindowDays={sharedLinkWindowDays}
/>
</Suspense>
) : null}
</div>
</div>
);
}
export function DashboardPage() {
return (
<Suspense fallback={<DashboardPageSkeleton />}>
<DashboardPageContent />
</Suspense>
);
}
export { DashboardPage as Component };

View File

@@ -17,25 +17,24 @@ export const Header = ({
const isSmallScreen = useMediaQuery('(max-width: 768px)');
return (
<div>
<div className="flex items-center px-6 gap-4 h-[56px]">
<div className="border-b border-border/60 bg-background/80 backdrop-blur-sm">
<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 tracking-tight">{title}</div>
{endFix && <div className="ml-auto">{endFix}</div>}
</div>
<Separator />
</div>
);
};
@@ -52,30 +51,29 @@ export const RightPanelHeader = ({
canSave: boolean;
}) => {
return (
<div>
<div className=" flex justify-between items-center h-[56px] px-6">
<div className="border-b border-border/60 bg-card/80 backdrop-blur-sm">
<div className="flex h-14 items-center justify-between px-4">
<Button
type="button"
size="icon"
className="w-7 h-7"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
variant="ghost"
onClick={handleClose}
>
<XIcon size={20} />
<XIcon size={18} />
</Button>
<span className="text-base font-medium">{title}</span>
<span className="text-sm font-semibold tracking-tight">{title}</span>
<Button
type="submit"
size="icon"
className="w-7 h-7"
className="h-7 w-7 text-primary hover:text-primary"
variant="ghost"
onClick={handleConfirm}
disabled={!canSave}
>
<CheckIcon size={20} />
<CheckIcon size={18} />
</Button>
</div>
<Separator />
</div>
);
};

View File

@@ -5,10 +5,9 @@ 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';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import { useLocation } from 'react-router-dom';
@@ -23,7 +22,6 @@ import {
} from '../components/ui/sheet';
import { Logo } from './accounts/components/logo';
import { useMediaQuery } from './common';
import { NavContext } from './nav/context';
import { Nav } from './nav/nav';
import {
PanelContext,
@@ -43,10 +41,6 @@ export function Layout({ children }: PropsWithChildren) {
const leftPanelRef = useRef<ImperativePanelHandle>(null);
const location = useLocation();
const [activeTab, setActiveTab] = useState('');
const [activeSubTab, setActiveSubTab] = useState('server');
const [currentModule, setCurrentModule] = useState('server');
const handleLeftExpand = useCallback(() => {
if (leftPanelRef.current?.getSize() === 0) {
leftPanelRef.current?.resize(30);
@@ -127,60 +121,66 @@ export function Layout({ children }: PropsWithChildren) {
handleSetRightPanelContent(null);
closeRightPanel();
}, [location.pathname, closeRightPanel, handleSetRightPanelContent]);
const panelContextValue = useMemo(
() => ({
leftPanel: {
isOpen: leftOpen,
panelContent: leftPanelContent,
setPanelContent: setLeftPanelContent,
togglePanel: toggleLeftPanel,
openPanel: openLeftPanel,
closePanel: closeLeftPanel,
},
rightPanel: {
isOpen: rightOpen,
panelContent: rightPanelContent,
setPanelContent: handleSetRightPanelContent,
togglePanel: toggleRightPanel,
openPanel: openRightPanel,
closePanel: closeRightPanel,
hasDirtyChanges: rightPanelHasDirtyChanges,
setHasDirtyChanges: setRightPanelHasDirtyChanges,
},
}),
[
closeLeftPanel,
closeRightPanel,
handleSetRightPanelContent,
leftOpen,
leftPanelContent,
openLeftPanel,
openRightPanel,
rightOpen,
rightPanelContent,
rightPanelHasDirtyChanges,
setLeftPanelContent,
setRightPanelHasDirtyChanges,
toggleLeftPanel,
toggleRightPanel,
]
);
return (
<PanelContext.Provider
value={{
leftPanel: {
isOpen: leftOpen,
panelContent: leftPanelContent,
setPanelContent: setLeftPanelContent,
togglePanel: toggleLeftPanel,
openPanel: openLeftPanel,
closePanel: closeLeftPanel,
},
rightPanel: {
isOpen: rightOpen,
panelContent: rightPanelContent,
setPanelContent: handleSetRightPanelContent,
togglePanel: toggleRightPanel,
openPanel: openRightPanel,
closePanel: closeRightPanel,
hasDirtyChanges: rightPanelHasDirtyChanges,
setHasDirtyChanges: setRightPanelHasDirtyChanges,
},
}}
>
<NavContext.Provider
value={{
activeTab,
activeSubTab,
currentModule,
setActiveTab,
setActiveSubTab,
setCurrentModule,
}}
>
<TooltipProvider delayDuration={0}>
<div className="flex h-screen w-full overflow-hidden">
<ResizablePanelGroup direction="horizontal">
<LeftPanel
panelRef={leftPanelRef as RefObject<ImperativePanelHandle>}
onExpand={handleLeftExpand}
onCollapse={handleLeftCollapse}
/>
<ResizablePanel id="1" order={1} minSize={50} defaultSize={50}>
{children}
</ResizablePanel>
<RightPanel
panelRef={rightPanelRef as RefObject<ImperativePanelHandle>}
onExpand={handleRightExpand}
onCollapse={handleRightCollapse}
/>
</ResizablePanelGroup>
</div>
</TooltipProvider>
</NavContext.Provider>
<PanelContext.Provider value={panelContextValue}>
<TooltipProvider delayDuration={0}>
<div className="flex h-dvh w-full overflow-hidden">
<ResizablePanelGroup direction="horizontal">
<LeftPanel
panelRef={leftPanelRef as RefObject<ImperativePanelHandle>}
onExpand={handleLeftExpand}
onCollapse={handleLeftCollapse}
/>
<ResizablePanel id="1" order={1} minSize={50} defaultSize={50}>
{children}
</ResizablePanel>
<RightPanel
panelRef={rightPanelRef as RefObject<ImperativePanelHandle>}
onExpand={handleRightExpand}
onCollapse={handleRightCollapse}
/>
</ResizablePanelGroup>
</div>
</TooltipProvider>
</PanelContext.Provider>
);
}
@@ -197,7 +197,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>
@@ -207,11 +211,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/60 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 />
@@ -239,21 +247,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/60 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'
)}
>
@@ -299,7 +299,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/60 bg-background p-0"
withoutCloseButton
>
<div className="h-full overflow-y-auto">{panelContent}</div>
</SheetContent>
</Sheet>
@@ -317,7 +321,7 @@ export const RightPanel = ({
collapsedSize={0}
onExpand={onExpand}
onCollapse={onCollapse}
className="border-l max-w-96"
className="max-w-96 border-l border-border/60 bg-background"
>
<div className="h-full overflow-y-auto">{panelContent}</div>
</ResizablePanel>

View File

@@ -1,44 +0,0 @@
import { useCallback } from 'react';
import { NavLink } from 'react-router-dom';
import { buttonVariants } from '../../components/ui/button';
import { cn } from '../../utils';
export const NormalSubItem = ({
module,
title,
changeModule,
indent = 'normal',
}: {
module: string;
title: string;
changeModule?: (module: string) => void;
indent?: 'normal' | 'nested';
}) => {
const handleClick = useCallback(() => {
changeModule?.(module);
}, [changeModule, module]);
const indentClassName = indent === 'nested' ? 'ml-12' : 'ml-8';
return (
<div className="w-full flex">
<NavLink
to={`/admin/settings/${module}`}
onClick={handleClick}
className={({ isActive }) => {
return cn(
buttonVariants({
variant: 'ghost',
className: cn(
indentClassName,
'px-2 w-full justify-start',
isActive && 'bg-zinc-100'
),
})
);
}}
>
{title}
</NavLink>
</div>
);
};

View File

@@ -1,21 +0,0 @@
import { createContext, useContext } from 'react';
interface NavContextType {
activeTab: string;
activeSubTab: string;
currentModule: string;
setActiveTab: (tab: string) => void;
setActiveSubTab: (tab: string) => void;
setCurrentModule: (module: string) => void;
}
export const NavContext = createContext<NavContextType | undefined>(undefined);
export const useNav = () => {
const context = useContext(NavContext);
if (!context) {
throw new Error('useNav must be used within a NavProvider');
}
return context;
};

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,34 @@ interface NavItemProps {
isCollapsed?: boolean;
}
const navItemBaseClass =
'group inline-flex h-9 items-center gap-2 rounded-lg text-sm font-medium transition-all duration-150';
const navItemStateClass =
'text-sidebar-foreground-secondary hover:bg-sidebar-hover hover:text-sidebar-foreground';
const navItemActiveClass =
'bg-sidebar-active text-sidebar-foreground shadow-sm';
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,198 +1,15 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@affine/admin/components/ui/accordion';
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 * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cssVarV2 } from '@toeverything/theme/v2';
import { NavLink } from 'react-router-dom';
import { KNOWN_CONFIG_GROUPS, UNKNOWN_CONFIG_GROUPS } from '../settings/config';
import { NormalSubItem } from './collapsible-item';
import { useNav } from './context';
import { NavItem } from './nav-item';
export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
const { setCurrentModule } = useNav();
if (isCollapsed) {
return (
<NavigationMenuPrimitive.Root
className="flex-none relative"
orientation="vertical"
>
<NavigationMenuPrimitive.List>
<NavigationMenuPrimitive.Item>
<NavigationMenuPrimitive.Trigger className="[&>svg]:hidden m-0 p-0">
<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>
</NavigationMenuPrimitive.Trigger>
<NavigationMenuPrimitive.Content>
<ul
className="border rounded-lg w-full flex flex-col p-1 min-w-[160px] max-h-[200px] overflow-y-auto"
style={{
backgroundColor: cssVarV2('layer/background/overlayPanel'),
borderColor: cssVarV2('layer/insideBorder/blackBorder'),
}}
>
{KNOWN_CONFIG_GROUPS.map(group => (
<li key={group.module} className="flex">
<NavLink
to={`/admin/settings/${group.module}`}
className={cn(
buttonVariants({
variant: 'ghost',
className:
'p-2 rounded-[6px] text-[14px] w-full justify-start font-normal',
})
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
})}
onClick={() => setCurrentModule?.(group.module)}
>
{group.name}
</NavLink>
</li>
))}
{UNKNOWN_CONFIG_GROUPS.length ? (
<li className="flex px-2 pt-1 pb-0.5 text-xs font-medium opacity-70">
Experimental
</li>
) : null}
{UNKNOWN_CONFIG_GROUPS.map(group => (
<li key={group.module} className="flex">
<NavLink
to={`/admin/settings/${group.module}`}
className={cn(
buttonVariants({
variant: 'ghost',
className:
'p-2 pl-6 rounded-[6px] text-[14px] w-full justify-start font-normal',
})
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
})}
onClick={() => setCurrentModule?.(group.module)}
>
{group.name}
</NavLink>
</li>
))}
</ul>
</NavigationMenuPrimitive.Content>
</NavigationMenuPrimitive.Item>
</NavigationMenuPrimitive.List>
<NavigationMenuPrimitive.Viewport className="absolute z-10 left-11 top-0" />
</NavigationMenuPrimitive.Root>
);
}
return (
<Accordion type="multiple" className="w-full overflow-hidden">
<AccordionItem
value="item-1"
className="border-b-0 h-full flex flex-col gap-1 w-full"
>
<NavLink
to={'/admin/settings'}
className={cn(
buttonVariants({
variant: 'ghost',
}),
'justify-start flex-none w-full px-2'
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
})}
>
<AccordionTrigger
className={
'flex items-center justify-between w-full [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360'
}
>
<div className="flex items-center">
<span className="flex items-center p-0.5 mr-2">
<SettingsIcon fontSize={20} />
</span>
<span>Settings</span>
</div>
</AccordionTrigger>
</NavLink>
<AccordionContent className="h-full overflow-hidden w-full pb-0">
<ScrollAreaPrimitive.Root
className={cn('relative overflow-hidden w-full h-full')}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
{KNOWN_CONFIG_GROUPS.map(group => (
<NormalSubItem
key={group.module}
module={group.module}
title={group.name}
changeModule={setCurrentModule}
/>
))}
{UNKNOWN_CONFIG_GROUPS.length ? (
<Accordion type="multiple" className="w-full">
<AccordionItem value="item-1" className="border-b-0">
<AccordionTrigger className="ml-8 py-2 px-2 rounded [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360">
Experimental
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 py-1">
{UNKNOWN_CONFIG_GROUPS.map(group => (
<NormalSubItem
key={group.module}
module={group.module}
title={group.name}
changeModule={setCurrentModule}
indent="nested"
/>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
) : null}
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.ScrollAreaScrollbar
className={cn(
'flex touch-none select-none transition-colors',
'h-full w-2.5 border-l border-l-transparent p-[1px]'
)}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
</AccordionContent>
</AccordionItem>
</Accordion>
<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-md border border-border/60 bg-chip-blue px-2 py-0.5 text-xxs font-medium 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,3 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import '@queuedash/ui/dist/styles.css';
import './queue.css';
@@ -8,7 +7,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

@@ -0,0 +1,107 @@
/**
* @vitest-environment happy-dom
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { ConfigRow } from './config-input-row';
describe('ConfigRow', () => {
afterEach(() => {
cleanup();
});
beforeAll(() => {
if (!Element.prototype.hasPointerCapture) {
Object.defineProperty(Element.prototype, 'hasPointerCapture', {
value: () => false,
});
}
if (!Element.prototype.setPointerCapture) {
Object.defineProperty(Element.prototype, 'setPointerCapture', {
value: () => {},
});
}
if (!Element.prototype.releasePointerCapture) {
Object.defineProperty(Element.prototype, 'releasePointerCapture', {
value: () => {},
});
}
});
test('triggers onChange when enum option changes', () => {
const handleChange = vi.fn();
render(
<ConfigRow
field="storages/blob.storage/provider"
desc="Storage provider"
type="Enum"
options={['fs', 'aws-s3', 'cloudflare-r2']}
defaultValue="fs"
onChange={handleChange}
/>
);
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' });
fireEvent.click(screen.getByRole('option', { name: 'aws-s3' }));
expect(handleChange).toHaveBeenCalledWith(
'storages/blob.storage/provider',
'aws-s3'
);
});
test('triggers onChange when json text becomes invalid', () => {
const handleChange = vi.fn();
render(
<ConfigRow
field="server/hosts"
desc="Server hosts"
type="JSON"
defaultValue={[]}
onChange={handleChange}
/>
);
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '[]asdasdasd' },
});
expect(handleChange).toHaveBeenCalledWith('server/hosts', '[]asdasdasd');
});
test('shows json validation error and clears after input is fixed', () => {
const handleChange = vi.fn();
render(
<ConfigRow
field="server/hosts"
desc="Server hosts"
type="JSON"
defaultValue={[]}
onChange={handleChange}
/>
);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, {
target: { value: '[]asdasdasd' },
});
expect(screen.queryByText('Invalid JSON format')).not.toBeNull();
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-destructive');
expect(handleChange).toHaveBeenLastCalledWith('server/hosts', [
'localhost',
]);
});
});

View File

@@ -7,7 +7,8 @@ import {
SelectValue,
} from '@affine/admin/components/ui/select';
import { Switch } from '@affine/admin/components/ui/switch';
import { useCallback } from 'react';
import { cn } from '@affine/admin/utils';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Textarea } from '../../components/ui/textarea';
@@ -17,6 +18,7 @@ export type ConfigInputProps = {
defaultValue: any;
onChange: (field: string, value: any) => void;
error?: string;
onErrorChange?: (field: string, error?: string) => void;
} & (
| {
type: 'String' | 'Number' | 'Boolean' | 'JSON';
@@ -34,6 +36,7 @@ const Inputs: Record<
onChange: (value?: any) => void;
options?: string[];
error?: string;
onValidationChange?: (error?: string) => void;
}>
> = {
Boolean: function SwitchInput({ defaultValue, onChange }) {
@@ -43,7 +46,7 @@ const Inputs: Record<
return (
<Switch
defaultChecked={defaultValue}
checked={Boolean(defaultValue)}
onCheckedChange={handleSwitchChange}
/>
);
@@ -57,43 +60,78 @@ const Inputs: Record<
<Input
type="text"
minLength={1}
defaultValue={defaultValue}
value={defaultValue ?? ''}
onChange={handleInputChange}
/>
);
},
Number: function NumberInput({ defaultValue, onChange }) {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(parseInt(e.target.value));
const next = e.target.value;
onChange(next === '' ? undefined : parseInt(next, 10));
};
return (
<Input
type="number"
defaultValue={defaultValue}
value={defaultValue ?? ''}
onChange={handleInputChange}
/>
);
},
JSON: function ObjectInput({ defaultValue, onChange }) {
JSON: function ObjectInput({
defaultValue,
onChange,
error,
onValidationChange,
}) {
const fallbackText = useMemo(
() =>
typeof defaultValue === 'string'
? defaultValue
: JSON.stringify(defaultValue ?? null),
[defaultValue]
);
const [text, setText] = useState(fallbackText);
useEffect(() => {
setText(fallbackText);
}, [fallbackText]);
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const nextText = e.target.value;
setText(nextText);
try {
const value = JSON.parse(e.target.value);
const value = JSON.parse(nextText);
onValidationChange?.(undefined);
onChange(value);
} catch {}
} catch {
onValidationChange?.('Invalid JSON format');
// Keep the draft "dirty" even when JSON is temporarily invalid
// so Save/Cancel state can reflect real editing progress.
onChange(nextText);
}
};
return (
<Textarea
defaultValue={JSON.stringify(defaultValue)}
value={text}
onChange={handleInputChange}
className="w-full"
className={cn(
'w-full',
error
? 'border-destructive hover:border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20'
: undefined
)}
/>
);
},
Enum: function EnumInput({ defaultValue, onChange, options }) {
return (
<Select defaultValue={defaultValue} onValueChange={onChange}>
<Select
value={typeof defaultValue === 'string' ? defaultValue : undefined}
onValueChange={onChange}
>
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
@@ -116,9 +154,11 @@ export const ConfigRow = ({
defaultValue,
onChange,
error,
onErrorChange,
...props
}: ConfigInputProps) => {
const Input = Inputs[type] ?? Inputs.JSON;
const [validationError, setValidationError] = useState<string>();
const onValueChange = useCallback(
(value?: any) => {
@@ -127,25 +167,46 @@ export const ConfigRow = ({
[field, onChange]
);
const onValidationChange = useCallback((nextError?: string) => {
setValidationError(nextError);
}, []);
const mergedError = error ?? validationError;
useEffect(() => {
onErrorChange?.(field, mergedError);
return () => {
onErrorChange?.(field, undefined);
};
}, [field, mergedError, onErrorChange]);
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}
error={error}
error={mergedError}
onValidationChange={onValidationChange}
{...props}
/>
{error && (
<div className="absolute bottom-[-25px] text-sm right-0 break-words text-red-500">
{error}
{mergedError && (
<div className="mt-1 w-full break-words text-sm text-destructive">
{mergedError}
</div>
)}
</div>

View File

@@ -0,0 +1,215 @@
/**
* @vitest-environment happy-dom
*/
import {
cleanup,
fireEvent,
render,
screen,
within,
} from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
const useAppConfigMock = vi.fn();
vi.mock('./use-app-config', () => ({
useAppConfig: () => useAppConfigMock(),
}));
vi.mock('../header', () => ({
Header: ({ title }: { title: string }) => <div>{title}</div>,
}));
vi.mock('./config-input-row', () => ({
ConfigRow: ({
field,
onErrorChange,
}: {
field: string;
onErrorChange?: (field: string, error?: string) => void;
}) => (
<div data-testid={`field-${field}`}>
<div>{field}</div>
<button
type="button"
onClick={() => {
onErrorChange?.(field, 'Invalid JSON format');
}}
>
mark-error-{field}
</button>
<button
type="button"
onClick={() => {
onErrorChange?.(field, undefined);
}}
>
clear-error-{field}
</button>
</div>
),
}));
vi.mock('./config', () => ({
ALL_CONFIG_DESCRIPTORS: {
server: {
name: {
desc: 'Server Name',
type: 'String',
},
},
auth: {
allowSignup: {
desc: 'Allow Signup',
type: 'Boolean',
},
},
},
ALL_SETTING_GROUPS: [
{
name: 'Server',
module: 'server',
fields: ['name'],
},
{
name: 'Auth',
module: 'auth',
fields: ['allowSignup'],
},
],
}));
import { SettingsPage } from './index';
describe('SettingsPage', () => {
beforeEach(() => {
useAppConfigMock.mockReset();
useAppConfigMock.mockReturnValue({
appConfig: {
server: {
name: 'AFFiNE',
},
auth: {
allowSignup: true,
},
},
patchedAppConfig: {
server: {
name: 'AFFiNE',
},
auth: {
allowSignup: true,
},
},
update: vi.fn(),
saveGroup: vi.fn().mockResolvedValue(undefined),
resetGroup: vi.fn(),
isGroupDirty: vi.fn().mockReturnValue(false),
isGroupSaving: vi.fn().mockReturnValue(false),
getGroupVersion: vi.fn().mockReturnValue(0),
});
});
afterEach(() => {
cleanup();
});
test('keeps all groups collapsed by default', () => {
render(
<MemoryRouter initialEntries={['/admin/settings']}>
<Routes>
<Route path="/admin/settings" element={<SettingsPage />} />
</Routes>
</MemoryRouter>
);
const serverItem = document.getElementById('config-module-server');
const authItem = document.getElementById('config-module-auth');
expect(serverItem?.dataset.state).toBe('closed');
expect(authItem?.dataset.state).toBe('closed');
});
test('keeps previous group open when another group is expanded', () => {
render(
<MemoryRouter initialEntries={['/admin/settings']}>
<Routes>
<Route path="/admin/settings" element={<SettingsPage />} />
</Routes>
</MemoryRouter>
);
fireEvent.click(screen.getAllByRole('button', { name: /Server/i })[0]);
fireEvent.click(screen.getAllByRole('button', { name: /Auth/i })[0]);
const serverItem = document.getElementById('config-module-server');
const authItem = document.getElementById('config-module-auth');
expect(serverItem?.dataset.state).toBe('open');
expect(authItem?.dataset.state).toBe('open');
});
test('disables save when group has validation errors even if group is dirty', () => {
useAppConfigMock.mockReset();
useAppConfigMock.mockReturnValue({
appConfig: {
server: {
name: 'AFFiNE',
},
auth: {
allowSignup: true,
},
},
patchedAppConfig: {
server: {
name: 'AFFiNE',
},
auth: {
allowSignup: true,
},
},
update: vi.fn(),
saveGroup: vi.fn().mockResolvedValue(undefined),
resetGroup: vi.fn(),
isGroupDirty: vi
.fn()
.mockImplementation((module: string) => module === 'server'),
isGroupSaving: vi.fn().mockReturnValue(false),
getGroupVersion: vi.fn().mockReturnValue(0),
});
render(
<MemoryRouter initialEntries={['/admin/settings']}>
<Routes>
<Route path="/admin/settings" element={<SettingsPage />} />
</Routes>
</MemoryRouter>
);
fireEvent.click(screen.getAllByRole('button', { name: /Server/i })[0]);
const serverItem = document.getElementById('config-module-server');
expect(serverItem).not.toBeNull();
if (!serverItem) {
return;
}
const saveButton = within(serverItem).getByRole('button', { name: 'Save' });
expect(saveButton.hasAttribute('disabled')).toBe(false);
fireEvent.click(
within(serverItem).getByRole('button', {
name: 'mark-error-server/name',
})
);
expect(saveButton.hasAttribute('disabled')).toBe(true);
fireEvent.click(
within(serverItem).getByRole('button', {
name: 'clear-error-server/name',
})
);
expect(saveButton.hasAttribute('disabled')).toBe(false);
});
});

View File

@@ -1,11 +1,15 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@affine/admin/components/ui/accordion';
import { Button } from '@affine/admin/components/ui/button';
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
import { get } from 'lodash-es';
import { CheckIcon } from 'lucide-react';
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { Header } from '../header';
import { useNav } from '../nav/context';
import {
ALL_CONFIG_DESCRIPTORS,
ALL_SETTING_GROUPS,
@@ -15,104 +19,239 @@ import { type ConfigInputProps, ConfigRow } from './config-input-row';
import { useAppConfig } from './use-app-config';
export function SettingsPage() {
const { appConfig, update, save, patchedAppConfig, updates } = useAppConfig();
const disableSave = Object.keys(updates).length === 0;
const saveChanges = useCallback(() => {
if (disableSave) {
return;
}
save();
}, [save, disableSave]);
const {
appConfig,
update,
saveGroup,
resetGroup,
patchedAppConfig,
isGroupDirty,
isGroupSaving,
getGroupVersion,
} = useAppConfig();
const [expandedModules, setExpandedModules] = useState<string[]>([]);
return (
<div className="h-screen flex-1 flex-col flex">
<Header
title="Settings"
endFix={
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={saveChanges}
disabled={disableSave}
>
<CheckIcon size={20} />
</Button>
}
/>
<div className="flex h-dvh flex-1 flex-col bg-background">
<Header title="Settings" />
<AdminPanel
expandedModules={expandedModules}
onExpandedModulesChange={setExpandedModules}
onUpdate={update}
appConfig={appConfig}
patchedAppConfig={patchedAppConfig}
onSaveGroup={saveGroup}
onResetGroup={resetGroup}
isGroupDirty={isGroupDirty}
isGroupSaving={isGroupSaving}
getGroupVersion={getGroupVersion}
/>
</div>
);
}
const AdminPanel = ({
expandedModules,
onExpandedModulesChange,
appConfig,
patchedAppConfig,
onUpdate,
onSaveGroup,
onResetGroup,
isGroupDirty,
isGroupSaving,
getGroupVersion,
}: {
expandedModules: string[];
onExpandedModulesChange: (modules: string[]) => void;
appConfig: AppConfig;
patchedAppConfig: AppConfig;
onUpdate: (path: string, value: any) => void;
onSaveGroup: (module: string) => Promise<void>;
onResetGroup: (module: string) => void;
isGroupDirty: (module: string) => boolean;
isGroupSaving: (module: string) => boolean;
getGroupVersion: (module: string) => number;
}) => {
const { currentModule } = useNav();
const group = ALL_SETTING_GROUPS.find(
group => group.module === currentModule
);
const [groupErrors, setGroupErrors] = useState<
Record<string, Record<string, string>>
>({});
if (!group) {
return null;
}
const onFieldErrorChange = useCallback((field: string, error?: string) => {
const [module] = field.split('/');
if (!module) {
return;
}
const { name, module, fields, operations } = group;
setGroupErrors(prev => {
const moduleErrors = prev[module] ?? {};
if (error) {
if (moduleErrors[field] === error) {
return prev;
}
return {
...prev,
[module]: {
...moduleErrors,
[field]: error,
},
};
}
if (!(field in moduleErrors)) {
return prev;
}
const nextModuleErrors = { ...moduleErrors };
delete nextModuleErrors[field];
if (Object.keys(nextModuleErrors).length === 0) {
const next = { ...prev };
delete next[module];
return next;
}
return {
...prev,
[module]: nextModuleErrors,
};
});
}, []);
const clearModuleErrors = useCallback((module: string) => {
setGroupErrors(prev => {
if (!prev[module]) {
return prev;
}
const next = { ...prev };
delete next[module];
return next;
});
}, []);
return (
<ScrollArea className="h-full">
<div className="flex flex-col h-full gap-5 py-5 px-6 w-full max-w-[800px] mx-auto">
<div className="text-2xl font-semibold">{name}</div>
<div className="flex flex-col gap-10" id={`config-module-${module}`}>
{fields.map(field => {
let desc: string;
let props: ConfigInputProps;
if (typeof field === 'string') {
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field];
desc = descriptor.desc;
props = {
field: `${module}/${field}`,
desc,
type: descriptor.type,
options: [],
defaultValue: get(appConfig[module], field),
onChange: onUpdate,
};
} else {
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field.key];
<div className="mx-auto flex w-full max-w-[900px] flex-col gap-4 px-6 py-5">
<Accordion
type="multiple"
className="w-full"
value={expandedModules}
onValueChange={onExpandedModulesChange}
>
{ALL_SETTING_GROUPS.map(group => {
const { name, module, fields, operations } = group;
const dirty = isGroupDirty(module);
const saving = isGroupSaving(module);
const sourceConfig = patchedAppConfig[module] ?? appConfig[module];
const version = getGroupVersion(module);
const hasValidationError = Boolean(
groupErrors[module] &&
Object.keys(groupErrors[module] ?? {}).length > 0
);
props = {
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
desc: field.desc ?? descriptor.desc,
type: field.type ?? descriptor.type,
// @ts-expect-error for enum type
options: field.options,
defaultValue: get(
appConfig[module],
field.key + (field.sub ? '.' + field.sub : '')
),
onChange: onUpdate,
};
}
return (
<AccordionItem
key={module}
value={module}
id={`config-module-${module}`}
className="mb-4 rounded-xl border border-border/60 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-base font-semibold">{name}</div>
<div className="text-xs text-muted-foreground">
Manage {name.toLowerCase()} settings
</div>
</div>
</AccordionTrigger>
return <ConfigRow key={props.field} {...props} />;
<AccordionContent className="pt-2 pb-2 px-1">
<div
className="flex flex-col gap-8"
key={`${module}-${version}`}
>
{fields.map(field => {
let props: ConfigInputProps;
if (typeof field === 'string') {
const descriptor =
ALL_CONFIG_DESCRIPTORS[module][field];
props = {
field: `${module}/${field}`,
desc: descriptor.desc,
type: descriptor.type,
options: [],
defaultValue: get(sourceConfig, field),
onChange: onUpdate,
};
} else {
const descriptor =
ALL_CONFIG_DESCRIPTORS[module][field.key];
props = {
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
desc: field.desc ?? descriptor.desc,
type: field.type ?? descriptor.type,
// @ts-expect-error for enum type
options: field.options,
defaultValue: get(
sourceConfig,
field.key + (field.sub ? '.' + field.sub : '')
),
onChange: onUpdate,
};
}
return (
<ConfigRow
key={props.field}
{...props}
onErrorChange={onFieldErrorChange}
/>
);
})}
{operations?.map(Operation => (
<Operation
key={Operation.name}
appConfig={patchedAppConfig}
/>
))}
<div className="flex justify-end gap-2">
{dirty ? (
<Button
type="button"
variant="outline"
className="h-9 min-w-[88px]"
onClick={() => {
onResetGroup(module);
clearModuleErrors(module);
}}
disabled={saving}
>
Cancel
</Button>
) : null}
<Button
type="button"
className="h-9 min-w-[88px]"
onClick={() => {
onSaveGroup(module).catch(err => {
console.error(err);
});
}}
disabled={!dirty || saving || hasValidationError}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
})}
{operations?.map(Operation => (
<Operation key={Operation.name} appConfig={patchedAppConfig} />
))}
</div>
</Accordion>
</div>
</ScrollArea>
);

View File

@@ -0,0 +1,193 @@
/**
* @vitest-environment happy-dom
*/
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mocked = vi.hoisted(() => {
let queryState: {
appConfig: {
server: {
name: string;
hosts: string[];
};
auth: {
allowSignup: boolean;
};
storages: {
blob: {
storage: {
provider: string;
};
};
};
};
} = {
appConfig: {
server: {
name: '',
hosts: [],
},
auth: {
allowSignup: true,
},
storages: {
blob: {
storage: {
provider: 'fs',
},
},
},
},
};
return {
getQueryState: () => queryState,
setQueryState: (next: typeof queryState) => {
queryState = next;
},
mutateMock: vi.fn(),
saveUpdatesMock: vi.fn(),
notifySuccessMock: vi.fn(),
notifyErrorMock: vi.fn(),
};
});
vi.mock('@affine/admin/use-query', () => ({
useQuery: () => ({
data: mocked.getQueryState(),
mutate: mocked.mutateMock,
}),
}));
vi.mock('@affine/admin/use-mutation', () => ({
useMutation: () => ({
trigger: mocked.saveUpdatesMock,
}),
}));
vi.mock('@affine/component', () => ({
notify: {
success: mocked.notifySuccessMock,
error: mocked.notifyErrorMock,
},
}));
import { useAppConfig } from './use-app-config';
describe('useAppConfig', () => {
beforeEach(() => {
mocked.setQueryState({
appConfig: {
server: {
name: 'AFFiNE',
hosts: ['localhost'],
},
auth: {
allowSignup: true,
},
storages: {
blob: {
storage: {
provider: 'fs',
},
},
},
},
});
mocked.mutateMock.mockReset();
mocked.saveUpdatesMock.mockReset();
mocked.notifySuccessMock.mockReset();
mocked.notifyErrorMock.mockReset();
mocked.mutateMock.mockImplementation(async updater => {
const currentState = mocked.getQueryState();
if (typeof updater === 'function') {
mocked.setQueryState(updater(currentState));
}
return mocked.getQueryState();
});
});
test('clears dirty state when value is changed back to original', () => {
const { result } = renderHook(() => useAppConfig());
act(() => {
result.current.update('server/name', 'AFFiNE Cloud');
});
expect(result.current.isGroupDirty('server')).toBe(true);
act(() => {
result.current.update('server/name', 'AFFiNE');
});
expect(result.current.isGroupDirty('server')).toBe(false);
});
test('resetGroup cancels only target group changes immediately', () => {
const { result } = renderHook(() => useAppConfig());
act(() => {
result.current.update('server/name', 'AFFiNE Cloud');
result.current.update('auth/allowSignup', false);
});
expect(result.current.isGroupDirty('server')).toBe(true);
expect(result.current.isGroupDirty('auth')).toBe(true);
act(() => {
result.current.resetGroup('server');
});
expect(result.current.isGroupDirty('server')).toBe(false);
expect(result.current.isGroupDirty('auth')).toBe(true);
expect(result.current.patchedAppConfig.server.name).toBe('AFFiNE');
expect(result.current.getGroupVersion('server')).toBe(1);
});
test('saveGroup submits only target group updates and keeps others dirty', async () => {
const { result } = renderHook(() => useAppConfig());
act(() => {
result.current.update('server/name', 'AFFiNE Cloud');
result.current.update('auth/allowSignup', false);
});
mocked.saveUpdatesMock.mockResolvedValue({
updateAppConfig: {
server: {
name: 'AFFiNE Cloud',
},
},
});
await act(async () => {
await result.current.saveGroup('server');
});
expect(mocked.saveUpdatesMock).toHaveBeenCalledWith({
updates: [
{
module: 'server',
key: 'name',
value: 'AFFiNE Cloud',
},
],
});
expect(result.current.isGroupDirty('server')).toBe(false);
expect(result.current.isGroupDirty('auth')).toBe(true);
expect(result.current.patchedAppConfig.server.name).toBe('AFFiNE Cloud');
expect(result.current.getGroupVersion('server')).toBe(1);
expect(mocked.notifySuccessMock).toHaveBeenCalledTimes(1);
});
test('marks group dirty when nested enum-like option changes', () => {
const { result } = renderHook(() => useAppConfig());
act(() => {
result.current.update('storages/blob.storage/provider', 'aws-s3');
});
expect(result.current.isGroupDirty('storages')).toBe(true);
});
});

View File

@@ -1,7 +1,6 @@
import { useMutation } from '@affine/admin/use-mutation';
import { useQuery } from '@affine/admin/use-query';
import { notify } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { UserFriendlyError } from '@affine/error';
import {
appConfigQuery,
@@ -9,13 +8,40 @@ import {
updateAppConfigMutation,
} from '@affine/graphql';
import { cloneDeep, get, merge, set } from 'lodash-es';
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { AppConfig } from './config';
import { isEqual } from './utils';
export { type UpdateAppConfigInput };
export type AppConfigUpdates = Record<string, { from: any; to: any }>;
type SaveResponse =
| { updateAppConfig?: Partial<AppConfig> }
| Partial<AppConfig>;
const getUpdateInputs = (
entries: Array<[string, { from: any; to: any }]>
): UpdateAppConfigInput[] => {
return entries.map(([key, value]) => {
const splitIndex = key.indexOf('.');
const module = key.slice(0, splitIndex);
const field = key.slice(splitIndex + 1);
return {
module,
key: field,
value: value.to,
};
});
};
const getSavedAppConfig = (response: SaveResponse): Partial<AppConfig> => {
if ('updateAppConfig' in response) {
return (response.updateAppConfig as Partial<AppConfig>) ?? {};
}
return response;
};
export const useAppConfig = () => {
const {
@@ -33,30 +59,70 @@ export const useAppConfig = () => {
const [patchedAppConfig, setPatchedAppConfig] = useState<AppConfig>(() =>
cloneDeep(appConfig)
);
const [savingModules, setSavingModules] = useState<Record<string, boolean>>(
{}
);
const [groupVersions, setGroupVersions] = useState<Record<string, number>>(
{}
);
const save = useAsyncCallback(async () => {
const updateInputs: UpdateAppConfigInput[] = Object.entries(updates).map(
([key, value]) => {
const splitIndex = key.indexOf('.');
const module = key.slice(0, splitIndex);
const field = key.slice(splitIndex + 1);
useEffect(() => {
if (Object.keys(updates).length === 0) {
setPatchedAppConfig(cloneDeep(appConfig));
}
}, [appConfig, updates]);
return {
module,
key: field,
value: value.to,
};
}
);
const getEntriesByModule = useCallback(
(module: string, source: AppConfigUpdates = updates) => {
return Object.entries(source).filter(([key]) =>
key.startsWith(`${module}.`)
);
},
[updates]
);
const clearModuleUpdates = useCallback(
(module: string) => {
setUpdates(prev => {
const next = { ...prev };
Object.keys(next).forEach(key => {
if (key.startsWith(`${module}.`)) {
delete next[key];
}
});
return next;
});
},
[setUpdates]
);
const bumpGroupVersion = useCallback((module: string) => {
setGroupVersions(prev => ({
...prev,
[module]: (prev[module] ?? 0) + 1,
}));
}, []);
const save = useCallback(async () => {
const allEntries = Object.entries(updates);
if (allEntries.length === 0) {
return;
}
try {
const savedUpdates = await saveUpdates({
updates: updateInputs,
});
const response = (await saveUpdates({
updates: getUpdateInputs(allEntries),
})) as SaveResponse;
const savedAppConfig = getSavedAppConfig(response);
await mutate(prev => {
return { appConfig: merge({}, prev, savedUpdates) };
return {
appConfig: merge({}, prev?.appConfig ?? {}, savedAppConfig),
};
});
setUpdates({});
setPatchedAppConfig(prev => merge({}, prev, savedAppConfig));
notify.success({
title: 'Saved',
message: 'Settings have been saved successfully.',
@@ -71,6 +137,60 @@ export const useAppConfig = () => {
}
}, [updates, mutate, saveUpdates]);
const saveGroup = useCallback(
async (module: string) => {
const moduleEntries = getEntriesByModule(module);
if (moduleEntries.length === 0) {
return;
}
setSavingModules(prev => ({
...prev,
[module]: true,
}));
try {
const response = (await saveUpdates({
updates: getUpdateInputs(moduleEntries),
})) as SaveResponse;
const savedAppConfig = getSavedAppConfig(response);
await mutate(prev => {
return {
appConfig: merge({}, prev?.appConfig ?? {}, savedAppConfig),
};
});
clearModuleUpdates(module);
setPatchedAppConfig(prev => merge({}, prev, savedAppConfig));
bumpGroupVersion(module);
notify.success({
title: 'Saved',
message: 'Settings have been saved successfully.',
});
} catch (e) {
const error = UserFriendlyError.fromAny(e);
notify.error({
title: 'Failed to save',
message: error.message,
});
console.error(e);
} finally {
setSavingModules(prev => ({
...prev,
[module]: false,
}));
}
},
[
bumpGroupVersion,
clearModuleUpdates,
getEntriesByModule,
mutate,
saveUpdates,
]
);
const update = useCallback(
(path: string, value: any) => {
const [module, field, subField] = path.split('/');
@@ -78,9 +198,15 @@ export const useAppConfig = () => {
const from = get(appConfig, key);
setUpdates(prev => {
const to = subField
? set(prev[key]?.to ?? { ...from }, subField, value)
? set(cloneDeep(prev[key]?.to ?? from ?? {}), subField, value)
: value;
if (isEqual(from, to)) {
const next = { ...prev };
delete next[key];
return next;
}
return {
...prev,
[key]: {
@@ -91,21 +217,62 @@ export const useAppConfig = () => {
});
setPatchedAppConfig(prev => {
return set(
prev,
`${module}.${field}${subField ? `.${subField}` : ''}`,
value
);
const next = cloneDeep(prev);
if (subField) {
const nextValue = set(
cloneDeep(get(next, `${module}.${field}`) ?? {}),
subField,
value
);
set(next, `${module}.${field}`, nextValue);
return next;
}
set(next, `${module}.${field}`, value);
return next;
});
},
[appConfig]
);
const resetGroup = useCallback(
(module: string) => {
clearModuleUpdates(module);
setPatchedAppConfig(prev => {
return {
...prev,
[module]: cloneDeep(appConfig[module]),
};
});
bumpGroupVersion(module);
},
[appConfig, bumpGroupVersion, clearModuleUpdates]
);
const isGroupDirty = useCallback(
(module: string) => getEntriesByModule(module).length > 0,
[getEntriesByModule]
);
const isGroupSaving = useCallback(
(module: string) => Boolean(savingModules[module]),
[savingModules]
);
const getGroupVersion = useCallback(
(module: string) => groupVersions[module] ?? 0,
[groupVersions]
);
return {
appConfig: appConfig as AppConfig,
patchedAppConfig,
update,
save,
saveGroup,
resetGroup,
isGroupDirty,
isGroupSaving,
getGroupVersion,
updates,
};
};

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-md border border-border/60 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-md border border-border/60 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,17 +59,10 @@ 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">
<div className="flex items-center gap-3 min-w-0">
<Avatar className="w-9 h-9">
<AvatarImage src={owner.avatarUrl ?? undefined} />
<AvatarFallback>
@@ -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-xl border border-border/60 bg-card p-3 shadow-sm">
<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-xl border border-border/60 bg-card shadow-sm">
<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-xl border border-border/60 bg-card p-3 shadow-sm">
<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-xl border border-border/60 bg-card shadow-sm">
<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-xl border border-border/60 bg-card p-3 shadow-sm">
<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-xl border border-border/60 bg-card shadow-sm">
{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' },

View File

@@ -2,9 +2,9 @@ import { ModalConfigContext } from '@affine/component';
import { NavigationGestureService } from '@affine/core/mobile/modules/navigation-gesture';
import { globalVars } from '@affine/core/mobile/styles/variables.css';
import { useService } from '@toeverything/infra';
import { type PropsWithChildren, useCallback } from 'react';
import { useCallback, useMemo } from 'react';
export const ModalConfigProvider = ({ children }: PropsWithChildren) => {
export const ModalConfigProvider = ({ children }: React.PropsWithChildren) => {
const navigationGesture = useService(NavigationGestureService);
const onOpen = useCallback(() => {
@@ -17,11 +17,13 @@ export const ModalConfigProvider = ({ children }: PropsWithChildren) => {
}
return;
}, [navigationGesture]);
const modalConfigValue = useMemo(
() => ({ onOpen, dynamicKeyboardHeight: globalVars.appKeyboardHeight }),
[onOpen]
);
return (
<ModalConfigContext.Provider
value={{ onOpen, dynamicKeyboardHeight: globalVars.appKeyboardHeight }}
>
<ModalConfigContext.Provider value={modalConfigValue}>
{children}
</ModalConfigContext.Provider>
);

View File

@@ -7,6 +7,7 @@ import {
useContext,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react';
@@ -44,11 +45,14 @@ export const MobileMenu = ({
}: MenuProps) => {
const [subMenus, setSubMenus] = useState<SubMenuContent[]>([]);
const [open, setOpen] = useState(false);
const mobileContextValue = {
subMenus,
setSubMenus,
setOpen,
};
const mobileContextValue = useMemo(
() => ({
subMenus,
setSubMenus,
setOpen,
}),
[subMenus]
);
const { removeSubMenu, removeAllSubMenus } =
useMobileSubMenuHelper(mobileContextValue);
@@ -95,6 +99,10 @@ export const MobileMenu = ({
},
[onInteractOutside, onPointerDownOutside, removeAllSubMenus, rootOptions]
);
const mobileMenuContextValue = useMemo(
() => ({ subMenus, setSubMenus, setOpen: onOpenChange }),
[onOpenChange, subMenus]
);
useImperativeHandle(
ref,
@@ -139,9 +147,7 @@ export const MobileMenu = ({
return (
<>
<Slot onClick={onItemClick}>{children}</Slot>
<MobileMenuContext.Provider
value={{ subMenus, setSubMenus, setOpen: onOpenChange }}
>
<MobileMenuContext.Provider value={mobileMenuContextValue}>
<Modal
open={finalOpen}
onOpenChange={onOpenChange}

View File

@@ -1,7 +1,12 @@
import { DialogTrigger } from '@radix-ui/react-dialog';
import clsx from 'clsx';
import type { PropsWithChildren } from 'react';
import { createContext, useCallback, useContext, useState } from 'react';
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import type { ButtonProps } from '../button';
import { Button } from '../button';
@@ -151,7 +156,7 @@ const ConfirmModalContext = createContext<ConfirmModalContextProps>({
openConfirmModal: () => {},
closeConfirmModal: () => {},
});
export const ConfirmModalProvider = ({ children }: PropsWithChildren) => {
export const ConfirmModalProvider = ({ children }: React.PropsWithChildren) => {
const [modalProps, setModalProps] = useState<ConfirmModalProps>({
open: false,
});
@@ -200,11 +205,13 @@ export const ConfirmModalProvider = ({ children }: PropsWithChildren) => {
},
[modalProps]
);
const confirmModalContextValue = useMemo(
() => ({ openConfirmModal, closeConfirmModal, modalProps }),
[closeConfirmModal, modalProps, openConfirmModal]
);
return (
<ConfirmModalContext.Provider
value={{ openConfirmModal, closeConfirmModal, modalProps }}
>
<ConfirmModalContext.Provider value={confirmModalContextValue}>
{children}
{/* TODO(@catsjuice): multi-instance support(unnecessary for now) */}
<ConfirmModal {...modalProps} onOpenChange={onOpenChange} />

View File

@@ -1,7 +1,13 @@
import { DialogTrigger } from '@radix-ui/react-dialog';
import clsx from 'clsx';
import type { PropsWithChildren } from 'react';
import { createContext, useCallback, useContext, useState } from 'react';
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import type { ButtonProps } from '../button';
import { Button } from '../button';
@@ -205,15 +211,17 @@ export const PromptModalProvider = ({ children }: PropsWithChildren) => {
},
[modalProps]
);
const promptModalContextValue = useMemo(
() => ({
openPromptModal,
closePromptModal,
modalProps,
}),
[closePromptModal, modalProps, openPromptModal]
);
return (
<PromptModalContext.Provider
value={{
openPromptModal: openPromptModal,
closePromptModal: closePromptModal,
modalProps,
}}
>
<PromptModalContext.Provider value={promptModalContextValue}>
{children}
{/* TODO(@catsjuice): multi-instance support(unnecessary for now) */}
<PromptModal {...modalProps} onOpenChange={onOpenChange} />

View File

@@ -271,7 +271,7 @@ export const NavigationPanelDocNode = ({
return (
<NavigationPanelTreeNode
icon={Icon}
name={t.t(docTitle)}
name={docTitle}
dndData={dndData}
onDrop={handleDropOnDoc}
renameable

View File

@@ -152,7 +152,7 @@ export const NavigationPanelDocNode = ({
return (
<NavigationPanelTreeNode
icon={Icon}
name={t.t(docTitle)}
name={docTitle}
extractEmojiAsIcon={enableEmojiIcon}
collapsed={collapsed}
setCollapsed={setCollapsed}

View File

@@ -105,11 +105,13 @@ const TagRenameContent = ({
},
[color, onConfirm]
);
const tagColorContextValue = useMemo(
() => ({ colors, color, setColor, show, setShow, enableAnimation }),
[color, colors, enableAnimation, show]
);
return (
<TagColorContext.Provider
value={{ colors, color, setColor, show, setShow, enableAnimation }}
>
<TagColorContext.Provider value={tagColorContextValue}>
<RenameContent
inputPrefixRenderer={ColorPickerTrigger}
inputBelowRenderer={ColorPickerSelect}

View File

@@ -9,11 +9,10 @@ import { assignInlineVars } from '@vanilla-extract/dynamic';
import { animate } from 'animejs';
import {
createContext,
type PropsWithChildren,
type RefObject,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from 'react';
import { createPortal } from 'react-dom';
@@ -21,7 +20,7 @@ import { createPortal } from 'react-dom';
import { SwipeHelper } from '../../utils';
import * as styles from './swipe-dialog.css';
export interface SwipeDialogProps extends PropsWithChildren {
export interface SwipeDialogProps extends React.PropsWithChildren {
triggerSize?: number;
title?: string;
open?: boolean;
@@ -142,7 +141,7 @@ const close = (
};
const SwipeDialogContext = createContext<{
stack: Array<RefObject<HTMLElement | null>>;
stack: Array<React.RefObject<HTMLElement | null>>;
}>({
stack: [],
});
@@ -162,6 +161,10 @@ export const SwipeDialog = ({
const { stack } = useContext(SwipeDialogContext);
const prev = stack[stack.length - 1]?.current;
const swipeDialogContextValue = useMemo(
() => ({ stack: [...stack, dialogRef] }),
[stack]
);
const handleClose = useCallback(() => {
onOpenChange?.(false);
@@ -222,7 +225,7 @@ export const SwipeDialog = ({
if (!open) return null;
return (
<SwipeDialogContext.Provider value={{ stack: [...stack, dialogRef] }}>
<SwipeDialogContext.Provider value={swipeDialogContextValue}>
<InsideModalContext.Provider value={insideModal + 1}>
{createPortal(
<div className={styles.root}>

View File

@@ -1,4 +1,10 @@
import { type HTMLAttributes, useCallback, useEffect, useState } from 'react';
import {
type HTMLAttributes,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { JournalDatePickerContext } from './context';
import { ResizeViewport } from './viewport';
@@ -31,18 +37,21 @@ export const JournalDatePicker = ({
},
[onChange]
);
const width = window.innerWidth;
const journalDatePickerContextValue = useMemo(
() => ({
selected,
onSelect,
cursor,
setCursor,
width,
withDotDates,
}),
[cursor, onSelect, selected, width, withDotDates]
);
return (
<JournalDatePickerContext.Provider
value={{
selected,
onSelect,
cursor,
setCursor,
width: window.innerWidth,
withDotDates,
}}
>
<JournalDatePickerContext.Provider value={journalDatePickerContextValue}>
<ResizeViewport {...attrs} />
</JournalDatePickerContext.Provider>
);

View File

@@ -18,6 +18,10 @@ export const ViewRoot = ({
routes: RouteObject[];
}) => {
const viewRouter = useMemo(() => createMemoryRouter(routes), [routes]);
const routeContextValue = useMemo(
() => ({ outlet: null, matches: [], isDataRoute: false }),
[]
);
const location = useLiveData(view.location$);
@@ -31,13 +35,7 @@ export const ViewRoot = ({
return (
<FrameworkScope scope={view.scope}>
<UNSAFE_LocationContext.Provider value={null as any}>
<UNSAFE_RouteContext.Provider
value={{
outlet: null,
matches: [],
isDataRoute: false,
}}
>
<UNSAFE_RouteContext.Provider value={routeContextValue}>
<RouterProvider router={viewRouter} />
</UNSAFE_RouteContext.Provider>
</UNSAFE_LocationContext.Provider>

View File

@@ -215,6 +215,7 @@ __metadata:
"@radix-ui/react-tooltip": "npm:^1.1.5"
"@sentry/react": "npm:^9.47.1"
"@tanstack/react-table": "npm:^8.20.5"
"@testing-library/react": "npm:^16.3.2"
"@toeverything/infra": "workspace:*"
"@toeverything/theme": "npm:^1.1.23"
"@types/lodash-es": "npm:^4.17.12"
@@ -241,6 +242,7 @@ __metadata:
tailwindcss: "npm:^4.1.17"
tailwindcss-animate: "npm:^1.0.7"
vaul: "npm:^1.1.2"
vitest: "npm:^3.2.4"
zod: "npm:^3.25.76"
languageName: unknown
linkType: soft
@@ -16555,9 +16557,9 @@ __metadata:
languageName: node
linkType: hard
"@testing-library/react@npm:^16.1.0":
version: 16.3.0
resolution: "@testing-library/react@npm:16.3.0"
"@testing-library/react@npm:^16.1.0, @testing-library/react@npm:^16.3.2":
version: 16.3.2
resolution: "@testing-library/react@npm:16.3.2"
dependencies:
"@babel/runtime": "npm:^7.12.5"
peerDependencies:
@@ -16571,7 +16573,7 @@ __metadata:
optional: true
"@types/react-dom":
optional: true
checksum: 10/0ee9e31dd0d2396a924682d0e61a4ecc6bfab8eaff23dbf8a72c3c2ce22c116fa578148baeb4de75b968ef99d22e6e6aa0a00dba40286f71184918bb6bb5b06a
checksum: 10/0ca88c6f672d00c2afd1bdedeff9b5382dd8157038efeb9762dc016731030075624be7106b92d2b5e5c52812faea85263e69272c14b6f8700eb48a4a8af6feef
languageName: node
linkType: hard