refactor(core): move mobile components to core (#8258)

This commit is contained in:
EYHN
2024-09-14 05:51:14 +00:00
parent ff15ea1eec
commit 3d80725c1a
192 changed files with 157 additions and 165 deletions

View File

@@ -0,0 +1,75 @@
import {
NoPermissionOrNotFound,
NotFoundPage,
} from '@affine/component/not-found-page';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { apis } from '@affine/electron-api';
import { useLiveData, useService } from '@toeverything/infra';
import type { ReactElement } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { SignOutModal } from '../../components/affine/sign-out-modal';
import {
RouteLogic,
useNavigateHelper,
} from '../../components/hooks/use-navigate-helper';
import { AuthService } from '../../modules/cloud';
import { SignIn } from './auth/sign-in';
export const PageNotFound = ({
noPermission,
}: {
noPermission?: boolean;
}): ReactElement => {
const authService = useService(AuthService);
const account = useLiveData(authService.session.account$);
const { jumpToIndex } = useNavigateHelper();
const [open, setOpen] = useState(false);
const handleBackButtonClick = useCallback(
() => jumpToIndex(RouteLogic.REPLACE),
[jumpToIndex]
);
const handleOpenSignOutModal = useCallback(() => {
setOpen(true);
}, [setOpen]);
const onConfirmSignOut = useAsyncCallback(async () => {
setOpen(false);
await authService.signOut();
}, [authService]);
useEffect(() => {
apis?.ui.pingAppLayoutReady().catch(console.error);
}, []);
return (
<>
{noPermission ? (
<NoPermissionOrNotFound
user={account}
onBack={handleBackButtonClick}
onSignOut={handleOpenSignOutModal}
signInComponent={<SignIn />}
/>
) : (
<NotFoundPage
user={account}
onBack={handleBackButtonClick}
onSignOut={handleOpenSignOutModal}
/>
)}
<SignOutModal
open={open}
onOpenChange={setOpen}
onConfirm={onConfirmSignOut}
/>
</>
);
};
export const Component = () => {
return <PageNotFound />;
};

View File

@@ -0,0 +1,5 @@
import { AIUpgradeSuccess } from '../../components/affine/subscription-landing';
export const Component = () => {
return <AIUpgradeSuccess />;
};

View File

@@ -0,0 +1,206 @@
import { notify } from '@affine/component';
import {
ChangeEmailPage,
ChangePasswordPage,
ConfirmChangeEmail,
ConfirmVerifiedEmail,
OnboardingPage,
SetPasswordPage,
SignInSuccessPage,
SignUpPage,
} from '@affine/component/auth-components';
import {
changeEmailMutation,
changePasswordMutation,
fetcher,
sendVerifyChangeEmailMutation,
verifyEmailMutation,
} from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import { redirect, useParams, useSearchParams } from 'react-router-dom';
import { z } from 'zod';
import { useMutation } from '../../../components/hooks/use-mutation';
import {
RouteLogic,
useNavigateHelper,
} from '../../../components/hooks/use-navigate-helper';
import { AuthService, ServerConfigService } from '../../../modules/cloud';
const authTypeSchema = z.enum([
'onboarding',
'setPassword',
'signIn',
'changePassword',
'signUp',
'changeEmail',
'confirm-change-email',
'subscription-redirect',
'verify-email',
]);
export const Component = () => {
const authService = useService(AuthService);
const account = useLiveData(authService.session.account$);
const t = useI18n();
const serverConfig = useService(ServerConfigService).serverConfig;
const passwordLimits = useLiveData(
serverConfig.credentialsRequirement$.map(r => r?.password)
);
const { authType } = useParams();
const [searchParams] = useSearchParams();
const { trigger: changePassword } = useMutation({
mutation: changePasswordMutation,
});
const { trigger: sendVerifyChangeEmail } = useMutation({
mutation: sendVerifyChangeEmailMutation,
});
const { jumpToIndex } = useNavigateHelper();
const onSendVerifyChangeEmail = useCallback(
async (email: string) => {
const res = await sendVerifyChangeEmail({
token: searchParams.get('token') || '',
email,
callbackUrl: `/auth/confirm-change-email`,
}).catch(console.error);
// FIXME: There is not notification
if (res?.sendVerifyChangeEmail) {
notify.success({
title: t['com.affine.auth.sent.verify.email.hint'](),
});
} else {
notify.error({
title: t['com.affine.auth.sent.change.email.fail'](),
});
}
return !!res?.sendVerifyChangeEmail;
},
[searchParams, sendVerifyChangeEmail, t]
);
const onSetPassword = useCallback(
async (password: string) => {
await changePassword({
token: searchParams.get('token') || '',
userId: searchParams.get('userId') || '',
newPassword: password,
});
},
[changePassword, searchParams]
);
const onOpenAffine = useCallback(() => {
jumpToIndex(RouteLogic.REPLACE);
}, [jumpToIndex]);
if (!passwordLimits) {
// TODO(@eyhn): loading UI
return null;
}
switch (authType) {
case 'onboarding':
return (
account && <OnboardingPage user={account} onOpenAffine={onOpenAffine} />
);
case 'signUp': {
return (
account && (
<SignUpPage
user={account}
passwordLimits={passwordLimits}
onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine}
/>
)
);
}
case 'signIn': {
return <SignInSuccessPage onOpenAffine={onOpenAffine} />;
}
case 'changePassword': {
return (
<ChangePasswordPage
passwordLimits={passwordLimits}
onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine}
/>
);
}
case 'setPassword': {
return (
<SetPasswordPage
passwordLimits={passwordLimits}
onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine}
/>
);
}
case 'changeEmail': {
return (
<ChangeEmailPage
onChangeEmail={onSendVerifyChangeEmail}
onOpenAffine={onOpenAffine}
/>
);
}
case 'confirm-change-email': {
return <ConfirmChangeEmail onOpenAffine={onOpenAffine} />;
}
case 'verify-email': {
return <ConfirmVerifiedEmail onOpenAffine={onOpenAffine} />;
}
}
return null;
};
export const loader: LoaderFunction = async args => {
if (!args.params.authType) {
return redirect('/404');
}
if (!authTypeSchema.safeParse(args.params.authType).success) {
return redirect('/404');
}
if (args.params.authType === 'confirm-change-email') {
const url = new URL(args.request.url);
const searchParams = url.searchParams;
const token = searchParams.get('token') ?? '';
const email = decodeURIComponent(searchParams.get('email') ?? '');
const res = await fetcher({
query: changeEmailMutation,
variables: {
token: token,
email: email,
},
}).catch(console.error);
// TODO(@eyhn): Add error handling
if (!res?.changeEmail) {
return redirect('/expired');
}
} else if (args.params.authType === 'verify-email') {
const url = new URL(args.request.url);
const searchParams = url.searchParams;
const token = searchParams.get('token') ?? '';
const res = await fetcher({
query: verifyEmailMutation,
variables: {
token: token,
},
}).catch(console.error);
if (!res?.verifyEmail) {
return redirect('/expired');
}
}
return null;
};

View File

@@ -0,0 +1,9 @@
import { z } from 'zod';
export const supportedClient = z.enum([
'web',
'affine',
'affine-canary',
'affine-beta',
...(BUILD_CONFIG.debug ? ['affine-dev'] : []),
]);

View File

@@ -0,0 +1,75 @@
import { useService } from '@toeverything/infra';
import { useEffect } from 'react';
import {
type LoaderFunction,
redirect,
useLoaderData,
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
useNavigate,
} from 'react-router-dom';
import { AuthService } from '../../../modules/cloud';
import { supportedClient } from './common';
interface LoaderData {
token: string;
email: string;
redirectUri: string | null;
}
export const loader: LoaderFunction = ({ request }) => {
const url = new URL(request.url);
const params = url.searchParams;
const client = params.get('client');
const email = params.get('email');
const token = params.get('token');
const redirectUri = params.get('redirect_uri');
if (!email || !token) {
return redirect('/sign-in?error=Invalid magic link');
}
const payload: LoaderData = {
email,
token,
redirectUri,
};
if (!client || client === 'web') {
return payload;
}
const clientCheckResult = supportedClient.safeParse(client);
if (!clientCheckResult.success) {
return redirect('/sign-in?error=Invalid callback parameters');
}
const authParams = new URLSearchParams();
authParams.set('method', 'magic-link');
authParams.set('payload', JSON.stringify(payload));
return redirect(
`/open-app/url?url=${encodeURIComponent(`${client}://authentication?${authParams.toString()}`)}`
);
};
export const Component = () => {
// TODO(@eyhn): loading ui
const auth = useService(AuthService);
const data = useLoaderData() as LoaderData;
const nav = useNavigate();
useEffect(() => {
auth
.signInMagicLink(data.email, data.token)
.then(() => {
nav(data.redirectUri ?? '/');
})
.catch(e => {
nav(`/sign-in?error=${encodeURIComponent(e.message)}`);
});
}, [data, auth, nav]);
return null;
};

View File

@@ -0,0 +1,80 @@
import { useService } from '@toeverything/infra';
import { useEffect } from 'react';
import {
type LoaderFunction,
redirect,
useLoaderData,
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
useNavigate,
} from 'react-router-dom';
import { AuthService } from '../../../modules/cloud';
import { supportedClient } from './common';
interface LoaderData {
state: string;
code: string;
provider: string;
}
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const queries = url.searchParams;
const code = queries.get('code');
let stateStr = queries.get('state') ?? '{}';
if (!code || !stateStr) {
return redirect('/sign-in?error=Invalid oauth callback parameters');
}
try {
const { state, client, provider } = JSON.parse(stateStr);
stateStr = state;
const payload: LoaderData = {
state,
code,
provider,
};
if (!client || client === 'web') {
return payload;
}
const clientCheckResult = supportedClient.safeParse(client);
if (!clientCheckResult.success) {
return redirect('/sign-in?error=Invalid oauth callback parameters');
}
const authParams = new URLSearchParams();
authParams.set('method', 'oauth');
authParams.set('payload', JSON.stringify(payload));
return redirect(
`/open-app/url?url=${encodeURIComponent(`${client}://authentication?${authParams.toString()}`)}`
);
} catch {
return redirect('/sign-in?error=Invalid oauth callback parameters');
}
};
export const Component = () => {
const auth = useService(AuthService);
const data = useLoaderData() as LoaderData;
const nav = useNavigate();
useEffect(() => {
auth
.signInOauth(data.code, data.state, data.provider)
.then(({ redirectUri }) => {
// TODO(@forehalo): need a good way to go back to previous tab and close current one
nav(redirectUri ?? '/');
})
.catch(e => {
nav(`/sign-in?error=${encodeURIComponent(e.message)}`);
});
}, [data, auth, nav]);
return null;
};

View File

@@ -0,0 +1,78 @@
import { AuthService } from '@affine/core/modules/cloud';
import { OAuthProviderType } from '@affine/graphql';
import { useService } from '@toeverything/infra';
import { useEffect } from 'react';
import {
type LoaderFunction,
redirect,
useLoaderData,
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
useNavigate,
} from 'react-router-dom';
import { z } from 'zod';
import { supportedClient } from './common';
const supportedProvider = z.nativeEnum(OAuthProviderType);
const oauthParameters = z.object({
provider: supportedProvider,
client: supportedClient,
redirectUri: z.string().optional().nullable(),
});
interface LoaderData {
provider: OAuthProviderType;
client: string;
redirectUri?: string;
}
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const searchParams = url.searchParams;
const provider = searchParams.get('provider');
const client = searchParams.get('client') ?? 'web';
const redirectUri = searchParams.get('redirect_uri');
// sign out first
await fetch('/api/auth/sign-out');
const paramsParseResult = oauthParameters.safeParse({
provider,
client,
redirectUri,
});
if (paramsParseResult.success) {
return {
provider,
client,
redirectUri,
};
}
return redirect(
`/sign-in?error=${encodeURIComponent(`Invalid oauth parameters`)}`
);
};
export const Component = () => {
const auth = useService(AuthService);
const data = useLoaderData() as LoaderData;
const nav = useNavigate();
useEffect(() => {
auth
.oauthPreflight(data.provider, data.client, data.redirectUri)
.then(url => {
// this is the url of oauth provider auth page, can't navigate with react-router
location.href = url;
})
.catch(e => {
nav(`/sign-in?error=${encodeURIComponent(e.message)}`);
});
}, [data, auth, nav]);
return null;
};

View File

@@ -0,0 +1,56 @@
import { AffineOtherPageLayout } from '@affine/component/affine-other-page-layout';
import { SignInPageContainer } from '@affine/component/auth-components';
import { AuthService } from '@affine/core/modules/cloud';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useNavigate, useSearchParams } from 'react-router-dom';
import { AuthPanel } from '../../../components/affine/auth';
import {
RouteLogic,
useNavigateHelper,
} from '../../../components/hooks/use-navigate-helper';
export const SignIn = () => {
const session = useService(AuthService).session;
const status = useLiveData(session.status$);
const isRevalidating = useLiveData(session.isRevalidating$);
const navigate = useNavigate();
const { jumpToIndex } = useNavigateHelper();
const [searchParams] = useSearchParams();
const isLoggedIn = status === 'authenticated' && !isRevalidating;
useEffect(() => {
if (isLoggedIn) {
const redirectUri = searchParams.get('redirect_uri');
if (redirectUri) {
navigate(redirectUri, {
replace: true,
});
} else {
jumpToIndex(RouteLogic.REPLACE, {
search: searchParams.toString(),
});
}
}
}, [jumpToIndex, navigate, isLoggedIn, searchParams]);
return (
<SignInPageContainer>
<div style={{ maxWidth: '400px', width: '100%' }}>
<AuthPanel onSkip={jumpToIndex} />
</div>
</SignInPageContainer>
);
};
export const Component = () => {
return (
<AffineOtherPageLayout>
<div style={{ padding: '0 20px' }}>
<SignIn />
</div>
</AffineOtherPageLayout>
);
};

View File

@@ -0,0 +1,28 @@
import { AuthPageContainer } from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { useI18n } from '@affine/i18n';
import { useCallback } from 'react';
import {
RouteLogic,
useNavigateHelper,
} from '../../components/hooks/use-navigate-helper';
export const Component = () => {
const t = useI18n();
const { jumpToIndex } = useNavigateHelper();
const onOpenAffine = useCallback(() => {
jumpToIndex(RouteLogic.REPLACE);
}, [jumpToIndex]);
return (
<AuthPageContainer
title={t['com.affine.expired.page.title']()}
subtitle={t['com.affine.expired.page.subtitle']()}
>
<Button variant="primary" size="large" onClick={onOpenAffine}>
{t['com.affine.auth.open.affine']()}
</Button>
</AuthPageContainer>
);
};

View File

@@ -0,0 +1,22 @@
import type { DocMode } from '@blocksuite/blocks';
import { useService } from '@toeverything/infra';
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useNavigateHelper } from '../../components/hooks/use-navigate-helper';
import { ImportTemplateDialogService } from '../../modules/import-template';
export const Component = () => {
const importTemplateDialogService = useService(ImportTemplateDialogService);
const [searchParams] = useSearchParams();
const { jumpToIndex } = useNavigateHelper();
useEffect(() => {
importTemplateDialogService.dialog.open({
templateName: searchParams.get('name') ?? '',
templateMode: (searchParams.get('mode') as DocMode) ?? 'page',
snapshotUrl: searchParams.get('snapshotUrl') ?? '',
});
}, [importTemplateDialogService.dialog, jumpToIndex, searchParams]);
// no ui for this route, just open the dialog
return null;
};

