mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-24 04:27:27 +08:00
feat: doc status & share status (#14426)
#### PR Dependency Tree * **PR #14426** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Admin dashboard: view workspace analytics (storage, sync activity, top shared links) with charts and configurable windows. * Document analytics tab: see total/unique/guest views and trends over selectable time windows. * Last-accessed members: view who last accessed a document, with pagination. * Shared links analytics: browse and paginate all shared links with view/unique/guest metrics and share URLs. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -53,6 +53,7 @@
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.7",
|
||||
"vaul": "^1.1.2",
|
||||
|
||||
@@ -23,6 +23,9 @@ export const Setup = lazy(
|
||||
export const Accounts = lazy(
|
||||
() => import(/* webpackChunkName: "accounts" */ './modules/accounts')
|
||||
);
|
||||
export const Dashboard = lazy(
|
||||
() => import(/* webpackChunkName: "dashboard" */ './modules/dashboard')
|
||||
);
|
||||
export const Workspaces = lazy(
|
||||
() => import(/* webpackChunkName: "workspaces" */ './modules/workspaces')
|
||||
);
|
||||
@@ -75,7 +78,15 @@ function RootRoutes() {
|
||||
}
|
||||
|
||||
if (/^\/admin\/?$/.test(location.pathname)) {
|
||||
return <Navigate to="/admin/accounts" />;
|
||||
return (
|
||||
<Navigate
|
||||
to={
|
||||
environment.isSelfHosted
|
||||
? ROUTES.admin.accounts
|
||||
: ROUTES.admin.dashboard
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
@@ -96,6 +107,16 @@ export const App = () => {
|
||||
<Route path={ROUTES.admin.auth} element={<Auth />} />
|
||||
<Route path={ROUTES.admin.setup} element={<Setup />} />
|
||||
<Route element={<AuthenticatedRoutes />}>
|
||||
<Route
|
||||
path={ROUTES.admin.dashboard}
|
||||
element={
|
||||
environment.isSelfHosted ? (
|
||||
<Navigate to={ROUTES.admin.accounts} replace />
|
||||
) : (
|
||||
<Dashboard />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path={ROUTES.admin.accounts} element={<Accounts />} />
|
||||
<Route
|
||||
path={ROUTES.admin.workspaces}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as React from 'react';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import { ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
|
||||
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode;
|
||||
color?: string;
|
||||
theme?: Partial<Record<keyof typeof THEMES, string>>;
|
||||
}
|
||||
>;
|
||||
|
||||
type ChartContextValue = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextValue | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const value = React.useContext(ChartContext);
|
||||
if (!value) {
|
||||
throw new Error('useChart must be used within <ChartContainer />');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ChartStyle({
|
||||
chartId,
|
||||
config,
|
||||
}: {
|
||||
chartId: string;
|
||||
config: ChartConfig;
|
||||
}) {
|
||||
const colorEntries = Object.entries(config).filter(
|
||||
([, item]) => item.color || item.theme
|
||||
);
|
||||
|
||||
if (!colorEntries.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const css = Object.entries(THEMES)
|
||||
.map(([themeKey, prefix]) => {
|
||||
const declarations = colorEntries
|
||||
.map(([key, item]) => {
|
||||
const color =
|
||||
item.theme?.[themeKey as keyof typeof THEMES] ?? item.color;
|
||||
return color ? ` --color-${key}: ${color};` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
if (!declarations) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${prefix} [data-chart="${chartId}"] {\n${declarations}\n}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
if (!css) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <style dangerouslySetInnerHTML={{ __html: css }} />;
|
||||
}
|
||||
|
||||
type ChartContainerProps = React.ComponentProps<'div'> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof ResponsiveContainer>['children'];
|
||||
};
|
||||
|
||||
const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
|
||||
({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
ref={ref}
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
'flex min-h-0 w-full items-center justify-center text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle chartId={chartId} config={config} />
|
||||
<ResponsiveContainer>{children}</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
ChartContainer.displayName = 'ChartContainer';
|
||||
|
||||
const ChartTooltip = RechartsTooltip;
|
||||
|
||||
type TooltipContentProps = {
|
||||
active?: boolean;
|
||||
payload?: TooltipProps<number, string>['payload'];
|
||||
label?: string | number;
|
||||
labelFormatter?: (
|
||||
label: string | number,
|
||||
payload: TooltipProps<number, string>['payload']
|
||||
) => React.ReactNode;
|
||||
valueFormatter?: (value: number, key: string) => React.ReactNode;
|
||||
};
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
TooltipContentProps
|
||||
>(({ active, payload, label, labelFormatter, valueFormatter }, ref) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = labelFormatter ? labelFormatter(label ?? '', payload) : label;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="min-w-44 rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
|
||||
>
|
||||
{title ? (
|
||||
<div className="mb-2 font-medium text-foreground/90">{title}</div>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
{payload.map((item, index) => {
|
||||
const dataKey = String(item.dataKey ?? item.name ?? index);
|
||||
const itemConfig = config[dataKey];
|
||||
const labelText = itemConfig?.label ?? item.name ?? dataKey;
|
||||
const numericValue =
|
||||
typeof item.value === 'number'
|
||||
? item.value
|
||||
: Number(item.value ?? 0);
|
||||
const valueText = valueFormatter
|
||||
? valueFormatter(numericValue, dataKey)
|
||||
: numericValue;
|
||||
const color = item.color ?? `var(--color-${dataKey})`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${dataKey}-${index}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground">{labelText}</span>
|
||||
<span className="ml-auto font-medium tabular-nums">
|
||||
{valueText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ChartTooltipContent.displayName = 'ChartTooltipContent';
|
||||
|
||||
export { ChartContainer, ChartTooltip, ChartTooltipContent };
|
||||
@@ -0,0 +1,645 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@affine/admin/components/ui/card';
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@affine/admin/components/ui/chart';
|
||||
import { Label } from '@affine/admin/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@affine/admin/components/ui/select';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@affine/admin/components/ui/table';
|
||||
import { useQuery } from '@affine/admin/use-query';
|
||||
import { adminDashboardQuery } from '@affine/graphql';
|
||||
import { ROUTES } from '@affine/routes';
|
||||
import {
|
||||
DatabaseIcon,
|
||||
MessageSquareTextIcon,
|
||||
RefreshCwIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { Header } from '../header';
|
||||
import { formatBytes } from '../workspaces/utils';
|
||||
|
||||
const intFormatter = new Intl.NumberFormat('en-US');
|
||||
const compactFormatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const utcDateTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const utcDateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const STORAGE_DAY_OPTIONS = [7, 14, 30, 60, 90] as const;
|
||||
const SYNC_HOUR_OPTIONS = [1, 6, 12, 24, 48, 72] as const;
|
||||
const SHARED_DAY_OPTIONS = [7, 14, 28, 60, 90] as const;
|
||||
|
||||
type DualNumberPoint = {
|
||||
label: string;
|
||||
primary: number;
|
||||
secondary: number;
|
||||
};
|
||||
|
||||
type TrendPoint = {
|
||||
x: number;
|
||||
label: string;
|
||||
primary: number;
|
||||
secondary?: number;
|
||||
};
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return utcDateTimeFormatter.format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return utcDateFormatter.format(new Date(value));
|
||||
}
|
||||
|
||||
function downsample<T>(items: T[], maxPoints: number) {
|
||||
if (items.length <= maxPoints) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const step = Math.ceil(items.length / maxPoints);
|
||||
return items.filter(
|
||||
(_, index) => index % step === 0 || index === items.length - 1
|
||||
);
|
||||
}
|
||||
|
||||
function toIndexedTrendPoints<T extends Omit<TrendPoint, 'x'>>(points: T[]) {
|
||||
return points.map((point, index) => ({
|
||||
...point,
|
||||
x: index,
|
||||
}));
|
||||
}
|
||||
|
||||
function TrendChart({
|
||||
ariaLabel,
|
||||
points,
|
||||
primaryLabel,
|
||||
primaryFormatter,
|
||||
secondaryLabel,
|
||||
secondaryFormatter,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
points: TrendPoint[];
|
||||
primaryLabel: string;
|
||||
primaryFormatter: (value: number) => string;
|
||||
secondaryLabel?: string;
|
||||
secondaryFormatter?: (value: number) => string;
|
||||
}) {
|
||||
if (points.length === 0) {
|
||||
return <div className="text-sm text-muted-foreground">No data</div>;
|
||||
}
|
||||
|
||||
const chartPoints =
|
||||
points.length === 1
|
||||
? [points[0], { ...points[0], x: points[0].x + 1 }]
|
||||
: points;
|
||||
|
||||
const hasSecondary =
|
||||
Boolean(secondaryLabel) &&
|
||||
chartPoints.some(point => typeof point.secondary === 'number');
|
||||
const config: ChartConfig = {
|
||||
primary: {
|
||||
label: primaryLabel,
|
||||
color: 'hsl(var(--primary))',
|
||||
},
|
||||
...(hasSecondary
|
||||
? {
|
||||
secondary: {
|
||||
label: secondaryLabel,
|
||||
color: 'hsl(var(--foreground) / 0.6)',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className="h-44 w-full"
|
||||
aria-label={ariaLabel}
|
||||
role="img"
|
||||
>
|
||||
<LineChart
|
||||
data={chartPoints}
|
||||
margin={{ top: 8, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
stroke="hsl(var(--border) / 0.6)"
|
||||
strokeDasharray="3 4"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="x"
|
||||
type="number"
|
||||
hide
|
||||
allowDecimals={false}
|
||||
domain={['dataMin', 'dataMax']}
|
||||
/>
|
||||
<YAxis
|
||||
hide
|
||||
domain={[
|
||||
0,
|
||||
(max: number) => {
|
||||
if (max <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return Math.ceil(max * 1.1);
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--border))',
|
||||
strokeDasharray: '4 4',
|
||||
strokeWidth: 1,
|
||||
}}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const item = payload?.[0];
|
||||
return item?.payload?.label ?? '';
|
||||
}}
|
||||
valueFormatter={(value, key) => {
|
||||
if (key === 'secondary') {
|
||||
return secondaryFormatter
|
||||
? secondaryFormatter(value)
|
||||
: intFormatter.format(value);
|
||||
}
|
||||
return primaryFormatter(value);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="primary"
|
||||
type="monotone"
|
||||
fill="var(--color-primary)"
|
||||
fillOpacity={0.16}
|
||||
stroke="none"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="primary"
|
||||
type="monotone"
|
||||
stroke="var(--color-primary)"
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
{hasSecondary ? (
|
||||
<Line
|
||||
dataKey="secondary"
|
||||
type="monotone"
|
||||
stroke="var(--color-secondary)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
strokeDasharray="6 4"
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
) : null}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground tabular-nums">
|
||||
<span>{points[0]?.label}</span>
|
||||
<span>{points[points.length - 1]?.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryMetricCard({
|
||||
value,
|
||||
description,
|
||||
}: {
|
||||
value: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="lg:col-span-5 border-primary/30 bg-gradient-to-br from-primary/10 via-card to-card shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="flex items-center gap-2 text-foreground/75">
|
||||
<UsersIcon className="h-4 w-4" aria-hidden="true" />
|
||||
Current Sync Active Users
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<div className="text-4xl font-bold tracking-tight tabular-nums">
|
||||
{value}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryMetricCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="lg:col-span-3 border-border/70 bg-card/95 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
{title}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||
{value}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowSelect({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
unit,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
options: readonly number[];
|
||||
unit: string;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 min-w-40">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="text-xs uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={next => onChange(Number(next))}
|
||||
>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue placeholder={`Select ${label.toLowerCase()}…`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(option => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option} {unit}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [storageHistoryDays, setStorageHistoryDays] = useState<number>(30);
|
||||
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
|
||||
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
|
||||
|
||||
const variables = useMemo(
|
||||
() => ({
|
||||
input: {
|
||||
storageHistoryDays,
|
||||
syncHistoryHours,
|
||||
sharedLinkWindowDays,
|
||||
timezone: 'UTC',
|
||||
},
|
||||
}),
|
||||
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours]
|
||||
);
|
||||
|
||||
const { data, isValidating, mutate } = useQuery(
|
||||
{
|
||||
query: adminDashboardQuery,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: true,
|
||||
revalidateOnReconnect: true,
|
||||
}
|
||||
);
|
||||
|
||||
const dashboard = data.adminDashboard;
|
||||
|
||||
const syncPoints = useMemo(
|
||||
() =>
|
||||
toIndexedTrendPoints(
|
||||
downsample(
|
||||
dashboard.syncActiveUsersTimeline.map(point => ({
|
||||
label: formatDateTime(point.minute),
|
||||
primary: point.activeUsers,
|
||||
})),
|
||||
96
|
||||
)
|
||||
),
|
||||
[dashboard.syncActiveUsersTimeline]
|
||||
);
|
||||
|
||||
const storagePoints = useMemo(() => {
|
||||
const merged: DualNumberPoint[] = dashboard.workspaceStorageHistory.map(
|
||||
(point, index) => ({
|
||||
label: formatDate(point.date),
|
||||
primary: point.value,
|
||||
secondary: dashboard.blobStorageHistory[index]?.value ?? 0,
|
||||
})
|
||||
);
|
||||
return toIndexedTrendPoints(downsample(merged, 60));
|
||||
}, [dashboard.blobStorageHistory, dashboard.workspaceStorageHistory]);
|
||||
|
||||
const totalStorageBytes =
|
||||
dashboard.workspaceStorageBytes + dashboard.blobStorageBytes;
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1 flex-col flex overflow-hidden">
|
||||
<Header
|
||||
title="Dashboard"
|
||||
endFix={
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
Updated at {formatDateTime(dashboard.generatedAt)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
mutate().catch(() => {});
|
||||
}}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`h-3.5 w-3.5 mr-1.5 ${isValidating ? 'animate-spin' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<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">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Window Controls</CardTitle>
|
||||
<CardDescription>
|
||||
Tune dashboard windows. Data is sampled in UTC and refreshes
|
||||
automatically.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 grid-cols-1 md:grid-cols-3 items-end">
|
||||
<WindowSelect
|
||||
id="storage-history-window"
|
||||
label="Storage History"
|
||||
value={storageHistoryDays}
|
||||
options={STORAGE_DAY_OPTIONS}
|
||||
unit="days"
|
||||
onChange={setStorageHistoryDays}
|
||||
/>
|
||||
<WindowSelect
|
||||
id="sync-history-window"
|
||||
label="Sync History"
|
||||
value={syncHistoryHours}
|
||||
options={SYNC_HOUR_OPTIONS}
|
||||
unit="hours"
|
||||
onChange={setSyncHistoryHours}
|
||||
/>
|
||||
<WindowSelect
|
||||
id="shared-link-window"
|
||||
label="Shared Link Window"
|
||||
value={sharedLinkWindowDays}
|
||||
options={SHARED_DAY_OPTIONS}
|
||||
unit="days"
|
||||
onChange={setSharedLinkWindowDays}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-5 grid-cols-1 lg:grid-cols-12">
|
||||
<PrimaryMetricCard
|
||||
value={intFormatter.format(dashboard.syncActiveUsers)}
|
||||
description={`${dashboard.syncWindow.effectiveSize}h active window`}
|
||||
/>
|
||||
<SecondaryMetricCard
|
||||
title="Copilot Conversations"
|
||||
value={intFormatter.format(dashboard.copilotConversations)}
|
||||
description={`${dashboard.topSharedLinksWindow.effectiveSize}d aggregation`}
|
||||
icon={
|
||||
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
|
||||
}
|
||||
/>
|
||||
<Card className="lg:col-span-4 border-border/70 bg-gradient-to-br from-card via-card to-muted/15 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<DatabaseIcon className="h-4 w-4" aria-hidden="true" />
|
||||
Managed Storage
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||
{formatBytes(totalStorageBytes)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Workspace {formatBytes(dashboard.workspaceStorageBytes)} • Blob{' '}
|
||||
{formatBytes(dashboard.blobStorageBytes)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
|
||||
<Card className="xl:col-span-1 border-border/70 bg-card/95 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Sync Active Users Trend
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{dashboard.syncWindow.effectiveSize}h at minute bucket
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<TrendChart
|
||||
ariaLabel="Sync active users trend"
|
||||
points={syncPoints}
|
||||
primaryLabel="Sync Active Users"
|
||||
primaryFormatter={value => intFormatter.format(value)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="xl:col-span-2 border-border/70 bg-gradient-to-br from-primary/5 via-card to-card shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Storage Trend (Workspace + Blob)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{dashboard.storageWindow.effectiveSize}d at day bucket
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<TrendChart
|
||||
ariaLabel="Workspace and blob storage trend"
|
||||
points={storagePoints}
|
||||
primaryLabel="Workspace Storage"
|
||||
primaryFormatter={value => formatBytes(value)}
|
||||
secondaryLabel="Blob Storage"
|
||||
secondaryFormatter={value => formatBytes(value)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-primary" />
|
||||
Workspace: {formatBytes(dashboard.workspaceStorageBytes)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-foreground/50" />
|
||||
Blob: {formatBytes(dashboard.blobStorageBytes)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/70 bg-card/95 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Top Shared Links</CardTitle>
|
||||
<CardDescription>
|
||||
Top {dashboard.topSharedLinks.length} links in the last{' '}
|
||||
{dashboard.topSharedLinksWindow.effectiveSize} days
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{dashboard.topSharedLinks.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center bg-muted/20">
|
||||
<div className="text-sm font-medium">
|
||||
No shared links in this window
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
Publish pages and collect traffic, then this table will rank
|
||||
links by views.
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm" className="mt-4">
|
||||
<Link to={ROUTES.admin.workspaces}>Go to Workspaces</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Document</TableHead>
|
||||
<TableHead>Workspace</TableHead>
|
||||
<TableHead className="text-right">Views</TableHead>
|
||||
<TableHead className="text-right">Unique</TableHead>
|
||||
<TableHead className="text-right">Guest</TableHead>
|
||||
<TableHead>Last Accessed</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dashboard.topSharedLinks.map(link => (
|
||||
<TableRow
|
||||
key={`${link.workspaceId}-${link.docId}`}
|
||||
className="hover:bg-muted/40"
|
||||
>
|
||||
<TableCell className="max-w-80 min-w-0">
|
||||
<a
|
||||
href={link.shareUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline-offset-4 hover:underline truncate block"
|
||||
>
|
||||
{link.title || link.docId}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs tabular-nums">
|
||||
{link.workspaceId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{compactFormatter.format(link.views)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{compactFormatter.format(link.uniqueViews)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{compactFormatter.format(link.guestViews)}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{link.lastAccessedAt
|
||||
? formatDateTime(link.lastAccessedAt)
|
||||
: '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<div className="flex justify-between text-xs text-muted-foreground tabular-nums">
|
||||
<span>{formatDate(dashboard.topSharedLinksWindow.from)}</span>
|
||||
<span>{formatDate(dashboard.topSharedLinksWindow.to)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DashboardPage as Component };
|
||||
@@ -1,8 +1,13 @@
|
||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import { ROUTES } from '@affine/routes';
|
||||
import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { LayoutDashboardIcon, ListChecksIcon } from 'lucide-react';
|
||||
import {
|
||||
BarChart3Icon,
|
||||
LayoutDashboardIcon,
|
||||
ListChecksIcon,
|
||||
} from 'lucide-react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { ServerVersion } from './server-version';
|
||||
@@ -85,22 +90,30 @@ export function Nav({ isCollapsed = false }: NavProps) {
|
||||
isCollapsed && 'items-center px-0 gap-1 overflow-visible'
|
||||
)}
|
||||
>
|
||||
{environment.isSelfHosted ? null : (
|
||||
<NavItem
|
||||
to={ROUTES.admin.dashboard}
|
||||
icon={<BarChart3Icon size={18} />}
|
||||
label="Dashboard"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
to="/admin/accounts"
|
||||
to={ROUTES.admin.accounts}
|
||||
icon={<AccountIcon fontSize={20} />}
|
||||
label="Accounts"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{environment.isSelfHosted ? null : (
|
||||
<NavItem
|
||||
to="/admin/workspaces"
|
||||
to={ROUTES.admin.workspaces}
|
||||
icon={<LayoutDashboardIcon size={18} />}
|
||||
label="Workspaces"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
to="/admin/queue"
|
||||
to={ROUTES.admin.queue}
|
||||
icon={<ListChecksIcon size={18} />}
|
||||
label="Queue"
|
||||
isCollapsed={isCollapsed}
|
||||
@@ -113,7 +126,7 @@ export function Nav({ isCollapsed = false }: NavProps) {
|
||||
/> */}
|
||||
<SettingsItem isCollapsed={isCollapsed} />
|
||||
<NavItem
|
||||
to="/admin/about"
|
||||
to={ROUTES.admin.about}
|
||||
icon={<SelfhostIcon fontSize={20} />}
|
||||
label="About"
|
||||
isCollapsed={isCollapsed}
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-transition-state": "^2.2.0",
|
||||
"react-virtuoso": "^4.12.3",
|
||||
"recharts": "^2.15.4",
|
||||
"rxjs": "^7.8.2",
|
||||
"semver": "^7.7.3",
|
||||
"ses": "^1.14.0",
|
||||
|
||||
@@ -43,6 +43,7 @@ import { focusBlockEnd } from '@blocksuite/affine/shared/commands';
|
||||
import { getLastNoteBlock } from '@blocksuite/affine/shared/utils';
|
||||
import {
|
||||
AiIcon,
|
||||
ChartPanelIcon,
|
||||
CommentIcon,
|
||||
ExportIcon,
|
||||
FrameIcon,
|
||||
@@ -67,6 +68,7 @@ import * as styles from './detail-page.css';
|
||||
import { DetailPageHeader } from './detail-page-header';
|
||||
import { DetailPageWrapper } from './detail-page-wrapper';
|
||||
import { EditorAdapterPanel } from './tabs/adapter';
|
||||
import { EditorAnalyticsPanel } from './tabs/analytics';
|
||||
import { EditorChatPanel } from './tabs/chat';
|
||||
import { EditorFramePanel } from './tabs/frame';
|
||||
import { EditorJournalPanel } from './tabs/journal';
|
||||
@@ -433,6 +435,17 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
||||
</ViewSidebarTab>
|
||||
)}
|
||||
|
||||
{workspace.flavour === 'affine-cloud' && (
|
||||
<ViewSidebarTab tabId="analytics" icon={<ChartPanelIcon />}>
|
||||
<Scrollable.Root className={styles.sidebarScrollArea}>
|
||||
<Scrollable.Viewport>
|
||||
<EditorAnalyticsPanel workspaceId={workspace.id} docId={doc.id} />
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
</ViewSidebarTab>
|
||||
)}
|
||||
|
||||
<GlobalPageHistoryModal />
|
||||
{/* FIXME: wait for better ai, <PageAIOnboarding /> */}
|
||||
</FrameworkScope>
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100%',
|
||||
padding: '16px',
|
||||
gap: '20px',
|
||||
});
|
||||
|
||||
export const section = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const sectionHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const sectionTitle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: '8px',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontSize: cssVar('fontBase'),
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const sectionSubtitle = style({
|
||||
color: cssVar('textSecondaryColor'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
});
|
||||
|
||||
export const windowButton = style({
|
||||
minWidth: '124px',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
export const lockButton = style({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
color: cssVar('textSecondaryColor'),
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
color: cssVar('textPrimaryColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const metrics = style({
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const metricCard = style({
|
||||
minWidth: '0',
|
||||
flex: 1,
|
||||
borderRadius: '10px',
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||
padding: '8px 10px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
});
|
||||
|
||||
export const metricLabel = style({
|
||||
color: cssVar('textSecondaryColor'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: 1.2,
|
||||
});
|
||||
|
||||
export const metricValue = style({
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontSize: cssVar('fontBase'),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
});
|
||||
|
||||
export const chartContainer = style({
|
||||
height: '228px',
|
||||
borderRadius: '12px',
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||
padding: '10px 10px 8px 10px',
|
||||
});
|
||||
|
||||
export const axisLabels = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
marginTop: '4px',
|
||||
});
|
||||
|
||||
export const chartLegend = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
});
|
||||
|
||||
export const legendItem = style({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
});
|
||||
|
||||
export const legendDot = style({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
});
|
||||
|
||||
export const tooltip = style({
|
||||
minWidth: '160px',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||
boxShadow: cssVar('shadow2'),
|
||||
padding: '8px 10px',
|
||||
});
|
||||
|
||||
export const tooltipTitle = style({
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
marginBottom: '6px',
|
||||
});
|
||||
|
||||
export const tooltipRow = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: 1.4,
|
||||
});
|
||||
|
||||
export const tooltipValue = style({
|
||||
marginLeft: 'auto',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontWeight: 600,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
});
|
||||
|
||||
export const emptyState = style({
|
||||
borderRadius: '10px',
|
||||
border: `1px dashed ${cssVar('borderColor')}`,
|
||||
color: cssVar('textSecondaryColor'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
minHeight: '96px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
padding: '0 16px',
|
||||
});
|
||||
|
||||
export const viewersList = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
});
|
||||
|
||||
export const viewerRow = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
borderRadius: '8px',
|
||||
padding: '6px 8px',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: cssVar('hoverColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const viewerUser = style({
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const viewerName = style({
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
});
|
||||
|
||||
export const viewerTime = style({
|
||||
color: cssVar('textSecondaryColor'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const loadMoreButton = style({
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
|
||||
export const loading = style({
|
||||
minHeight: '120px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Loading,
|
||||
Menu,
|
||||
MenuItem,
|
||||
toast,
|
||||
} from '@affine/component';
|
||||
import { useQuery } from '@affine/core/components/hooks/use-query';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import {
|
||||
getDocLastAccessedMembersQuery,
|
||||
getDocPageAnalyticsQuery,
|
||||
} from '@affine/graphql';
|
||||
import { i18nTime, useI18n } from '@affine/i18n';
|
||||
import {
|
||||
ArrowDownSmallIcon,
|
||||
CalendarPanelIcon,
|
||||
LockIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip as RechartsTooltip,
|
||||
type TooltipProps,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import * as styles from './analytics.css';
|
||||
import {
|
||||
type AnalyticsChartPoint,
|
||||
buildAnalyticsChartPoints,
|
||||
clampAnalyticsWindowDays,
|
||||
DEFAULT_ANALYTICS_WINDOW_DAYS,
|
||||
ensureMinimumChartPoints,
|
||||
getAvailableAnalyticsWindowOptions,
|
||||
INITIAL_MEMBERS_PAGE_SIZE,
|
||||
isLockedAnalyticsWindowOption,
|
||||
MAX_MEMBERS_PAGE_SIZE,
|
||||
} from './analytics.utils';
|
||||
|
||||
const intFormatter = new Intl.NumberFormat('en-US');
|
||||
const totalViewsColor = cssVar('primaryColor');
|
||||
const uniqueViewsColor = cssVar('processingColor');
|
||||
|
||||
function formatChartDate(value: string) {
|
||||
return i18nTime(value, { absolute: { accuracy: 'day' } });
|
||||
}
|
||||
|
||||
function AnalyticsChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: TooltipProps<number, string>) {
|
||||
const t = useI18n();
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const point = payload[0]?.payload as AnalyticsChartPoint | undefined;
|
||||
if (!point) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueByKey = payload.reduce<Record<string, number>>((acc, item) => {
|
||||
if (!item.dataKey) {
|
||||
return acc;
|
||||
}
|
||||
acc[String(item.dataKey)] =
|
||||
typeof item.value === 'number' ? item.value : Number(item.value ?? 0);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className={styles.tooltip}>
|
||||
<div className={styles.tooltipTitle}>{formatChartDate(point.date)}</div>
|
||||
<div className={styles.tooltipRow}>
|
||||
<span
|
||||
className={styles.legendDot}
|
||||
style={{ backgroundColor: totalViewsColor }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t['com.affine.doc.analytics.chart.total-views']()}
|
||||
<span className={styles.tooltipValue}>
|
||||
{intFormatter.format(valueByKey.totalViews ?? point.totalViews)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.tooltipRow}>
|
||||
<span
|
||||
className={styles.legendDot}
|
||||
style={{ backgroundColor: uniqueViewsColor }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t['com.affine.doc.analytics.chart.unique-views']()}
|
||||
<span className={styles.tooltipValue}>
|
||||
{intFormatter.format(valueByKey.uniqueViews ?? point.uniqueViews)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const EditorAnalyticsPanel = ({
|
||||
workspaceId,
|
||||
docId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const permission = useService(WorkspacePermissionService).permission;
|
||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||
const isTeam = useLiveData(permission.isTeam$);
|
||||
const isTeamWorkspace = isTeam ?? false;
|
||||
const [windowDays, setWindowDays] = useState(DEFAULT_ANALYTICS_WINDOW_DAYS);
|
||||
const [membersPageSize, setMembersPageSize] = useState(
|
||||
INITIAL_MEMBERS_PAGE_SIZE
|
||||
);
|
||||
const allowedWindowOptions = useMemo(
|
||||
() => getAvailableAnalyticsWindowOptions(),
|
||||
[]
|
||||
);
|
||||
const effectiveWindowDays = useMemo(
|
||||
() => clampAnalyticsWindowDays(windowDays, isTeamWorkspace),
|
||||
[isTeamWorkspace, windowDays]
|
||||
);
|
||||
const timezone = useMemo(
|
||||
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setMembersPageSize(INITIAL_MEMBERS_PAGE_SIZE);
|
||||
}, [docId]);
|
||||
|
||||
useEffect(() => {
|
||||
permission.revalidate();
|
||||
}, [permission, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (windowDays !== effectiveWindowDays) {
|
||||
setWindowDays(effectiveWindowDays);
|
||||
}
|
||||
}, [effectiveWindowDays, windowDays]);
|
||||
|
||||
const {
|
||||
data: analyticsData,
|
||||
isLoading: analyticsLoading,
|
||||
error: analyticsError,
|
||||
} = useQuery(
|
||||
{
|
||||
query: getDocPageAnalyticsQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
docId,
|
||||
input: {
|
||||
windowDays: effectiveWindowDays,
|
||||
timezone,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
suspense: false,
|
||||
keepPreviousData: true,
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: membersData,
|
||||
isLoading: membersLoading,
|
||||
error: membersError,
|
||||
} = useQuery(
|
||||
{
|
||||
query: getDocLastAccessedMembersQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
docId,
|
||||
pagination: {
|
||||
first: membersPageSize,
|
||||
offset: 0,
|
||||
},
|
||||
includeTotal: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
suspense: false,
|
||||
keepPreviousData: true,
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
);
|
||||
|
||||
const analytics = analyticsData?.workspace.doc.analytics;
|
||||
const summary = analytics?.summary;
|
||||
const chartPoints = useMemo(
|
||||
() =>
|
||||
ensureMinimumChartPoints(
|
||||
buildAnalyticsChartPoints(analytics?.series ?? [])
|
||||
),
|
||||
[analytics?.series]
|
||||
);
|
||||
|
||||
const membersConnection = membersData?.workspace.doc.lastAccessedMembers;
|
||||
const members = useMemo(
|
||||
() => membersConnection?.edges.map(edge => edge.node) ?? [],
|
||||
[membersConnection?.edges]
|
||||
);
|
||||
const totalMembers = membersConnection?.totalCount ?? members.length;
|
||||
const hasMoreMembers =
|
||||
Boolean(membersConnection?.pageInfo.hasNextPage) &&
|
||||
membersPageSize < MAX_MEMBERS_PAGE_SIZE;
|
||||
const openTeamPricing = useCallback(() => {
|
||||
workspaceDialogService.open('setting', {
|
||||
activeTab: 'plans',
|
||||
scrollAnchor: 'cloudPricingPlan',
|
||||
});
|
||||
}, [workspaceDialogService]);
|
||||
const showTeamPlanToast = useCallback(() => {
|
||||
toast(t['com.affine.doc.analytics.paywall.toast']());
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<section className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<span>{t['com.affine.doc.analytics.title']()}</span>
|
||||
<span className={styles.sectionSubtitle}>
|
||||
{summary
|
||||
? t.t('com.affine.doc.analytics.summary.total', {
|
||||
count: intFormatter.format(summary.totalViews),
|
||||
})
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
<Menu
|
||||
contentOptions={{ align: 'end' }}
|
||||
items={
|
||||
<>
|
||||
{allowedWindowOptions.map(option => {
|
||||
const isLocked = isLockedAnalyticsWindowOption(
|
||||
option,
|
||||
isTeamWorkspace
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={option}
|
||||
selected={effectiveWindowDays === option}
|
||||
suffixIcon={
|
||||
isLocked ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.lockButton}
|
||||
aria-label={t[
|
||||
'com.affine.doc.analytics.paywall.open-pricing'
|
||||
]()}
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openTeamPricing();
|
||||
}}
|
||||
>
|
||||
<LockIcon />
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
onSelect={() => {
|
||||
if (isLocked) {
|
||||
showTeamPlanToast();
|
||||
return;
|
||||
}
|
||||
setWindowDays(option);
|
||||
}}
|
||||
>
|
||||
{t.t('com.affine.doc.analytics.window.last-days', {
|
||||
days: option,
|
||||
})}
|
||||
{isLocked
|
||||
? ` (${t['com.affine.payment.cloud.team-workspace.name']()})`
|
||||
: ''}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="default"
|
||||
className={styles.windowButton}
|
||||
prefix={<CalendarPanelIcon />}
|
||||
suffix={<ArrowDownSmallIcon />}
|
||||
>
|
||||
{t.t('com.affine.doc.analytics.window.last-days', {
|
||||
days: effectiveWindowDays,
|
||||
})}
|
||||
</Button>
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
<div className={styles.metrics}>
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
{t['com.affine.doc.analytics.metric.total']()}
|
||||
</div>
|
||||
<div className={styles.metricValue}>
|
||||
{intFormatter.format(summary?.totalViews ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
{t['com.affine.doc.analytics.metric.unique']()}
|
||||
</div>
|
||||
<div className={styles.metricValue}>
|
||||
{intFormatter.format(summary?.uniqueViews ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.metricCard}>
|
||||
<div className={styles.metricLabel}>
|
||||
{t['com.affine.doc.analytics.metric.guest']()}
|
||||
</div>
|
||||
<div className={styles.metricValue}>
|
||||
{intFormatter.format(summary?.guestViews ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analyticsLoading && !analytics ? (
|
||||
<div className={styles.loading}>
|
||||
<Loading size={20} />
|
||||
</div>
|
||||
) : analyticsError && !analytics ? (
|
||||
<div className={styles.emptyState}>
|
||||
{t['com.affine.doc.analytics.error.load-analytics']()}
|
||||
</div>
|
||||
) : chartPoints.length ? (
|
||||
<>
|
||||
<div className={styles.chartContainer}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={chartPoints}
|
||||
margin={{ top: 10, right: 6, bottom: 6, left: 6 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
stroke={cssVar('borderColor')}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="x"
|
||||
type="number"
|
||||
hide
|
||||
allowDecimals={false}
|
||||
domain={['dataMin', 'dataMax']}
|
||||
/>
|
||||
<YAxis
|
||||
hide
|
||||
domain={[
|
||||
0,
|
||||
(max: number) => {
|
||||
if (max <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return Math.ceil(max * 1.1);
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
cursor={{
|
||||
stroke: cssVar('borderColor'),
|
||||
strokeDasharray: '4 4',
|
||||
}}
|
||||
content={<AnalyticsChartTooltip />}
|
||||
/>
|
||||
<Area
|
||||
dataKey="totalViews"
|
||||
type="monotone"
|
||||
stroke={totalViewsColor}
|
||||
fill={totalViewsColor}
|
||||
fillOpacity={0.15}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
dataKey="uniqueViews"
|
||||
type="monotone"
|
||||
stroke={uniqueViewsColor}
|
||||
fill={uniqueViewsColor}
|
||||
fillOpacity={0.1}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="totalViews"
|
||||
type="monotone"
|
||||
stroke={totalViewsColor}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="uniqueViews"
|
||||
type="monotone"
|
||||
stroke={uniqueViewsColor}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className={styles.axisLabels}>
|
||||
<span>{formatChartDate(chartPoints[0].date)}</span>
|
||||
<span>
|
||||
{formatChartDate(chartPoints[chartPoints.length - 1].date)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.chartLegend}>
|
||||
<span className={styles.legendItem}>
|
||||
<span
|
||||
className={styles.legendDot}
|
||||
style={{ backgroundColor: totalViewsColor }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t['com.affine.doc.analytics.chart.total-views']()}
|
||||
</span>
|
||||
<span className={styles.legendItem}>
|
||||
<span
|
||||
className={styles.legendDot}
|
||||
style={{ backgroundColor: uniqueViewsColor }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t['com.affine.doc.analytics.chart.unique-views']()}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.emptyState}>
|
||||
{t['com.affine.doc.analytics.empty.no-page-views']()}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<span>{t['com.affine.doc.analytics.viewers.title']()}</span>
|
||||
<span className={styles.sectionSubtitle}>
|
||||
({intFormatter.format(totalMembers)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{membersLoading && !membersConnection ? (
|
||||
<div className={styles.loading}>
|
||||
<Loading size={20} />
|
||||
</div>
|
||||
) : membersError && !membersConnection ? (
|
||||
<div className={styles.emptyState}>
|
||||
{t['com.affine.doc.analytics.error.load-viewers']()}
|
||||
</div>
|
||||
) : members.length ? (
|
||||
<>
|
||||
<div className={styles.viewersList}>
|
||||
{members.map(member => (
|
||||
<div className={styles.viewerRow} key={member.user.id}>
|
||||
<div className={styles.viewerUser}>
|
||||
<Avatar
|
||||
size={24}
|
||||
url={member.user.avatarUrl || ''}
|
||||
name={member.user.name}
|
||||
/>
|
||||
<span className={styles.viewerName}>
|
||||
{member.user.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className={styles.viewerTime}>
|
||||
{i18nTime(member.lastAccessedAt, { relative: true })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hasMoreMembers ? (
|
||||
<Button
|
||||
variant="plain"
|
||||
className={styles.loadMoreButton}
|
||||
onClick={() => setMembersPageSize(MAX_MEMBERS_PAGE_SIZE)}
|
||||
>
|
||||
{t['com.affine.doc.analytics.viewers.show-all']()}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.emptyState}>
|
||||
{t['com.affine.doc.analytics.empty.no-viewers']()}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
ANALYTICS_WINDOW_OPTIONS,
|
||||
buildAnalyticsChartPoints,
|
||||
clampAnalyticsWindowDays,
|
||||
DEFAULT_ANALYTICS_WINDOW_DAYS,
|
||||
ensureMinimumChartPoints,
|
||||
getAvailableAnalyticsWindowOptions,
|
||||
isLockedAnalyticsWindowOption,
|
||||
NON_TEAM_ANALYTICS_WINDOW_DAYS,
|
||||
} from './analytics.utils';
|
||||
|
||||
describe('analytics.utils', () => {
|
||||
test('clampAnalyticsWindowDays returns default for unsupported values', () => {
|
||||
expect(clampAnalyticsWindowDays(28, true)).toBe(28);
|
||||
expect(clampAnalyticsWindowDays(15, true)).toBe(
|
||||
DEFAULT_ANALYTICS_WINDOW_DAYS
|
||||
);
|
||||
});
|
||||
|
||||
test('clampAnalyticsWindowDays returns only 7 days for non-team workspaces', () => {
|
||||
expect(clampAnalyticsWindowDays(7, false)).toBe(
|
||||
NON_TEAM_ANALYTICS_WINDOW_DAYS
|
||||
);
|
||||
expect(clampAnalyticsWindowDays(28, false)).toBe(
|
||||
NON_TEAM_ANALYTICS_WINDOW_DAYS
|
||||
);
|
||||
});
|
||||
|
||||
test('getAvailableAnalyticsWindowOptions keeps all options visible', () => {
|
||||
expect(getAvailableAnalyticsWindowOptions()).toEqual([
|
||||
...ANALYTICS_WINDOW_OPTIONS,
|
||||
]);
|
||||
});
|
||||
|
||||
test('isLockedAnalyticsWindowOption locks windows over 7 days for non-team workspaces', () => {
|
||||
expect(
|
||||
isLockedAnalyticsWindowOption(NON_TEAM_ANALYTICS_WINDOW_DAYS, false)
|
||||
).toBe(false);
|
||||
expect(isLockedAnalyticsWindowOption(14, false)).toBe(true);
|
||||
expect(isLockedAnalyticsWindowOption(14, true)).toBe(false);
|
||||
});
|
||||
|
||||
test('buildAnalyticsChartPoints sorts series by date and maps values', () => {
|
||||
const points = buildAnalyticsChartPoints([
|
||||
{
|
||||
date: '2026-02-12',
|
||||
totalViews: 4,
|
||||
uniqueViews: 2,
|
||||
guestViews: 1,
|
||||
},
|
||||
{
|
||||
date: '2026-02-10',
|
||||
totalViews: 9,
|
||||
uniqueViews: 3,
|
||||
guestViews: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(points).toEqual([
|
||||
{
|
||||
x: 0,
|
||||
date: '2026-02-10',
|
||||
totalViews: 9,
|
||||
uniqueViews: 3,
|
||||
guestViews: 0,
|
||||
},
|
||||
{
|
||||
x: 1,
|
||||
date: '2026-02-12',
|
||||
totalViews: 4,
|
||||
uniqueViews: 2,
|
||||
guestViews: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('ensureMinimumChartPoints duplicates the only data point', () => {
|
||||
const points = ensureMinimumChartPoints([
|
||||
{
|
||||
x: 0,
|
||||
date: '2026-02-12',
|
||||
totalViews: 4,
|
||||
uniqueViews: 2,
|
||||
guestViews: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(points).toEqual([
|
||||
{
|
||||
x: 0,
|
||||
date: '2026-02-12',
|
||||
totalViews: 4,
|
||||
uniqueViews: 2,
|
||||
guestViews: 1,
|
||||
},
|
||||
{
|
||||
x: 1,
|
||||
date: '2026-02-12',
|
||||
totalViews: 4,
|
||||
uniqueViews: 2,
|
||||
guestViews: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import type { GetDocPageAnalyticsQuery } from '@affine/graphql';
|
||||
|
||||
export const ANALYTICS_WINDOW_OPTIONS = [7, 14, 28, 60, 90] as const;
|
||||
export const DEFAULT_ANALYTICS_WINDOW_DAYS = 28;
|
||||
export const NON_TEAM_ANALYTICS_WINDOW_DAYS = 7;
|
||||
export const INITIAL_MEMBERS_PAGE_SIZE = 5;
|
||||
export const MAX_MEMBERS_PAGE_SIZE = 50;
|
||||
|
||||
export type AnalyticsSeriesPoint =
|
||||
GetDocPageAnalyticsQuery['workspace']['doc']['analytics']['series'][number];
|
||||
|
||||
export type AnalyticsChartPoint = {
|
||||
x: number;
|
||||
date: string;
|
||||
totalViews: number;
|
||||
uniqueViews: number;
|
||||
guestViews: number;
|
||||
};
|
||||
|
||||
export function getAvailableAnalyticsWindowOptions() {
|
||||
return [...ANALYTICS_WINDOW_OPTIONS];
|
||||
}
|
||||
|
||||
export function isLockedAnalyticsWindowOption(
|
||||
value: number,
|
||||
isTeamWorkspace: boolean
|
||||
) {
|
||||
return !isTeamWorkspace && value > NON_TEAM_ANALYTICS_WINDOW_DAYS;
|
||||
}
|
||||
|
||||
export function clampAnalyticsWindowDays(
|
||||
value: number,
|
||||
isTeamWorkspace: boolean
|
||||
) {
|
||||
if (!isTeamWorkspace) {
|
||||
return NON_TEAM_ANALYTICS_WINDOW_DAYS;
|
||||
}
|
||||
return ANALYTICS_WINDOW_OPTIONS.includes(
|
||||
value as (typeof ANALYTICS_WINDOW_OPTIONS)[number]
|
||||
)
|
||||
? value
|
||||
: DEFAULT_ANALYTICS_WINDOW_DAYS;
|
||||
}
|
||||
|
||||
export function buildAnalyticsChartPoints(series: AnalyticsSeriesPoint[]) {
|
||||
const sorted = [...series].sort(
|
||||
(left, right) =>
|
||||
new Date(left.date).getTime() - new Date(right.date).getTime()
|
||||
);
|
||||
|
||||
return sorted.map((point, index) => ({
|
||||
x: index,
|
||||
date: point.date,
|
||||
totalViews: point.totalViews,
|
||||
uniqueViews: point.uniqueViews,
|
||||
guestViews: point.guestViews,
|
||||
})) satisfies AnalyticsChartPoint[];
|
||||
}
|
||||
|
||||
export function ensureMinimumChartPoints(points: AnalyticsChartPoint[]) {
|
||||
if (points.length !== 1) {
|
||||
return points;
|
||||
}
|
||||
|
||||
return [
|
||||
points[0],
|
||||
{
|
||||
...points[0],
|
||||
x: points[0].x + 1,
|
||||
},
|
||||
] satisfies AnalyticsChartPoint[];
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"ar": 97,
|
||||
"ar": 96,
|
||||
"ca": 98,
|
||||
"da": 4,
|
||||
"de": 98,
|
||||
"el-GR": 97,
|
||||
"de": 97,
|
||||
"el-GR": 96,
|
||||
"en": 100,
|
||||
"es-AR": 97,
|
||||
"es-AR": 96,
|
||||
"es-CL": 98,
|
||||
"es": 97,
|
||||
"fa": 97,
|
||||
"es": 96,
|
||||
"fa": 96,
|
||||
"fr": 98,
|
||||
"hi": 2,
|
||||
"hi": 1,
|
||||
"it-IT": 98,
|
||||
"it": 1,
|
||||
"ja": 97,
|
||||
"ko": 98,
|
||||
"nb-NO": 48,
|
||||
"ja": 96,
|
||||
"ko": 97,
|
||||
"nb-NO": 47,
|
||||
"pl": 98,
|
||||
"pt-BR": 97,
|
||||
"pt-BR": 96,
|
||||
"ru": 98,
|
||||
"sv-SE": 97,
|
||||
"uk": 97,
|
||||
"uk": 96,
|
||||
"ur": 2,
|
||||
"zh-Hans": 99,
|
||||
"zh-Hans": 98,
|
||||
"zh-Hant": 97
|
||||
}
|
||||
|
||||
@@ -4574,6 +4574,74 @@ export function useAFFiNEI18N(): {
|
||||
* `Copied key to clipboard`
|
||||
*/
|
||||
["com.affine.payment.license-success.copy"](): string;
|
||||
/**
|
||||
* `View analytics`
|
||||
*/
|
||||
["com.affine.doc.analytics.title"](): string;
|
||||
/**
|
||||
* `({{count}} total)`
|
||||
*/
|
||||
["com.affine.doc.analytics.summary.total"](options: {
|
||||
readonly count: string;
|
||||
}): string;
|
||||
/**
|
||||
* `Last {{days}} days`
|
||||
*/
|
||||
["com.affine.doc.analytics.window.last-days"](options: {
|
||||
readonly days: string;
|
||||
}): string;
|
||||
/**
|
||||
* `Total`
|
||||
*/
|
||||
["com.affine.doc.analytics.metric.total"](): string;
|
||||
/**
|
||||
* `Unique`
|
||||
*/
|
||||
["com.affine.doc.analytics.metric.unique"](): string;
|
||||
/**
|
||||
* `Guest`
|
||||
*/
|
||||
["com.affine.doc.analytics.metric.guest"](): string;
|
||||
/**
|
||||
* `Total views`
|
||||
*/
|
||||
["com.affine.doc.analytics.chart.total-views"](): string;
|
||||
/**
|
||||
* `Unique views`
|
||||
*/
|
||||
["com.affine.doc.analytics.chart.unique-views"](): string;
|
||||
/**
|
||||
* `Unable to load analytics.`
|
||||
*/
|
||||
["com.affine.doc.analytics.error.load-analytics"](): string;
|
||||
/**
|
||||
* `Unable to load viewers.`
|
||||
*/
|
||||
["com.affine.doc.analytics.error.load-viewers"](): string;
|
||||
/**
|
||||
* `No page views in this window.`
|
||||
*/
|
||||
["com.affine.doc.analytics.empty.no-page-views"](): string;
|
||||
/**
|
||||
* `No viewers in this window.`
|
||||
*/
|
||||
["com.affine.doc.analytics.empty.no-viewers"](): string;
|
||||
/**
|
||||
* `Viewers`
|
||||
*/
|
||||
["com.affine.doc.analytics.viewers.title"](): string;
|
||||
/**
|
||||
* `Show all viewers`
|
||||
*/
|
||||
["com.affine.doc.analytics.viewers.show-all"](): string;
|
||||
/**
|
||||
* `Open pricing plans`
|
||||
*/
|
||||
["com.affine.doc.analytics.paywall.open-pricing"](): string;
|
||||
/**
|
||||
* `Doc analytics over 7 days require an AFFiNE Team subscription.`
|
||||
*/
|
||||
["com.affine.doc.analytics.paywall.toast"](): string;
|
||||
/**
|
||||
* `Close`
|
||||
*/
|
||||
|
||||
@@ -1134,6 +1134,22 @@
|
||||
"com.affine.payment.license-success.hint": "You can use this key to upgrade in Settings > Workspace > License > Use purchased key",
|
||||
"com.affine.payment.license-success.open-affine": "Open AFFiNE",
|
||||
"com.affine.payment.license-success.copy": "Copied key to clipboard",
|
||||
"com.affine.doc.analytics.title": "View analytics",
|
||||
"com.affine.doc.analytics.summary.total": "({{count}} total)",
|
||||
"com.affine.doc.analytics.window.last-days": "Last {{days}} days",
|
||||
"com.affine.doc.analytics.metric.total": "Total",
|
||||
"com.affine.doc.analytics.metric.unique": "Unique",
|
||||
"com.affine.doc.analytics.metric.guest": "Guest",
|
||||
"com.affine.doc.analytics.chart.total-views": "Total views",
|
||||
"com.affine.doc.analytics.chart.unique-views": "Unique views",
|
||||
"com.affine.doc.analytics.error.load-analytics": "Unable to load analytics.",
|
||||
"com.affine.doc.analytics.error.load-viewers": "Unable to load viewers.",
|
||||
"com.affine.doc.analytics.empty.no-page-views": "No page views in this window.",
|
||||
"com.affine.doc.analytics.empty.no-viewers": "No viewers in this window.",
|
||||
"com.affine.doc.analytics.viewers.title": "Viewers",
|
||||
"com.affine.doc.analytics.viewers.show-all": "Show all viewers",
|
||||
"com.affine.doc.analytics.paywall.open-pricing": "Open pricing plans",
|
||||
"com.affine.doc.analytics.paywall.toast": "Doc analytics over 7 days require an AFFiNE Team subscription.",
|
||||
"com.affine.peek-view-controls.close": "Close",
|
||||
"com.affine.peek-view-controls.open-doc": "Open this doc",
|
||||
"com.affine.peek-view-controls.open-doc-in-edgeless": "Open in edgeless",
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
"children": {
|
||||
"auth": "auth",
|
||||
"setup": "setup",
|
||||
"dashboard": "dashboard",
|
||||
"accounts": "accounts",
|
||||
"workspaces": "workspaces",
|
||||
"queue": "queue",
|
||||
"ai": "ai",
|
||||
"settings": {
|
||||
"route": "settings",
|
||||
|
||||
@@ -11,6 +11,7 @@ export const ROUTES = {
|
||||
index: '/admin',
|
||||
auth: '/admin/auth',
|
||||
setup: '/admin/setup',
|
||||
dashboard: '/admin/dashboard',
|
||||
accounts: '/admin/accounts',
|
||||
workspaces: '/admin/workspaces',
|
||||
queue: '/admin/queue',
|
||||
@@ -29,6 +30,7 @@ export const RELATIVE_ROUTES = {
|
||||
index: 'admin',
|
||||
auth: 'auth',
|
||||
setup: 'setup',
|
||||
dashboard: 'dashboard',
|
||||
accounts: 'accounts',
|
||||
workspaces: 'workspaces',
|
||||
queue: 'queue',
|
||||
@@ -45,6 +47,7 @@ const home = () => '/';
|
||||
const admin = () => '/admin';
|
||||
admin.auth = () => '/admin/auth';
|
||||
admin.setup = () => '/admin/setup';
|
||||
admin.dashboard = () => '/admin/dashboard';
|
||||
admin.accounts = () => '/admin/accounts';
|
||||
admin.workspaces = () => '/admin/workspaces';
|
||||
admin.queue = () => '/admin/queue';
|
||||
|
||||
Reference in New Issue
Block a user