mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 02:13:00 +08:00
feat: improve admin panel design (#14464)
This commit is contained in:
@@ -222,7 +222,7 @@
|
|||||||
},
|
},
|
||||||
"SMTP.sender": {
|
"SMTP.sender": {
|
||||||
"type": "string",
|
"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 <noreply@example.com>\")\n@default \"AFFiNE Self Hosted <noreply@example.com>\"\n@environment `MAILER_SENDER`",
|
||||||
"default": "AFFiNE Self Hosted <noreply@example.com>"
|
"default": "AFFiNE Self Hosted <noreply@example.com>"
|
||||||
},
|
},
|
||||||
"SMTP.ignoreTLS": {
|
"SMTP.ignoreTLS": {
|
||||||
@@ -262,7 +262,7 @@
|
|||||||
},
|
},
|
||||||
"fallbackSMTP.sender": {
|
"fallbackSMTP.sender": {
|
||||||
"type": "string",
|
"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 <noreply@example.com>\")\n@default \"\"",
|
||||||
"default": ""
|
"default": ""
|
||||||
},
|
},
|
||||||
"fallbackSMTP.ignoreTLS": {
|
"fallbackSMTP.ignoreTLS": {
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -148,7 +148,7 @@ jobs:
|
|||||||
name: Wait for approval
|
name: Wait for approval
|
||||||
with:
|
with:
|
||||||
secret: ${{ secrets.GITHUB_TOKEN }}
|
secret: ${{ secrets.GITHUB_TOKEN }}
|
||||||
approvers: darkskygit,pengx17,L-Sun,EYHN
|
approvers: darkskygit
|
||||||
minimum-approvals: 1
|
minimum-approvals: 1
|
||||||
fail-on-denial: true
|
fail-on-denial: true
|
||||||
issue-title: Please confirm to release docker image
|
issue-title: Please confirm to release docker image
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ defineModuleConfig('mailer', {
|
|||||||
env: 'MAILER_PASSWORD',
|
env: 'MAILER_PASSWORD',
|
||||||
},
|
},
|
||||||
'SMTP.sender': {
|
'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 <noreply@example.com>")',
|
||||||
default: 'AFFiNE Self Hosted <noreply@example.com>',
|
default: 'AFFiNE Self Hosted <noreply@example.com>',
|
||||||
env: 'MAILER_SENDER',
|
env: 'MAILER_SENDER',
|
||||||
},
|
},
|
||||||
@@ -92,7 +92,7 @@ defineModuleConfig('mailer', {
|
|||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
'fallbackSMTP.sender': {
|
'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 <noreply@example.com>")',
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
'fallbackSMTP.ignoreTLS': {
|
'fallbackSMTP.ignoreTLS': {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Field,
|
Field,
|
||||||
|
Info,
|
||||||
InputType,
|
InputType,
|
||||||
Int,
|
Int,
|
||||||
Mutation,
|
Mutation,
|
||||||
@@ -14,6 +15,12 @@ import {
|
|||||||
ResolveField,
|
ResolveField,
|
||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
|
import {
|
||||||
|
type FragmentDefinitionNode,
|
||||||
|
type GraphQLResolveInfo,
|
||||||
|
Kind,
|
||||||
|
type SelectionNode,
|
||||||
|
} from 'graphql';
|
||||||
import { SafeIntResolver } from 'graphql-scalars';
|
import { SafeIntResolver } from 'graphql-scalars';
|
||||||
|
|
||||||
import { PaginationInput, URLHelper } from '../../../base';
|
import { PaginationInput, URLHelper } from '../../../base';
|
||||||
@@ -53,6 +60,44 @@ registerEnumType(AdminSharedLinksOrder, {
|
|||||||
name: '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()
|
@InputType()
|
||||||
class ListWorkspaceInput {
|
class ListWorkspaceInput {
|
||||||
@Field(() => Int, { defaultValue: 20 })
|
@Field(() => Int, { defaultValue: 20 })
|
||||||
@@ -471,22 +516,40 @@ export class AdminWorkspaceResolver {
|
|||||||
})
|
})
|
||||||
async adminDashboard(
|
async adminDashboard(
|
||||||
@Args('input', { nullable: true, type: () => AdminDashboardInput })
|
@Args('input', { nullable: true, type: () => AdminDashboardInput })
|
||||||
input?: AdminDashboardInput
|
input?: AdminDashboardInput,
|
||||||
|
@Info() info?: GraphQLResolveInfo
|
||||||
) {
|
) {
|
||||||
this.assertCloudOnly();
|
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({
|
const dashboard = await this.models.workspaceAnalytics.adminGetDashboard({
|
||||||
timezone: input?.timezone,
|
timezone: input?.timezone,
|
||||||
storageHistoryDays: input?.storageHistoryDays,
|
storageHistoryDays: input?.storageHistoryDays,
|
||||||
syncHistoryHours: input?.syncHistoryHours,
|
syncHistoryHours: input?.syncHistoryHours,
|
||||||
sharedLinkWindowDays: input?.sharedLinkWindowDays,
|
sharedLinkWindowDays: input?.sharedLinkWindowDays,
|
||||||
|
includeTopSharedLinks,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...dashboard,
|
...dashboard,
|
||||||
topSharedLinks: dashboard.topSharedLinks.map(link => ({
|
topSharedLinks: includeTopSharedLinks
|
||||||
...link,
|
? dashboard.topSharedLinks.map(link => ({
|
||||||
shareUrl: this.url.link(`/workspace/${link.workspaceId}/${link.docId}`),
|
...link,
|
||||||
})),
|
shareUrl: this.url.link(
|
||||||
|
`/workspace/${link.workspaceId}/${link.docId}`
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import pkg from '../package.json' with { type: 'json' };
|
import pkg from '../package.json' with { type: 'json' };
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
// oxlint-disable-next-line no-shadow-restricted-names
|
||||||
namespace globalThis {
|
namespace globalThis {
|
||||||
// oxlint-disable-next-line no-var
|
// oxlint-disable-next-line no-var
|
||||||
var env: Readonly<Env>;
|
var env: Readonly<Env>;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export type AdminDashboardOptions = {
|
|||||||
storageHistoryDays?: number;
|
storageHistoryDays?: number;
|
||||||
syncHistoryHours?: number;
|
syncHistoryHours?: number;
|
||||||
sharedLinkWindowDays?: number;
|
sharedLinkWindowDays?: number;
|
||||||
|
includeTopSharedLinks?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminAllSharedLinksOptions = {
|
export type AdminAllSharedLinksOptions = {
|
||||||
@@ -262,6 +263,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
90,
|
90,
|
||||||
DEFAULT_SHARED_LINK_WINDOW_DAYS
|
DEFAULT_SHARED_LINK_WINDOW_DAYS
|
||||||
);
|
);
|
||||||
|
const includeTopSharedLinks = options.includeTopSharedLinks ?? true;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -274,6 +276,66 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
const storageFrom = addUtcDays(currentDay, -(storageHistoryDays - 1));
|
const storageFrom = addUtcDays(currentDay, -(storageHistoryDays - 1));
|
||||||
const sharedFrom = addUtcDays(currentDay, -(sharedLinkWindowDays - 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 [
|
const [
|
||||||
syncCurrent,
|
syncCurrent,
|
||||||
syncTimeline,
|
syncTimeline,
|
||||||
@@ -350,63 +412,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
AND created_at >= ${sharedFrom}
|
AND created_at >= ${sharedFrom}
|
||||||
AND created_at <= ${now}
|
AND created_at <= ${now}
|
||||||
`,
|
`,
|
||||||
this.db.$queryRaw<
|
topSharedLinksPromise,
|
||||||
{
|
|
||||||
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
|
|
||||||
`,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const storageHistorySeries = storageHistory.map(row => ({
|
const storageHistorySeries = storageHistory.map(row => ({
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ export class IndexerJob {
|
|||||||
indexed: true,
|
indexed: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!missingDocIds.length && !deletedDocIds.length) {
|
||||||
|
this.logger.verbose(`workspace ${workspaceId} is already indexed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`indexed workspace ${workspaceId} with ${missingDocIds.length} missing docs and ${deletedDocIds.length} deleted docs`
|
`indexed workspace ${workspaceId} with ${missingDocIds.length} missing docs and ${deletedDocIds.length} deleted docs`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -67,7 +68,8 @@
|
|||||||
"shadcn-ui": "^0.9.5",
|
"shadcn-ui": "^0.9.5",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "affine bundle",
|
"build": "affine bundle",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { SWRConfig } from 'swr';
|
import { SWRConfig } from 'swr';
|
||||||
|
|
||||||
|
import { ThemeProvider } from './components/theme-provider';
|
||||||
import { TooltipProvider } from './components/ui/tooltip';
|
import { TooltipProvider } from './components/ui/tooltip';
|
||||||
import { isAdmin, useCurrentUser, useServerConfig } from './modules/common';
|
import { isAdmin, useCurrentUser, useServerConfig } from './modules/common';
|
||||||
import { Layout } from './modules/layout';
|
import { Layout } from './modules/layout';
|
||||||
@@ -94,58 +95,55 @@ function RootRoutes() {
|
|||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<ThemeProvider>
|
||||||
<SWRConfig
|
<TooltipProvider>
|
||||||
value={{
|
<SWRConfig
|
||||||
revalidateOnFocus: false,
|
value={{
|
||||||
revalidateOnMount: false,
|
revalidateOnFocus: false,
|
||||||
}}
|
revalidateOnMount: false,
|
||||||
>
|
}}
|
||||||
<BrowserRouter basename={environment.subPath}>
|
>
|
||||||
<Routes>
|
<BrowserRouter basename={environment.subPath}>
|
||||||
<Route path={ROUTES.admin.index} element={<RootRoutes />}>
|
<Routes>
|
||||||
<Route path={ROUTES.admin.auth} element={<Auth />} />
|
<Route path={ROUTES.admin.index} element={<RootRoutes />}>
|
||||||
<Route path={ROUTES.admin.setup} element={<Setup />} />
|
<Route path={ROUTES.admin.auth} element={<Auth />} />
|
||||||
<Route element={<AuthenticatedRoutes />}>
|
<Route path={ROUTES.admin.setup} element={<Setup />} />
|
||||||
<Route
|
<Route element={<AuthenticatedRoutes />}>
|
||||||
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
|
||||||
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 />}
|
element={<Settings />}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Routes>
|
||||||
</Routes>
|
</BrowserRouter>
|
||||||
</BrowserRouter>
|
</SWRConfig>
|
||||||
</SWRConfig>
|
<Toaster />
|
||||||
<Toaster />
|
</TooltipProvider>
|
||||||
</TooltipProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const ConfirmDialog = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:w-[460px]">
|
<DialogContent className="sm:max-w-[460px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="leading-7">{title}</DialogTitle>
|
<DialogTitle className="leading-7">{title}</DialogTitle>
|
||||||
<DialogDescription className="leading-6">
|
<DialogDescription className="leading-6">
|
||||||
@@ -48,13 +48,19 @@ export const ConfirmDialog = ({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="mt-6">
|
<DialogFooter className="mt-6">
|
||||||
<div className="flex justify-end gap-2 items-center w-full">
|
<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>
|
<span>{cancelText}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
variant={confirmButtonVariant}
|
variant={confirmButtonVariant}
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
<span>{confirmText}</span>
|
<span>{confirmText}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,8 @@ import { type ReactNode, useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { DataTablePagination } from './data-table-pagination';
|
import { DataTablePagination } from './data-table-pagination';
|
||||||
|
|
||||||
|
const DEFAULT_RESET_FILTERS_DEPS: unknown[] = [];
|
||||||
|
|
||||||
interface DataTableProps<TData, TValue> {
|
interface DataTableProps<TData, TValue> {
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
data: TData[];
|
data: TData[];
|
||||||
@@ -58,7 +60,7 @@ export function SharedDataTable<TData extends { id: string }, TValue>({
|
|||||||
rowSelection,
|
rowSelection,
|
||||||
onRowSelectionChange,
|
onRowSelectionChange,
|
||||||
renderToolbar,
|
renderToolbar,
|
||||||
resetFiltersDeps = [],
|
resetFiltersDeps = DEFAULT_RESET_FILTERS_DEPS,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
|
||||||
@@ -88,13 +90,13 @@ export function SharedDataTable<TData extends { id: string }, TValue>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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)}
|
{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 ? (
|
{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
|
<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"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -119,7 +121,10 @@ export function SharedDataTable<TData extends { id: string }, TValue>({
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map(headerGroup => (
|
{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 => {
|
{headerGroup.headers.map(header => {
|
||||||
// Use meta.className if available, otherwise default to flex-1
|
// Use meta.className if available, otherwise default to flex-1
|
||||||
const meta = header.column.columnDef.meta as
|
const meta = header.column.columnDef.meta as
|
||||||
@@ -154,7 +159,7 @@ export function SharedDataTable<TData extends { id: string }, TValue>({
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && 'selected'}
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
className="flex items-center"
|
className="flex items-center bg-card"
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map(cell => {
|
{row.getVisibleCells().map(cell => {
|
||||||
const meta = cell.column.columnDef.meta as
|
const meta = cell.column.columnDef.meta as
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Label } from '@affine/admin/components/ui/label';
|
|||||||
import { Separator } from '@affine/admin/components/ui/separator';
|
import { Separator } from '@affine/admin/components/ui/separator';
|
||||||
import { Switch } from '@affine/admin/components/ui/switch';
|
import { Switch } from '@affine/admin/components/ui/switch';
|
||||||
import type { FeatureType } from '@affine/graphql';
|
import type { FeatureType } from '@affine/graphql';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { cn } from '../../utils';
|
import { cn } from '../../utils';
|
||||||
@@ -42,10 +41,7 @@ export const FeatureToggleList = ({
|
|||||||
|
|
||||||
if (!features.length) {
|
if (!features.length) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn(className, 'px-3 py-2 text-xs text-muted-foreground')}>
|
||||||
className={cn(className, 'px-3 py-2 text-xs')}
|
|
||||||
style={{ color: cssVarV2('text/secondary') }}
|
|
||||||
>
|
|
||||||
No configurable features.
|
No configurable features.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -57,10 +53,10 @@ export const FeatureToggleList = ({
|
|||||||
<div key={feature}>
|
<div key={feature}>
|
||||||
<Label
|
<Label
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer',
|
'cursor-pointer transition-colors duration-100',
|
||||||
controlPosition === 'right'
|
controlPosition === 'right'
|
||||||
? 'flex items-center justify-between p-3 text-[15px] gap-2 font-medium leading-6 overflow-hidden'
|
? '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'
|
: 'flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted/40'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{controlPosition === 'left' ? (
|
{controlPosition === 'left' ? (
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { nextThemeProviderMock } = vi.hoisted(() => ({
|
||||||
|
nextThemeProviderMock: vi.fn(({ children }: { children?: any }) => (
|
||||||
|
<div data-testid="next-theme-provider">{children}</div>
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next-themes', () => ({
|
||||||
|
ThemeProvider: nextThemeProviderMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ThemeProvider } from './theme-provider';
|
||||||
|
|
||||||
|
describe('Admin ThemeProvider', () => {
|
||||||
|
test('uses the same dark/light/system behavior as main frontend', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<div>content</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('content')).not.toBeNull();
|
||||||
|
expect(nextThemeProviderMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const props = nextThemeProviderMock.mock.calls[0]?.[0] as any;
|
||||||
|
expect(props?.themes).toEqual(['dark', 'light']);
|
||||||
|
expect(props?.enableSystem).toBe(true);
|
||||||
|
expect(props?.defaultTheme).toBe('system');
|
||||||
|
});
|
||||||
|
});
|
||||||
16
packages/frontend/admin/src/components/theme-provider.tsx
Normal file
16
packages/frontend/admin/src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { ThemeProvider as NextThemeProvider } from 'next-themes';
|
||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
const themes = ['dark', 'light'];
|
||||||
|
|
||||||
|
export const ThemeProvider = ({ children }: PropsWithChildren) => {
|
||||||
|
return (
|
||||||
|
<NextThemeProvider
|
||||||
|
themes={themes}
|
||||||
|
enableSystem={true}
|
||||||
|
defaultTheme="system"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NextThemeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
packages/frontend/admin/src/components/ui/button.spec.tsx
Normal file
36
packages/frontend/admin/src/components/ui/button.spec.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { cleanup, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { Button } from './button';
|
||||||
|
|
||||||
|
describe('Button', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses token-aligned default styles', () => {
|
||||||
|
render(<Button>Save</Button>);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: 'Save' });
|
||||||
|
expect(button.className).toContain('rounded-lg');
|
||||||
|
expect(button.className).toContain('bg-primary');
|
||||||
|
expect(button.className).toContain('focus-visible:ring-ring/30');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports outline/sm variant with disabled state', () => {
|
||||||
|
render(
|
||||||
|
<Button variant="outline" size="sm" disabled={true}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: 'Cancel' });
|
||||||
|
expect(button.className).toContain('border-border');
|
||||||
|
expect(button.className).toContain('h-8');
|
||||||
|
expect(button.className).toContain('text-xs');
|
||||||
|
expect(button.hasAttribute('disabled')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,25 +4,25 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
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:
|
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:
|
outline:
|
||||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
'border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
secondary:
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-muted',
|
||||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
ghost: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-10 px-4 py-2',
|
default: 'h-9 px-4',
|
||||||
sm: 'h-9 rounded-md px-3',
|
sm: 'h-8 px-3 text-xs',
|
||||||
lg: 'h-11 rounded-md px-8',
|
lg: 'h-10 px-6',
|
||||||
icon: 'h-10 w-10',
|
icon: 'h-9 w-9',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import useEmblaCarousel, {
|
|||||||
type UseEmblaCarouselType,
|
type UseEmblaCarouselType,
|
||||||
} from 'embla-carousel-react';
|
} from 'embla-carousel-react';
|
||||||
import { ArrowLeft, ArrowRight } from 'lucide-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 CarouselApi = UseEmblaCarouselType[1];
|
||||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||||
@@ -27,10 +35,10 @@ type CarouselContextProps = {
|
|||||||
canScrollNext: boolean;
|
canScrollNext: boolean;
|
||||||
} & CarouselProps;
|
} & CarouselProps;
|
||||||
|
|
||||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
const CarouselContext = createContext<CarouselContextProps | null>(null);
|
||||||
|
|
||||||
function useCarousel() {
|
function useCarousel() {
|
||||||
const context = React.useContext(CarouselContext);
|
const context = useContext(CarouselContext);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useCarousel must be used within a <Carousel />');
|
throw new Error('useCarousel must be used within a <Carousel />');
|
||||||
@@ -39,7 +47,7 @@ function useCarousel() {
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Carousel = React.forwardRef<
|
const Carousel = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||||
>(
|
>(
|
||||||
@@ -62,10 +70,10 @@ const Carousel = React.forwardRef<
|
|||||||
},
|
},
|
||||||
plugins
|
plugins
|
||||||
);
|
);
|
||||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
const [canScrollPrev, setCanScrollPrev] = useState(false);
|
||||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
const [canScrollNext, setCanScrollNext] = useState(false);
|
||||||
|
|
||||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
const onSelect = useCallback((api: CarouselApi) => {
|
||||||
if (!api) {
|
if (!api) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -74,15 +82,15 @@ const Carousel = React.forwardRef<
|
|||||||
setCanScrollNext(api.canScrollNext());
|
setCanScrollNext(api.canScrollNext());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scrollPrev = React.useCallback(() => {
|
const scrollPrev = useCallback(() => {
|
||||||
api?.scrollPrev();
|
api?.scrollPrev();
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
const scrollNext = React.useCallback(() => {
|
const scrollNext = useCallback(() => {
|
||||||
api?.scrollNext();
|
api?.scrollNext();
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
const handleKeyDown = React.useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (event.key === 'ArrowLeft') {
|
if (event.key === 'ArrowLeft') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -95,7 +103,7 @@ const Carousel = React.forwardRef<
|
|||||||
[scrollPrev, scrollNext]
|
[scrollPrev, scrollNext]
|
||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!api || !setApi) {
|
if (!api || !setApi) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -103,7 +111,7 @@ const Carousel = React.forwardRef<
|
|||||||
setApi(api);
|
setApi(api);
|
||||||
}, [api, setApi]);
|
}, [api, setApi]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!api) {
|
if (!api) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -117,20 +125,33 @@ const Carousel = React.forwardRef<
|
|||||||
};
|
};
|
||||||
}, [api, onSelect]);
|
}, [api, onSelect]);
|
||||||
|
|
||||||
|
const resolvedOrientation =
|
||||||
|
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal');
|
||||||
|
const carouselContextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
carouselRef,
|
||||||
|
api,
|
||||||
|
opts,
|
||||||
|
orientation: resolvedOrientation,
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
canScrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
carouselRef,
|
||||||
|
opts,
|
||||||
|
resolvedOrientation,
|
||||||
|
scrollNext,
|
||||||
|
scrollPrev,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CarouselContext.Provider
|
<CarouselContext.Provider value={carouselContextValue}>
|
||||||
value={{
|
|
||||||
carouselRef,
|
|
||||||
api: api,
|
|
||||||
opts,
|
|
||||||
orientation:
|
|
||||||
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
|
||||||
scrollPrev,
|
|
||||||
scrollNext,
|
|
||||||
canScrollPrev,
|
|
||||||
canScrollNext,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onKeyDownCapture={handleKeyDown}
|
onKeyDownCapture={handleKeyDown}
|
||||||
@@ -147,7 +168,7 @@ const Carousel = React.forwardRef<
|
|||||||
);
|
);
|
||||||
Carousel.displayName = 'Carousel';
|
Carousel.displayName = 'Carousel';
|
||||||
|
|
||||||
const CarouselContent = React.forwardRef<
|
const CarouselContent = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
@@ -169,7 +190,7 @@ const CarouselContent = React.forwardRef<
|
|||||||
});
|
});
|
||||||
CarouselContent.displayName = 'CarouselContent';
|
CarouselContent.displayName = 'CarouselContent';
|
||||||
|
|
||||||
const CarouselItem = React.forwardRef<
|
const CarouselItem = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
@@ -191,7 +212,7 @@ const CarouselItem = React.forwardRef<
|
|||||||
});
|
});
|
||||||
CarouselItem.displayName = 'CarouselItem';
|
CarouselItem.displayName = 'CarouselItem';
|
||||||
|
|
||||||
const CarouselPrevious = React.forwardRef<
|
const CarouselPrevious = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLButtonElement,
|
||||||
React.ComponentProps<typeof Button>
|
React.ComponentProps<typeof Button>
|
||||||
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||||
@@ -220,7 +241,7 @@ const CarouselPrevious = React.forwardRef<
|
|||||||
});
|
});
|
||||||
CarouselPrevious.displayName = 'CarouselPrevious';
|
CarouselPrevious.displayName = 'CarouselPrevious';
|
||||||
|
|
||||||
const CarouselNext = React.forwardRef<
|
const CarouselNext = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLButtonElement,
|
||||||
React.ComponentProps<typeof Button>
|
React.ComponentProps<typeof Button>
|
||||||
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cn } from '@affine/admin/utils';
|
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 type { TooltipProps } from 'recharts';
|
||||||
import { ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
|
import { ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
|
||||||
|
|
||||||
@@ -18,10 +18,10 @@ type ChartContextValue = {
|
|||||||
config: ChartConfig;
|
config: ChartConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChartContext = React.createContext<ChartContextValue | null>(null);
|
const ChartContext = createContext<ChartContextValue | null>(null);
|
||||||
|
|
||||||
function useChart() {
|
function useChart() {
|
||||||
const value = React.useContext(ChartContext);
|
const value = useContext(ChartContext);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error('useChart must be used within <ChartContainer />');
|
throw new Error('useChart must be used within <ChartContainer />');
|
||||||
}
|
}
|
||||||
@@ -75,13 +75,14 @@ type ChartContainerProps = React.ComponentProps<'div'> & {
|
|||||||
children: React.ComponentProps<typeof ResponsiveContainer>['children'];
|
children: React.ComponentProps<typeof ResponsiveContainer>['children'];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
|
const ChartContainer = forwardRef<HTMLDivElement, ChartContainerProps>(
|
||||||
({ id, className, children, config, ...props }, ref) => {
|
({ id, className, children, config, ...props }, ref) => {
|
||||||
const uniqueId = React.useId();
|
const uniqueId = useId();
|
||||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
|
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
|
||||||
|
const chartContextValue = useMemo(() => ({ config }), [config]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContext.Provider value={{ config }}>
|
<ChartContext.Provider value={chartContextValue}>
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-chart={chartId}
|
data-chart={chartId}
|
||||||
@@ -113,61 +114,60 @@ type TooltipContentProps = {
|
|||||||
valueFormatter?: (value: number, key: string) => React.ReactNode;
|
valueFormatter?: (value: number, key: string) => React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChartTooltipContent = React.forwardRef<
|
const ChartTooltipContent = forwardRef<HTMLDivElement, TooltipContentProps>(
|
||||||
HTMLDivElement,
|
({ active, payload, label, labelFormatter, valueFormatter }, ref) => {
|
||||||
TooltipContentProps
|
const { config } = useChart();
|
||||||
>(({ active, payload, label, labelFormatter, valueFormatter }, ref) => {
|
|
||||||
const { config } = useChart();
|
|
||||||
|
|
||||||
if (!active || !payload?.length) {
|
if (!active || !payload?.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = labelFormatter ? labelFormatter(label ?? '', payload) : label;
|
const title = labelFormatter ? labelFormatter(label ?? '', payload) : label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="min-w-44 rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
|
className="min-w-44 rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
|
||||||
>
|
>
|
||||||
{title ? (
|
{title ? (
|
||||||
<div className="mb-2 font-medium text-foreground/90">{title}</div>
|
<div className="mb-2 font-medium text-foreground/90">{title}</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{payload.map((item, index) => {
|
{payload.map((item, index) => {
|
||||||
const dataKey = String(item.dataKey ?? item.name ?? index);
|
const dataKey = String(item.dataKey ?? item.name ?? index);
|
||||||
const itemConfig = config[dataKey];
|
const itemConfig = config[dataKey];
|
||||||
const labelText = itemConfig?.label ?? item.name ?? dataKey;
|
const labelText = itemConfig?.label ?? item.name ?? dataKey;
|
||||||
const numericValue =
|
const numericValue =
|
||||||
typeof item.value === 'number'
|
typeof item.value === 'number'
|
||||||
? item.value
|
? item.value
|
||||||
: Number(item.value ?? 0);
|
: Number(item.value ?? 0);
|
||||||
const valueText = valueFormatter
|
const valueText = valueFormatter
|
||||||
? valueFormatter(numericValue, dataKey)
|
? valueFormatter(numericValue, dataKey)
|
||||||
: numericValue;
|
: numericValue;
|
||||||
const color = item.color ?? `var(--color-${dataKey})`;
|
const color = item.color ?? `var(--color-${dataKey})`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${dataKey}-${index}`}
|
key={`${dataKey}-${index}`}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="h-2 w-2 rounded-full"
|
className="h-2 w-2 rounded-full"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground">{labelText}</span>
|
<span className="text-muted-foreground">{labelText}</span>
|
||||||
<span className="ml-auto font-medium tabular-nums">
|
<span className="ml-auto font-medium tabular-nums">
|
||||||
{valueText}
|
{valueText}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
});
|
);
|
||||||
ChartTooltipContent.displayName = 'ChartTooltipContent';
|
ChartTooltipContent.displayName = 'ChartTooltipContent';
|
||||||
|
|
||||||
export { ChartContainer, ChartTooltip, ChartTooltipContent };
|
export { ChartContainer, ChartTooltip, ChartTooltipContent };
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Label } from '@affine/admin/components/ui/label';
|
|||||||
import { cn } from '@affine/admin/utils';
|
import { cn } from '@affine/admin/utils';
|
||||||
import type * as LabelPrimitive from '@radix-ui/react-label';
|
import type * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
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 type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
|
||||||
import { Controller, FormProvider, useFormContext } from 'react-hook-form';
|
import { Controller, FormProvider, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ type FormFieldContextValue<
|
|||||||
name: TName;
|
name: TName;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
const FormFieldContext = createContext<FormFieldContextValue>(
|
||||||
{} as FormFieldContextValue
|
{} as FormFieldContextValue
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -25,16 +25,21 @@ const FormField = <
|
|||||||
>({
|
>({
|
||||||
...props
|
...props
|
||||||
}: ControllerProps<TFieldValues, TName>) => {
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
const formFieldContextValue = useMemo(
|
||||||
|
() => ({ name: props.name }),
|
||||||
|
[props.name]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
<FormFieldContext.Provider value={formFieldContextValue}>
|
||||||
<Controller {...props} />
|
<Controller {...props} />
|
||||||
</FormFieldContext.Provider>
|
</FormFieldContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const useFormField = () => {
|
const useFormField = () => {
|
||||||
const fieldContext = React.useContext(FormFieldContext);
|
const fieldContext = useContext(FormFieldContext);
|
||||||
const itemContext = React.useContext(FormItemContext);
|
const itemContext = useContext(FormItemContext);
|
||||||
const { getFieldState, formState } = useFormContext();
|
const { getFieldState, formState } = useFormContext();
|
||||||
|
|
||||||
const fieldState = getFieldState(fieldContext.name, formState);
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
@@ -59,26 +64,27 @@ type FormItemContextValue = {
|
|||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
const FormItemContext = createContext<FormItemContextValue>(
|
||||||
{} as FormItemContextValue
|
{} as FormItemContextValue
|
||||||
);
|
);
|
||||||
|
|
||||||
const FormItem = React.forwardRef<
|
const FormItem = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
const id = React.useId();
|
const id = useId();
|
||||||
|
const formItemContextValue = useMemo(() => ({ id }), [id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItemContext.Provider value={{ id }}>
|
<FormItemContext.Provider value={formItemContextValue}>
|
||||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||||
</FormItemContext.Provider>
|
</FormItemContext.Provider>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
FormItem.displayName = 'FormItem';
|
FormItem.displayName = 'FormItem';
|
||||||
|
|
||||||
const FormLabel = React.forwardRef<
|
const FormLabel = forwardRef<
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
React.ComponentRef<typeof LabelPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
const { error, formItemId } = useFormField();
|
const { error, formItemId } = useFormField();
|
||||||
@@ -94,8 +100,8 @@ const FormLabel = React.forwardRef<
|
|||||||
});
|
});
|
||||||
FormLabel.displayName = 'FormLabel';
|
FormLabel.displayName = 'FormLabel';
|
||||||
|
|
||||||
const FormControl = React.forwardRef<
|
const FormControl = forwardRef<
|
||||||
React.ElementRef<typeof Slot>,
|
React.ComponentRef<typeof Slot>,
|
||||||
React.ComponentPropsWithoutRef<typeof Slot>
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
>(({ ...props }, ref) => {
|
>(({ ...props }, ref) => {
|
||||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||||
@@ -117,7 +123,7 @@ const FormControl = React.forwardRef<
|
|||||||
});
|
});
|
||||||
FormControl.displayName = 'FormControl';
|
FormControl.displayName = 'FormControl';
|
||||||
|
|
||||||
const FormDescription = React.forwardRef<
|
const FormDescription = forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => {
|
||||||
@@ -134,7 +140,7 @@ const FormDescription = React.forwardRef<
|
|||||||
});
|
});
|
||||||
FormDescription.displayName = 'FormDescription';
|
FormDescription.displayName = 'FormDescription';
|
||||||
|
|
||||||
const FormMessage = React.forwardRef<
|
const FormMessage = forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, children, ...props }, ref) => {
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -72,7 +72,7 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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' &&
|
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',
|
'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
|
className
|
||||||
@@ -115,7 +115,7 @@ const SelectItem = React.forwardRef<
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const SheetOverlay = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ function Skeleton({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('animate-pulse rounded-md bg-muted', className)}
|
className={cn('animate-pulse rounded-lg bg-muted', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
toastOptions={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast:
|
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',
|
description: 'group-[.toast]:text-muted-foreground',
|
||||||
actionButton:
|
actionButton:
|
||||||
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const Switch = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -16,7 +16,7 @@ const Switch = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<SwitchPrimitives.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
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>
|
</SwitchPrimitives.Root>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const TableRow = React.forwardRef<
|
|||||||
<tr
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -2,39 +2,44 @@ import { toggleVariants } from '@affine/admin/components/ui/toggle';
|
|||||||
import { cn } from '@affine/admin/utils';
|
import { cn } from '@affine/admin/utils';
|
||||||
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
|
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
|
||||||
import type { VariantProps } from 'class-variance-authority';
|
import type { VariantProps } from 'class-variance-authority';
|
||||||
import * as React from 'react';
|
import { createContext, forwardRef, useContext, useMemo } from 'react';
|
||||||
|
|
||||||
const ToggleGroupContext = React.createContext<
|
const ToggleGroupContext = createContext<VariantProps<typeof toggleVariants>>({
|
||||||
VariantProps<typeof toggleVariants>
|
|
||||||
>({
|
|
||||||
size: 'default',
|
size: 'default',
|
||||||
variant: 'default',
|
variant: 'default',
|
||||||
});
|
});
|
||||||
|
|
||||||
const ToggleGroup = React.forwardRef<
|
const ToggleGroup = forwardRef<
|
||||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
React.ComponentRef<typeof ToggleGroupPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||||
VariantProps<typeof toggleVariants>
|
VariantProps<typeof toggleVariants>
|
||||||
>(({ className, variant, size, children, ...props }, ref) => (
|
>(({ className, variant, size, children, ...props }, ref) => {
|
||||||
<ToggleGroupPrimitive.Root
|
const toggleGroupContextValue = useMemo(
|
||||||
ref={ref}
|
() => ({ variant, size }),
|
||||||
className={cn('flex items-center justify-center gap-1', className)}
|
[size, variant]
|
||||||
{...props}
|
);
|
||||||
>
|
|
||||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
return (
|
||||||
{children}
|
<ToggleGroupPrimitive.Root
|
||||||
</ToggleGroupContext.Provider>
|
ref={ref}
|
||||||
</ToggleGroupPrimitive.Root>
|
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;
|
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
|
||||||
|
|
||||||
const ToggleGroupItem = React.forwardRef<
|
const ToggleGroupItem = forwardRef<
|
||||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
React.ComponentRef<typeof ToggleGroupPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||||
VariantProps<typeof toggleVariants>
|
VariantProps<typeof toggleVariants>
|
||||||
>(({ className, children, variant, size, ...props }, ref) => {
|
>(({ className, children, variant, size, ...props }, ref) => {
|
||||||
const context = React.useContext(ToggleGroupContext);
|
const context = useContext(ToggleGroupContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToggleGroupPrimitive.Item
|
<ToggleGroupPrimitive.Item
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
},
|
},
|
||||||
"SMTP.sender": {
|
"SMTP.sender": {
|
||||||
"type": "String",
|
"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 <noreply@example.com>\")",
|
||||||
"env": "MAILER_SENDER"
|
"env": "MAILER_SENDER"
|
||||||
},
|
},
|
||||||
"SMTP.ignoreTLS": {
|
"SMTP.ignoreTLS": {
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
},
|
},
|
||||||
"fallbackSMTP.sender": {
|
"fallbackSMTP.sender": {
|
||||||
"type": "String",
|
"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 <noreply@example.com>\")"
|
||||||
},
|
},
|
||||||
"fallbackSMTP.ignoreTLS": {
|
"fallbackSMTP.ignoreTLS": {
|
||||||
"type": "Boolean",
|
"type": "Boolean",
|
||||||
|
|||||||
@@ -6,35 +6,63 @@
|
|||||||
|
|
||||||
@plugin 'tailwindcss-animate';
|
@plugin 'tailwindcss-animate';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *, [data-theme='dark'] *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-border: hsl(var(--border));
|
--color-border: var(--border);
|
||||||
--color-input: hsl(var(--input));
|
--color-input: var(--input);
|
||||||
--color-ring: hsl(var(--ring));
|
--color-ring: var(--ring);
|
||||||
--color-background: hsl(var(--background));
|
--color-background: var(--background);
|
||||||
--color-foreground: hsl(var(--foreground));
|
--color-foreground: var(--foreground);
|
||||||
|
|
||||||
--color-primary: hsl(var(--primary));
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
|
||||||
--color-secondary: hsl(var(--secondary));
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
|
||||||
--color-destructive: hsl(var(--destructive));
|
--color-destructive: var(--destructive);
|
||||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
--color-muted: hsl(var(--muted));
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
|
||||||
--color-accent: hsl(var(--accent));
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
|
||||||
--color-popover: hsl(var(--popover));
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
|
||||||
--color-card: hsl(var(--card));
|
--color-card: var(--card);
|
||||||
--color-card-foreground: hsl(var(--card-foreground));
|
--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-lg: var(--radius);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--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
|
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.
|
color utility to any element that depends on these defaults.
|
||||||
*/
|
*/
|
||||||
@layer base {
|
@layer base {
|
||||||
*,
|
*,
|
||||||
::after,
|
::after,
|
||||||
::before,
|
::before,
|
||||||
::backdrop,
|
::backdrop,
|
||||||
::file-selector-button {
|
::file-selector-button {
|
||||||
border-color: var(--color-gray-200, currentColor);
|
border-color: var(--border, currentColor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: var(--affine-v2-layer-background-primary);
|
||||||
--foreground: 240 10% 3.9%;
|
--foreground: var(--affine-v2-text-primary);
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: var(--affine-v2-layer-background-secondary);
|
||||||
--card-foreground: 240 10% 3.9%;
|
--card-foreground: var(--affine-v2-text-primary);
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--popover: var(--affine-v2-layer-background-overlayPanel);
|
||||||
--popover-foreground: 240 10% 3.9%;
|
--popover-foreground: var(--affine-v2-text-primary);
|
||||||
|
|
||||||
--primary: 240 5.9% 10%;
|
--primary: var(--affine-v2-button-primary);
|
||||||
--primary-foreground: 0 0% 98%;
|
--primary-foreground: var(--affine-v2-button-pureWhiteText);
|
||||||
|
|
||||||
--secondary: 240 4.8% 95.9%;
|
--secondary: var(--affine-v2-layer-background-secondary);
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
--secondary-foreground: var(--affine-v2-text-primary);
|
||||||
|
|
||||||
--muted: 240 4.8% 95.9%;
|
--muted: var(--affine-v2-layer-background-tertiary);
|
||||||
--muted-foreground: 240 3.8% 46.1%;
|
--muted-foreground: var(--affine-v2-text-secondary);
|
||||||
|
|
||||||
--accent: 240 4.8% 95.9%;
|
--accent: var(--affine-v2-layer-background-hoverOverlay);
|
||||||
--accent-foreground: 240 5.9% 10%;
|
--accent-foreground: var(--affine-v2-text-primary);
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: var(--affine-v2-button-error);
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: var(--affine-v2-button-pureWhiteText);
|
||||||
|
|
||||||
--border: 240 5.9% 90%;
|
--border: var(--affine-v2-layer-insideBorder-border);
|
||||||
--input: 240 5.9% 90%;
|
--input: var(--affine-v2-input-border-default);
|
||||||
--ring: 240 10% 3.9%;
|
--ring: var(--affine-v2-input-border-active);
|
||||||
|
--radius: var(--affine-popover-radius);
|
||||||
--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%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +155,31 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
packages/frontend/admin/src/global.spec.ts
Normal file
27
packages/frontend/admin/src/global.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
const css = readFileSync(new URL('./global.css', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
describe('admin global token mapping', () => {
|
||||||
|
test('maps semantic colors to affine tokens', () => {
|
||||||
|
expect(css).toContain(
|
||||||
|
'--background: var(--affine-v2-layer-background-primary);'
|
||||||
|
);
|
||||||
|
expect(css).toContain('--foreground: var(--affine-v2-text-primary);');
|
||||||
|
expect(css).toContain('--primary: var(--affine-v2-button-primary);');
|
||||||
|
expect(css).toContain('--ring: var(--affine-v2-input-border-active);');
|
||||||
|
expect(css).toContain('--radius: var(--affine-popover-radius);');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not keep hardcoded shadcn light/dark values', () => {
|
||||||
|
expect(css).not.toContain('--background: 0 0% 100%;');
|
||||||
|
expect(css).not.toContain('--foreground: 240 10% 3.9%;');
|
||||||
|
expect(css).not.toContain('--background: 240 10% 3.9%;');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports data-theme based dark variant', () => {
|
||||||
|
expect(css).toContain("[data-theme='dark']");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,7 +74,7 @@ export function AboutAFFiNE() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>{`App Version: ${appName} ${BUILD_CONFIG.appVersion}`}</div>
|
||||||
<div>{`Editor Version: ${BUILD_CONFIG.editorVersion}`}</div>
|
<div>{`Editor Version: ${BUILD_CONFIG.editorVersion}`}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { AboutAFFiNE } from './about';
|
|||||||
|
|
||||||
export function ConfigPage() {
|
export function ConfigPage() {
|
||||||
return (
|
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" />
|
<Header title="Server" />
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
<AboutAFFiNE />
|
<AboutAFFiNE />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
AvatarImage,
|
AvatarImage,
|
||||||
} from '@affine/admin/components/ui/avatar';
|
} from '@affine/admin/components/ui/avatar';
|
||||||
|
import { cn } from '@affine/admin/utils';
|
||||||
import { FeatureType } from '@affine/graphql';
|
import { FeatureType } from '@affine/graphql';
|
||||||
import {
|
import {
|
||||||
AccountIcon,
|
AccountIcon,
|
||||||
@@ -12,7 +13,6 @@ import {
|
|||||||
UnlockIcon,
|
UnlockIcon,
|
||||||
} from '@blocksuite/icons/rc';
|
} from '@blocksuite/icons/rc';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -39,10 +39,10 @@ const StatusItem = ({
|
|||||||
textFalse: string;
|
textFalse: string;
|
||||||
}) => (
|
}) => (
|
||||||
<div
|
<div
|
||||||
className="flex gap-1 items-center"
|
className={cn(
|
||||||
style={{
|
'flex items-center gap-1',
|
||||||
color: condition ? cssVarV2('text/secondary') : cssVarV2('status/error'),
|
condition ? 'text-muted-foreground' : 'text-destructive'
|
||||||
}}
|
)}
|
||||||
>
|
>
|
||||||
{condition ? (
|
{condition ? (
|
||||||
<>
|
<>
|
||||||
@@ -152,36 +152,17 @@ export const useColumns = ({
|
|||||||
<div className="text-sm font-medium max-w-full overflow-hidden gap-[6px]">
|
<div className="text-sm font-medium max-w-full overflow-hidden gap-[6px]">
|
||||||
<span>{row.original.name}</span>
|
<span>{row.original.name}</span>
|
||||||
{row.original.features.includes(FeatureType.Admin) && (
|
{row.original.features.includes(FeatureType.Admin) && (
|
||||||
<span
|
<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">
|
||||||
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
|
Admin
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{row.original.disabled && (
|
{row.original.disabled && (
|
||||||
<span
|
<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">
|
||||||
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'),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Disabled
|
Disabled
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="max-w-full overflow-hidden text-xs font-medium text-muted-foreground">
|
||||||
className="text-xs font-medium max-w-full overflow-hidden"
|
|
||||||
style={{
|
|
||||||
color: cssVarV2('text/secondary'),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.original.email}
|
{row.original.email}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,16 +188,10 @@ export const useColumns = ({
|
|||||||
<StatusItem
|
<StatusItem
|
||||||
condition={user.hasPassword}
|
condition={user.hasPassword}
|
||||||
IconTrue={
|
IconTrue={
|
||||||
<LockIcon
|
<LockIcon fontSize={16} className="text-muted-foreground" />
|
||||||
fontSize={16}
|
|
||||||
color={cssVarV2('selfhost/icon/tertiary')}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
IconFalse={
|
IconFalse={
|
||||||
<UnlockIcon
|
<UnlockIcon fontSize={16} className="text-destructive" />
|
||||||
fontSize={16}
|
|
||||||
color={cssVarV2('toast/iconState/error')}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
textTrue="Password Set"
|
textTrue="Password Set"
|
||||||
textFalse="No Password"
|
textFalse="No Password"
|
||||||
@@ -226,13 +201,13 @@ export const useColumns = ({
|
|||||||
IconTrue={
|
IconTrue={
|
||||||
<EmailIcon
|
<EmailIcon
|
||||||
fontSize={16}
|
fontSize={16}
|
||||||
color={cssVarV2('selfhost/icon/tertiary')}
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
IconFalse={
|
IconFalse={
|
||||||
<EmailWarningIcon
|
<EmailWarningIcon
|
||||||
fontSize={16}
|
fontSize={16}
|
||||||
color={cssVarV2('toast/iconState/error')}
|
className="text-destructive"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
textTrue="Email Verified"
|
textTrue="Email Verified"
|
||||||
@@ -244,24 +219,13 @@ export const useColumns = ({
|
|||||||
user.features.map(feature => (
|
user.features.map(feature => (
|
||||||
<span
|
<span
|
||||||
key={feature}
|
key={feature}
|
||||||
className="rounded px-2 py-0.5 text-xs h-5 border inline-flex items-center"
|
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"
|
||||||
style={{
|
|
||||||
borderRadius: '4px',
|
|
||||||
backgroundColor: cssVarV2('chip/label/white'),
|
|
||||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{feature}
|
{feature}
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span className="text-muted-foreground">No features</span>
|
||||||
style={{
|
|
||||||
color: cssVarV2('text/secondary'),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No features
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -168,13 +168,14 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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} />
|
<MoreHorizontalIcon fontSize={20} />
|
||||||
<span className="sr-only">Open menu</span>
|
<span className="sr-only">Open menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-[214px] p-[5px] gap-2">
|
<DropdownMenuContent align="end" className="w-[214px] p-1.5">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={handleEdit}
|
onSelect={handleEdit}
|
||||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
||||||
@@ -201,7 +202,7 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{!user.disabled && (
|
{!user.disabled && (
|
||||||
<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={openDisableDialog}
|
onSelect={openDisableDialog}
|
||||||
>
|
>
|
||||||
<AccountBanIcon fontSize={20} />
|
<AccountBanIcon fontSize={20} />
|
||||||
@@ -209,7 +210,7 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<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}
|
onSelect={openDeleteDialog}
|
||||||
>
|
>
|
||||||
<DeleteIcon fontSize={20} />
|
<DeleteIcon fontSize={20} />
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { WarningIcon } from '@blocksuite/icons/rc';
|
import { WarningIcon } from '@blocksuite/icons/rc';
|
||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
interface CsvFormatGuidanceProps {
|
interface CsvFormatGuidanceProps {
|
||||||
@@ -17,16 +15,9 @@ export const CsvFormatGuidance: FC<CsvFormatGuidanceProps> = ({
|
|||||||
passwordLimits,
|
passwordLimits,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex gap-1 rounded-[6px] bg-secondary p-1.5 text-xs text-muted-foreground">
|
||||||
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 justify-center py-0.5">
|
<div className="flex justify-center py-0.5">
|
||||||
<WarningIcon fontSize={16} color={cssVarV2('icon/primary')} />
|
<WarningIcon fontSize={16} className="text-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p>CSV file includes username, email, and password.</p>
|
<p>CSV file includes username, email, and password.</p>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||||
import { UploadIcon } from '@blocksuite/icons/rc';
|
import { UploadIcon } from '@blocksuite/icons/rc';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import {
|
import {
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
@@ -86,37 +85,28 @@ export const FileUploadArea = forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex justify-center p-8 border-2 border-dashed rounded-[6px] ${
|
className={`flex justify-center rounded-[6px] border-2 border-dashed p-8 transition-colors ${
|
||||||
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
|
isDragging
|
||||||
|
? 'border-ring bg-accent/40'
|
||||||
|
: 'border-border hover:border-ring/50'
|
||||||
}`}
|
}`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragEnter={handleDragOver}
|
onDragEnter={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={triggerFileInput}
|
onClick={triggerFileInput}
|
||||||
style={{
|
|
||||||
borderColor: isDragging
|
|
||||||
? cssVarV2('button/primary')
|
|
||||||
: cssVarV2('layer/insideBorder/blackBorder'),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<UploadIcon
|
<UploadIcon
|
||||||
fontSize={24}
|
fontSize={24}
|
||||||
className="mx-auto mb-3"
|
className="mx-auto mb-3 text-muted-foreground"
|
||||||
style={{
|
|
||||||
color: cssVarV2('selfhost/icon/secondary'),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
className="text-xs font-medium"
|
|
||||||
style={{ color: cssVarV2('text/secondary') }}
|
|
||||||
>
|
|
||||||
{isDragging
|
{isDragging
|
||||||
? 'Release mouse to upload file'
|
? 'Release mouse to upload file'
|
||||||
: 'Upload your CSV file or drag it here'}
|
: 'Upload your CSV file or drag it here'}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{isDragging ? 'Preparing to upload...' : ''}
|
{isDragging ? 'Preparing to upload...' : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import type { FC, RefObject } from 'react';
|
import type { FC, RefObject } from 'react';
|
||||||
|
|
||||||
import type { ParsedUser } from '../../utils/csv-utils';
|
import type { ParsedUser } from '../../utils/csv-utils';
|
||||||
@@ -21,7 +20,7 @@ export const ImportPreviewContent: FC<ImportPreviewContentProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{!isImported && (
|
{!isImported && (
|
||||||
<p style={{ color: cssVarV2('text/secondary') }}>
|
<p className="text-sm text-muted-foreground">
|
||||||
{parsedUsers.length} users detected from the CSV file. Please confirm
|
{parsedUsers.length} users detected from the CSV file. Please confirm
|
||||||
the user list below and import.
|
the user list below and import.
|
||||||
</p>
|
</p>
|
||||||
@@ -50,7 +49,7 @@ export const ImportInitialContent: FC<ImportInitialContentProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3">
|
<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
|
You need to import the accounts by importing a CSV file in the correct
|
||||||
format. Please download the CSV template.
|
format. Please download the CSV template.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const Logo = () => {
|
|||||||
>
|
>
|
||||||
<path
|
<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"
|
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>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Input } from '@affine/admin/components/ui/input';
|
|||||||
import { Label } from '@affine/admin/components/ui/label';
|
import { Label } from '@affine/admin/components/ui/label';
|
||||||
import { Separator } from '@affine/admin/components/ui/separator';
|
import { Separator } from '@affine/admin/components/ui/separator';
|
||||||
import type { FeatureType } from '@affine/graphql';
|
import type { FeatureType } from '@affine/graphql';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import { ChevronRightIcon } from 'lucide-react';
|
import { ChevronRightIcon } from 'lucide-react';
|
||||||
import type { ChangeEvent } from 'react';
|
import type { ChangeEvent } from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
@@ -114,15 +113,15 @@ function UserForm({
|
|||||||
}, [defaultUser]);
|
}, [defaultUser]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full gap-1">
|
<div className="flex h-full flex-col bg-background">
|
||||||
<RightPanelHeader
|
<RightPanelHeader
|
||||||
title={title}
|
title={title}
|
||||||
handleClose={handleClose}
|
handleClose={handleClose}
|
||||||
handleConfirm={handleConfirm}
|
handleConfirm={handleConfirm}
|
||||||
canSave={canSave}
|
canSave={canSave}
|
||||||
/>
|
/>
|
||||||
<div className="p-4 flex-grow overflow-y-auto space-y-[8px]">
|
<div className="flex-grow space-y-3 overflow-y-auto p-4">
|
||||||
<div className="flex flex-col rounded-md border">
|
<div className="flex flex-col rounded-xl border border-border bg-card shadow-sm">
|
||||||
<InputItem
|
<InputItem
|
||||||
label="User name"
|
label="User name"
|
||||||
field="name"
|
field="name"
|
||||||
@@ -154,7 +153,7 @@ function UserForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FeatureToggleList
|
<FeatureToggleList
|
||||||
className="border rounded-md"
|
className="rounded-xl border border-border bg-card shadow-sm"
|
||||||
features={serverConfig.availableUserFeatures}
|
features={serverConfig.availableUserFeatures}
|
||||||
selected={changes.features ?? []}
|
selected={changes.features ?? []}
|
||||||
onChange={handleFeaturesChange}
|
onChange={handleFeaturesChange}
|
||||||
@@ -191,24 +190,18 @@ function InputItem({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5 p-3">
|
<div className="flex flex-col gap-2 p-3">
|
||||||
<Label
|
<Label className="flex flex-wrap text-xs font-medium leading-5 text-muted-foreground uppercase tracking-wide">
|
||||||
className="text-[15px] font-medium flex-wrap flex"
|
|
||||||
style={{ lineHeight: '1.6rem' }}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
{optional && (
|
{optional && (
|
||||||
<span
|
<span className="ml-1 font-normal text-muted-foreground">
|
||||||
className="font-normal ml-1"
|
|
||||||
style={{ color: cssVarV2('text/secondary') }}
|
|
||||||
>
|
|
||||||
(optional)
|
(optional)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
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}
|
value={value}
|
||||||
onChange={onValueChange}
|
onChange={onValueChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -316,24 +309,24 @@ export function UpdateUserForm({
|
|||||||
onValidate={validateUpdateUser}
|
onValidate={validateUpdateUser}
|
||||||
onDirtyChange={onDirtyChange}
|
onDirtyChange={onDirtyChange}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<div className="space-y-2">
|
||||||
<Button
|
<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"
|
variant="outline"
|
||||||
onClick={onResetPassword}
|
onClick={onResetPassword}
|
||||||
>
|
>
|
||||||
<span>Reset Password</span>
|
<span>Reset Password</span>
|
||||||
<ChevronRightIcon size={16} />
|
<ChevronRightIcon size={16} className="text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
<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"
|
variant="outline"
|
||||||
onClick={onDeleteAccount}
|
onClick={onDeleteAccount}
|
||||||
>
|
>
|
||||||
<span>Delete Account</span>
|
<span>Delete Account</span>
|
||||||
<ChevronRightIcon size={16} />
|
<ChevronRightIcon size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,34 +9,34 @@ interface UserTableProps {
|
|||||||
*/
|
*/
|
||||||
export const UserTable: React.FC<UserTableProps> = ({ users }) => {
|
export const UserTable: React.FC<UserTableProps> = ({ users }) => {
|
||||||
return (
|
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">
|
<table className="w-full border-collapse">
|
||||||
<thead className="bg-white sticky top-0">
|
<thead className="sticky top-0 bg-muted/40">
|
||||||
<tr>
|
<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
|
Name
|
||||||
</th>
|
</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
|
Email
|
||||||
</th>
|
</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
|
Password
|
||||||
</th>
|
</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
|
Status
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200">
|
<tbody className="divide-y divide-border">
|
||||||
{users.map((user, index) => (
|
{users.map((user, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={`${user.email}-${index}`}
|
key={`${user.email}-${index}`}
|
||||||
className={`${user.valid === false ? 'bg-red-50' : ''}
|
className={`${user.valid === false ? 'bg-destructive/10' : ''}
|
||||||
${user.importStatus === ImportStatus.Failed ? 'bg-red-50' : ''}
|
${user.importStatus === ImportStatus.Failed ? 'bg-destructive/10' : ''}
|
||||||
${user.importStatus === ImportStatus.Success ? 'bg-green-50' : ''}
|
${user.importStatus === ImportStatus.Success ? 'bg-[var(--affine-v2-layer-background-success)]' : ''}
|
||||||
${user.importStatus === ImportStatus.Processing ? 'bg-yellow-50' : ''}`}
|
${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 || '-'}
|
{user.name || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
@@ -44,8 +44,8 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
|
|||||||
user.valid === false &&
|
user.valid === false &&
|
||||||
(user.error?.toLowerCase().includes('email') ||
|
(user.error?.toLowerCase().includes('email') ||
|
||||||
!user.error?.toLowerCase().includes('password'))
|
!user.error?.toLowerCase().includes('password'))
|
||||||
? 'text-red-500'
|
? 'text-destructive'
|
||||||
: 'text-gray-900'
|
: 'text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{user.email}
|
{user.email}
|
||||||
@@ -54,36 +54,36 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
|
|||||||
className={`py-2 px-4 text-sm truncate max-w-[150px] ${
|
className={`py-2 px-4 text-sm truncate max-w-[150px] ${
|
||||||
user.valid === false &&
|
user.valid === false &&
|
||||||
user.error?.toLowerCase().includes('password')
|
user.error?.toLowerCase().includes('password')
|
||||||
? 'text-red-500'
|
? 'text-destructive'
|
||||||
: 'text-gray-900'
|
: 'text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{user.password || '-'}
|
{user.password || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-4 text-sm">
|
<td className="py-2 px-4 text-sm">
|
||||||
{user.importStatus === ImportStatus.Success ? (
|
{user.importStatus === ImportStatus.Success ? (
|
||||||
<span className="text-gray-900">
|
<span className="text-foreground">
|
||||||
<span className="h-2 w-2 bg-gray-900 rounded-full inline-block mr-2" />
|
<span className="mr-2 inline-block h-2 w-2 rounded-full bg-[var(--affine-v2-status-success)]" />
|
||||||
Success
|
Success
|
||||||
</span>
|
</span>
|
||||||
) : user.importStatus === ImportStatus.Failed ? (
|
) : user.importStatus === ImportStatus.Failed ? (
|
||||||
<span className="text-red-500" title={user.importError}>
|
<span className="text-destructive" title={user.importError}>
|
||||||
<span className="h-2 w-2 bg-red-500 rounded-full inline-block mr-2" />
|
<span className="mr-2 inline-block h-2 w-2 rounded-full bg-destructive" />
|
||||||
Failed ({user.importError})
|
Failed ({user.importError})
|
||||||
</span>
|
</span>
|
||||||
) : user.importStatus === ImportStatus.Processing ? (
|
) : user.importStatus === ImportStatus.Processing ? (
|
||||||
<span className="text-yellow-500">
|
<span className="text-primary">
|
||||||
<span className="h-2 w-2 bg-yellow-500 rounded-full inline-block mr-2" />
|
<span className="mr-2 inline-block h-2 w-2 rounded-full bg-primary" />
|
||||||
Processing...
|
Processing...
|
||||||
</span>
|
</span>
|
||||||
) : user.valid === false ? (
|
) : user.valid === false ? (
|
||||||
<span className="text-red-500" title={user.error}>
|
<span className="text-destructive" title={user.error}>
|
||||||
<span className="h-2 w-2 bg-red-500 rounded-full inline-block mr-2" />
|
<span className="mr-2 inline-block h-2 w-2 rounded-full bg-destructive" />
|
||||||
Invalid ({user.error})
|
Invalid ({user.error})
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-900">
|
<span className="text-foreground">
|
||||||
<span className="h-2 w-2 bg-gray-900 rounded-full inline-block mr-2" />
|
<span className="mr-2 inline-block h-2 w-2 rounded-full bg-foreground" />
|
||||||
Valid
|
Valid
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function AccountPage() {
|
|||||||
}, [selectedUserIds, memoUsers]);
|
}, [selectedUserIds, memoUsers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className=" h-screen flex-1 flex-col flex">
|
<div className="h-dvh flex-1 flex-col flex">
|
||||||
<Header title="Accounts" />
|
<Header title="Accounts" />
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -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="px-5 py-4 overflow-y-auto space-y-[10px] flex flex-col gap-5">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="text-sm font-medium">Name</div>
|
<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}
|
{item.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{item.action ? (
|
{item.action ? (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="text-sm font-medium">Action</div>
|
<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}
|
{item.action}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="text-sm font-medium">Model</div>
|
<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}
|
{item.model}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,7 +91,7 @@ export function EditPrompt({
|
|||||||
<div key={key} className="flex flex-col">
|
<div key={key} className="flex flex-col">
|
||||||
{index !== 0 && <Separator />}
|
{index !== 0 && <Separator />}
|
||||||
<span className="text-sm font-normal">{key}</span>
|
<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()}
|
{value?.toString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +106,7 @@ export function EditPrompt({
|
|||||||
{index !== 0 && <Separator />}
|
{index !== 0 && <Separator />}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-normal">Role</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}
|
{message.role}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +120,7 @@ export function EditPrompt({
|
|||||||
{index !== 0 && <Separator />}
|
{index !== 0 && <Separator />}
|
||||||
<span className="text-sm font-normal">{key}</span>
|
<span className="text-sm font-normal">{key}</span>
|
||||||
<span
|
<span
|
||||||
className="text-sm font-normal text-zinc-500"
|
className="text-sm font-normal text-muted-foreground"
|
||||||
style={{ overflowWrap: 'break-word' }}
|
style={{ overflowWrap: 'break-word' }}
|
||||||
>
|
>
|
||||||
{value.toString()}
|
{value.toString()}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function AiPage() {
|
|||||||
const [enableAi, setEnableAi] = useState(false);
|
const [enableAi, setEnableAi] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex-1 flex-col flex">
|
<div className="h-dvh flex-1 flex-col flex">
|
||||||
<Header title="AI" />
|
<Header title="AI" />
|
||||||
<ScrollAreaPrimitive.Root
|
<ScrollAreaPrimitive.Root
|
||||||
className={cn('relative overflow-hidden w-full')}
|
className={cn('relative overflow-hidden w-full')}
|
||||||
@@ -19,7 +19,7 @@ function AiPage() {
|
|||||||
<div className="text-[20px]">AI</div>
|
<div className="text-[20px]">AI</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<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">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
AI functionality is not currently supported. Self-hosted AI
|
AI functionality is not currently supported. Self-hosted AI
|
||||||
support is in progress.
|
support is in progress.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function Keys() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<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
|
Custom API keys may not perform as expected. AFFiNE does not
|
||||||
guarantee results when using custom API keys.
|
guarantee results when using custom API keys.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function Auth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="flex items-center justify-center py-12">
|
||||||
<div className="mx-auto grid w-[350px] gap-6">
|
<div className="mx-auto grid w-[350px] gap-6">
|
||||||
<div className="grid gap-2 text-center">
|
<div className="grid gap-2 text-center">
|
||||||
|
|||||||
125
packages/frontend/admin/src/modules/dashboard/index.spec.tsx
Normal file
125
packages/frontend/admin/src/modules/dashboard/index.spec.tsx
Normal 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))');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@affine/admin/components/ui/select';
|
} from '@affine/admin/components/ui/select';
|
||||||
import { Separator } from '@affine/admin/components/ui/separator';
|
import { Separator } from '@affine/admin/components/ui/separator';
|
||||||
|
import { Skeleton } from '@affine/admin/components/ui/skeleton';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -38,13 +39,82 @@ import {
|
|||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from 'lucide-react';
|
} 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 { Link } from 'react-router-dom';
|
||||||
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import { useMutateQueryResource } from '../../use-mutation';
|
||||||
import { Header } from '../header';
|
import { Header } from '../header';
|
||||||
import { formatBytes } from '../workspaces/utils';
|
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 intFormatter = new Intl.NumberFormat('en-US');
|
||||||
const compactFormatter = new Intl.NumberFormat('en-US', {
|
const compactFormatter = new Intl.NumberFormat('en-US', {
|
||||||
notation: 'compact',
|
notation: 'compact',
|
||||||
@@ -140,13 +210,13 @@ function TrendChart({
|
|||||||
const config: ChartConfig = {
|
const config: ChartConfig = {
|
||||||
primary: {
|
primary: {
|
||||||
label: primaryLabel,
|
label: primaryLabel,
|
||||||
color: 'hsl(var(--primary))',
|
color: 'var(--primary)',
|
||||||
},
|
},
|
||||||
...(hasSecondary
|
...(hasSecondary
|
||||||
? {
|
? {
|
||||||
secondary: {
|
secondary: {
|
||||||
label: secondaryLabel,
|
label: secondaryLabel,
|
||||||
color: 'hsl(var(--foreground) / 0.6)',
|
color: 'var(--muted-foreground)',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -166,7 +236,7 @@ function TrendChart({
|
|||||||
>
|
>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
vertical={false}
|
vertical={false}
|
||||||
stroke="hsl(var(--border) / 0.6)"
|
stroke="var(--border)"
|
||||||
strokeDasharray="3 4"
|
strokeDasharray="3 4"
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
@@ -190,7 +260,7 @@ function TrendChart({
|
|||||||
/>
|
/>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
cursor={{
|
cursor={{
|
||||||
stroke: 'hsl(var(--border))',
|
stroke: 'var(--border)',
|
||||||
strokeDasharray: '4 4',
|
strokeDasharray: '4 4',
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
}}
|
}}
|
||||||
@@ -244,7 +314,7 @@ function TrendChart({
|
|||||||
</LineChart>
|
</LineChart>
|
||||||
</ChartContainer>
|
</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[0]?.label}</span>
|
||||||
<span>{points[points.length - 1]?.label}</span>
|
<span>{points[points.length - 1]?.label}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,14 +330,14 @@ function PrimaryMetricCard({
|
|||||||
description: string;
|
description: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<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" />
|
<UsersIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
Current Sync Active Users
|
Current Sync Active Users
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-1">
|
<CardContent className="space-y-1.5">
|
||||||
<div className="text-4xl font-bold tracking-tight tabular-nums">
|
<div className="text-4xl font-bold tracking-tight tabular-nums">
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
@@ -289,9 +359,9 @@ function SecondaryMetricCard({
|
|||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<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>
|
<span aria-hidden="true">{icon}</span>
|
||||||
{title}
|
{title}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
@@ -300,7 +370,7 @@ function SecondaryMetricCard({
|
|||||||
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -322,7 +392,7 @@ function WindowSelect({
|
|||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 min-w-40">
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
className="text-xs uppercase tracking-wide text-muted-foreground"
|
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 [storageHistoryDays, setStorageHistoryDays] = useState<number>(30);
|
||||||
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
|
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
|
||||||
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
|
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
|
||||||
|
const shouldShowTopSharedLinks = !environment.isSelfHosted;
|
||||||
|
const revalidateQueryResource = useMutateQueryResource();
|
||||||
|
|
||||||
const variables = useMemo(
|
const variables = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -365,9 +617,9 @@ export function DashboardPage() {
|
|||||||
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours]
|
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isValidating, mutate } = useQuery(
|
const { data, isValidating } = useQuery(
|
||||||
{
|
{
|
||||||
query: adminDashboardQuery,
|
query: adminDashboardOverviewQuery,
|
||||||
variables,
|
variables,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -409,7 +661,7 @@ export function DashboardPage() {
|
|||||||
dashboard.workspaceStorageBytes + dashboard.blobStorageBytes;
|
dashboard.workspaceStorageBytes + dashboard.blobStorageBytes;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex-1 flex-col flex overflow-hidden">
|
<div className="h-dvh flex-1 flex-col flex overflow-hidden">
|
||||||
<Header
|
<Header
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
endFix={
|
endFix={
|
||||||
@@ -421,7 +673,7 @@ export function DashboardPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
mutate().catch(() => {});
|
revalidateQueryResource(adminDashboardQuery).catch(() => {});
|
||||||
}}
|
}}
|
||||||
disabled={isValidating}
|
disabled={isValidating}
|
||||||
>
|
>
|
||||||
@@ -436,7 +688,7 @@ export function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
<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">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base">Window Controls</CardTitle>
|
<CardTitle className="text-base">Window Controls</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -444,7 +696,7 @@ export function DashboardPage() {
|
|||||||
automatically.
|
automatically.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</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
|
<WindowSelect
|
||||||
id="storage-history-window"
|
id="storage-history-window"
|
||||||
label="Storage History"
|
label="Storage History"
|
||||||
@@ -472,40 +724,46 @@ export function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid gap-5 grid-cols-1 lg:grid-cols-12">
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-12">
|
||||||
<PrimaryMetricCard
|
<div className="h-full min-w-0 lg:col-span-5">
|
||||||
value={intFormatter.format(dashboard.syncActiveUsers)}
|
<PrimaryMetricCard
|
||||||
description={`${dashboard.syncWindow.effectiveSize}h active window`}
|
value={intFormatter.format(dashboard.syncActiveUsers)}
|
||||||
/>
|
description={`${dashboard.syncWindow.effectiveSize}h active window`}
|
||||||
<SecondaryMetricCard
|
/>
|
||||||
title="Copilot Conversations"
|
</div>
|
||||||
value={intFormatter.format(dashboard.copilotConversations)}
|
<div className="h-full min-w-0 lg:col-span-3">
|
||||||
description={`${dashboard.topSharedLinksWindow.effectiveSize}d aggregation`}
|
<SecondaryMetricCard
|
||||||
icon={
|
title="Copilot Conversations"
|
||||||
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
|
value={intFormatter.format(dashboard.copilotConversations)}
|
||||||
}
|
description={`${sharedLinkWindowDays}d aggregation`}
|
||||||
/>
|
icon={
|
||||||
<Card className="lg:col-span-4 border-border/70 bg-gradient-to-br from-card via-card to-muted/15 shadow-sm">
|
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
<CardHeader className="pb-2">
|
}
|
||||||
<CardDescription className="flex items-center gap-2">
|
/>
|
||||||
<DatabaseIcon className="h-4 w-4" aria-hidden="true" />
|
</div>
|
||||||
Managed Storage
|
<div className="h-full min-w-0 lg:col-span-4">
|
||||||
</CardDescription>
|
<Card className="h-full border-border/60 bg-card shadow-1">
|
||||||
</CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardContent>
|
<CardDescription className="flex items-center gap-2 text-sm">
|
||||||
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
<DatabaseIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
{formatBytes(totalStorageBytes)}
|
Managed Storage
|
||||||
</div>
|
</CardDescription>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
</CardHeader>
|
||||||
Workspace {formatBytes(dashboard.workspaceStorageBytes)} • Blob{' '}
|
<CardContent>
|
||||||
{formatBytes(dashboard.blobStorageBytes)}
|
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||||
</p>
|
{formatBytes(totalStorageBytes)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Workspace {formatBytes(dashboard.workspaceStorageBytes)} •
|
||||||
|
Blob {formatBytes(dashboard.blobStorageBytes)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||||
<Card className="xl:col-span-1 border-border/70 bg-card/95 shadow-sm">
|
<Card className="border-border/60 bg-card shadow-1 lg:col-span-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
Sync Active Users Trend
|
Sync Active Users Trend
|
||||||
@@ -524,7 +782,7 @@ export function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
Storage Trend (Workspace + Blob)
|
Storage Trend (Workspace + Blob)
|
||||||
@@ -557,89 +815,24 @@ export function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-border/70 bg-card/95 shadow-sm">
|
{shouldShowTopSharedLinks ? (
|
||||||
<CardHeader>
|
<Suspense fallback={<TopSharedLinksCardSkeleton />}>
|
||||||
<CardTitle className="text-base">Top Shared Links</CardTitle>
|
<TopSharedLinksSection
|
||||||
<CardDescription>
|
sharedLinkWindowDays={sharedLinkWindowDays}
|
||||||
Top {dashboard.topSharedLinks.length} links in the last{' '}
|
/>
|
||||||
{dashboard.topSharedLinksWindow.effectiveSize} days
|
</Suspense>
|
||||||
</CardDescription>
|
) : null}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<DashboardPageSkeleton />}>
|
||||||
|
<DashboardPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { DashboardPage as Component };
|
export { DashboardPage as Component };
|
||||||
|
|||||||
@@ -17,25 +17,24 @@ export const Header = ({
|
|||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="border-b border-border/60 bg-background/80 backdrop-blur-sm">
|
||||||
<div className="flex items-center px-6 gap-4 h-[56px]">
|
<div className="flex h-14 items-center gap-4 px-6">
|
||||||
{isSmallScreen ? (
|
{isSmallScreen ? (
|
||||||
<div className="h-7 w-7 p-1" />
|
<div className="h-7 w-7 p-1" />
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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}
|
onClick={togglePanel}
|
||||||
>
|
>
|
||||||
<SidebarIcon width={20} height={20} />
|
<SidebarIcon width={20} height={20} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Separator orientation="vertical" className="h-5" />
|
<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>}
|
{endFix && <div className="ml-auto">{endFix}</div>}
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -52,30 +51,29 @@ export const RightPanelHeader = ({
|
|||||||
canSave: boolean;
|
canSave: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="border-b border-border/60 bg-card/80 backdrop-blur-sm">
|
||||||
<div className=" flex justify-between items-center h-[56px] px-6">
|
<div className="flex h-14 items-center justify-between px-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="w-7 h-7"
|
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
>
|
>
|
||||||
<XIcon size={20} />
|
<XIcon size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-base font-medium">{title}</span>
|
<span className="text-sm font-semibold tracking-tight">{title}</span>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="w-7 h-7"
|
className="h-7 w-7 text-primary hover:text-primary"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={!canSave}
|
disabled={!canSave}
|
||||||
>
|
>
|
||||||
<CheckIcon size={20} />
|
<CheckIcon size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import {
|
|||||||
import { Separator } from '@affine/admin/components/ui/separator';
|
import { Separator } from '@affine/admin/components/ui/separator';
|
||||||
import { TooltipProvider } from '@affine/admin/components/ui/tooltip';
|
import { TooltipProvider } from '@affine/admin/components/ui/tooltip';
|
||||||
import { cn } from '@affine/admin/utils';
|
import { cn } from '@affine/admin/utils';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import { AlignJustifyIcon } from 'lucide-react';
|
import { AlignJustifyIcon } from 'lucide-react';
|
||||||
import type { PropsWithChildren, ReactNode, RefObject } from '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 type { ImperativePanelHandle } from 'react-resizable-panels';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ import {
|
|||||||
} from '../components/ui/sheet';
|
} from '../components/ui/sheet';
|
||||||
import { Logo } from './accounts/components/logo';
|
import { Logo } from './accounts/components/logo';
|
||||||
import { useMediaQuery } from './common';
|
import { useMediaQuery } from './common';
|
||||||
import { NavContext } from './nav/context';
|
|
||||||
import { Nav } from './nav/nav';
|
import { Nav } from './nav/nav';
|
||||||
import {
|
import {
|
||||||
PanelContext,
|
PanelContext,
|
||||||
@@ -43,10 +41,6 @@ export function Layout({ children }: PropsWithChildren) {
|
|||||||
const leftPanelRef = useRef<ImperativePanelHandle>(null);
|
const leftPanelRef = useRef<ImperativePanelHandle>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('');
|
|
||||||
const [activeSubTab, setActiveSubTab] = useState('server');
|
|
||||||
const [currentModule, setCurrentModule] = useState('server');
|
|
||||||
|
|
||||||
const handleLeftExpand = useCallback(() => {
|
const handleLeftExpand = useCallback(() => {
|
||||||
if (leftPanelRef.current?.getSize() === 0) {
|
if (leftPanelRef.current?.getSize() === 0) {
|
||||||
leftPanelRef.current?.resize(30);
|
leftPanelRef.current?.resize(30);
|
||||||
@@ -127,60 +121,66 @@ export function Layout({ children }: PropsWithChildren) {
|
|||||||
handleSetRightPanelContent(null);
|
handleSetRightPanelContent(null);
|
||||||
closeRightPanel();
|
closeRightPanel();
|
||||||
}, [location.pathname, closeRightPanel, handleSetRightPanelContent]);
|
}, [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 (
|
return (
|
||||||
<PanelContext.Provider
|
<PanelContext.Provider value={panelContextValue}>
|
||||||
value={{
|
<TooltipProvider delayDuration={0}>
|
||||||
leftPanel: {
|
<div className="flex h-dvh w-full overflow-hidden">
|
||||||
isOpen: leftOpen,
|
<ResizablePanelGroup direction="horizontal">
|
||||||
panelContent: leftPanelContent,
|
<LeftPanel
|
||||||
setPanelContent: setLeftPanelContent,
|
panelRef={leftPanelRef as RefObject<ImperativePanelHandle>}
|
||||||
togglePanel: toggleLeftPanel,
|
onExpand={handleLeftExpand}
|
||||||
openPanel: openLeftPanel,
|
onCollapse={handleLeftCollapse}
|
||||||
closePanel: closeLeftPanel,
|
/>
|
||||||
},
|
<ResizablePanel id="1" order={1} minSize={50} defaultSize={50}>
|
||||||
rightPanel: {
|
{children}
|
||||||
isOpen: rightOpen,
|
</ResizablePanel>
|
||||||
panelContent: rightPanelContent,
|
<RightPanel
|
||||||
setPanelContent: handleSetRightPanelContent,
|
panelRef={rightPanelRef as RefObject<ImperativePanelHandle>}
|
||||||
togglePanel: toggleRightPanel,
|
onExpand={handleRightExpand}
|
||||||
openPanel: openRightPanel,
|
onCollapse={handleRightCollapse}
|
||||||
closePanel: closeRightPanel,
|
/>
|
||||||
hasDirtyChanges: rightPanelHasDirtyChanges,
|
</ResizablePanelGroup>
|
||||||
setHasDirtyChanges: setRightPanelHasDirtyChanges,
|
</div>
|
||||||
},
|
</TooltipProvider>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
</PanelContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -197,7 +197,11 @@ export const LeftPanel = ({
|
|||||||
return (
|
return (
|
||||||
<Sheet>
|
<Sheet>
|
||||||
<SheetTrigger asChild>
|
<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} />
|
<AlignJustifyIcon size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
@@ -207,11 +211,15 @@ export const LeftPanel = ({
|
|||||||
Admin panel for managing accounts, AI, config, and settings
|
Admin panel for managing accounts, AI, config, and settings
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</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="flex flex-col w-full h-full">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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 />
|
<Logo />
|
||||||
@@ -239,21 +247,13 @@ export const LeftPanel = ({
|
|||||||
onCollapse={onCollapse}
|
onCollapse={onCollapse}
|
||||||
className={cn(
|
className={cn(
|
||||||
isCollapsed ? 'min-w-[57px] max-w-[57px]' : 'min-w-56 max-w-56',
|
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
|
<div className="flex h-full max-w-56 flex-col">
|
||||||
className="flex flex-col max-w-56 h-full "
|
|
||||||
style={{
|
|
||||||
backgroundColor: cssVarV2(
|
|
||||||
'selfhost/layer/background/sidebarBg/sidebarBg'
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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'
|
isCollapsed && 'justify-center px-2'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -299,7 +299,11 @@ export const RightPanel = ({
|
|||||||
For displaying additional information
|
For displaying additional information
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</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>
|
<div className="h-full overflow-y-auto">{panelContent}</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
@@ -317,7 +321,7 @@ export const RightPanel = ({
|
|||||||
collapsedSize={0}
|
collapsedSize={0}
|
||||||
onExpand={onExpand}
|
onExpand={onExpand}
|
||||||
onCollapse={onCollapse}
|
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>
|
<div className="h-full overflow-y-auto">{panelContent}</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
42
packages/frontend/admin/src/modules/nav/nav-item.spec.tsx
Normal file
42
packages/frontend/admin/src/modules/nav/nav-item.spec.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
|
||||||
import { cn } from '@affine/admin/utils';
|
import { cn } from '@affine/admin/utils';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
interface NavItemProps {
|
interface NavItemProps {
|
||||||
@@ -11,52 +9,34 @@ interface NavItemProps {
|
|||||||
isCollapsed?: boolean;
|
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) => {
|
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) {
|
if (isCollapsed) {
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink to={to} className={className}>
|
||||||
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}
|
{icon}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink to={to} className={className}>
|
||||||
to={to}
|
<span className="flex items-center p-0.5">{icon}</span>
|
||||||
className={cn(
|
<span className="truncate">{label}</span>
|
||||||
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>
|
</NavLink>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,77 +1,17 @@
|
|||||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
|
||||||
import { cn } from '@affine/admin/utils';
|
import { cn } from '@affine/admin/utils';
|
||||||
import { ROUTES } from '@affine/routes';
|
import { ROUTES } from '@affine/routes';
|
||||||
import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc';
|
import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import {
|
import {
|
||||||
BarChart3Icon,
|
BarChart3Icon,
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
ListChecksIcon,
|
ListChecksIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { NavLink } from 'react-router-dom';
|
|
||||||
|
|
||||||
|
import { NavItem } from './nav-item';
|
||||||
import { ServerVersion } from './server-version';
|
import { ServerVersion } from './server-version';
|
||||||
import { SettingsItem } from './settings-item';
|
import { SettingsItem } from './settings-item';
|
||||||
import { UserDropdown } from './user-dropdown';
|
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 {
|
interface NavProps {
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
@@ -80,13 +20,13 @@ export function Nav({ isCollapsed = false }: NavProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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'
|
isCollapsed && 'overflow-visible'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
className={cn(
|
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'
|
isCollapsed && 'items-center px-0 gap-1 overflow-visible'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -134,7 +74,7 @@ export function Nav({ isCollapsed = false }: NavProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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'
|
isCollapsed && 'items-center px-0 gap-1'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -19,7 +18,7 @@ export const ServerVersion = () => {
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
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}
|
onClick={handleClick}
|
||||||
title={`New Version ${availableUpgrade.version} Available`}
|
title={`New Version ${availableUpgrade.version} Available`}
|
||||||
>
|
>
|
||||||
@@ -32,12 +31,7 @@ export const ServerVersion = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<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">
|
||||||
className="inline-flex items-center justify-between pt-2 border-t px-2 text-xs flex-nowrap gap-1"
|
|
||||||
style={{
|
|
||||||
color: cssVarV2('text/tertiary'),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>ServerVersion</span>
|
<span>ServerVersion</span>
|
||||||
<span
|
<span
|
||||||
className="overflow-hidden text-ellipsis whitespace-nowrap"
|
className="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
|||||||
@@ -1,198 +1,15 @@
|
|||||||
import {
|
import { ROUTES } from '@affine/routes';
|
||||||
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 { SettingsIcon } from '@blocksuite/icons/rc';
|
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 { NavItem } from './nav-item';
|
||||||
import { NormalSubItem } from './collapsible-item';
|
|
||||||
import { useNav } from './context';
|
|
||||||
|
|
||||||
export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
|
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 (
|
return (
|
||||||
<Accordion type="multiple" className="w-full overflow-hidden">
|
<NavItem
|
||||||
<AccordionItem
|
to={ROUTES.admin.settings.index}
|
||||||
value="item-1"
|
icon={<SettingsIcon fontSize={20} />}
|
||||||
className="border-b-0 h-full flex flex-col gap-1 w-full"
|
label="Settings"
|
||||||
>
|
isCollapsed={isCollapsed}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@affine/admin/components/ui/dropdown-menu';
|
} from '@affine/admin/components/ui/dropdown-menu';
|
||||||
import { MoreVerticalIcon } from '@blocksuite/icons/rc';
|
import { MoreVerticalIcon } from '@blocksuite/icons/rc';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import { CircleUser } from 'lucide-react';
|
import { CircleUser } from 'lucide-react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -25,6 +24,9 @@ interface UserDropdownProps {
|
|||||||
isCollapsed: boolean;
|
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 = ({
|
const UserInfo = ({
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
@@ -44,21 +46,41 @@ const UserInfo = ({
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col font-medium gap-1">
|
<div className="flex flex-col font-medium gap-1">
|
||||||
{name ?? email.split('@')[0]}
|
{name ?? email.split('@')[0]}
|
||||||
<span
|
<span className={adminBadgeClass}>Admin</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>
|
|
||||||
</div>
|
</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) {
|
export function UserDropdown({ isCollapsed }: UserDropdownProps) {
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const relative = useRevalidateCurrentUser();
|
const relative = useRevalidateCurrentUser();
|
||||||
@@ -78,8 +100,8 @@ export function UserDropdown({ isCollapsed }: UserDropdownProps) {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="w-10 h-10" size="icon">
|
<Button variant="ghost" className="h-9 w-9 rounded-lg" size="icon">
|
||||||
<Avatar className="w-5 h-5">
|
<Avatar className="h-5 w-5">
|
||||||
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />
|
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
<CircleUser size={24} />
|
<CircleUser size={24} />
|
||||||
@@ -105,43 +127,24 @@ export function UserDropdown({ isCollapsed }: UserDropdownProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex items-center justify-between px-1 py-3">
|
||||||
className={`flex flex-none items-center ${isCollapsed ? 'justify-center' : 'justify-between'} px-1 py-3 flex-nowrap`}
|
<div className="flex min-w-0 items-center gap-2 font-medium">
|
||||||
>
|
<Avatar className="h-5 w-5">
|
||||||
<div className="flex items-center gap-2 font-medium text-ellipsis break-words overflow-hidden">
|
|
||||||
<Avatar className="w-5 h-5">
|
|
||||||
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />
|
<AvatarImage src={currentUser?.avatarUrl ?? undefined} />
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
<CircleUser size={24} />
|
<CircleUser size={24} />
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{currentUser?.name ? (
|
<UserName name={currentUser?.name} email={currentUser?.email} />
|
||||||
<span
|
<span className={adminBadgeClass}>Admin</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>
|
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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} />
|
<MoreVerticalIcon fontSize={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
|
||||||
import '@queuedash/ui/dist/styles.css';
|
import '@queuedash/ui/dist/styles.css';
|
||||||
import './queue.css';
|
import './queue.css';
|
||||||
|
|
||||||
@@ -8,7 +7,7 @@ import { Header } from '../header';
|
|||||||
|
|
||||||
export function QueuePage() {
|
export function QueuePage() {
|
||||||
return (
|
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" />
|
<Header title="Queue" />
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<QueueDashApp
|
<QueueDashApp
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@affine/admin/components/ui/select';
|
} from '@affine/admin/components/ui/select';
|
||||||
import { Switch } from '@affine/admin/components/ui/switch';
|
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';
|
import { Textarea } from '../../components/ui/textarea';
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export type ConfigInputProps = {
|
|||||||
defaultValue: any;
|
defaultValue: any;
|
||||||
onChange: (field: string, value: any) => void;
|
onChange: (field: string, value: any) => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
onErrorChange?: (field: string, error?: string) => void;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
type: 'String' | 'Number' | 'Boolean' | 'JSON';
|
type: 'String' | 'Number' | 'Boolean' | 'JSON';
|
||||||
@@ -34,6 +36,7 @@ const Inputs: Record<
|
|||||||
onChange: (value?: any) => void;
|
onChange: (value?: any) => void;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
error?: string;
|
error?: string;
|
||||||
|
onValidationChange?: (error?: string) => void;
|
||||||
}>
|
}>
|
||||||
> = {
|
> = {
|
||||||
Boolean: function SwitchInput({ defaultValue, onChange }) {
|
Boolean: function SwitchInput({ defaultValue, onChange }) {
|
||||||
@@ -43,7 +46,7 @@ const Inputs: Record<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={defaultValue}
|
checked={Boolean(defaultValue)}
|
||||||
onCheckedChange={handleSwitchChange}
|
onCheckedChange={handleSwitchChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -57,43 +60,78 @@ const Inputs: Record<
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
minLength={1}
|
minLength={1}
|
||||||
defaultValue={defaultValue}
|
value={defaultValue ?? ''}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
Number: function NumberInput({ defaultValue, onChange }) {
|
Number: function NumberInput({ defaultValue, onChange }) {
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
onChange(parseInt(e.target.value));
|
const next = e.target.value;
|
||||||
|
onChange(next === '' ? undefined : parseInt(next, 10));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
defaultValue={defaultValue}
|
value={defaultValue ?? ''}
|
||||||
onChange={handleInputChange}
|
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 handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const nextText = e.target.value;
|
||||||
|
setText(nextText);
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(e.target.value);
|
const value = JSON.parse(nextText);
|
||||||
|
onValidationChange?.(undefined);
|
||||||
onChange(value);
|
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 (
|
return (
|
||||||
<Textarea
|
<Textarea
|
||||||
defaultValue={JSON.stringify(defaultValue)}
|
value={text}
|
||||||
onChange={handleInputChange}
|
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 }) {
|
Enum: function EnumInput({ defaultValue, onChange, options }) {
|
||||||
return (
|
return (
|
||||||
<Select defaultValue={defaultValue} onValueChange={onChange}>
|
<Select
|
||||||
|
value={typeof defaultValue === 'string' ? defaultValue : undefined}
|
||||||
|
onValueChange={onChange}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select an option" />
|
<SelectValue placeholder="Select an option" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -116,9 +154,11 @@ export const ConfigRow = ({
|
|||||||
defaultValue,
|
defaultValue,
|
||||||
onChange,
|
onChange,
|
||||||
error,
|
error,
|
||||||
|
onErrorChange,
|
||||||
...props
|
...props
|
||||||
}: ConfigInputProps) => {
|
}: ConfigInputProps) => {
|
||||||
const Input = Inputs[type] ?? Inputs.JSON;
|
const Input = Inputs[type] ?? Inputs.JSON;
|
||||||
|
const [validationError, setValidationError] = useState<string>();
|
||||||
|
|
||||||
const onValueChange = useCallback(
|
const onValueChange = useCallback(
|
||||||
(value?: any) => {
|
(value?: any) => {
|
||||||
@@ -127,25 +167,46 @@ export const ConfigRow = ({
|
|||||||
[field, onChange]
|
[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex justify-between flex-grow space-y-[10px]
|
className={cn(
|
||||||
${type === 'Boolean' ? 'flex-row' : 'flex-col'}`}
|
'flex flex-grow gap-3',
|
||||||
|
type === 'Boolean' ? 'items-start justify-between' : 'flex-col'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="text-base font-bold flex-3"
|
className="flex-3 text-sm font-semibold leading-6 text-foreground"
|
||||||
dangerouslySetInnerHTML={{ __html: desc }}
|
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
|
<Input
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
onChange={onValueChange}
|
onChange={onValueChange}
|
||||||
error={error}
|
error={mergedError}
|
||||||
|
onValidationChange={onValidationChange}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{mergedError && (
|
||||||
<div className="absolute bottom-[-25px] text-sm right-0 break-words text-red-500">
|
<div className="mt-1 w-full break-words text-sm text-destructive">
|
||||||
{error}
|
{mergedError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
215
packages/frontend/admin/src/modules/settings/index.spec.tsx
Normal file
215
packages/frontend/admin/src/modules/settings/index.spec.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from '@affine/admin/components/ui/accordion';
|
||||||
import { Button } from '@affine/admin/components/ui/button';
|
import { Button } from '@affine/admin/components/ui/button';
|
||||||
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
|
||||||
import { get } from 'lodash-es';
|
import { get } from 'lodash-es';
|
||||||
import { CheckIcon } from 'lucide-react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { Header } from '../header';
|
import { Header } from '../header';
|
||||||
import { useNav } from '../nav/context';
|
|
||||||
import {
|
import {
|
||||||
ALL_CONFIG_DESCRIPTORS,
|
ALL_CONFIG_DESCRIPTORS,
|
||||||
ALL_SETTING_GROUPS,
|
ALL_SETTING_GROUPS,
|
||||||
@@ -15,104 +19,239 @@ import { type ConfigInputProps, ConfigRow } from './config-input-row';
|
|||||||
import { useAppConfig } from './use-app-config';
|
import { useAppConfig } from './use-app-config';
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { appConfig, update, save, patchedAppConfig, updates } = useAppConfig();
|
const {
|
||||||
const disableSave = Object.keys(updates).length === 0;
|
appConfig,
|
||||||
|
update,
|
||||||
const saveChanges = useCallback(() => {
|
saveGroup,
|
||||||
if (disableSave) {
|
resetGroup,
|
||||||
return;
|
patchedAppConfig,
|
||||||
}
|
isGroupDirty,
|
||||||
save();
|
isGroupSaving,
|
||||||
}, [save, disableSave]);
|
getGroupVersion,
|
||||||
|
} = useAppConfig();
|
||||||
|
const [expandedModules, setExpandedModules] = useState<string[]>([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex-1 flex-col flex">
|
<div className="flex h-dvh flex-1 flex-col bg-background">
|
||||||
<Header
|
<Header title="Settings" />
|
||||||
title="Settings"
|
|
||||||
endFix={
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="icon"
|
|
||||||
className="w-7 h-7"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={saveChanges}
|
|
||||||
disabled={disableSave}
|
|
||||||
>
|
|
||||||
<CheckIcon size={20} />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<AdminPanel
|
<AdminPanel
|
||||||
|
expandedModules={expandedModules}
|
||||||
|
onExpandedModulesChange={setExpandedModules}
|
||||||
onUpdate={update}
|
onUpdate={update}
|
||||||
appConfig={appConfig}
|
appConfig={appConfig}
|
||||||
patchedAppConfig={patchedAppConfig}
|
patchedAppConfig={patchedAppConfig}
|
||||||
|
onSaveGroup={saveGroup}
|
||||||
|
onResetGroup={resetGroup}
|
||||||
|
isGroupDirty={isGroupDirty}
|
||||||
|
isGroupSaving={isGroupSaving}
|
||||||
|
getGroupVersion={getGroupVersion}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdminPanel = ({
|
const AdminPanel = ({
|
||||||
|
expandedModules,
|
||||||
|
onExpandedModulesChange,
|
||||||
appConfig,
|
appConfig,
|
||||||
patchedAppConfig,
|
patchedAppConfig,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
onSaveGroup,
|
||||||
|
onResetGroup,
|
||||||
|
isGroupDirty,
|
||||||
|
isGroupSaving,
|
||||||
|
getGroupVersion,
|
||||||
}: {
|
}: {
|
||||||
|
expandedModules: string[];
|
||||||
|
onExpandedModulesChange: (modules: string[]) => void;
|
||||||
appConfig: AppConfig;
|
appConfig: AppConfig;
|
||||||
patchedAppConfig: AppConfig;
|
patchedAppConfig: AppConfig;
|
||||||
onUpdate: (path: string, value: any) => void;
|
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 [groupErrors, setGroupErrors] = useState<
|
||||||
const group = ALL_SETTING_GROUPS.find(
|
Record<string, Record<string, string>>
|
||||||
group => group.module === currentModule
|
>({});
|
||||||
);
|
|
||||||
|
|
||||||
if (!group) {
|
const onFieldErrorChange = useCallback((field: string, error?: string) => {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<ScrollArea className="h-full">
|
<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="mx-auto flex w-full max-w-[900px] flex-col gap-4 px-6 py-5">
|
||||||
<div className="text-2xl font-semibold">{name}</div>
|
<Accordion
|
||||||
<div className="flex flex-col gap-10" id={`config-module-${module}`}>
|
type="multiple"
|
||||||
{fields.map(field => {
|
className="w-full"
|
||||||
let desc: string;
|
value={expandedModules}
|
||||||
let props: ConfigInputProps;
|
onValueChange={onExpandedModulesChange}
|
||||||
if (typeof field === 'string') {
|
>
|
||||||
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field];
|
{ALL_SETTING_GROUPS.map(group => {
|
||||||
desc = descriptor.desc;
|
const { name, module, fields, operations } = group;
|
||||||
props = {
|
const dirty = isGroupDirty(module);
|
||||||
field: `${module}/${field}`,
|
const saving = isGroupSaving(module);
|
||||||
desc,
|
const sourceConfig = patchedAppConfig[module] ?? appConfig[module];
|
||||||
type: descriptor.type,
|
const version = getGroupVersion(module);
|
||||||
options: [],
|
const hasValidationError = Boolean(
|
||||||
defaultValue: get(appConfig[module], field),
|
groupErrors[module] &&
|
||||||
onChange: onUpdate,
|
Object.keys(groupErrors[module] ?? {}).length > 0
|
||||||
};
|
);
|
||||||
} else {
|
|
||||||
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field.key];
|
|
||||||
|
|
||||||
props = {
|
return (
|
||||||
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
|
<AccordionItem
|
||||||
desc: field.desc ?? descriptor.desc,
|
key={module}
|
||||||
type: field.type ?? descriptor.type,
|
value={module}
|
||||||
// @ts-expect-error for enum type
|
id={`config-module-${module}`}
|
||||||
options: field.options,
|
className="mb-4 rounded-xl border border-border/60 bg-card px-5 shadow-1"
|
||||||
defaultValue: get(
|
>
|
||||||
appConfig[module],
|
<AccordionTrigger className="hover:no-underline py-4">
|
||||||
field.key + (field.sub ? '.' + field.sub : '')
|
<div className="flex flex-col items-start text-left gap-1">
|
||||||
),
|
<div className="text-base font-semibold">{name}</div>
|
||||||
onChange: onUpdate,
|
<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 => (
|
</Accordion>
|
||||||
<Operation key={Operation.name} appConfig={patchedAppConfig} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMutation } from '@affine/admin/use-mutation';
|
import { useMutation } from '@affine/admin/use-mutation';
|
||||||
import { useQuery } from '@affine/admin/use-query';
|
import { useQuery } from '@affine/admin/use-query';
|
||||||
import { notify } from '@affine/component';
|
import { notify } from '@affine/component';
|
||||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
|
||||||
import { UserFriendlyError } from '@affine/error';
|
import { UserFriendlyError } from '@affine/error';
|
||||||
import {
|
import {
|
||||||
appConfigQuery,
|
appConfigQuery,
|
||||||
@@ -9,13 +8,40 @@ import {
|
|||||||
updateAppConfigMutation,
|
updateAppConfigMutation,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { cloneDeep, get, merge, set } from 'lodash-es';
|
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 type { AppConfig } from './config';
|
||||||
|
import { isEqual } from './utils';
|
||||||
|
|
||||||
export { type UpdateAppConfigInput };
|
export { type UpdateAppConfigInput };
|
||||||
|
|
||||||
export type AppConfigUpdates = Record<string, { from: any; to: any }>;
|
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 = () => {
|
export const useAppConfig = () => {
|
||||||
const {
|
const {
|
||||||
@@ -33,30 +59,70 @@ export const useAppConfig = () => {
|
|||||||
const [patchedAppConfig, setPatchedAppConfig] = useState<AppConfig>(() =>
|
const [patchedAppConfig, setPatchedAppConfig] = useState<AppConfig>(() =>
|
||||||
cloneDeep(appConfig)
|
cloneDeep(appConfig)
|
||||||
);
|
);
|
||||||
|
const [savingModules, setSavingModules] = useState<Record<string, boolean>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const [groupVersions, setGroupVersions] = useState<Record<string, number>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
const save = useAsyncCallback(async () => {
|
useEffect(() => {
|
||||||
const updateInputs: UpdateAppConfigInput[] = Object.entries(updates).map(
|
if (Object.keys(updates).length === 0) {
|
||||||
([key, value]) => {
|
setPatchedAppConfig(cloneDeep(appConfig));
|
||||||
const splitIndex = key.indexOf('.');
|
}
|
||||||
const module = key.slice(0, splitIndex);
|
}, [appConfig, updates]);
|
||||||
const field = key.slice(splitIndex + 1);
|
|
||||||
|
|
||||||
return {
|
const getEntriesByModule = useCallback(
|
||||||
module,
|
(module: string, source: AppConfigUpdates = updates) => {
|
||||||
key: field,
|
return Object.entries(source).filter(([key]) =>
|
||||||
value: value.to,
|
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 {
|
try {
|
||||||
const savedUpdates = await saveUpdates({
|
const response = (await saveUpdates({
|
||||||
updates: updateInputs,
|
updates: getUpdateInputs(allEntries),
|
||||||
});
|
})) as SaveResponse;
|
||||||
|
const savedAppConfig = getSavedAppConfig(response);
|
||||||
|
|
||||||
await mutate(prev => {
|
await mutate(prev => {
|
||||||
return { appConfig: merge({}, prev, savedUpdates) };
|
return {
|
||||||
|
appConfig: merge({}, prev?.appConfig ?? {}, savedAppConfig),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
setUpdates({});
|
setUpdates({});
|
||||||
|
setPatchedAppConfig(prev => merge({}, prev, savedAppConfig));
|
||||||
notify.success({
|
notify.success({
|
||||||
title: 'Saved',
|
title: 'Saved',
|
||||||
message: 'Settings have been saved successfully.',
|
message: 'Settings have been saved successfully.',
|
||||||
@@ -71,6 +137,60 @@ export const useAppConfig = () => {
|
|||||||
}
|
}
|
||||||
}, [updates, mutate, saveUpdates]);
|
}, [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(
|
const update = useCallback(
|
||||||
(path: string, value: any) => {
|
(path: string, value: any) => {
|
||||||
const [module, field, subField] = path.split('/');
|
const [module, field, subField] = path.split('/');
|
||||||
@@ -78,9 +198,15 @@ export const useAppConfig = () => {
|
|||||||
const from = get(appConfig, key);
|
const from = get(appConfig, key);
|
||||||
setUpdates(prev => {
|
setUpdates(prev => {
|
||||||
const to = subField
|
const to = subField
|
||||||
? set(prev[key]?.to ?? { ...from }, subField, value)
|
? set(cloneDeep(prev[key]?.to ?? from ?? {}), subField, value)
|
||||||
: value;
|
: value;
|
||||||
|
|
||||||
|
if (isEqual(from, to)) {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[key]: {
|
[key]: {
|
||||||
@@ -91,21 +217,62 @@ export const useAppConfig = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setPatchedAppConfig(prev => {
|
setPatchedAppConfig(prev => {
|
||||||
return set(
|
const next = cloneDeep(prev);
|
||||||
prev,
|
if (subField) {
|
||||||
`${module}.${field}${subField ? `.${subField}` : ''}`,
|
const nextValue = set(
|
||||||
value
|
cloneDeep(get(next, `${module}.${field}`) ?? {}),
|
||||||
);
|
subField,
|
||||||
|
value
|
||||||
|
);
|
||||||
|
set(next, `${module}.${field}`, nextValue);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
set(next, `${module}.${field}`, value);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[appConfig]
|
[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 {
|
return {
|
||||||
appConfig: appConfig as AppConfig,
|
appConfig: appConfig as AppConfig,
|
||||||
patchedAppConfig,
|
patchedAppConfig,
|
||||||
update,
|
update,
|
||||||
save,
|
save,
|
||||||
|
saveGroup,
|
||||||
|
resetGroup,
|
||||||
|
isGroupDirty,
|
||||||
|
isGroupSaving,
|
||||||
|
getGroupVersion,
|
||||||
updates,
|
updates,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const CreateAdmin = ({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p
|
<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.
|
Invalid email address.
|
||||||
</p>
|
</p>
|
||||||
@@ -99,7 +99,7 @@ export const CreateAdmin = ({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p
|
<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{' '}
|
{invalidPassword ? 'Invalid password. ' : ''}Please enter{' '}
|
||||||
{String(passwordLimits.minLength)}-
|
{String(passwordLimits.minLength)}-
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function Setup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center justify-center py-12 h-full">
|
||||||
<Form />
|
<Form />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<svg width="730" height="703" viewBox="0 0 730 703" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="730" height="703" viewBox="0 0 730 703" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<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"
|
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>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -5,7 +5,6 @@ import {
|
|||||||
} from '@affine/admin/components/ui/avatar';
|
} from '@affine/admin/components/ui/avatar';
|
||||||
import { AccountIcon, LinkIcon } from '@blocksuite/icons/rc';
|
import { AccountIcon, LinkIcon } from '@blocksuite/icons/rc';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import type { WorkspaceListItem } from '../schema';
|
import type { WorkspaceListItem } from '../schema';
|
||||||
@@ -27,42 +26,27 @@ export const useColumns = () => {
|
|||||||
{workspace.name || workspace.id}
|
{workspace.name || workspace.id}
|
||||||
</span>
|
</span>
|
||||||
{workspace.public ? (
|
{workspace.public ? (
|
||||||
<span
|
<span className="inline-flex items-center gap-1 rounded-md border border-border/60 bg-chip-white px-2 py-0.5 text-xxs">
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-[11px] rounded border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: cssVarV2('chip/label/white'),
|
|
||||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LinkIcon fontSize={14} />
|
<LinkIcon fontSize={14} />
|
||||||
Public
|
Public
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="w-full truncate font-mono text-xs text-muted-foreground">
|
||||||
className="text-xs font-mono truncate w-full"
|
|
||||||
style={{ color: cssVarV2('text/secondary') }}
|
|
||||||
>
|
|
||||||
{workspace.id}
|
{workspace.id}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 text-[11px]">
|
<div className="flex flex-wrap gap-2 text-xxs">
|
||||||
{workspace.features.length ? (
|
{workspace.features.length ? (
|
||||||
workspace.features.map(feature => (
|
workspace.features.map(feature => (
|
||||||
<span
|
<span
|
||||||
key={feature}
|
key={feature}
|
||||||
className="px-2 py-0.5 rounded border"
|
className="rounded-md border border-border/60 bg-chip-white px-2 py-0.5"
|
||||||
style={{
|
|
||||||
backgroundColor: cssVarV2('chip/label/white'),
|
|
||||||
borderColor: cssVarV2('layer/insideBorder/border'),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{feature}
|
{feature}
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: cssVarV2('text/secondary') }}>
|
<span className="text-muted-foreground">No features</span>
|
||||||
No features
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,17 +59,10 @@ export const useColumns = () => {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const owner = row.original.owner;
|
const owner = row.original.owner;
|
||||||
if (!owner) {
|
if (!owner) {
|
||||||
return (
|
return <div className="text-xs text-muted-foreground">Unknown</div>;
|
||||||
<div
|
|
||||||
className="text-xs"
|
|
||||||
style={{ color: cssVarV2('text/secondary') }}
|
|
||||||
>
|
|
||||||
Unknown
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
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">
|
<Avatar className="w-9 h-9">
|
||||||
<AvatarImage src={owner.avatarUrl ?? undefined} />
|
<AvatarImage src={owner.avatarUrl ?? undefined} />
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
@@ -94,10 +71,7 @@ export const useColumns = () => {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col overflow-hidden min-w-0">
|
<div className="flex flex-col overflow-hidden min-w-0">
|
||||||
<div className="text-sm font-medium truncate">{owner.name}</div>
|
<div className="text-sm font-medium truncate">{owner.name}</div>
|
||||||
<div
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
className="text-xs truncate"
|
|
||||||
style={{ color: cssVarV2('text/secondary') }}
|
|
||||||
>
|
|
||||||
{owner.email}
|
{owner.email}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,15 +88,13 @@ export const useColumns = () => {
|
|||||||
<div className="flex flex-col gap-1 text-xs">
|
<div className="flex flex-col gap-1 text-xs">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<span>Snapshot {formatBytes(ws.snapshotSize)}</span>
|
<span>Snapshot {formatBytes(ws.snapshotSize)}</span>
|
||||||
<span style={{ color: cssVarV2('text/secondary') }}>
|
<span className="text-muted-foreground">
|
||||||
({ws.snapshotCount})
|
({ws.snapshotCount})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<span>Blobs {formatBytes(ws.blobSize)}</span>
|
<span>Blobs {formatBytes(ws.blobSize)}</span>
|
||||||
<span style={{ color: cssVarV2('text/secondary') }}>
|
<span className="text-muted-foreground">({ws.blobCount})</span>
|
||||||
({ws.blobCount})
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -137,15 +109,11 @@ export const useColumns = () => {
|
|||||||
<div className="flex flex-col text-xs gap-1">
|
<div className="flex flex-col text-xs gap-1">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<span className="font-medium">{ws.memberCount}</span>
|
<span className="font-medium">{ws.memberCount}</span>
|
||||||
<span style={{ color: cssVarV2('text/secondary') }}>
|
<span className="text-muted-foreground">members</span>
|
||||||
members
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<span className="font-medium">{ws.publicPageCount}</span>
|
<span className="font-medium">{ws.publicPageCount}</span>
|
||||||
<span style={{ color: cssVarV2('text/secondary') }}>
|
<span className="text-muted-foreground">shared pages</span>
|
||||||
shared pages
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
adminWorkspacesQuery,
|
adminWorkspacesQuery,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { AccountIcon } from '@blocksuite/icons/rc';
|
import { AccountIcon } from '@blocksuite/icons/rc';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -53,10 +52,7 @@ export function WorkspacePanel({
|
|||||||
handleConfirm={onClose}
|
handleConfirm={onClose}
|
||||||
canSave={false}
|
canSave={false}
|
||||||
/>
|
/>
|
||||||
<div
|
<div className="p-6 text-sm text-muted-foreground">
|
||||||
className="p-6 text-sm"
|
|
||||||
style={{ color: cssVarV2('text/secondary') }}
|
|
||||||
>
|
|
||||||
Workspace not found.
|
Workspace not found.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,29 +169,19 @@ function WorkspacePanelContent({
|
|||||||
const memberList = workspace.members ?? [];
|
const memberList = workspace.members ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex h-full flex-col bg-background">
|
||||||
<RightPanelHeader
|
<RightPanelHeader
|
||||||
title="Update Workspace"
|
title="Update Workspace"
|
||||||
handleClose={onClose}
|
handleClose={onClose}
|
||||||
handleConfirm={handleSave}
|
handleConfirm={handleSave}
|
||||||
canSave={hasChanges && !isMutating}
|
canSave={hasChanges && !isMutating}
|
||||||
/>
|
/>
|
||||||
<div className="p-4 flex flex-col gap-4 overflow-y-auto">
|
<div className="flex flex-col gap-4 overflow-y-auto p-4">
|
||||||
<div className="border rounded-md p-3 space-y-2">
|
<div className="space-y-2 rounded-xl border border-border/60 bg-card p-3 shadow-sm">
|
||||||
<div
|
<div className="text-xs text-muted-foreground">Workspace ID</div>
|
||||||
className="text-xs"
|
|
||||||
style={{ color: cssVarV2('text/secondary') }}
|
|
||||||
>
|
|
||||||
Workspace ID
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-mono break-all">{workspace.id}</div>
|
<div className="text-sm font-mono break-all">{workspace.id}</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<Label
|
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||||
className="text-xs"
|
|
||||||
style={{ color: cssVarV2('text/secondary') }}
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
value={flags.name}
|
value={flags.name}
|
||||||
onChange={e =>
|
onChange={e =>
|
||||||
@@ -206,7 +192,7 @@ function WorkspacePanelContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-md">
|
<div className="rounded-xl border border-border/60 bg-card shadow-sm">
|
||||||
<FlagItem
|
<FlagItem
|
||||||
label="Public"
|
label="Public"
|
||||||
description="Allow public access to workspace pages"
|
description="Allow public access to workspace pages"
|
||||||
@@ -253,7 +239,7 @@ function WorkspacePanelContent({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div className="text-sm font-medium">Features</div>
|
||||||
<FeatureToggleList
|
<FeatureToggleList
|
||||||
features={serverConfig.availableWorkspaceFeatures ?? []}
|
features={serverConfig.availableWorkspaceFeatures ?? []}
|
||||||
@@ -286,15 +272,12 @@ function WorkspacePanelContent({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div className="px-3 py-2 text-sm font-medium">Members</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex flex-col divide-y">
|
<div className="flex flex-col divide-y">
|
||||||
{memberList.length === 0 ? (
|
{memberList.length === 0 ? (
|
||||||
<div
|
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||||
className="px-3 py-3 text-xs"
|
|
||||||
style={{ color: cssVarV2('text/secondary') }}
|
|
||||||
>
|
|
||||||
No members.
|
No members.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -313,10 +296,7 @@ function WorkspacePanelContent({
|
|||||||
<div className="text-sm font-medium truncate">
|
<div className="text-sm font-medium truncate">
|
||||||
{member.name || member.email}
|
{member.name || member.email}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
className="text-xs truncate"
|
|
||||||
style={{ color: cssVarV2('text/secondary') }}
|
|
||||||
>
|
|
||||||
{member.email}
|
{member.email}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,9 +328,7 @@ function FlagItem({
|
|||||||
<div className="flex items-start justify-between gap-2 p-3">
|
<div className="flex items-start justify-between gap-2 p-3">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="text-sm font-medium">{label}</div>
|
<div className="text-sm font-medium">{label}</div>
|
||||||
<div className="text-xs" style={{ color: cssVarV2('text/secondary') }}>
|
<div className="text-xs text-muted-foreground">{description}</div>
|
||||||
{description}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={checked} onCheckedChange={onCheckedChange} />
|
<Switch checked={checked} onCheckedChange={onCheckedChange} />
|
||||||
</div>
|
</div>
|
||||||
@@ -359,10 +337,8 @@ function FlagItem({
|
|||||||
|
|
||||||
function MetricCard({ label, value }: { label: string; value: string }) {
|
function MetricCard({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-md p-3 flex flex-col gap-1">
|
<div className="flex flex-col gap-1 rounded-xl border border-border/60 bg-card p-3 shadow-sm">
|
||||||
<div className="text-xs" style={{ color: cssVarV2('text/secondary') }}>
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-semibold">{value}</div>
|
<div className="text-sm font-semibold">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Separator } from '@affine/admin/components/ui/separator';
|
import { Separator } from '@affine/admin/components/ui/separator';
|
||||||
import { adminWorkspaceQuery } from '@affine/graphql';
|
import { adminWorkspaceQuery } from '@affine/graphql';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useQuery } from '../../../use-query';
|
import { useQuery } from '../../../use-query';
|
||||||
@@ -44,10 +43,7 @@ export function WorkspaceSharedLinksPanel({
|
|||||||
handleConfirm={onClose}
|
handleConfirm={onClose}
|
||||||
canSave={false}
|
canSave={false}
|
||||||
/>
|
/>
|
||||||
<div
|
<div className="p-6 text-sm text-muted-foreground">
|
||||||
className="p-6 text-sm"
|
|
||||||
style={{ color: cssVarV2('text/secondary') }}
|
|
||||||
>
|
|
||||||
Workspace not found.
|
Workspace not found.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,23 +51,18 @@ export function WorkspaceSharedLinksPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex h-full flex-col bg-background">
|
||||||
<RightPanelHeader
|
<RightPanelHeader
|
||||||
title="Shared Links"
|
title="Shared Links"
|
||||||
handleClose={onClose}
|
handleClose={onClose}
|
||||||
handleConfirm={onClose}
|
handleConfirm={onClose}
|
||||||
canSave={false}
|
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 ? (
|
{sharedLinks.length === 0 ? (
|
||||||
<div
|
<div className="text-sm text-muted-foreground">No shared links.</div>
|
||||||
className="text-sm"
|
|
||||||
style={{ color: cssVarV2('text/secondary') }}
|
|
||||||
>
|
|
||||||
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 => (
|
{sharedLinks.map(link => (
|
||||||
<SharedLinkItem key={link.docId} link={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="text-sm font-medium truncate">{title}</div>
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<Separator className="h-3" orientation="vertical" />
|
<Separator className="h-3" orientation="vertical" />
|
||||||
<span style={{ color: cssVarV2('text/secondary') }}>
|
<span className="text-muted-foreground">Shared on {sharedDate}</span>
|
||||||
Shared on {sharedDate}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function WorkspacePage() {
|
|||||||
const columns = useColumns();
|
const columns = useColumns();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex-1 flex-col flex">
|
<div className="h-dvh flex-1 flex-col flex">
|
||||||
<Header title="Workspaces" />
|
<Header title="Workspaces" />
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
const { baseTheme, themeToVar } = require('@toeverything/theme');
|
||||||
|
|
||||||
|
const themeVar = (key, fallback) =>
|
||||||
|
`var(${themeToVar(key)}${fallback ? `, ${fallback}` : ''})`;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: ['class'],
|
darkMode: ['class'],
|
||||||
// TODO(@forehalo): we are not running webpack in admin dir
|
// Keep both roots so class scanning works in monorepo-root and package-root runs.
|
||||||
content: ['./packages/frontend/admin/src/**/*.{ts,tsx}'],
|
content: [
|
||||||
|
'./src/**/*.{ts,tsx}',
|
||||||
|
'./packages/frontend/admin/src/**/*.{ts,tsx}',
|
||||||
|
],
|
||||||
prefix: '',
|
prefix: '',
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
@@ -13,46 +21,87 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
extend: {
|
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: {
|
colors: {
|
||||||
border: 'hsl(var(--border))',
|
border: 'var(--border)',
|
||||||
input: 'hsl(var(--input))',
|
input: 'var(--input)',
|
||||||
ring: 'hsl(var(--ring))',
|
ring: 'var(--ring)',
|
||||||
background: 'hsl(var(--background))',
|
background: 'var(--background)',
|
||||||
foreground: 'hsl(var(--foreground))',
|
foreground: 'var(--foreground)',
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
DEFAULT: 'var(--primary)',
|
||||||
foreground: 'hsl(var(--primary-foreground))',
|
foreground: 'var(--primary-foreground)',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
DEFAULT: 'var(--secondary)',
|
||||||
foreground: 'hsl(var(--secondary-foreground))',
|
foreground: 'var(--secondary-foreground)',
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: 'hsl(var(--destructive))',
|
DEFAULT: 'var(--destructive)',
|
||||||
foreground: 'hsl(var(--destructive-foreground))',
|
foreground: 'var(--destructive-foreground)',
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: 'var(--muted)',
|
||||||
foreground: 'hsl(var(--muted-foreground))',
|
foreground: 'var(--muted-foreground)',
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: 'hsl(var(--accent))',
|
DEFAULT: 'var(--accent)',
|
||||||
foreground: 'hsl(var(--accent-foreground))',
|
foreground: 'var(--accent-foreground)',
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: 'hsl(var(--popover))',
|
DEFAULT: 'var(--popover)',
|
||||||
foreground: 'hsl(var(--popover-foreground))',
|
foreground: 'var(--popover-foreground)',
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: 'hsl(var(--card))',
|
DEFAULT: 'var(--card)',
|
||||||
foreground: 'hsl(var(--card-foreground))',
|
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: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: `var(--radius, ${themeVar('popoverRadius')})`,
|
||||||
md: 'calc(var(--radius) - 2px)',
|
md: 'calc(var(--radius) - 2px)',
|
||||||
sm: 'calc(var(--radius) - 4px)',
|
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: {
|
keyframes: {
|
||||||
'accordion-down': {
|
'accordion-down': {
|
||||||
from: { height: '0' },
|
from: { height: '0' },
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { ModalConfigContext } from '@affine/component';
|
|||||||
import { NavigationGestureService } from '@affine/core/mobile/modules/navigation-gesture';
|
import { NavigationGestureService } from '@affine/core/mobile/modules/navigation-gesture';
|
||||||
import { globalVars } from '@affine/core/mobile/styles/variables.css';
|
import { globalVars } from '@affine/core/mobile/styles/variables.css';
|
||||||
import { useService } from '@toeverything/infra';
|
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 navigationGesture = useService(NavigationGestureService);
|
||||||
|
|
||||||
const onOpen = useCallback(() => {
|
const onOpen = useCallback(() => {
|
||||||
@@ -17,11 +17,13 @@ export const ModalConfigProvider = ({ children }: PropsWithChildren) => {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}, [navigationGesture]);
|
}, [navigationGesture]);
|
||||||
|
const modalConfigValue = useMemo(
|
||||||
|
() => ({ onOpen, dynamicKeyboardHeight: globalVars.appKeyboardHeight }),
|
||||||
|
[onOpen]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalConfigContext.Provider
|
<ModalConfigContext.Provider value={modalConfigValue}>
|
||||||
value={{ onOpen, dynamicKeyboardHeight: globalVars.appKeyboardHeight }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</ModalConfigContext.Provider>
|
</ModalConfigContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
@@ -44,11 +45,14 @@ export const MobileMenu = ({
|
|||||||
}: MenuProps) => {
|
}: MenuProps) => {
|
||||||
const [subMenus, setSubMenus] = useState<SubMenuContent[]>([]);
|
const [subMenus, setSubMenus] = useState<SubMenuContent[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const mobileContextValue = {
|
const mobileContextValue = useMemo(
|
||||||
subMenus,
|
() => ({
|
||||||
setSubMenus,
|
subMenus,
|
||||||
setOpen,
|
setSubMenus,
|
||||||
};
|
setOpen,
|
||||||
|
}),
|
||||||
|
[subMenus]
|
||||||
|
);
|
||||||
|
|
||||||
const { removeSubMenu, removeAllSubMenus } =
|
const { removeSubMenu, removeAllSubMenus } =
|
||||||
useMobileSubMenuHelper(mobileContextValue);
|
useMobileSubMenuHelper(mobileContextValue);
|
||||||
@@ -95,6 +99,10 @@ export const MobileMenu = ({
|
|||||||
},
|
},
|
||||||
[onInteractOutside, onPointerDownOutside, removeAllSubMenus, rootOptions]
|
[onInteractOutside, onPointerDownOutside, removeAllSubMenus, rootOptions]
|
||||||
);
|
);
|
||||||
|
const mobileMenuContextValue = useMemo(
|
||||||
|
() => ({ subMenus, setSubMenus, setOpen: onOpenChange }),
|
||||||
|
[onOpenChange, subMenus]
|
||||||
|
);
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
@@ -139,9 +147,7 @@ export const MobileMenu = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Slot onClick={onItemClick}>{children}</Slot>
|
<Slot onClick={onItemClick}>{children}</Slot>
|
||||||
<MobileMenuContext.Provider
|
<MobileMenuContext.Provider value={mobileMenuContextValue}>
|
||||||
value={{ subMenus, setSubMenus, setOpen: onOpenChange }}
|
|
||||||
>
|
|
||||||
<Modal
|
<Modal
|
||||||
open={finalOpen}
|
open={finalOpen}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { DialogTrigger } from '@radix-ui/react-dialog';
|
import { DialogTrigger } from '@radix-ui/react-dialog';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { PropsWithChildren } from 'react';
|
import {
|
||||||
import { createContext, useCallback, useContext, useState } from 'react';
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import type { ButtonProps } from '../button';
|
import type { ButtonProps } from '../button';
|
||||||
import { Button } from '../button';
|
import { Button } from '../button';
|
||||||
@@ -151,7 +156,7 @@ const ConfirmModalContext = createContext<ConfirmModalContextProps>({
|
|||||||
openConfirmModal: () => {},
|
openConfirmModal: () => {},
|
||||||
closeConfirmModal: () => {},
|
closeConfirmModal: () => {},
|
||||||
});
|
});
|
||||||
export const ConfirmModalProvider = ({ children }: PropsWithChildren) => {
|
export const ConfirmModalProvider = ({ children }: React.PropsWithChildren) => {
|
||||||
const [modalProps, setModalProps] = useState<ConfirmModalProps>({
|
const [modalProps, setModalProps] = useState<ConfirmModalProps>({
|
||||||
open: false,
|
open: false,
|
||||||
});
|
});
|
||||||
@@ -200,11 +205,13 @@ export const ConfirmModalProvider = ({ children }: PropsWithChildren) => {
|
|||||||
},
|
},
|
||||||
[modalProps]
|
[modalProps]
|
||||||
);
|
);
|
||||||
|
const confirmModalContextValue = useMemo(
|
||||||
|
() => ({ openConfirmModal, closeConfirmModal, modalProps }),
|
||||||
|
[closeConfirmModal, modalProps, openConfirmModal]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmModalContext.Provider
|
<ConfirmModalContext.Provider value={confirmModalContextValue}>
|
||||||
value={{ openConfirmModal, closeConfirmModal, modalProps }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
{/* TODO(@catsjuice): multi-instance support(unnecessary for now) */}
|
{/* TODO(@catsjuice): multi-instance support(unnecessary for now) */}
|
||||||
<ConfirmModal {...modalProps} onOpenChange={onOpenChange} />
|
<ConfirmModal {...modalProps} onOpenChange={onOpenChange} />
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { DialogTrigger } from '@radix-ui/react-dialog';
|
import { DialogTrigger } from '@radix-ui/react-dialog';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { PropsWithChildren } from 'react';
|
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 type { ButtonProps } from '../button';
|
||||||
import { Button } from '../button';
|
import { Button } from '../button';
|
||||||
@@ -205,15 +211,17 @@ export const PromptModalProvider = ({ children }: PropsWithChildren) => {
|
|||||||
},
|
},
|
||||||
[modalProps]
|
[modalProps]
|
||||||
);
|
);
|
||||||
|
const promptModalContextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
openPromptModal,
|
||||||
|
closePromptModal,
|
||||||
|
modalProps,
|
||||||
|
}),
|
||||||
|
[closePromptModal, modalProps, openPromptModal]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PromptModalContext.Provider
|
<PromptModalContext.Provider value={promptModalContextValue}>
|
||||||
value={{
|
|
||||||
openPromptModal: openPromptModal,
|
|
||||||
closePromptModal: closePromptModal,
|
|
||||||
modalProps,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
{/* TODO(@catsjuice): multi-instance support(unnecessary for now) */}
|
{/* TODO(@catsjuice): multi-instance support(unnecessary for now) */}
|
||||||
<PromptModal {...modalProps} onOpenChange={onOpenChange} />
|
<PromptModal {...modalProps} onOpenChange={onOpenChange} />
|
||||||
|
|||||||
@@ -105,11 +105,13 @@ const TagRenameContent = ({
|
|||||||
},
|
},
|
||||||
[color, onConfirm]
|
[color, onConfirm]
|
||||||
);
|
);
|
||||||
|
const tagColorContextValue = useMemo(
|
||||||
|
() => ({ colors, color, setColor, show, setShow, enableAnimation }),
|
||||||
|
[color, colors, enableAnimation, show]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TagColorContext.Provider
|
<TagColorContext.Provider value={tagColorContextValue}>
|
||||||
value={{ colors, color, setColor, show, setShow, enableAnimation }}
|
|
||||||
>
|
|
||||||
<RenameContent
|
<RenameContent
|
||||||
inputPrefixRenderer={ColorPickerTrigger}
|
inputPrefixRenderer={ColorPickerTrigger}
|
||||||
inputBelowRenderer={ColorPickerSelect}
|
inputBelowRenderer={ColorPickerSelect}
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ import { assignInlineVars } from '@vanilla-extract/dynamic';
|
|||||||
import { animate } from 'animejs';
|
import { animate } from 'animejs';
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type PropsWithChildren,
|
|
||||||
type RefObject,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@@ -21,7 +20,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { SwipeHelper } from '../../utils';
|
import { SwipeHelper } from '../../utils';
|
||||||
import * as styles from './swipe-dialog.css';
|
import * as styles from './swipe-dialog.css';
|
||||||
|
|
||||||
export interface SwipeDialogProps extends PropsWithChildren {
|
export interface SwipeDialogProps extends React.PropsWithChildren {
|
||||||
triggerSize?: number;
|
triggerSize?: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
@@ -142,7 +141,7 @@ const close = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SwipeDialogContext = createContext<{
|
const SwipeDialogContext = createContext<{
|
||||||
stack: Array<RefObject<HTMLElement | null>>;
|
stack: Array<React.RefObject<HTMLElement | null>>;
|
||||||
}>({
|
}>({
|
||||||
stack: [],
|
stack: [],
|
||||||
});
|
});
|
||||||
@@ -162,6 +161,10 @@ export const SwipeDialog = ({
|
|||||||
|
|
||||||
const { stack } = useContext(SwipeDialogContext);
|
const { stack } = useContext(SwipeDialogContext);
|
||||||
const prev = stack[stack.length - 1]?.current;
|
const prev = stack[stack.length - 1]?.current;
|
||||||
|
const swipeDialogContextValue = useMemo(
|
||||||
|
() => ({ stack: [...stack, dialogRef] }),
|
||||||
|
[stack]
|
||||||
|
);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
onOpenChange?.(false);
|
onOpenChange?.(false);
|
||||||
@@ -222,7 +225,7 @@ export const SwipeDialog = ({
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SwipeDialogContext.Provider value={{ stack: [...stack, dialogRef] }}>
|
<SwipeDialogContext.Provider value={swipeDialogContextValue}>
|
||||||
<InsideModalContext.Provider value={insideModal + 1}>
|
<InsideModalContext.Provider value={insideModal + 1}>
|
||||||
{createPortal(
|
{createPortal(
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
|
|||||||
@@ -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 { JournalDatePickerContext } from './context';
|
||||||
import { ResizeViewport } from './viewport';
|
import { ResizeViewport } from './viewport';
|
||||||
@@ -31,18 +37,21 @@ export const JournalDatePicker = ({
|
|||||||
},
|
},
|
||||||
[onChange]
|
[onChange]
|
||||||
);
|
);
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const journalDatePickerContextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
cursor,
|
||||||
|
setCursor,
|
||||||
|
width,
|
||||||
|
withDotDates,
|
||||||
|
}),
|
||||||
|
[cursor, onSelect, selected, width, withDotDates]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JournalDatePickerContext.Provider
|
<JournalDatePickerContext.Provider value={journalDatePickerContextValue}>
|
||||||
value={{
|
|
||||||
selected,
|
|
||||||
onSelect,
|
|
||||||
cursor,
|
|
||||||
setCursor,
|
|
||||||
width: window.innerWidth,
|
|
||||||
withDotDates,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ResizeViewport {...attrs} />
|
<ResizeViewport {...attrs} />
|
||||||
</JournalDatePickerContext.Provider>
|
</JournalDatePickerContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export const ViewRoot = ({
|
|||||||
routes: RouteObject[];
|
routes: RouteObject[];
|
||||||
}) => {
|
}) => {
|
||||||
const viewRouter = useMemo(() => createMemoryRouter(routes), [routes]);
|
const viewRouter = useMemo(() => createMemoryRouter(routes), [routes]);
|
||||||
|
const routeContextValue = useMemo(
|
||||||
|
() => ({ outlet: null, matches: [], isDataRoute: false }),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const location = useLiveData(view.location$);
|
const location = useLiveData(view.location$);
|
||||||
|
|
||||||
@@ -31,13 +35,7 @@ export const ViewRoot = ({
|
|||||||
return (
|
return (
|
||||||
<FrameworkScope scope={view.scope}>
|
<FrameworkScope scope={view.scope}>
|
||||||
<UNSAFE_LocationContext.Provider value={null as any}>
|
<UNSAFE_LocationContext.Provider value={null as any}>
|
||||||
<UNSAFE_RouteContext.Provider
|
<UNSAFE_RouteContext.Provider value={routeContextValue}>
|
||||||
value={{
|
|
||||||
outlet: null,
|
|
||||||
matches: [],
|
|
||||||
isDataRoute: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RouterProvider router={viewRouter} />
|
<RouterProvider router={viewRouter} />
|
||||||
</UNSAFE_RouteContext.Provider>
|
</UNSAFE_RouteContext.Provider>
|
||||||
</UNSAFE_LocationContext.Provider>
|
</UNSAFE_LocationContext.Provider>
|
||||||
|
|||||||
10
yarn.lock
10
yarn.lock
@@ -215,6 +215,7 @@ __metadata:
|
|||||||
"@radix-ui/react-tooltip": "npm:^1.1.5"
|
"@radix-ui/react-tooltip": "npm:^1.1.5"
|
||||||
"@sentry/react": "npm:^9.47.1"
|
"@sentry/react": "npm:^9.47.1"
|
||||||
"@tanstack/react-table": "npm:^8.20.5"
|
"@tanstack/react-table": "npm:^8.20.5"
|
||||||
|
"@testing-library/react": "npm:^16.3.2"
|
||||||
"@toeverything/infra": "workspace:*"
|
"@toeverything/infra": "workspace:*"
|
||||||
"@toeverything/theme": "npm:^1.1.23"
|
"@toeverything/theme": "npm:^1.1.23"
|
||||||
"@types/lodash-es": "npm:^4.17.12"
|
"@types/lodash-es": "npm:^4.17.12"
|
||||||
@@ -241,6 +242,7 @@ __metadata:
|
|||||||
tailwindcss: "npm:^4.1.17"
|
tailwindcss: "npm:^4.1.17"
|
||||||
tailwindcss-animate: "npm:^1.0.7"
|
tailwindcss-animate: "npm:^1.0.7"
|
||||||
vaul: "npm:^1.1.2"
|
vaul: "npm:^1.1.2"
|
||||||
|
vitest: "npm:^3.2.4"
|
||||||
zod: "npm:^3.25.76"
|
zod: "npm:^3.25.76"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
@@ -16555,9 +16557,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@testing-library/react@npm:^16.1.0":
|
"@testing-library/react@npm:^16.1.0, @testing-library/react@npm:^16.3.2":
|
||||||
version: 16.3.0
|
version: 16.3.2
|
||||||
resolution: "@testing-library/react@npm:16.3.0"
|
resolution: "@testing-library/react@npm:16.3.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime": "npm:^7.12.5"
|
"@babel/runtime": "npm:^7.12.5"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -16571,7 +16573,7 @@ __metadata:
|
|||||||
optional: true
|
optional: true
|
||||||
"@types/react-dom":
|
"@types/react-dom":
|
||||||
optional: true
|
optional: true
|
||||||
checksum: 10/0ee9e31dd0d2396a924682d0e61a4ecc6bfab8eaff23dbf8a72c3c2ce22c116fa578148baeb4de75b968ef99d22e6e6aa0a00dba40286f71184918bb6bb5b06a
|
checksum: 10/0ca88c6f672d00c2afd1bdedeff9b5382dd8157038efeb9762dc016731030075624be7106b92d2b5e5c52812faea85263e69272c14b6f8700eb48a4a8af6feef
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user