View File

@@ -0,0 +1,166 @@
import { apis } from '@affine/electron-api';
import { WorkspaceFlavour } from '@affine/env/workspace';
import {
useLiveData,
useService,
WorkspacesService,
} from '@toeverything/infra';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { type LoaderFunction, useSearchParams } from 'react-router-dom';
import {
buildShowcaseWorkspace,
createFirstAppData,
} from '../../bootstrap/first-app-data';
import { AppFallback } from '../../components/affine/app-container';
import { useNavigateHelper } from '../../components/hooks/use-navigate-helper';
import { WorkspaceNavigator } from '../../components/workspace-selector';
import { AuthService } from '../../modules/cloud';
export const loader: LoaderFunction = async () => {
return null;
};
export const Component = ({
defaultIndexRoute = 'all',
}: {
defaultIndexRoute?: string;
}) => {
// navigating and creating may be slow, to avoid flickering, we show workspace fallback
const [navigating, setNavigating] = useState(true);
const [creating, setCreating] = useState(false);
const authService = useService(AuthService);
const loggedIn = useLiveData(
authService.session.status$.map(s => s === 'authenticated')
);
const workspacesService = useService(WorkspacesService);
const list = useLiveData(workspacesService.list.workspaces$);
const listIsLoading = useLiveData(workspacesService.list.isRevalidating$);
const { openPage, jumpToPage } = useNavigateHelper();
const [searchParams] = useSearchParams();
const createOnceRef = useRef(false);
const createCloudWorkspace = useCallback(() => {
if (createOnceRef.current) return;
createOnceRef.current = true;
buildShowcaseWorkspace(
workspacesService,
WorkspaceFlavour.AFFINE_CLOUD,
'AFFiNE Cloud'
)
.then(({ meta, defaultDocId }) => {
if (defaultDocId) {
jumpToPage(meta.id, defaultDocId);
} else {
openPage(meta.id, defaultIndexRoute);
}
})
.catch(err => console.error('Failed to create cloud workspace', err));
}, [defaultIndexRoute, jumpToPage, openPage, workspacesService]);
useLayoutEffect(() => {
if (!navigating) {
return;
}
if (listIsLoading) {
return;
}
// check is user logged in && has cloud workspace
if (searchParams.get('initCloud') === 'true') {
if (loggedIn) {
if (list.every(w => w.flavour !== WorkspaceFlavour.AFFINE_CLOUD)) {
createCloudWorkspace();
return;
}
// open first cloud workspace
const openWorkspace =
list.find(w => w.flavour === WorkspaceFlavour.AFFINE_CLOUD) ??
list[0];
openPage(openWorkspace.id, defaultIndexRoute);
} else {
return;
}
} else {
if (list.length === 0) {
setNavigating(false);
return;
}
// open last workspace
const lastId = localStorage.getItem('last_workspace_id');
const openWorkspace = list.find(w => w.id === lastId) ?? list[0];
openPage(openWorkspace.id, defaultIndexRoute);
}
}, [
createCloudWorkspace,
list,
openPage,
searchParams,
listIsLoading,
loggedIn,
navigating,
defaultIndexRoute,
]);
useEffect(() => {
apis?.ui.pingAppLayoutReady().catch(console.error);
}, []);
useEffect(() => {
setCreating(true);
createFirstAppData(workspacesService)
.then(createdWorkspace => {
if (createdWorkspace) {
if (createdWorkspace.defaultPageId) {
jumpToPage(
createdWorkspace.meta.id,
createdWorkspace.defaultPageId
);
} else {
openPage(createdWorkspace.meta.id, 'all');
}
}
})
.catch(err => {
console.error('Failed to create first app data', err);
})
.finally(() => {
setCreating(false);
});
}, [jumpToPage, openPage, workspacesService]);
if (navigating || creating) {
return <AppFallback></AppFallback>;
}
// TODO(@eyhn): We need a no workspace page
return (
<div
style={{
position: 'fixed',
left: 'calc(50% - 150px)',
top: '50%',
}}
>
<WorkspaceNavigator
open={true}
menuContentOptions={{
forceMount: true,
}}
/>
</div>
);
};

View File

@@ -0,0 +1,100 @@
import { AcceptInvitePage } from '@affine/component/member-components';
import type { GetInviteInfoQuery } from '@affine/graphql';
import {
acceptInviteByInviteIdMutation,
fetcher,
getInviteInfoQuery,
} from '@affine/graphql';
import { useLiveData, useService } from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import { redirect, useLoaderData } from 'react-router-dom';
import { authAtom } from '../../components/atoms';
import {
RouteLogic,
useNavigateHelper,
} from '../../components/hooks/use-navigate-helper';
import { AuthService } from '../../modules/cloud';
export const loader: LoaderFunction = async args => {
const inviteId = args.params.inviteId || '';
const res = await fetcher({
query: getInviteInfoQuery,
variables: {
inviteId,
},
}).catch(console.error);
// If the inviteId is invalid, redirect to 404 page
if (!res || !res?.getInviteInfo) {
return redirect('/404');
}
// No mater sign in or not, we need to accept the invite
await fetcher({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: res.getInviteInfo.workspace.id,
inviteId,
sendAcceptMail: true,
},
}).catch(console.error);
return {
inviteId,
inviteInfo: res.getInviteInfo,
};
};
export const Component = () => {
const authService = useService(AuthService);
const isRevalidating = useLiveData(authService.session.isRevalidating$);
const loginStatus = useLiveData(authService.session.status$);
useEffect(() => {
authService.session.revalidate();
}, [authService]);
const { jumpToSignIn } = useNavigateHelper();
const { jumpToPage } = useNavigateHelper();
const setAuthAtom = useSetAtom(authAtom);
const { inviteInfo } = useLoaderData() as {
inviteId: string;
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
};
const openWorkspace = useCallback(() => {
jumpToPage(inviteInfo.workspace.id, 'all', RouteLogic.REPLACE);
}, [inviteInfo.workspace.id, jumpToPage]);
useEffect(() => {
if (loginStatus === 'unauthenticated' && !isRevalidating) {
// We can not pass function to navigate state, so we need to save it in atom
jumpToSignIn(
`/workspace/${inviteInfo.workspace.id}/all`,
RouteLogic.REPLACE
);
}
}, [
inviteInfo.workspace.id,
isRevalidating,
jumpToSignIn,
loginStatus,
openWorkspace,
setAuthAtom,
]);
if (loginStatus === 'authenticated') {
return (
<AcceptInvitePage
inviteInfo={inviteInfo}
onOpenWorkspace={openWorkspace}
/>
);
}
return null;
};

View File

@@ -0,0 +1,42 @@
import { apis } from '@affine/electron-api';
import { assertExists } from '@blocksuite/global/utils';
import { useCallback } from 'react';
import { redirect } from 'react-router-dom';
import { Onboarding } from '../../components/affine/onboarding/onboarding';
import {
appConfigStorage,
useAppConfigStorage,
} from '../../components/hooks/use-app-config-storage';
import {
RouteLogic,
useNavigateHelper,
} from '../../components/hooks/use-navigate-helper';
export const loader = () => {
if (!BUILD_CONFIG.isElectron && !appConfigStorage.get('onBoarding')) {
// onboarding is off, redirect to index
return redirect('/');
}
return null;
};
export const Component = () => {
const { jumpToIndex } = useNavigateHelper();
const [, setOnboarding] = useAppConfigStorage('onBoarding');
const openApp = useCallback(() => {
if (BUILD_CONFIG.isElectron) {
assertExists(apis);
apis.ui.handleOpenMainApp().catch(err => {
console.log('failed to open main app', err);
});
} else {
jumpToIndex(RouteLogic.REPLACE);
setOnboarding(false);
}
}, [jumpToIndex, setOnboarding]);
return <Onboarding onOpenApp={openApp} />;
};

View File

@@ -0,0 +1,51 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const root = style({
height: '100vh',
width: '100vw',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: cssVar('fontBase'),
position: 'relative',
});
export const affineLogo = style({
color: 'inherit',
});
export const topNav = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 120px',
});
export const topNavLinks = style({
display: 'flex',
columnGap: 4,
});
export const topNavLink = style({
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontSm'),
fontWeight: 500,
textDecoration: 'none',
padding: '4px 18px',
});
export const tryAgainLink = style({
color: cssVar('linkColor'),
fontWeight: 500,
textDecoration: 'none',
fontSize: cssVar('fontSm'),
});
export const centerContent = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginTop: 40,
});
export const prompt = style({
marginTop: 20,
marginBottom: 12,
});

View File

@@ -0,0 +1,210 @@
import { Button } from '@affine/component/ui/button';
import type { GetCurrentUserQuery } from '@affine/graphql';
import { fetcher, getCurrentUserQuery } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
import { Logo1Icon } from '@blocksuite/icons/rc';
import { useCallback, useMemo } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import { useLoaderData, useSearchParams } from 'react-router-dom';
import { z } from 'zod';
import * as styles from './open-app.css';
let lastOpened = '';
const appSchemas = z.enum([
'affine',
'affine-canary',
'affine-beta',
'affine-internal',
'affine-dev',
]);
const appChannelSchema = z.enum(['stable', 'canary', 'beta', 'internal']);
type Schema = z.infer<typeof appSchemas>;
type Channel = z.infer<typeof appChannelSchema>;
const schemaToChanel = {
affine: 'stable',
'affine-canary': 'canary',
'affine-beta': 'beta',
'affine-internal': 'internal',
'affine-dev': 'canary', // dev does not have a dedicated app. use canary as the placeholder.
} as Record<Schema, Channel>;
export const appIconMap = {
stable: '/imgs/app-icon-stable.ico',
canary: '/imgs/app-icon-canary.ico',
beta: '/imgs/app-icon-beta.ico',
internal: '/imgs/app-icon-internal.ico',
} satisfies Record<Channel, string>;
export const appNames = {
stable: 'AFFiNE',
canary: 'AFFiNE Canary',
beta: 'AFFiNE Beta',
internal: 'AFFiNE Internal',
} satisfies Record<Channel, string>;
interface OpenAppProps {
urlToOpen?: string | null;
channel: Channel;
}
interface LoaderData {
action: 'url' | 'signin-redirect';
currentUser?: GetCurrentUserQuery['currentUser'];
}
const OpenAppImpl = ({ urlToOpen, channel }: OpenAppProps) => {
const t = useI18n();
const openDownloadLink = useCallback(() => {
const url = `https://affine.pro/download?channel=${channel}`;
open(url, '_blank');
}, [channel]);
const appIcon = appIconMap[channel];
const appName = appNames[channel];
const [params] = useSearchParams();
const autoOpen = useMemo(() => params.get('open') !== 'false', [params]);
if (urlToOpen && lastOpened !== urlToOpen && autoOpen) {
lastOpened = urlToOpen;
location.href = urlToOpen;
}
if (!urlToOpen) {
return null;
}
return (
<div className={styles.root}>
<div className={styles.topNav}>
<a href="/" rel="noreferrer" className={styles.affineLogo}>
<Logo1Icon width={24} height={24} />
</a>
<div className={styles.topNavLinks}>
<a
href="https://affine.pro"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Official Website
</a>
<a
href="https://community.affine.pro/home"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
AFFiNE Community
</a>
<a
href="https://affine.pro/blog"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Blog
</a>
<a
href="https://affine.pro/about-us"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Contact us
</a>
</div>
<Button onClick={openDownloadLink}>
{t['com.affine.auth.open.affine.download-app']()}
</Button>
</div>
<div className={styles.centerContent}>
<img src={appIcon} alt={appName} width={120} height={120} />
<div className={styles.prompt}>
<Trans i18nKey="com.affine.auth.open.affine.prompt">
Open {appName} app now
</Trans>
</div>
<a
className={styles.tryAgainLink}
href={urlToOpen}
target="_blank"
rel="noreferrer"
>
{t['com.affine.auth.open.affine.try-again']()}
</a>
</div>
</div>
);
};
const OpenUrl = () => {
const [params] = useSearchParams();
const urlToOpen = params.get('url');
params.delete('url');
const urlObj = new URL(urlToOpen || '');
const maybeSchema = appSchemas.safeParse(urlObj.protocol.replace(':', ''));
const channel =
schemaToChanel[maybeSchema.success ? maybeSchema.data : 'affine'];
params.forEach((v, k) => {
urlObj.searchParams.set(k, v);
});
return <OpenAppImpl urlToOpen={urlObj.toString()} channel={channel} />;
};
/**
* @deprecated
*/
const OpenOAuthJwt = () => {
const { currentUser } = useLoaderData() as LoaderData;
const [params] = useSearchParams();
const maybeSchema = appSchemas.safeParse(params.get('schema'));
const schema = maybeSchema.success ? maybeSchema.data : 'affine';
const next = params.get('next');
const channel = schemaToChanel[schema as Schema];
if (!currentUser || !currentUser?.token?.sessionToken) {
return null;
}
const urlToOpen = `${schema}://signin-redirect?token=${
currentUser.token.sessionToken
}&next=${next || ''}`;
return <OpenAppImpl urlToOpen={urlToOpen} channel={channel} />;
};
export const Component = () => {
const { action } = useLoaderData() as LoaderData;
if (action === 'url') {
return <OpenUrl />;
} else if (action === 'signin-redirect') {
return <OpenOAuthJwt />;
}
return null;
};
export const loader: LoaderFunction = async args => {
const action = args.params.action || '';
const res = await fetcher({
query: getCurrentUserQuery,
}).catch(console.error);
return {
action,
currentUser: res?.currentUser || null,
};
};

View File

@@ -0,0 +1,55 @@
import { DebugLogger } from '@affine/debug';
import { type LoaderFunction, Navigate, useLoaderData } from 'react-router-dom';
const trustedDomain = [
'google.com',
'stripe.com',
'github.com',
'twitter.com',
'discord.gg',
'youtube.com',
't.me',
'reddit.com',
'affine.pro',
];
const logger = new DebugLogger('redirect_proxy');
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const searchParams = url.searchParams;
const redirectUri = searchParams.get('redirect_uri');
if (!redirectUri) {
return { allow: false };
}
try {
const target = new URL(redirectUri);
if (
target.hostname === window.location.hostname ||
trustedDomain.some(domain =>
new RegExp(`.?${domain}$`).test(target.hostname)
)
) {
location.href = redirectUri;
return { allow: true };
}
} catch (e) {
logger.error('Failed to parse redirect uri', e);
return { allow: false };
}
return { allow: true };
};
export const Component = () => {
const { allow } = useLoaderData() as { allow: boolean };
if (allow) {
return null;
}
return <Navigate to="/404" />;
};

View File

@@ -0,0 +1,12 @@
import { Outlet } from 'react-router-dom';
import { AllWorkspaceModals } from '../../components/providers/modal-provider';
export const RootWrapper = () => {
return (
<>
<AllWorkspaceModals />
<Outlet />
</>
);
};

View File

@@ -0,0 +1,13 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const container = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
width: '100%',
lineHeight: 4,
color: cssVar('--affine-text-secondary-color'),
});

View File

@@ -0,0 +1,171 @@
import { Button, Loading } from '@affine/component';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import { mixpanel, track } from '@affine/track';
import { effect, fromPromise, useServices } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { EMPTY, mergeMap, switchMap } from 'rxjs';
import { generateSubscriptionCallbackLink } from '../../components/hooks/affine/use-subscription-notify';
import {
RouteLogic,
useNavigateHelper,
} from '../../components/hooks/use-navigate-helper';
import { AuthService, SubscriptionService } from '../../modules/cloud';
import { container } from './subscribe.css';
export const Component = () => {
const { authService, subscriptionService } = useServices({
AuthService,
SubscriptionService,
});
const [searchParams] = useSearchParams();
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [retryKey, setRetryKey] = useState(0);
const { jumpToSignIn, jumpToIndex } = useNavigateHelper();
const idempotencyKey = useMemo(() => nanoid(), []);
const plan = searchParams.get('plan') as string | null;
const recurring = searchParams.get('recurring') as string | null;
useEffect(() => {
const allowedPlan = ['ai', 'pro'];
const allowedRecurring = ['monthly', 'yearly', 'lifetime'];
const receivedPlan = plan?.toLowerCase() ?? '';
const receivedRecurring = recurring?.toLowerCase() ?? '';
const invalids = [];
if (!allowedPlan.includes(receivedPlan)) invalids.push('plan');
if (!allowedRecurring.includes(receivedRecurring))
invalids.push('recurring');
if (invalids.length) {
setError(`Invalid ${invalids.join(', ')}`);
return;
}
const call = effect(
switchMap(() => {
return fromPromise(async signal => {
retryKey;
// TODO(@eyhn): i18n
setMessage('Checking account status...');
setError('');
await authService.session.waitForRevalidation(signal);
const loggedIn =
authService.session.status$.value === 'authenticated';
if (!loggedIn) {
setMessage('Redirecting to sign in...');
jumpToSignIn(
location.pathname + location.search,
RouteLogic.REPLACE
);
return;
}
setMessage('Checking subscription status...');
await subscriptionService.subscription.waitForRevalidation(signal);
const subscribed =
receivedPlan === 'ai'
? !!subscriptionService.subscription.ai$.value
: receivedRecurring === 'lifetime'
? !!subscriptionService.subscription.isBeliever$.value
: !!subscriptionService.subscription.pro$.value;
if (!subscribed) {
setMessage('Creating checkout...');
try {
const account = authService.session.account$.value;
// should never reach
if (!account) throw new Error('No account');
const targetPlan =
receivedPlan === 'ai'
? SubscriptionPlan.AI
: SubscriptionPlan.Pro;
const targetRecurring =
receivedRecurring === 'monthly'
? SubscriptionRecurring.Monthly
: receivedRecurring === 'yearly'
? SubscriptionRecurring.Yearly
: SubscriptionRecurring.Lifetime;
track.subscriptionLanding.$.$.checkout({
control: 'pricing',
plan: targetPlan,
recurring: targetRecurring,
});
const checkout = await subscriptionService.createCheckoutSession({
idempotencyKey,
plan: targetPlan,
coupon: null,
recurring: targetRecurring,
successCallbackLink: generateSubscriptionCallbackLink(
account,
targetPlan,
targetRecurring
),
});
setMessage('Redirecting...');
location.href = checkout;
if (plan) {
mixpanel.people.set({
[SubscriptionPlan.AI === plan ? 'ai plan' : plan]: plan,
recurring: recurring,
});
}
} catch (err) {
console.error(err);
setError('Something went wrong. Please try again.');
}
} else {
setMessage('Your account is already subscribed. Redirecting...');
await new Promise(resolve => {
setTimeout(resolve, 5000);
});
jumpToIndex(RouteLogic.REPLACE);
}
}).pipe(mergeMap(() => EMPTY));
})
);
call();
return () => {
call.unsubscribe();
};
}, [
authService,
subscriptionService,
jumpToSignIn,
idempotencyKey,
plan,
jumpToIndex,
recurring,
retryKey,
]);
useEffect(() => {
authService.session.revalidate();
}, [authService]);
return (
<div className={container}>
{!error ? (
<>
{message}
<br />
<Loading size={20} />
</>
) : (
<>
{error}
<br />
<Button variant="primary" onClick={() => setRetryKey(i => i + 1)}>
Retry
</Button>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,5 @@
import { ThemeEditor } from '../../modules/theme-editor';
export const Component = () => {
return <ThemeEditor />;
};

View File

@@ -0,0 +1,5 @@
import { CloudUpgradeSuccess } from '../../components/affine/subscription-landing';
export const Component = () => {
return <CloudUpgradeSuccess />;
};

View File

@@ -0,0 +1,12 @@
import { style } from '@vanilla-extract/css';
export const headerCreateNewCollectionIconButton = style({
width: '32px',
height: '32px',
borderRadius: 8,
transition: 'all 0.1s ease-in-out',
});
export const headerCreateNewButtonHidden = style({
opacity: 0,
pointerEvents: 'none',
});

View File

@@ -0,0 +1,32 @@
import { IconButton } from '@affine/component';
import { Header } from '@affine/core/components/pure/header';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
import { PlusIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import * as styles from './header.css';
export const AllCollectionHeader = ({
showCreateNew,
onCreateCollection,
}: {
showCreateNew: boolean;
onCreateCollection?: () => void;
}) => {
return (
<Header
right={
<IconButton
size="16"
icon={<PlusIcon />}
onClick={onCreateCollection}
className={clsx(
styles.headerCreateNewCollectionIconButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
/>
}
center={<WorkspaceModeFilterTab activeFilter={'collections'} />}
/>
);
};

View File

@@ -0,0 +1,9 @@
import { style } from '@vanilla-extract/css';
export const body = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
height: '100%',
width: '100%',
});

View File

@@ -0,0 +1,94 @@
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import type { CollectionMeta } from '@affine/core/components/page-list';
import {
CollectionListHeader,
createEmptyCollection,
useEditCollectionName,
VirtualizedCollectionList,
} from '@affine/core/components/page-list';
import {
ViewIcon,
ViewTitle,
} from '@affine/core/modules/workbench/view/view-meta';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback, useMemo, useState } from 'react';
import { CollectionService } from '../../../../modules/collection';
import { ViewBody, ViewHeader } from '../../../../modules/workbench';
import { EmptyCollectionList } from '../page-list-empty';
import { AllCollectionHeader } from './header';
import * as styles from './index.css';
export const AllCollection = () => {
const t = useI18n();
const currentWorkspace = useService(WorkspaceService).workspace;
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
const collectionService = useService(CollectionService);
const collections = useLiveData(collectionService.collections$);
const collectionMetas = useMemo(() => {
const collectionsList: CollectionMeta[] = collections.map(collection => {
return {
...collection,
title: collection.name,
};
});
return collectionsList;
}, [collections]);
const navigateHelper = useNavigateHelper();
const { open } = useEditCollectionName({
title: t['com.affine.editCollection.createCollection'](),
showTips: true,
});
const handleCreateCollection = useCallback(() => {
open('')
.then(name => {
const id = nanoid();
collectionService.addCollection(createEmptyCollection(id, { name }));
navigateHelper.jumpToCollection(currentWorkspace.id, id);
})
.catch(err => {
console.error(err);
});
}, [collectionService, currentWorkspace, navigateHelper, open]);
return (
<>
<ViewTitle title={t['Collections']()} />
<ViewIcon icon="collection" />
<ViewHeader>
<AllCollectionHeader
showCreateNew={!hideHeaderCreateNew}
onCreateCollection={handleCreateCollection}
/>
</ViewHeader>
<ViewBody>
<div className={styles.body}>
{collectionMetas.length > 0 ? (
<VirtualizedCollectionList
collections={collections}
collectionMetas={collectionMetas}
setHideHeaderCreateNewCollection={setHideHeaderCreateNew}
handleCreateCollection={handleCreateCollection}
/>
) : (
<EmptyCollectionList
heading={
<CollectionListHeader onCreate={handleCreateCollection} />
}
/>
)}
</div>
</ViewBody>
</>
);
};
export const Component = () => {
return <AllCollection />;
};

View File

@@ -0,0 +1,54 @@
import { CollectionService } from '@affine/core/modules/collection';
import type { Collection, Filter } from '@affine/env/filter';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useCallback } from 'react';
import { filterContainerStyle } from '../../../../components/filter-container.css';
import { useNavigateHelper } from '../../../../components/hooks/use-navigate-helper';
import {
FilterList,
SaveAsCollectionButton,
} from '../../../../components/page-list';
export const FilterContainer = ({
filters,
onChangeFilters,
}: {
filters: Filter[];
onChangeFilters: (filters: Filter[]) => void;
}) => {
const currentWorkspace = useService(WorkspaceService).workspace;
const navigateHelper = useNavigateHelper();
const collectionService = useService(CollectionService);
const saveToCollection = useCallback(
(collection: Collection) => {
collectionService.addCollection({
...collection,
filterList: filters,
});
navigateHelper.jumpToCollection(currentWorkspace.id, collection.id);
},
[collectionService, filters, navigateHelper, currentWorkspace.id]
);
if (!filters.length) {
return null;
}
return (
<div className={filterContainerStyle}>
<div style={{ flex: 1 }}>
<FilterList
propertiesMeta={currentWorkspace.docCollection.meta.properties}
value={filters}
onChange={onChangeFilters}
/>
</div>
<div>
{filters.length > 0 ? (
<SaveAsCollectionButton onConfirm={saveToCollection} />
) : null}
</div>
</div>
);
};

View File

@@ -0,0 +1,85 @@
import { usePageHelper } from '@affine/core/components/blocksuite/block-suite-page-list/utils';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import {
AllPageListOperationsMenu,
PageDisplayMenu,
PageListNewPageButton,
} from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
import { isNewTabTrigger } from '@affine/core/utils';
import type { Filter } from '@affine/env/filter';
import { track } from '@affine/track';
import { PlusIcon } from '@blocksuite/icons/rc';
import { useServices, WorkspaceService } from '@toeverything/infra';
import clsx from 'clsx';
import * as styles from './all-page.css';
export const AllPageHeader = ({
showCreateNew,
filters,
onChangeFilters,
}: {
showCreateNew: boolean;
filters: Filter[];
onChangeFilters: (filters: Filter[]) => void;
}) => {
const { workspaceService } = useServices({
WorkspaceService,
});
const workspace = workspaceService.workspace;
const { importFile, createEdgeless, createPage } = usePageHelper(
workspace.docCollection
);
const onImportFile = useAsyncCallback(async () => {
const options = await importFile();
if (options.isWorkspaceFile) {
track.allDocs.header.actions.createWorkspace({
control: 'import',
});
} else {
track.allDocs.header.actions.createDoc({
control: 'import',
});
}
}, [importFile]);
return (
<Header
left={
<AllPageListOperationsMenu
filterList={filters}
onChangeFilterList={onChangeFilters}
propertiesMeta={workspace.docCollection.meta.properties}
/>
}
right={
<>
<PageListNewPageButton
size="small"
className={clsx(
styles.headerCreateNewButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
onCreateEdgeless={e =>
createEdgeless(isNewTabTrigger(e) ? 'new-tab' : true)
}
onCreatePage={e =>
createPage('page', isNewTabTrigger(e) ? 'new-tab' : true)
}
onCreateDoc={e =>
createPage(undefined, isNewTabTrigger(e) ? 'new-tab' : true)
}
onImportFile={onImportFile}
>
<PlusIcon />
</PageListNewPageButton>
<PageDisplayMenu />
</>
}
center={<WorkspaceModeFilterTab activeFilter={'docs'} />}
/>
);
};

View File

@@ -0,0 +1,29 @@
import { style } from '@vanilla-extract/css';
export const scrollContainer = style({
flex: 1,
width: '100%',
paddingBottom: '32px',
});
export const headerCreateNewButton = style({
transition: 'opacity 0.1s ease-in-out',
});
export const headerCreateNewCollectionIconButton = style({
padding: '4px 8px',
fontSize: '16px',
width: '32px',
height: '28px',
borderRadius: '8px',
});
export const headerCreateNewButtonHidden = style({
opacity: 0,
pointerEvents: 'none',
});
export const body = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
height: '100%',
width: '100%',
});

View File

@@ -0,0 +1,84 @@
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import {
PageListHeader,
useFilteredPageMetas,
VirtualizedPageList,
} from '@affine/core/components/page-list';
import type { Filter } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import {
GlobalContextService,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { useEffect, useState } from 'react';
import {
useIsActiveView,
ViewBody,
ViewHeader,
ViewIcon,
ViewTitle,
} from '../../../../modules/workbench';
import { EmptyPageList } from '../page-list-empty';
import * as styles from './all-page.css';
import { FilterContainer } from './all-page-filter';
import { AllPageHeader } from './all-page-header';
export const AllPage = () => {
const currentWorkspace = useService(WorkspaceService).workspace;
const globalContext = useService(GlobalContextService).globalContext;
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
const [filters, setFilters] = useState<Filter[]>([]);
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
filters: filters,
});
const isActiveView = useIsActiveView();
useEffect(() => {
if (isActiveView) {
globalContext.isAllDocs.set(true);
return () => {
globalContext.isAllDocs.set(false);
};
}
return;
}, [globalContext, isActiveView]);
const t = useI18n();
return (
<>
<ViewTitle title={t['All pages']()} />
<ViewIcon icon="allDocs" />
<ViewHeader>
<AllPageHeader
showCreateNew={!hideHeaderCreateNew}
filters={filters}
onChangeFilters={setFilters}
/>
</ViewHeader>
<ViewBody>
<div className={styles.body}>
<FilterContainer filters={filters} onChangeFilters={setFilters} />
{filteredPageMetas.length > 0 ? (
<VirtualizedPageList
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
filters={filters}
/>
) : (
<EmptyPageList type="all" heading={<PageListHeader />} />
)}
</div>
</ViewBody>
</>
);
};
export const Component = () => {
return <AllPage />;
};

View File

@@ -0,0 +1,9 @@
import { style } from '@vanilla-extract/css';
export const body = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
height: '100%',
width: '100%',
});

View File

@@ -0,0 +1,6 @@
import { Header } from '@affine/core/components/pure/header';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
export const AllTagHeader = () => {
return <Header center={<WorkspaceModeFilterTab activeFilter={'tags'} />} />;
};

View File

@@ -0,0 +1,96 @@
import {
TagListHeader,
VirtualizedTagList,
} from '@affine/core/components/page-list/tags';
import { CreateOrEditTag } from '@affine/core/components/page-list/tags/create-tag';
import type { TagMeta } from '@affine/core/components/page-list/types';
import { DeleteTagConfirmModal, TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import {
ViewBody,
ViewHeader,
ViewIcon,
ViewTitle,
} from '../../../../modules/workbench';
import { EmptyTagList } from '../page-list-empty';
import * as styles from './all-tag.css';
import { AllTagHeader } from './header';
const EmptyTagListHeader = () => {
const [showCreateTagInput, setShowCreateTagInput] = useState(false);
const handleOpen = useCallback(() => {
setShowCreateTagInput(true);
}, [setShowCreateTagInput]);
return (
<div>
<TagListHeader onOpen={handleOpen} />
<CreateOrEditTag
open={showCreateTagInput}
onOpenChange={setShowCreateTagInput}
/>
</div>
);
};
export const AllTag = () => {
const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tags$);
const [open, setOpen] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const tagMetas: TagMeta[] = useLiveData(tagList.tagMetas$);
const handleCloseModal = useCallback(
(open: boolean) => {
setOpen(open);
setSelectedTagIds([]);
},
[setOpen]
);
const onTagDelete = useCallback(
(tagIds: string[]) => {
setOpen(true);
setSelectedTagIds(tagIds);
},
[setOpen, setSelectedTagIds]
);
const t = useI18n();
return (
<>
<ViewTitle title={t['Tags']()} />
<ViewIcon icon="tag" />
<ViewHeader>
<AllTagHeader />
</ViewHeader>
<ViewBody>
<div className={styles.body}>
{tags.length > 0 ? (
<VirtualizedTagList
tags={tags}
tagMetas={tagMetas}
onTagDelete={onTagDelete}
/>
) : (
<EmptyTagList heading={<EmptyTagListHeader />} />
)}
</div>
</ViewBody>
<DeleteTagConfirmModal
open={open}
onOpenChange={handleCloseModal}
selectedTagIds={selectedTagIds}
/>
</>
);
};
export const Component = () => {
return <AllTag />;
};

View File

@@ -0,0 +1,37 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const placeholderButton = style({
padding: '8px 18px',
border: `1px solid ${cssVar('borderColor')}`,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
gap: 4,
fontWeight: 600,
cursor: 'pointer',
fontSize: 15,
lineHeight: '24px',
':hover': {
backgroundColor: cssVar('hoverColor'),
},
});
export const button = style({
userSelect: 'none',
borderRadius: 4,
cursor: 'pointer',
':hover': {
backgroundColor: cssVar('hoverColor'),
},
});
export const headerCreateNewButton = style({
transition: 'opacity 0.1s ease-in-out',
});
export const headerCreateNewCollectionIconButton = style({
width: '30px',
height: '30px',
borderRadius: '8px',
});
export const headerCreateNewButtonHidden = style({
opacity: 0,
pointerEvents: 'none',
});

View File

@@ -0,0 +1,37 @@
import { IconButton } from '@affine/component';
import { PageDisplayMenu } from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
import { PlusIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import * as styles from './collection.css';
export const CollectionDetailHeader = ({
showCreateNew,
onCreate,
}: {
showCreateNew: boolean;
onCreate: () => void;
}) => {
return (
<Header
right={
<>
<IconButton
size="16"
icon={<PlusIcon />}
onClick={onCreate}
className={clsx(
styles.headerCreateNewButton,
styles.headerCreateNewCollectionIconButton,
!showCreateNew && styles.headerCreateNewButtonHidden
)}
/>
<PageDisplayMenu />
</>
}
center={<WorkspaceModeFilterTab activeFilter={'collections'} />}
/>
);
};

View File

@@ -0,0 +1,196 @@
import { notify } from '@affine/component';
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty/collection-detail';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import {
useEditCollection,
VirtualizedPageList,
} from '@affine/core/components/page-list';
import { CollectionService } from '@affine/core/modules/collection';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import {
GlobalContextService,
useLiveData,
useService,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useNavigateHelper } from '../../../../components/hooks/use-navigate-helper';
import {
useIsActiveView,
ViewBody,
ViewHeader,
ViewIcon,
ViewTitle,
} from '../../../../modules/workbench';
import { CollectionDetailHeader } from './header';
export const CollectionDetail = ({
collection,
}: {
collection: Collection;
}) => {
const { open } = useEditCollection();
const collectionService = useService(CollectionService);
const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true);
const handleEditCollection = useAsyncCallback(async () => {
const ret = await open({ ...collection }, 'page');
collectionService.updateCollection(ret.id, () => ret);
}, [collection, collectionService, open]);
return (
<>
<ViewHeader>
<CollectionDetailHeader
showCreateNew={!hideHeaderCreateNew}
onCreate={handleEditCollection}
/>
</ViewHeader>
<ViewBody>
<VirtualizedPageList
collection={collection}
setHideHeaderCreateNewPage={setHideHeaderCreateNew}
/>
</ViewBody>
</>
);
};
export const Component = function CollectionPage() {
const { collectionService, globalContextService } = useServices({
CollectionService,
GlobalContextService,
});
const globalContext = globalContextService.globalContext;
const collections = useLiveData(collectionService.collections$);
const navigate = useNavigateHelper();
const params = useParams();
const workspace = useService(WorkspaceService).workspace;
const collection = collections.find(v => v.id === params.collectionId);
const isActiveView = useIsActiveView();
const notifyCollectionDeleted = useCallback(() => {
navigate.jumpToPage(workspace.id, 'all');
const collection = collectionService.collectionsTrash$.value.find(
v => v.collection.id === params.collectionId
);
let text = 'Collection does not exist';
if (collection) {
if (collection.userId) {
text = `${collection.collection.name} has been deleted by ${collection.userName}`;
} else {
text = `${collection.collection.name} has been deleted`;
}
}
return notify.error({ title: text });
}, [collectionService, navigate, params.collectionId, workspace.id]);
useEffect(() => {
if (isActiveView && collection) {
globalContext.collectionId.set(collection.id);
globalContext.isCollection.set(true);
return () => {
globalContext.collectionId.set(null);
globalContext.isCollection.set(false);
};
}
return;
}, [collection, globalContext, isActiveView]);
useEffect(() => {
if (!collection) {
notifyCollectionDeleted();
}
}, [collection, notifyCollectionDeleted]);
if (!collection) {
return null;
}
const inner = isEmptyCollection(collection) ? (
<Placeholder collection={collection} />
) : (
<CollectionDetail collection={collection} />
);
return (
<>
<ViewIcon icon="collection" />
<ViewTitle title={collection.name} />
{inner}
</>
);
};
const Placeholder = ({ collection }: { collection: Collection }) => {
const workspace = useService(WorkspaceService).workspace;
const { jumpToCollections } = useNavigateHelper();
const t = useI18n();
const handleJumpToCollections = useCallback(() => {
jumpToCollections(workspace.id);
}, [jumpToCollections, workspace]);
return (
<>
<ViewHeader>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 'var(--affine-font-xs)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
cursor: 'pointer',
color: 'var(--affine-text-secondary-color)',
['WebkitAppRegion' as string]: 'no-drag',
}}
onClick={handleJumpToCollections}
>
<ViewLayersIcon
style={{ color: 'var(--affine-icon-color)' }}
fontSize={14}
/>
{t['com.affine.collection.allCollections']()}
<div>/</div>
</div>
<div
data-testid="collection-name"
style={{
fontWeight: 600,
color: 'var(--affine-text-primary-color)',
['WebkitAppRegion' as string]: 'no-drag',
}}
>
{collection.name}
</div>
<div style={{ flex: 1 }} />
</div>
</ViewHeader>
<ViewBody>
<EmptyCollectionDetail
collection={collection}
style={{ height: '100%' }}
/>
</ViewBody>
</>
);
};
export const isEmptyCollection = (collection: Collection) => {
return (
collection.allowList.length === 0 && collection.filterList.length === 0
);
};

View File

@@ -0,0 +1,26 @@
import { style } from '@vanilla-extract/css';
export const header = style({
display: 'flex',
height: '100%',
width: '100%',
alignItems: 'center',
gap: 12,
});
export const spacer = style({
flexGrow: 1,
minWidth: 12,
});
export const journalWeekPicker = style({
minWidth: 100,
flexGrow: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const iconButtonContainer = style({
display: 'flex',
alignItems: 'center',
gap: 10,
});

View File

@@ -0,0 +1,192 @@
import {
Divider,
type InlineEditHandle,
observeResize,
} from '@affine/component';
import { InfoModal } from '@affine/core/components/affine/page-properties';
import { openInfoModalAtom } from '@affine/core/components/atoms';
import { FavoriteButton } from '@affine/core/components/blocksuite/block-suite-header/favorite';
import { InfoButton } from '@affine/core/components/blocksuite/block-suite-header/info';
import { JournalWeekDatePicker } from '@affine/core/components/blocksuite/block-suite-header/journal/date-picker';
import { JournalTodayButton } from '@affine/core/components/blocksuite/block-suite-header/journal/today-button';
import { PageHeaderMenuButton } from '@affine/core/components/blocksuite/block-suite-header/menu';
import { DetailPageHeaderPresentButton } from '@affine/core/components/blocksuite/block-suite-header/present/detail-header-present-button';
import { EditorModeSwitch } from '@affine/core/components/blocksuite/block-suite-mode-switch';
import { useRegisterCopyLinkCommands } from '@affine/core/components/hooks/affine/use-register-copy-link-commands';
import { useDocCollectionPageTitle } from '@affine/core/components/hooks/use-block-suite-workspace-page-title';
import { useJournalInfoHelper } from '@affine/core/components/hooks/use-journal';
import { EditorService } from '@affine/core/modules/editor';
import { ViewIcon, ViewTitle } from '@affine/core/modules/workbench';
import type { Doc } from '@blocksuite/store';
import { useLiveData, useService, type Workspace } from '@toeverything/infra';
import { useAtom, useAtomValue } from 'jotai';
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { SharePageButton } from '../../../../components/affine/share-page-modal';
import { appSidebarFloatingAtom } from '../../../../components/app-sidebar';
import { BlocksuiteHeaderTitle } from '../../../../components/blocksuite/block-suite-header/title/index';
import { HeaderDivider } from '../../../../components/pure/header';
import * as styles from './detail-page-header.css';
import { useDetailPageHeaderResponsive } from './use-header-responsive';
const Header = forwardRef<
HTMLDivElement,
{
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
>(({ children, style, className }, ref) => {
const appSidebarFloating = useAtomValue(appSidebarFloatingAtom);
return (
<div
data-testid="header"
style={style}
className={className}
ref={ref}
data-sidebar-floating={appSidebarFloating}
>
{children}
</div>
);
});
Header.displayName = 'forwardRef(Header)';
interface PageHeaderProps {
page: Doc;
workspace: Workspace;
}
export function JournalPageHeader({ page, workspace }: PageHeaderProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
return observeResize(container, entry => {
setContainerWidth(entry.contentRect.width);
});
}, []);
const { hideShare, hideToday } =
useDetailPageHeaderResponsive(containerWidth);
const title = useDocCollectionPageTitle(workspace.docCollection, page?.id);
return (
<Header className={styles.header} ref={containerRef}>
<ViewTitle title={title} />
<ViewIcon icon="journal" />
<EditorModeSwitch />
<div className={styles.journalWeekPicker}>
<JournalWeekDatePicker
docCollection={workspace.docCollection}
page={page}
/>
</div>
{hideToday ? null : (
<JournalTodayButton docCollection={workspace.docCollection} />
)}
<HeaderDivider />
<PageHeaderMenuButton
isJournal
page={page}
containerWidth={containerWidth}
/>
{page && !hideShare ? (
<SharePageButton workspace={workspace} page={page} />
) : null}
</Header>
);
}
export function NormalPageHeader({ page, workspace }: PageHeaderProps) {
const titleInputHandleRef = useRef<InlineEditHandle>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
return observeResize(container, entry => {
setContainerWidth(entry.contentRect.width);
});
}, []);
const { hideCollect, hideShare, hidePresent, showDivider } =
useDetailPageHeaderResponsive(containerWidth);
const onRename = useCallback(() => {
setTimeout(
() => titleInputHandleRef.current?.triggerEdit(),
500 /* wait for menu animation end */
);
}, []);
const title = useDocCollectionPageTitle(workspace.docCollection, page?.id);
const editor = useService(EditorService).editor;
const currentMode = useLiveData(editor.mode$);
return (
<Header className={styles.header} ref={containerRef}>
<ViewTitle title={title} />
<ViewIcon icon={currentMode ?? 'page'} />
<EditorModeSwitch />
<BlocksuiteHeaderTitle
docId={page.id}
inputHandleRef={titleInputHandleRef}
/>
<div className={styles.iconButtonContainer}>
{hideCollect ? null : (
<>
<FavoriteButton pageId={page?.id} />
<InfoButton />
</>
)}
<PageHeaderMenuButton
rename={onRename}
page={page}
containerWidth={containerWidth}
/>
</div>
<div className={styles.spacer} />
{!hidePresent ? <DetailPageHeaderPresentButton /> : null}
{page && !hideShare ? (
<SharePageButton workspace={workspace} page={page} />
) : null}
{showDivider ? (
<Divider orientation="vertical" style={{ height: 20, marginLeft: 4 }} />
) : null}
</Header>
);
}
export function DetailPageHeader(props: PageHeaderProps) {
const { page, workspace } = props;
const { isJournal } = useJournalInfoHelper(page.collection, page.id);
const isInTrash = page.meta?.trash;
const [openInfoModal, setOpenInfoModal] = useAtom(openInfoModalAtom);
useRegisterCopyLinkCommands({
workspaceMeta: workspace.meta,
docId: page.id,
});
return (
<>
{isJournal && !isInTrash ? (
<JournalPageHeader {...props} />
) : (
<NormalPageHeader {...props} />
)}
<InfoModal
open={openInfoModal}
onOpenChange={setOpenInfoModal}
docId={page.id}
/>
</>
);
}

View File

@@ -0,0 +1,102 @@
import type { Editor } from '@affine/core/modules/editor';
import { EditorsService } from '@affine/core/modules/editor';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import type { Doc } from '@toeverything/infra';
import {
DocsService,
FrameworkScope,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import {
type PropsWithChildren,
type ReactNode,
useEffect,
useLayoutEffect,
useState,
} from 'react';
const useLoadDoc = (pageId: string) => {
const currentWorkspace = useService(WorkspaceService).workspace;
const docsService = useService(DocsService);
const docRecordList = docsService.list;
const docListReady = useLiveData(docRecordList.isReady$);
const docRecord = useLiveData(docRecordList.doc$(pageId));
const viewService = useService(ViewService);
const [doc, setDoc] = useState<Doc | null>(null);
const [editor, setEditor] = useState<Editor | null>(null);
useLayoutEffect(() => {
if (!docRecord) {
return;
}
const { doc, release } = docsService.open(pageId);
setDoc(doc);
const editor = doc.scope.get(EditorsService).createEditor();
const unbind = editor.bindWorkbenchView(viewService.view);
setEditor(editor);
return () => {
unbind();
editor.dispose();
release();
};
}, [docRecord, docsService, pageId, viewService.view]);
// set sync engine priority target
useEffect(() => {
currentWorkspace.engine.doc.setPriority(pageId, 10);
return () => {
currentWorkspace.engine.doc.setPriority(pageId, 5);
};
}, [currentWorkspace, pageId]);
const isInTrash = useLiveData(doc?.meta$.map(meta => meta.trash));
useEffect(() => {
if (doc && isInTrash) {
currentWorkspace.docCollection.awarenessStore.setReadonly(
doc.blockSuiteDoc.blockCollection,
true
);
}
}, [currentWorkspace.docCollection.awarenessStore, doc, isInTrash]);
return {
doc,
editor,
docListReady,
};
};
/**
* A common wrapper for detail page for both mobile and desktop page.
* It only contains the logic for page loading, context setup, but not the page content.
*/
export const DetailPageWrapper = ({
pageId,
children,
skeleton,
notFound,
}: PropsWithChildren<{
pageId: string;
skeleton: ReactNode;
notFound: ReactNode;
}>) => {
const { doc, editor, docListReady } = useLoadDoc(pageId);
// if sync engine has been synced and the page is null, show 404 page.
if (docListReady && !doc) {
return notFound;
}
if (!doc || !editor) {
return skeleton;
}
return (
<FrameworkScope scope={doc.scope}>
<FrameworkScope scope={editor.scope}>{children}</FrameworkScope>
</FrameworkScope>
);
};

View File

@@ -0,0 +1,46 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const mainContainer = style({
containerType: 'inline-size',
display: 'flex',
flexDirection: 'column',
flex: 1,
overflow: 'hidden',
borderTop: `0.5px solid transparent`,
transition: 'border-color 0.2s',
selectors: {
'&[data-dynamic-top-border="false"]': {
borderColor: cssVar('borderColor'),
},
'&[data-has-scroll-top="true"]': {
borderColor: cssVar('borderColor'),
},
},
});
export const editorContainer = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
flex: 1,
zIndex: 0,
});
// brings styles of .affine-page-viewport from blocksuite
export const affineDocViewport = style({
display: 'flex',
flexDirection: 'column',
containerName: 'viewport',
containerType: 'inline-size',
background: cssVar('backgroundPrimaryColor'),
'@media': {
print: {
display: 'none',
zIndex: -1,
},
},
});
export const scrollbar = style({
marginRight: '4px',
});

View File

@@ -0,0 +1,305 @@
import { Scrollable, useHasScrollTop } from '@affine/component';
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import type { ChatPanel } from '@affine/core/blocksuite/presets/ai';
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
import { EditorOutlineViewer } from '@affine/core/components/blocksuite/outline-viewer';
import { useAppSettingHelper } from '@affine/core/components/hooks/affine/use-app-setting-helper';
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { EditorService } from '@affine/core/modules/editor';
import { RecentDocsService } from '@affine/core/modules/quicksearch';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { RefNodeSlotsProvider } from '@blocksuite/blocks';
import { DisposableGroup } from '@blocksuite/global/utils';
import { AiIcon, FrameIcon, TocIcon, TodayIcon } from '@blocksuite/icons/rc';
import { type AffineEditorContainer } from '@blocksuite/presets';
import {
DocService,
FeatureFlagService,
FrameworkScope,
GlobalContextService,
useLiveData,
useService,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { memo, useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { AffineErrorBoundary } from '../../../../components/affine/affine-error-boundary';
import { GlobalPageHistoryModal } from '../../../../components/affine/page-history-modal';
import { useRegisterBlocksuiteEditorCommands } from '../../../../components/hooks/affine/use-register-blocksuite-editor-commands';
import { useActiveBlocksuiteEditor } from '../../../../components/hooks/use-block-suite-editor';
import { usePageDocumentTitle } from '../../../../components/hooks/use-global-state';
import { useNavigateHelper } from '../../../../components/hooks/use-navigate-helper';
import { PageDetailEditor } from '../../../../components/page-detail-editor';
import { TrashPageFooter } from '../../../../components/pure/trash-page-footer';
import { TopTip } from '../../../../components/top-tip';
import {
useIsActiveView,
ViewBody,
ViewHeader,
ViewSidebarTab,
WorkbenchService,
} from '../../../../modules/workbench';
import { PageNotFound } from '../../404';
import * as styles from './detail-page.css';
import { DetailPageHeader } from './detail-page-header';
import { DetailPageWrapper } from './detail-page-wrapper';
import { EditorChatPanel } from './tabs/chat';
import { EditorFramePanel } from './tabs/frame';
import { EditorJournalPanel } from './tabs/journal';
import { EditorOutlinePanel } from './tabs/outline';
const DetailPageImpl = memo(function DetailPageImpl() {
const {
workbenchService,
viewService,
editorService,
docService,
workspaceService,
globalContextService,
featureFlagService,
} = useServices({
WorkbenchService,
ViewService,
EditorService,
DocService,
WorkspaceService,
GlobalContextService,
FeatureFlagService,
});
const workbench = workbenchService.workbench;
const editor = editorService.editor;
const view = viewService.view;
const workspace = workspaceService.workspace;
const docCollection = workspace.docCollection;
const globalContext = globalContextService.globalContext;
const doc = docService.doc;
const mode = useLiveData(editor.mode$);
const activeSidebarTab = useLiveData(view.activeSidebarTab$);
const isInTrash = useLiveData(doc.meta$.map(meta => meta.trash));
const { openPage, jumpToPageBlock } = useNavigateHelper();
const editorContainer = useLiveData(editor.editorContainer$);
const isSideBarOpen = useLiveData(workbench.sidebarOpen$);
const { appSettings } = useAppSettingHelper();
const chatPanelRef = useRef<ChatPanel | null>(null);
const { setDocReadonly } = useDocMetaHelper();
const isActiveView = useIsActiveView();
// TODO(@eyhn): remove jotai here
const [_, setActiveBlockSuiteEditor] = useActiveBlocksuiteEditor();
useEffect(() => {
if (isActiveView) {
setActiveBlockSuiteEditor(editorContainer);
}
}, [editorContainer, isActiveView, setActiveBlockSuiteEditor]);
useEffect(() => {
const disposable = AIProvider.slots.requestOpenWithChat.on(params => {
workbench.openSidebar();
view.activeSidebarTab('chat');
if (chatPanelRef.current) {
const chatCards = chatPanelRef.current.querySelector('chat-cards');
if (chatCards) chatCards.temporaryParams = params;
}
});
return () => disposable.dispose();
}, [activeSidebarTab, view, workbench]);
useEffect(() => {
if (isActiveView) {
globalContext.docId.set(doc.id);
globalContext.isDoc.set(true);
return () => {
globalContext.docId.set(null);
globalContext.isDoc.set(false);
};
}
return;
}, [doc, globalContext, isActiveView]);
useEffect(() => {
if (isActiveView) {
globalContext.docMode.set(mode);
return () => {
globalContext.docMode.set(null);
};
}
return;
}, [doc, globalContext, isActiveView, mode]);
useEffect(() => {
if ('isMobile' in environment && environment.isMobile) {
setDocReadonly(doc.id, true);
}
}, [doc.id, setDocReadonly]);
useEffect(() => {
if (isActiveView) {
globalContext.isTrashDoc.set(!!isInTrash);
return () => {
globalContext.isTrashDoc.set(null);
};
}
return;
}, [globalContext, isActiveView, isInTrash]);
useRegisterBlocksuiteEditorCommands(editor);
const title = useLiveData(doc.title$);
usePageDocumentTitle(title);
const onLoad = useCallback(
(editorContainer: AffineEditorContainer) => {
// blocksuite editor host
const editorHost = editorContainer.host;
const std = editorHost?.std;
const disposable = new DisposableGroup();
if (std) {
const refNodeSlots = std.getOptional(RefNodeSlotsProvider);
if (refNodeSlots) {
disposable.add(
refNodeSlots.docLinkClicked.on(({ pageId, params }) => {
if (params) {
const { mode, blockIds, elementIds } = params;
return jumpToPageBlock(
docCollection.id,
pageId,
mode,
blockIds,
elementIds
);
}
return openPage(docCollection.id, pageId);
})
);
}
}
editor.setEditorContainer(editorContainer);
const unbind = editor.bindEditorContainer(
editorContainer,
(editorContainer as any).docTitle // set from proxy
);
return () => {
unbind();
editor.setEditorContainer(null);
disposable.dispose();
};
},
[editor, openPage, docCollection.id, jumpToPageBlock]
);
const [refCallback, hasScrollTop] = useHasScrollTop();
const openOutlinePanel = useCallback(() => {
workbench.openSidebar();
view.activeSidebarTab('outline');
}, [workbench, view]);
return (
<FrameworkScope scope={editor.scope}>
<ViewHeader>
<DetailPageHeader page={doc.blockSuiteDoc} workspace={workspace} />
</ViewHeader>
<ViewBody>
<div
className={styles.mainContainer}
data-dynamic-top-border={BUILD_CONFIG.isElectron}
data-has-scroll-top={hasScrollTop}
>
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */}
<AffineErrorBoundary key={doc.id}>
<TopTip pageId={doc.id} workspace={workspace} />
<Scrollable.Root>
<Scrollable.Viewport
ref={refCallback}
className={clsx(
'affine-page-viewport',
styles.affineDocViewport,
styles.editorContainer
)}
>
<PageDetailEditor onLoad={onLoad} />
</Scrollable.Viewport>
<Scrollable.Scrollbar
className={clsx({
[styles.scrollbar]: !appSettings.clientBorder,
})}
/>
</Scrollable.Root>
<EditorOutlineViewer
editor={editorContainer}
show={mode === 'page' && !isSideBarOpen}
openOutlinePanel={openOutlinePanel}
/>
</AffineErrorBoundary>
{isInTrash ? <TrashPageFooter /> : null}
</div>
</ViewBody>
{featureFlagService.flags.enable_ai.value && (
<ViewSidebarTab
tabId="chat"
icon={<AiIcon />}
unmountOnInactive={false}
>
<EditorChatPanel editor={editorContainer} ref={chatPanelRef} />
</ViewSidebarTab>
)}
<ViewSidebarTab tabId="journal" icon={<TodayIcon />}>
<EditorJournalPanel />
</ViewSidebarTab>
<ViewSidebarTab tabId="outline" icon={<TocIcon />}>
<EditorOutlinePanel editor={editorContainer} />
</ViewSidebarTab>
<ViewSidebarTab tabId="frame" icon={<FrameIcon />}>
<EditorFramePanel editor={editorContainer} />
</ViewSidebarTab>
<GlobalPageHistoryModal />
<PageAIOnboarding />
</FrameworkScope>
);
});
export const Component = () => {
const params = useParams();
const recentPages = useService(RecentDocsService);
useEffect(() => {
if (params.pageId) {
const pageId = params.pageId;
localStorage.setItem('last_page_id', pageId);
recentPages.addRecentDoc(pageId);
}
}, [params, recentPages]);
const pageId = params.pageId;
return pageId ? (
<DetailPageWrapper
pageId={pageId}
skeleton={<PageDetailSkeleton />}
notFound={<PageNotFound noPermission />}
>
<DetailPageImpl />
</DetailPageWrapper>
) : null;
};

View File

@@ -0,0 +1 @@
export * from './detail-page';

View File

@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});

View File

@@ -0,0 +1,81 @@
import { ChatPanel } from '@affine/core/blocksuite/presets/ai';
import { DocModeProvider, RefNodeSlotsProvider } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import { forwardRef, useCallback, useEffect, useRef } from 'react';
import * as styles from './chat.css';
export interface SidebarTabProps {
editor: AffineEditorContainer | null;
onLoad?: ((component: HTMLElement) => void) | null;
}
// A wrapper for CopilotPanel
export const EditorChatPanel = forwardRef(function EditorChatPanel(
{ editor, onLoad }: SidebarTabProps,
ref: React.ForwardedRef<ChatPanel>
) {
const chatPanelRef = useRef<ChatPanel | null>(null);
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
assertExists(chatPanelRef.current, 'chat panel should be initialized');
container.append(chatPanelRef.current);
}
}, []);
useEffect(() => {
if (onLoad && chatPanelRef.current) {
(chatPanelRef.current as ChatPanel).updateComplete
.then(() => {
if (ref) {
if (typeof ref === 'function') {
ref(chatPanelRef.current);
} else {
ref.current = chatPanelRef.current;
}
}
})
.catch(console.error);
}
}, [onLoad, ref]);
useEffect(() => {
if (!editor) return;
const pageService = editor.host?.std.getService('affine:page');
if (!pageService) return;
const docModeService = editor.host?.std.get(DocModeProvider);
const refNodeService = editor.host?.std.getOptional(RefNodeSlotsProvider);
const disposable = [
refNodeService &&
refNodeService.docLinkClicked.on(() => {
(chatPanelRef.current as ChatPanel).doc = editor.doc;
}),
docModeService &&
docModeService.onPrimaryModeChange(() => {
if (!editor.host) return;
(chatPanelRef.current as ChatPanel).host = editor.host;
}, editor.doc.id),
];
return () => disposable.forEach(d => d?.dispose());
}, [editor]);
if (!editor) {
return;
}
if (!chatPanelRef.current) {
chatPanelRef.current = new ChatPanel();
}
if (editor.host) {
(chatPanelRef.current as ChatPanel).host = editor.host;
}
(chatPanelRef.current as ChatPanel).doc = editor.doc;
// (copilotPanelRef.current as CopilotPanel).fitPadding = [20, 20, 20, 20];
return <div className={styles.root} ref={onRefChange} />;
});

View File

@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});

View File

@@ -0,0 +1,37 @@
import { assertExists } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import { FramePanel } from '@blocksuite/presets';
import { useCallback, useRef } from 'react';
import * as styles from './frame.css';
// A wrapper for FramePanel
export const EditorFramePanel = ({
editor,
}: {
editor: AffineEditorContainer | null;
}) => {
const framePanelRef = useRef<FramePanel | null>(null);
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
assertExists(framePanelRef.current, 'frame panel should be initialized');
container.append(framePanelRef.current);
}
}, []);
if (!editor) {
return;
}
if (!framePanelRef.current) {
framePanelRef.current = new FramePanel();
}
if (editor.host !== framePanelRef.current?.host && editor.host) {
(framePanelRef.current as FramePanel).host = editor.host;
(framePanelRef.current as FramePanel).fitPadding = [20, 20, 20, 20];
}
return <div className={styles.root} ref={onRefChange} />;
};

View File

@@ -0,0 +1,243 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
const interactive = style({
position: 'relative',
cursor: 'pointer',
selectors: {
'&:hover': {
backgroundColor: cssVar('hoverColor'),
},
'&::before': {
content: '""',
position: 'absolute',
inset: 0,
opacity: 0,
borderRadius: 'inherit',
boxShadow: `0 0 0 3px ${cssVar('primaryColor')}`,
pointerEvents: 'none',
},
'&::after': {
content: '""',
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
boxShadow: `0 0 0 0px ${cssVar('primaryColor')}`,
pointerEvents: 'none',
},
'&:focus-visible::before': {
opacity: 0.2,
},
'&:focus-visible::after': {
boxShadow: `0 0 0 1px ${cssVar('primaryColor')}`,
},
},
});
export const calendar = style({
padding: '16px',
selectors: {
'&[data-mobile=true]': {
padding: '8px 16px',
},
},
});
export const journalPanel = style({
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
overflow: 'hidden',
});
export const dailyCount = style({
height: 0,
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: 8,
});
export const dailyCountHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
gap: 8,
});
export const dailyCountNav = style([
interactive,
{
height: 28,
width: 0,
flex: 1,
fontWeight: 500,
fontSize: 14,
padding: '4px 8px',
whiteSpace: 'nowrap',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: cssVar('textSecondaryColor'),
transition: 'all .3s',
selectors: {
'&[aria-selected="true"]': {
backgroundColor: cssVar('backgroundTertiaryColor'),
color: cssVar('textPrimaryColor'),
},
},
},
]);
export const dailyCountContainer = style({
height: 0,
flexGrow: 1,
display: 'flex',
width: `calc(var(--item-count) * 100%)`,
transition: 'transform .15s ease',
transform:
'translateX(calc(var(--active-index) * 100% / var(--item-count) * -1))',
});
export const dailyCountItem = style({
width: 'calc(100% / var(--item-count))',
height: '100%',
});
export const dailyCountContent = style({
padding: '8px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
});
export const dailyCountEmpty = style({
width: '100%',
height: '100%',
maxHeight: 220,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
lineHeight: '24px',
fontSize: 15,
color: cssVar('textSecondaryColor'),
textAlign: 'center',
padding: '0 70px',
fontWeight: 400,
});
// page item
export const pageItem = style([
interactive,
{
width: '100%',
display: 'flex',
alignItems: 'center',
borderRadius: 4,
padding: '0 4px',
gap: 8,
height: 30,
selectors: {
'&[aria-selected="true"]': {
backgroundColor: cssVar('hoverColor'),
},
},
},
]);
export const pageItemIcon = style({
width: 20,
height: 20,
color: cssVar('iconColor'),
});
export const pageItemLabel = style({
width: 0,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 500,
fontSize: cssVar('fontSm'),
color: cssVar('textPrimaryColor'),
textAlign: 'left',
selectors: {
'[aria-selected="true"] &': {
// TODO(@catsjuice): wait for design
color: cssVar('primaryColor'),
},
},
});
// conflict
export const journalConflictBlock = style({
padding: '0 16px 16px 16px',
});
export const journalConflictWrapper = style({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(170px, 1fr))',
rowGap: 4,
columnGap: 8,
});
export const journalConflictMoreTrigger = style([
interactive,
{
color: cssVar('textSecondaryColor'),
height: 30,
borderRadius: 4,
padding: '0px 8px',
fontSize: cssVar('fontSm'),
display: 'flex',
alignItems: 'center',
},
]);
// customize date-picker cell
export const journalDateCell = style([
interactive,
{
width: '100%',
height: '100%',
borderRadius: 8,
fontSize: cssVar('fontSm'),
color: cssVar('textPrimaryColor'),
fontWeight: 400,
position: 'relative',
selectors: {
'&[data-is-today="true"]': {
fontWeight: 600,
color: cssVar('brandColor'),
},
'&[data-not-current-month="true"]': {
color: cssVar('black10'),
},
'&[data-selected="true"]': {
backgroundColor: cssVar('brandColor'),
fontWeight: 500,
color: cssVar('pureWhite'),
},
'&[data-is-journal="false"][data-selected="true"]': {
backgroundColor: 'transparent',
color: 'var(--affine-text-primary-color)',
fontWeight: 500,
border: `1px solid ${cssVar('primaryColor')}`,
},
'&[data-mobile=true]': {
width: 34,
height: 34,
fontSize: 15,
fontWeight: 400,
},
},
},
]);
export const journalDateCellDot = style({
width: 4,
height: 4,
borderRadius: '50%',
backgroundColor: cssVar('primaryColor'),
position: 'absolute',
bottom: 0,
left: '50%',
transform: 'translateX(-50%)',
});
export const journalDateCellWrapper = style({
height: 34,
});

View File

@@ -0,0 +1,383 @@
import type { DateCell } from '@affine/component';
import { DatePicker, IconButton, Menu, Scrollable } from '@affine/component';
import { useTrashModalHelper } from '@affine/core/components/hooks/affine/use-trash-modal-helper';
import { useDocCollectionPageTitle } from '@affine/core/components/hooks/use-block-suite-workspace-page-title';
import {
useJournalHelper,
useJournalInfoHelper,
useJournalRouteHelper,
} from '@affine/core/components/hooks/use-journal';
import { MoveToTrash } from '@affine/core/components/page-list';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import {
EdgelessIcon,
MoreHorizontalIcon,
PageIcon,
TodayIcon,
} from '@blocksuite/icons/rc';
import type { DocRecord } from '@toeverything/infra';
import {
DocService,
DocsService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import dayjs from 'dayjs';
import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as styles from './journal.css';
/**
* @internal
*/
const CountDisplay = ({
count,
max = 99,
...attrs
}: { count: number; max?: number } & HTMLAttributes<HTMLSpanElement>) => {
return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
};
interface PageItemProps
extends Omit<HTMLAttributes<HTMLAnchorElement>, 'onClick'> {
docRecord: DocRecord;
right?: ReactNode;
}
const PageItem = ({ docRecord, right, className, ...attrs }: PageItemProps) => {
const mode = useLiveData(docRecord.primaryMode$);
const workspace = useService(WorkspaceService).workspace;
const title = useDocCollectionPageTitle(
workspace.docCollection,
docRecord.id
);
const { isJournal } = useJournalInfoHelper(
workspace.docCollection,
docRecord.id
);
const Icon = isJournal
? TodayIcon
: mode === 'edgeless'
? EdgelessIcon
: PageIcon;
return (
<WorkbenchLink
aria-label={title}
to={`/${docRecord.id}`}
className={clsx(className, styles.pageItem)}
{...attrs}
>
<div className={styles.pageItemIcon}>
<Icon width={20} height={20} />
</div>
<span className={styles.pageItemLabel}>{title}</span>
{right}
</WorkbenchLink>
);
};
type NavItemName = 'createdToday' | 'updatedToday';
interface NavItem {
name: NavItemName;
label: string;
count: number;
}
interface JournalBlockProps {
date: dayjs.Dayjs;
}
const mobile = environment.isMobile;
export const EditorJournalPanel = () => {
const t = useI18n();
const doc = useService(DocService).doc;
const workspace = useService(WorkspaceService).workspace;
const { journalDate, isJournal } = useJournalInfoHelper(
workspace.docCollection,
doc.id
);
const { openJournal } = useJournalRouteHelper(workspace.docCollection);
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
useEffect(() => {
journalDate && setDate(journalDate.format('YYYY-MM-DD'));
}, [journalDate]);
const onDateSelect = useCallback(
(date: string) => {
if (journalDate && dayjs(date).isSame(dayjs(journalDate))) return;
openJournal(date);
},
[journalDate, openJournal]
);
const customDayRenderer = useCallback(
(cell: DateCell) => {
// TODO(@catsjuice): add a dot to indicate journal
// has performance issue for now, better to calculate it in advance
// const hasJournal = !!getJournalsByDate(cell.date.format('YYYY-MM-DD'))?.length;
const hasJournal = false;
return (
<button
className={styles.journalDateCell}
data-is-date-cell
tabIndex={cell.focused ? 0 : -1}
data-is-today={cell.isToday}
data-not-current-month={cell.notCurrentMonth}
data-selected={cell.selected}
data-is-journal={isJournal}
data-has-journal={hasJournal}
data-mobile={mobile}
>
{cell.label}
{hasJournal && !cell.selected ? (
<div className={styles.journalDateCellDot} />
) : null}
</button>
);
},
[isJournal]
);
return (
<div className={styles.journalPanel} data-is-journal={isJournal}>
<div data-mobile={mobile} className={styles.calendar}>
<DatePicker
weekDays={t['com.affine.calendar-date-picker.week-days']()}
monthNames={t['com.affine.calendar-date-picker.month-names']()}
todayLabel={t['com.affine.calendar-date-picker.today']()}
customDayRenderer={customDayRenderer}
value={date}
onChange={onDateSelect}
monthHeaderCellClassName={styles.journalDateCellWrapper}
monthBodyCellClassName={styles.journalDateCellWrapper}
/>
</div>
<JournalConflictBlock date={dayjs(date)} />
<JournalDailyCountBlock date={dayjs(date)} />
</div>
);
};
const sortPagesByDate = (
docs: DocRecord[],
field: 'updatedDate' | 'createDate',
order: 'asc' | 'desc' = 'desc'
) => {
return [...docs].sort((a, b) => {
return (
(order === 'asc' ? 1 : -1) *
dayjs(b.meta$.value[field]).diff(dayjs(a.meta$.value[field]))
);
});
};
const DailyCountEmptyFallback = ({ name }: { name: NavItemName }) => {
const t = useI18n();
return (
<div className={styles.dailyCountEmpty}>
{name === 'createdToday'
? t['com.affine.journal.daily-count-created-empty-tips']()
: t['com.affine.journal.daily-count-updated-empty-tips']()}
</div>
);
};
const JournalDailyCountBlock = ({ date }: JournalBlockProps) => {
const nodeRef = useRef<HTMLDivElement>(null);
const t = useI18n();
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
const docRecords = useLiveData(useService(DocsService).list.docs$);
const getTodaysPages = useCallback(
(field: 'createDate' | 'updatedDate') => {
return sortPagesByDate(
docRecords.filter(docRecord => {
const meta = docRecord.meta$.value;
if (meta.trash) return false;
return meta[field] && dayjs(meta[field]).isSame(date, 'day');
}),
field
);
},
[date, docRecords]
);
const createdToday = useMemo(
() => getTodaysPages('createDate'),
[getTodaysPages]
);
const updatedToday = useMemo(
() => getTodaysPages('updatedDate'),
[getTodaysPages]
);
const headerItems = useMemo<NavItem[]>(
() => [
{
name: 'createdToday',
label: t['com.affine.journal.created-today'](),
count: createdToday.length,
},
{
name: 'updatedToday',
label: t['com.affine.journal.updated-today'](),
count: updatedToday.length,
},
],
[createdToday.length, t, updatedToday.length]
);
const activeIndex = headerItems.findIndex(({ name }) => name === activeItem);
const vars = assignInlineVars({
'--active-index': String(activeIndex),
'--item-count': String(headerItems.length),
});
return (
<div className={styles.dailyCount} style={vars}>
<header className={styles.dailyCountHeader}>
{headerItems.map(({ label, count, name }, index) => {
return (
<button
onClick={() => setActiveItem(name)}
aria-selected={activeItem === name}
className={styles.dailyCountNav}
key={index}
>
{label}
&nbsp;
<CountDisplay count={count} />
</button>
);
})}
</header>
<main className={styles.dailyCountContainer} data-active={activeItem}>
{headerItems.map(({ name }) => {
const renderList =
name === 'createdToday' ? createdToday : updatedToday;
if (renderList.length === 0)
return (
<div key={name} className={styles.dailyCountItem}>
<DailyCountEmptyFallback name={name} />
</div>
);
return (
<Scrollable.Root key={name} className={styles.dailyCountItem}>
<Scrollable.Scrollbar />
<Scrollable.Viewport>
<div className={styles.dailyCountContent} ref={nodeRef}>
{renderList.map((pageRecord, index) => (
<PageItem
tabIndex={name === activeItem ? 0 : -1}
key={index}
docRecord={pageRecord}
/>
))}
</div>
</Scrollable.Viewport>
</Scrollable.Root>
);
})}
</main>
</div>
);
};
const MAX_CONFLICT_COUNT = 5;
interface ConflictListProps
extends PropsWithChildren,
HTMLAttributes<HTMLDivElement> {
docRecords: DocRecord[];
}
const ConflictList = ({
docRecords,
children,
className,
...attrs
}: ConflictListProps) => {
const currentDoc = useService(DocService).doc;
const { setTrashModal } = useTrashModalHelper();
const handleOpenTrashModal = useCallback(
(docRecord: DocRecord) => {
setTrashModal({
open: true,
pageIds: [docRecord.id],
pageTitles: [docRecord.title$.value],
});
},
[setTrashModal]
);
return (
<div className={clsx(styles.journalConflictWrapper, className)} {...attrs}>
{docRecords.map(docRecord => {
const isCurrent = docRecord.id === currentDoc.id;
return (
<PageItem
aria-selected={isCurrent}
docRecord={docRecord}
key={docRecord.id}
right={
<Menu
items={
<MoveToTrash
onSelect={() => handleOpenTrashModal(docRecord)}
/>
}
>
<IconButton>
<MoreHorizontalIcon />
</IconButton>
</Menu>
}
/>
);
})}
{children}
</div>
);
};
const JournalConflictBlock = ({ date }: JournalBlockProps) => {
const t = useI18n();
const workspace = useService(WorkspaceService).workspace;
const docRecordList = useService(DocsService).list;
const journalHelper = useJournalHelper(workspace.docCollection);
const docs = journalHelper.getJournalsByDate(date.format('YYYY-MM-DD'));
const docRecords = useLiveData(
docRecordList.docs$.map(records =>
records.filter(v => {
return docs.some(doc => doc.id === v.id);
})
)
);
if (docs.length <= 1) return null;
return (
<ConflictList
className={styles.journalConflictBlock}
docRecords={docRecords.slice(0, MAX_CONFLICT_COUNT)}
>
{docs.length > MAX_CONFLICT_COUNT ? (
<Menu
items={
<ConflictList docRecords={docRecords.slice(MAX_CONFLICT_COUNT)} />
}
>
<div className={styles.journalConflictMoreTrigger}>
{t['com.affine.journal.conflict-show-more']({
count: (docRecords.length - MAX_CONFLICT_COUNT).toFixed(0),
})}
</div>
</Menu>
) : null}
</ConflictList>
);
};

View File

@@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
width: '100%',
});

View File

@@ -0,0 +1,39 @@
import type { AffineEditorContainer } from '@blocksuite/presets';
import { OutlinePanel } from '@blocksuite/presets';
import { useCallback, useRef } from 'react';
import * as styles from './outline.css';
// A wrapper for TOCNotesPanel
export const EditorOutlinePanel = ({
editor,
}: {
editor: AffineEditorContainer | null;
}) => {
const outlinePanelRef = useRef<OutlinePanel | null>(null);
const onRefChange = useCallback((container: HTMLDivElement | null) => {
if (container) {
if (outlinePanelRef.current === null) {
console.error('outline panel should be initialized');
return;
}
container.append(outlinePanelRef.current);
}
}, []);
if (!editor) {
return;
}
if (!outlinePanelRef.current) {
outlinePanelRef.current = new OutlinePanel();
}
if (editor !== outlinePanelRef.current?.editor) {
(outlinePanelRef.current as OutlinePanel).editor = editor;
(outlinePanelRef.current as OutlinePanel).fitPadding = [20, 20, 20, 20];
}
return <div className={styles.root} ref={onRefChange} />;
};

View File

@@ -0,0 +1,35 @@
import { EditorService } from '@affine/core/modules/editor';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useViewPosition } from '@affine/core/modules/workbench/view/use-view-position';
import { useLiveData, useService } from '@toeverything/infra';
export const useDetailPageHeaderResponsive = (availableWidth: number) => {
const mode = useLiveData(useService(EditorService).editor.mode$);
const workbench = useService(WorkbenchService).workbench;
const viewPosition = useViewPosition();
const workbenchViewsCount = useLiveData(
workbench.views$.map(views => views.length)
);
const rightSidebarOpen = useLiveData(workbench.sidebarOpen$);
// share button should be hidden once split-view is enabled
const hideShare = availableWidth < 500 || workbenchViewsCount > 1;
const hidePresent = availableWidth < 400 || mode !== 'edgeless';
const hideCollect = availableWidth < 300;
const hideToday = availableWidth < 300;
const showDivider =
!BUILD_CONFIG.isElectron &&
viewPosition.isLast &&
!rightSidebarOpen &&
!(hidePresent && hideShare);
return {
hideShare,
hidePresent,
hideCollect,
hideToday,
showDivider,
};
};

View File

@@ -0,0 +1,225 @@
import { AffineOtherPageLayout } from '@affine/component/affine-other-page-layout';
import { AppFallback } from '@affine/core/components/affine/app-container';
import { workbenchRoutes } from '@affine/core/desktop/workbench-router';
import { ZipTransformer } from '@blocksuite/blocks';
import type { Workspace, WorkspaceMetadata } from '@toeverything/infra';
import {
FrameworkScope,
GlobalContextService,
useLiveData,
useServices,
WorkspacesService,
} from '@toeverything/infra';
import type { ReactElement } from 'react';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { matchPath, useLocation, useParams } from 'react-router-dom';
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
import { WorkspaceLayout } from '../../../components/layouts/workspace-layout';
import { WorkbenchRoot } from '../../../modules/workbench';
import { PageNotFound } from '../404';
import { SharePage } from './share/share-page';
declare global {
/**
* @internal debug only
*/
// eslint-disable-next-line no-var
var currentWorkspace: Workspace | undefined;
// eslint-disable-next-line no-var
var exportWorkspaceSnapshot: (docs?: string[]) => Promise<void>;
// eslint-disable-next-line no-var
var importWorkspaceSnapshot: () => Promise<void>;
interface WindowEventMap {
'affine:workspace:change': CustomEvent<{ id: string }>;
}
}
export const Component = (): ReactElement => {
const { workspacesService } = useServices({
WorkspacesService,
});
const params = useParams();
const location = useLocation();
// check if we are in detail doc route, if so, maybe render share page
const detailDocRoute = useMemo(() => {
const match = matchPath(
'/workspace/:workspaceId/:docId',
location.pathname
);
if (
match &&
match.params.docId &&
match.params.workspaceId &&
// // TODO(eyhn): need a better way to check if it's a docId
workbenchRoutes.find(route =>
matchPath(route.path, '/' + match.params.docId)
)?.path === '/:pageId'
) {
return {
docId: match.params.docId,
workspaceId: match.params.workspaceId,
};
} else {
return null;
}
}, [location.pathname]);
const [workspaceNotFound, setWorkspaceNotFound] = useState(false);
const listLoading = useLiveData(workspacesService.list.isRevalidating$);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const meta = useMemo(() => {
return workspaces.find(({ id }) => id === params.workspaceId);
}, [workspaces, params.workspaceId]);
// if listLoading is false, we can show 404 page, otherwise we should show loading page.
useEffect(() => {
if (listLoading === false && meta === undefined) {
setWorkspaceNotFound(true);
}
if (meta) {
setWorkspaceNotFound(false);
}
}, [listLoading, meta, workspacesService]);
// if workspace is not found, we should revalidate in interval
useEffect(() => {
if (listLoading === false && meta === undefined) {
const timer = setInterval(
() => workspacesService.list.revalidate(),
5000
);
return () => clearInterval(timer);
}
return;
}, [listLoading, meta, workspaceNotFound, workspacesService]);
if (workspaceNotFound) {
if (
!BUILD_CONFIG.isElectron /* only browser has share page */ &&
detailDocRoute
) {
return (
<SharePage
docId={detailDocRoute.docId}
workspaceId={detailDocRoute.workspaceId}
/>
);
}
return (
<AffineOtherPageLayout>
<PageNotFound noPermission />
</AffineOtherPageLayout>
);
}
if (!meta) {
return <AppFallback />;
}
return <WorkspacePage meta={meta} />;
};
const WorkspacePage = ({ meta }: { meta: WorkspaceMetadata }) => {
const { workspacesService, globalContextService } = useServices({
WorkspacesService,
GlobalContextService,
});
const [workspace, setWorkspace] = useState<Workspace | null>(null);
useLayoutEffect(() => {
const ref = workspacesService.open({ metadata: meta });
setWorkspace(ref.workspace);
return () => {
ref.dispose();
};
}, [meta, workspacesService]);
const isRootDocReady =
useLiveData(workspace?.engine.rootDocState$.map(v => v.ready)) ?? false;
useEffect(() => {
if (workspace) {
// for debug purpose
window.currentWorkspace = workspace ?? undefined;
window.dispatchEvent(
new CustomEvent('affine:workspace:change', {
detail: {
id: workspace.id,
},
})
);
window.exportWorkspaceSnapshot = async (docs?: string[]) => {
const zip = await ZipTransformer.exportDocs(
workspace.docCollection,
Array.from(workspace.docCollection.docs.values())
.filter(doc => (docs ? docs.includes(doc.id) : true))
.map(doc => doc.getDoc())
);
const url = URL.createObjectURL(zip);
// download url
const a = document.createElement('a');
a.href = url;
a.download = `${workspace.docCollection.meta.name}.zip`;
a.click();
URL.revokeObjectURL(url);
};
window.importWorkspaceSnapshot = async () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.zip';
input.onchange = async () => {
if (input.files && input.files.length > 0) {
const file = input.files[0];
const blob = new Blob([file], { type: 'application/zip' });
const newDocs = await ZipTransformer.importDocs(
workspace.docCollection,
blob
);
console.log(
'imported docs',
newDocs
.filter(doc => !!doc)
.map(doc => ({
id: doc.id,
title: doc.meta?.title,
}))
);
}
};
input.click();
};
localStorage.setItem('last_workspace_id', workspace.id);
globalContextService.globalContext.workspaceId.set(workspace.id);
return () => {
window.currentWorkspace = undefined;
globalContextService.globalContext.workspaceId.set(null);
};
}
return;
}, [globalContextService, workspace]);
if (!workspace) {
return null; // skip this, workspace will be set in layout effect
}
if (!isRootDocReady) {
return (
<FrameworkScope scope={workspace.scope}>
<AppFallback />
</FrameworkScope>
);
}
return (
<FrameworkScope scope={workspace.scope}>
<AffineErrorBoundary height="100vh">
<WorkspaceLayout>
<WorkbenchRoot />
</WorkspaceLayout>
</AffineErrorBoundary>
</FrameworkScope>
);
};

View File

@@ -0,0 +1,44 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const pageListEmptyStyle = style({
height: '100%',
display: 'flex',
flexDirection: 'column',
});
export const pageListEmptyBody = style({
height: 0,
flex: 1,
});
export const emptyDescButton = style({
cursor: 'pointer',
color: cssVar('textSecondaryColor'),
background: cssVar('backgroundCodeBlock'),
border: `1px solid ${cssVar('borderColor')}`,
borderRadius: '4px',
padding: '0 6px',
boxSizing: 'border-box',
selectors: {
'&:hover': {
background: cssVar('hoverColor'),
},
},
});
export const emptyDescKbd = style([
emptyDescButton,
{
cursor: 'text',
},
]);
export const plusButton = style({
borderWidth: 1,
borderColor: cssVarV2('layer/insideBorder/border'),
boxShadow: 'none',
cursor: 'default',
});
export const descWrapper = style({
display: 'flex',
alignItems: 'center',
gap: 8,
});

View File

@@ -0,0 +1,44 @@
import { EmptyDocs, EmptyTags } from '@affine/core/components/affine/empty';
import { EmptyCollections } from '@affine/core/components/affine/empty/collections';
import type { ReactNode } from 'react';
import * as styles from './page-list-empty.css';
export const EmptyPageList = ({
type,
heading,
tagId,
}: {
type: 'all' | 'trash';
heading?: ReactNode;
tagId?: string;
}) => {
return (
<div className={styles.pageListEmptyStyle}>
{heading && <div>{heading}</div>}
<EmptyDocs
tagId={tagId}
type={type}
className={styles.pageListEmptyBody}
/>
</div>
);
};
export const EmptyCollectionList = ({ heading }: { heading: ReactNode }) => {
return (
<div className={styles.pageListEmptyStyle}>
{heading && <div>{heading}</div>}
<EmptyCollections className={styles.pageListEmptyBody} />
</div>
);
};
export const EmptyTagList = ({ heading }: { heading: ReactNode }) => {
return (
<div className={styles.pageListEmptyStyle}>
{heading && <div>{heading}</div>}
<EmptyTags className={styles.pageListEmptyBody} />
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const footerContainer = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
width: '100%',
maxWidth: cssVar('editorWidth'),
marginLeft: 'auto',
marginRight: 'auto',
paddingLeft: cssVar('editorSidePadding'),
paddingRight: cssVar('editorSidePadding'),
marginBottom: '200px',
'@media': {
'screen and (max-width: 800px)': {
paddingLeft: '24px',
paddingRight: '24px',
},
},
});
export const footer = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
width: '100%',
padding: '12px',
background: cssVar('backgroundOverlayPanelColor'),
});
export const description = style({
fontSize: cssVar('fontSm'),
color: cssVar('textSecondaryColor'),
textAlign: 'center',
});
export const getStartLink = style({
display: 'flex',
padding: '0px 4px',
justifyContent: 'center',
alignItems: 'center',
gap: '4px',
color: cssVar('black'),
selectors: {
'&:visited': {
color: cssVar('black'),
},
},
});

View File

@@ -0,0 +1,26 @@
import { useI18n } from '@affine/i18n';
import { ArrowRightBigIcon } from '@blocksuite/icons/rc';
import * as styles from './share-footer.css';
export const ShareFooter = () => {
const t = useI18n();
return (
<div className={styles.footerContainer}>
<div className={styles.footer}>
<div className={styles.description}>
{t['com.affine.share-page.footer.description']()}
</div>
<a
className={styles.getStartLink}
href="https://affine.pro/"
target="_blank"
rel="noreferrer"
>
{t['com.affine.share-page.footer.get-started']()}
<ArrowRightBigIcon fontSize={16} />
</a>
</div>
</div>
);
};

View File

@@ -0,0 +1,18 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const header = style({
display: 'flex',
height: '52px',
width: '100%',
alignItems: 'center',
flexShrink: 0,
background: cssVar('backgroundPrimaryColor'),
borderBottom: `1px solid ${cssVar('borderColor')}`,
padding: '0 16px',
});
export const spacer = style({
flexGrow: 1,
minWidth: 12,
});

View File

@@ -0,0 +1,36 @@
import { AuthModal } from '@affine/core/components/affine/auth';
import { BlocksuiteHeaderTitle } from '@affine/core/components/blocksuite/block-suite-header/title';
import { EditorModeSwitch } from '@affine/core/components/blocksuite/block-suite-mode-switch';
import ShareHeaderRightItem from '@affine/core/components/cloud/share-header-right-item';
import type { DocMode } from '@blocksuite/blocks';
import * as styles from './share-header.css';
export function ShareHeader({
pageId,
publishMode,
isTemplate,
templateName,
snapshotUrl,
}: {
pageId: string;
publishMode: DocMode;
isTemplate?: boolean;
templateName?: string;
snapshotUrl?: string;
}) {
return (
<div className={styles.header}>
<EditorModeSwitch />
<BlocksuiteHeaderTitle docId={pageId} />
<div className={styles.spacer} />
<ShareHeaderRightItem
publishMode={publishMode}
isTemplate={isTemplate}
snapshotUrl={snapshotUrl}
templateName={templateName}
/>
<AuthModal />
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
height: '100%',
overflow: 'hidden',
width: '100%',
});
export const mainContainer = style({
display: 'flex',
flex: 1,
height: '100%',
position: 'relative',
flexDirection: 'column',
minWidth: 0,
overflow: 'hidden',
background: cssVar('backgroundPrimaryColor'),
});
export const editorContainer = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
zIndex: 0,
});
export const link = style({
position: 'absolute',
right: '50%',
transform: 'translateX(50%)',
bottom: '20px',
zIndex: cssVar('zIndexPopover'),
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
background: cssVar('black'),
borderRadius: '8px',
border: `1px solid ${cssVar('pureBlack10')}`,
boxShadow: cssVar('--affine-button-inner-shadow'),
color: cssVar('white'),
padding: '8px 18px',
gap: '4px',
'@media': {
print: {
display: 'none',
},
},
});
export const linkText = style({
padding: '0px 4px',
fontSize: cssVar('fontBase'),
fontWeight: 700,
whiteSpace: 'nowrap',
});

View File

@@ -0,0 +1,290 @@
import { Scrollable } from '@affine/component';
import { AppFallback } from '@affine/core/components/affine/app-container';
import { EditorOutlineViewer } from '@affine/core/components/blocksuite/outline-viewer';
import { useActiveBlocksuiteEditor } from '@affine/core/components/hooks/use-block-suite-editor';
import { usePageDocumentTitle } from '@affine/core/components/hooks/use-global-state';
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
import { SharePageNotFoundError } from '@affine/core/components/share-page-not-found-error';
import { AppContainer, MainContainer } from '@affine/core/components/workspace';
import { AuthService } from '@affine/core/modules/cloud';
import {
type Editor,
EditorService,
EditorsService,
} from '@affine/core/modules/editor';
import { PeekViewManagerModal } from '@affine/core/modules/peek-view';
import { ShareReaderService } from '@affine/core/modules/share-doc';
import { CloudBlobStorage } from '@affine/core/modules/workspace-engine';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import { type DocMode, DocModes } from '@blocksuite/blocks';
import { Logo1Icon } from '@blocksuite/icons/rc';
import type { AffineEditorContainer } from '@blocksuite/presets';
import type { Doc, Workspace } from '@toeverything/infra';
import {
DocsService,
EmptyBlobStorage,
FrameworkScope,
ReadonlyDocStorage,
useLiveData,
useService,
useServices,
WorkspacesService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { PageNotFound } from '../../404';
import { ShareFooter } from './share-footer';
import { ShareHeader } from './share-header';
import * as styles from './share-page.css';
export const SharePage = ({
workspaceId,
docId,
}: {
workspaceId: string;
docId: string;
}) => {
const { shareReaderService } = useServices({
ShareReaderService,
});
const isLoading = useLiveData(shareReaderService.reader.isLoading$);
const error = useLiveData(shareReaderService.reader.error$);
const data = useLiveData(shareReaderService.reader.data$);
const location = useLocation();
const { mode, isTemplate, templateName, templateSnapshotUrl } =
useMemo(() => {
const searchParams = new URLSearchParams(location.search);
const queryStringMode = searchParams.get('mode') as DocMode | null;
return {
mode:
queryStringMode && DocModes.includes(queryStringMode)
? queryStringMode
: null,
isTemplate: searchParams.has('isTemplate'),
templateName: searchParams.get('templateName') || '',
templateSnapshotUrl: searchParams.get('snapshotUrl') || '',
};
}, [location.search]);
useEffect(() => {
shareReaderService.reader.loadShare({ workspaceId, docId });
}, [shareReaderService, docId, workspaceId]);
if (isLoading) {
return <AppFallback />;
}
if (error) {
// TODO(@eyhn): show error details
return <SharePageNotFoundError />;
}
if (data) {
return (
<SharePageInner
workspaceId={data.workspaceId}
docId={data.docId}
workspaceBinary={data.workspaceBinary}
docBinary={data.docBinary}
publishMode={mode || data.publishMode}
isTemplate={isTemplate}
templateName={templateName}
templateSnapshotUrl={templateSnapshotUrl}
/>
);
} else {
return <PageNotFound noPermission />;
}
};
const SharePageInner = ({
workspaceId,
docId,
workspaceBinary,
docBinary,
publishMode = 'page' as DocMode,
isTemplate,
templateName,
templateSnapshotUrl,
}: {
workspaceId: string;
docId: string;
workspaceBinary: Uint8Array;
docBinary: Uint8Array;
publishMode?: DocMode;
isTemplate?: boolean;
templateName?: string;
templateSnapshotUrl?: string;
}) => {
const workspacesService = useService(WorkspacesService);
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [page, setPage] = useState<Doc | null>(null);
const [editor, setEditor] = useState<Editor | null>(null);
const [editorContainer, setActiveBlocksuiteEditor] =
useActiveBlocksuiteEditor();
useEffect(() => {
// create a workspace for share page
const { workspace } = workspacesService.open(
{
metadata: {
id: workspaceId,
flavour: WorkspaceFlavour.AFFINE_CLOUD,
},
isSharedMode: true,
},
{
getDocStorage() {
return new ReadonlyDocStorage({
[workspaceId]: workspaceBinary,
[docId]: docBinary,
});
},
getAwarenessConnections() {
return [];
},
getDocServer() {
return null;
},
getLocalBlobStorage() {
return EmptyBlobStorage;
},
getRemoteBlobStorages() {
return [new CloudBlobStorage(workspaceId)];
},
}
);
setWorkspace(workspace);
workspace.engine
.waitForRootDocReady()
.then(() => {
const { doc } = workspace.scope.get(DocsService).open(docId);
workspace.docCollection.awarenessStore.setReadonly(
doc.blockSuiteDoc.blockCollection,
true
);
setPage(doc);
const editor = doc.scope.get(EditorsService).createEditor();
editor.setMode(publishMode);
setEditor(editor);
})
.catch(err => {
console.error(err);
});
}, [
docId,
workspaceId,
workspacesService,
publishMode,
workspaceBinary,
docBinary,
]);
const pageTitle = useLiveData(page?.title$);
usePageDocumentTitle(pageTitle);
const onEditorLoad = useCallback(
(editorContainer: AffineEditorContainer) => {
setActiveBlocksuiteEditor(editorContainer);
if (!editor) {
return;
}
editor.setEditorContainer(editorContainer);
const unbind = editor.bindEditorContainer(
editorContainer,
(editorContainer as any).docTitle
);
return () => {
unbind();
editor.setEditorContainer(null);
};
},
[editor, setActiveBlocksuiteEditor]
);
if (!workspace || !page || !editor) {
return;
}
return (
<FrameworkScope scope={workspace.scope}>
<FrameworkScope scope={page.scope}>
<FrameworkScope scope={editor.scope}>
<AppContainer>
<MainContainer>
<div className={styles.root}>
<div className={styles.mainContainer}>
<ShareHeader
pageId={page.id}
publishMode={publishMode}
isTemplate={isTemplate}
templateName={templateName}
snapshotUrl={templateSnapshotUrl}
/>
<Scrollable.Root>
<Scrollable.Viewport
className={clsx(
'affine-page-viewport',
styles.editorContainer
)}
>
<PageDetailEditor onLoad={onEditorLoad} />
{publishMode === 'page' ? <ShareFooter /> : null}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
<EditorOutlineViewer
editor={editorContainer}
show={publishMode === 'page'}
/>
<SharePageFooter />
</div>
</div>
</MainContainer>
<PeekViewManagerModal />
</AppContainer>
</FrameworkScope>
</FrameworkScope>
</FrameworkScope>
);
};
const SharePageFooter = () => {
const t = useI18n();
const editorService = useService(EditorService);
const isPresent = useLiveData(editorService.editor.isPresenting$);
const authService = useService(AuthService);
const loginStatus = useLiveData(authService.session.status$);
if (isPresent || loginStatus === 'authenticated') {
return null;
}
return (
<a
href="https://affine.pro"
target="_blank"
className={styles.link}
rel="noreferrer"
>
<span className={styles.linkText}>
{t['com.affine.share-page.footer.built-with']()}
</span>
<Logo1Icon fontSize={20} />
</a>
);
};

View File

@@ -0,0 +1,12 @@
import { PageDisplayMenu } from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
export const TagDetailHeader = () => {
return (
<Header
center={<WorkspaceModeFilterTab activeFilter={'tags'} />}
right={<PageDisplayMenu />}
/>
);
};

View File

@@ -0,0 +1,9 @@
import { style } from '@vanilla-extract/css';
export const body = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
height: '100%',
width: '100%',
});

View File

@@ -0,0 +1,101 @@
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import {
TagPageListHeader,
VirtualizedPageList,
} from '@affine/core/components/page-list';
import { TagService } from '@affine/core/modules/tag';
import {
useIsActiveView,
ViewBody,
ViewHeader,
ViewIcon,
ViewTitle,
} from '@affine/core/modules/workbench';
import {
GlobalContextService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { useEffect, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { PageNotFound } from '../../404';
import { EmptyPageList } from '../page-list-empty';
import { TagDetailHeader } from './header';
import * as styles from './index.css';
export const TagDetail = ({ tagId }: { tagId?: string }) => {
const globalContext = useService(GlobalContextService).globalContext;
const currentWorkspace = useService(WorkspaceService).workspace;
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const tagList = useService(TagService).tagList;
const currentTag = useLiveData(tagList.tagByTagId$(tagId));
const pageIds = useLiveData(currentTag?.pageIds$);
const filteredPageMetas = useMemo(() => {
const pageIdsSet = new Set(pageIds);
return pageMetas
.filter(page => pageIdsSet.has(page.id))
.filter(page => !page.trash);
}, [pageIds, pageMetas]);
const isActiveView = useIsActiveView();
const tagName = useLiveData(currentTag?.value$);
useEffect(() => {
if (isActiveView && currentTag) {
globalContext.tagId.set(currentTag.id);
globalContext.isTag.set(true);
return () => {
globalContext.tagId.set(null);
globalContext.isTag.set(false);
};
}
return;
}, [currentTag, globalContext, isActiveView]);
if (!currentTag) {
return <PageNotFound />;
}
return (
<>
<ViewTitle title={tagName ?? 'Untitled'} />
<ViewIcon icon="tag" />
<ViewHeader>
<TagDetailHeader />
</ViewHeader>
<ViewBody>
<div className={styles.body}>
{filteredPageMetas.length > 0 ? (
<VirtualizedPageList
tag={currentTag}
listItem={filteredPageMetas}
/>
) : (
<EmptyPageList
type="all"
tagId={tagId}
heading={
<TagPageListHeader
tag={currentTag}
workspaceId={currentWorkspace.id}
/>
}
/>
)}
</div>
</ViewBody>
</>
);
};
export const Component = () => {
const params = useParams();
return <TagDetail tagId={params.tagId} />;
};

View File

@@ -0,0 +1,21 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const trashTitle = style({
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 8px',
fontWeight: 600,
userSelect: 'none',
});
export const body = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
height: '100%',
width: '100%',
});
export const trashIcon = style({
color: cssVar('iconColor'),
fontSize: cssVar('fontH5'),
});

View File

@@ -0,0 +1,88 @@
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import {
useFilteredPageMetas,
VirtualizedTrashList,
} from '@affine/core/components/page-list';
import { Header } from '@affine/core/components/pure/header';
import { useI18n } from '@affine/i18n';
import { assertExists } from '@blocksuite/global/utils';
import { DeleteIcon } from '@blocksuite/icons/rc';
import {
GlobalContextService,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { useEffect } from 'react';
import {
useIsActiveView,
ViewBody,
ViewHeader,
ViewIcon,
ViewTitle,
} from '../../../modules/workbench';
import { EmptyPageList } from './page-list-empty';
import * as styles from './trash-page.css';
const TrashHeader = () => {
const t = useI18n();
return (
<Header
left={
<div className={styles.trashTitle}>
<DeleteIcon className={styles.trashIcon} />
{t['com.affine.workspaceSubPath.trash']()}
</div>
}
/>
);
};
export const TrashPage = () => {
const globalContextService = useService(GlobalContextService);
const currentWorkspace = useService(WorkspaceService).workspace;
const docCollection = currentWorkspace.docCollection;
assertExists(docCollection);
const pageMetas = useBlockSuiteDocMeta(docCollection);
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
trash: true,
});
const isActiveView = useIsActiveView();
useEffect(() => {
if (isActiveView) {
globalContextService.globalContext.isTrash.set(true);
return () => {
globalContextService.globalContext.isTrash.set(false);
};
}
return;
}, [globalContextService.globalContext.isTrash, isActiveView]);
const t = useI18n();
return (
<>
<ViewTitle title={t['Trash']()} />
<ViewIcon icon={'trash'} />
<ViewHeader>
<TrashHeader />
</ViewHeader>
<ViewBody>
<div className={styles.body}>
{filteredPageMetas.length > 0 ? (
<VirtualizedTrashList />
) : (
<EmptyPageList type="trash" />
)}
</div>
</ViewBody>
</>
);
};
export const Component = () => {
return <TrashPage />;
};

View File

@@ -0,0 +1,28 @@
import {
useBindWorkbenchToBrowserRouter,
WorkbenchService,
} from '@affine/core/modules/workbench';
import { ViewRoot } from '@affine/core/modules/workbench/view/view-root';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
import { type RouteObject, useLocation } from 'react-router-dom';
export const MobileWorkbenchRoot = ({ routes }: { routes: RouteObject[] }) => {
const workbench = useService(WorkbenchService).workbench;
// for debugging
(window as any).workbench = workbench;
const views = useLiveData(workbench.views$);
const location = useLocation();
const basename = location.pathname.match(/\/workspace\/[^/]+/g)?.[0] ?? '/';
useBindWorkbenchToBrowserRouter(workbench, basename);
useEffect(() => {
workbench.updateBasename(basename);
}, [basename, workbench]);
return <ViewRoot routes={routes} view={views[0]} />;
};

View File

@@ -0,0 +1,177 @@
import { wrapCreateBrowserRouter } from '@sentry/react';
import { useEffect, useState } from 'react';
import type { RouteObject } from 'react-router-dom';
import {
createBrowserRouter as reactRouterCreateBrowserRouter,
redirect,
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
useNavigate,
} from 'react-router-dom';
import { NavigateContext } from '../components/hooks/use-navigate-helper';
import { RootWrapper } from './pages/root';
export function RootRouter() {
const navigate = useNavigate();
const [ready, setReady] = useState(false);
useEffect(() => {
// a hack to make sure router is ready
setReady(true);
}, []);
return (
ready && (
<NavigateContext.Provider value={navigate}>
<RootWrapper />
</NavigateContext.Provider>
)
);
}
export const topLevelRoutes = [
{
element: <RootRouter />,
children: [
{
path: '/',
lazy: () => import('./pages/index'),
},
{
path: '/workspace/:workspaceId/*',
lazy: () => import('./pages/workspace/index'),
},
{
path: '/share/:workspaceId/:pageId',
loader: ({ params }) => {
return redirect(`/workspace/${params.workspaceId}/${params.pageId}`);
},
},
{
path: '/404',
lazy: () => import('./pages/404'),
},
{
path: '/expired',
lazy: () => import('./pages/expired'),
},
{
path: '/invite/:inviteId',
lazy: () => import('./pages/invite'),
},
{
path: '/upgrade-success',
lazy: () => import('./pages/upgrade-success'),
},
{
path: '/ai-upgrade-success',
lazy: () => import('./pages/ai-upgrade-success'),
},
{
path: '/onboarding',
lazy: () => import('./pages/onboarding'),
},
{
path: '/redirect-proxy',
lazy: () => import('./pages/redirect'),
},
{
path: '/subscribe',
lazy: () => import('./pages/subscribe'),
},
{
path: '/try-cloud',
loader: () => {
return redirect(
`/sign-in?redirect_uri=${encodeURIComponent('/?initCloud=true')}`
);
},
},
{
path: '/theme-editor',
lazy: () => import('./pages/theme-editor'),
},
{
path: '/template/import',
lazy: () => import('./pages/import-template'),
},
{
path: '/template/preview',
loader: ({ request }) => {
const url = new URL(request.url);
const workspaceId = url.searchParams.get('workspaceId');
const docId = url.searchParams.get('docId');
const templateName = url.searchParams.get('name');
const templateMode = url.searchParams.get('mode');
const snapshotUrl = url.searchParams.get('snapshotUrl');
return redirect(
`/workspace/${workspaceId}/${docId}?${new URLSearchParams({
isTemplate: 'true',
templateName: templateName ?? '',
snapshotUrl: snapshotUrl ?? '',
templateMode: templateMode ?? 'page',
}).toString()}`
);
},
},
{
path: '/auth/:authType',
lazy: () => import(/* webpackChunkName: "auth" */ './pages/auth/auth'),
},
{
path: '/sign-In',
lazy: () =>
import(/* webpackChunkName: "auth" */ './pages/auth/sign-in'),
},
{
path: '/magic-link',
lazy: () =>
import(/* webpackChunkName: "auth" */ './pages/auth/magic-link'),
},
{
path: '/oauth/login',
lazy: () =>
import(/* webpackChunkName: "auth" */ './pages/auth/oauth-login'),
},
{
path: '/oauth/callback',
lazy: () =>
import(/* webpackChunkName: "auth" */ './pages/auth/oauth-callback'),
},
// deprecated, keep for old client compatibility
// TODO(@forehalo): remove
{
path: '/desktop-signin',
lazy: () =>
import(/* webpackChunkName: "auth" */ './pages/auth/oauth-login'),
},
// deprecated, keep for old client compatibility
// use '/sign-in'
// TODO(@forehalo): remove
{
path: '/signIn',
lazy: () =>
import(/* webpackChunkName: "auth" */ './pages/auth/sign-in'),
},
{
path: '/open-app/:action',
lazy: () => import('./pages/open-app'),
},
{
path: '*',
lazy: () => import('./pages/404'),
},
],
},
] satisfies [RouteObject, ...RouteObject[]];
const createBrowserRouter = wrapCreateBrowserRouter(
reactRouterCreateBrowserRouter
);
export const router = (
window.SENTRY_RELEASE ? createBrowserRouter : reactRouterCreateBrowserRouter
)(topLevelRoutes, {
future: {
v7_normalizeFormMethod: true,
},
});

View File

@@ -0,0 +1,36 @@
import type { RouteObject } from 'react-router-dom';
export const workbenchRoutes = [
{
path: '/all',
lazy: () => import('./pages/workspace/all-page/all-page'),
},
{
path: '/collection',
lazy: () => import('./pages/workspace/all-collection'),
},
{
path: '/collection/:collectionId',
lazy: () => import('./pages/workspace/collection/index'),
},
{
path: '/tag',
lazy: () => import('./pages/workspace/all-tag'),
},
{
path: '/tag/:tagId',
lazy: () => import('./pages/workspace/tag'),
},
{
path: '/trash',
lazy: () => import('./pages/workspace/trash-page'),
},
{
path: '/:pageId',
lazy: () => import('./pages/workspace/detail-page/detail-page'),
},
{
path: '*',
lazy: () => import('./pages/404'),
},
] satisfies RouteObject[];