refactor(infra): directory structure (#4615)

This commit is contained in:
Joooye_34
2023-10-18 23:30:08 +08:00
committed by GitHub
parent 814d552be8
commit bed9310519
1150 changed files with 539 additions and 584 deletions

View File

@@ -0,0 +1,53 @@
import { NotFoundPage } from '@affine/component/not-found-page';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
import type { ReactElement } from 'react';
import { useCallback, useState } from 'react';
import { SignOutModal } from '../components/affine/sign-out-modal';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
import { signOutCloud } from '../utils/cloud-utils';
export const Component = (): ReactElement => {
const { data: session } = useSession();
const { jumpToIndex } = useNavigateHelper();
const [open, setOpen] = useState(false);
const handleBackButtonClick = useCallback(
() => jumpToIndex(RouteLogic.REPLACE),
[jumpToIndex]
);
const handleOpenSignOutModal = useCallback(() => {
setOpen(true);
}, [setOpen]);
const onConfirmSignOut = useCallback(async () => {
setOpen(false);
await signOutCloud({
callbackUrl: '/signIn',
});
}, [setOpen]);
return (
<>
<NotFoundPage
user={
session?.user
? {
name: session.user.name || '',
email: session.user.email || '',
avatar: session.user.image || '',
}
: null
}
onBack={handleBackButtonClick}
onSignOut={handleOpenSignOutModal}
/>
<SignOutModal
open={open}
onOpenChange={setOpen}
onConfirm={onConfirmSignOut}
/>
</>
);
};

View File

@@ -0,0 +1,177 @@
import {
ChangeEmailPage,
ChangePasswordPage,
ConfirmChangeEmail,
SetPasswordPage,
SignInSuccessPage,
SignUpPage,
} from '@affine/component/auth-components';
import { pushNotificationAtom } from '@affine/component/notification-center';
import {
changeEmailMutation,
changePasswordMutation,
sendVerifyChangeEmailMutation,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { fetcher, useMutation } from '@affine/workspace/affine/gql';
import { useSetAtom } from 'jotai/react';
import type { ReactElement } from 'react';
import { useCallback } from 'react';
import {
type LoaderFunction,
redirect,
useParams,
useSearchParams,
} from 'react-router-dom';
import { z } from 'zod';
import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status';
import { useCurrentUser } from '../hooks/affine/use-current-user';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
const authTypeSchema = z.enum([
'setPassword',
'signIn',
'changePassword',
'signUp',
'changeEmail',
'confirm-change-email',
]);
export const AuthPage = (): ReactElement | null => {
const user = useCurrentUser();
const t = useAFFiNEI18N();
const { authType } = useParams();
const [searchParams] = useSearchParams();
const pushNotification = useSetAtom(pushNotificationAtom);
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) {
pushNotification({
title: t['com.affine.auth.sent.change.email.hint'](),
type: 'success',
});
}
return !!res?.sendVerifyChangeEmail;
},
[pushNotification, searchParams, sendVerifyChangeEmail, t]
);
const onSetPassword = useCallback(
(password: string) => {
changePassword({
token: searchParams.get('token') || '',
newPassword: password,
}).catch(console.error);
},
[changePassword, searchParams]
);
const onOpenAffine = useCallback(() => {
jumpToIndex(RouteLogic.REPLACE);
}, [jumpToIndex]);
switch (authType) {
case 'signUp': {
return (
<SignUpPage
user={user}
onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine}
/>
);
}
case 'signIn': {
return <SignInSuccessPage onOpenAffine={onOpenAffine} />;
}
case 'changePassword': {
return (
<ChangePasswordPage
user={user}
onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine}
/>
);
}
case 'setPassword': {
return (
<SetPasswordPage
user={user}
onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine}
/>
);
}
case 'changeEmail': {
return (
<ChangeEmailPage
onChangeEmail={onSendVerifyChangeEmail}
onOpenAffine={onOpenAffine}
/>
);
}
case 'confirm-change-email': {
return <ConfirmChangeEmail 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 res = await fetcher({
query: changeEmailMutation,
variables: {
token: token || '',
},
}).catch(console.error);
// TODO: Add error handling
if (!res?.changeEmail) {
return redirect('/expired');
}
}
return null;
};
export const Component = () => {
const loginStatus = useCurrentLoginStatus();
const { jumpToExpired } = useNavigateHelper();
if (loginStatus === 'unauthenticated') {
jumpToExpired(RouteLogic.REPLACE);
}
if (loginStatus === 'authenticated') {
return <AuthPage />;
}
return null;
};

View File

@@ -0,0 +1,39 @@
import { getSession } from 'next-auth/react';
import { type LoaderFunction } from 'react-router-dom';
import { z } from 'zod';
import { signInCloud, signOutCloud } from '../utils/cloud-utils';
const supportedProvider = z.enum(['google']);
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const searchParams = url.searchParams;
const provider = searchParams.get('provider');
const callback_url = searchParams.get('callback_url');
if (!callback_url) {
return null;
}
const session = await getSession();
if (session) {
// already signed in, need to sign out first
await signOutCloud({
callbackUrl: request.url, // retry
});
}
const maybeProvider = supportedProvider.safeParse(provider);
if (maybeProvider.success) {
const provider = maybeProvider.data;
await signInCloud(provider, {
callbackUrl: callback_url,
});
}
return null;
};
export const Component = () => {
return null;
};

View File

@@ -0,0 +1,25 @@
import { AuthPageContainer } from '@affine/component/auth-components';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { useCallback } from 'react';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
export const Component = () => {
const t = useAFFiNEI18N();
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 type="primary" size="large" onClick={onOpenAffine}>
{t['com.affine.auth.open.affine']()}
</Button>
</AuthPageContainer>
);
};

View File

@@ -0,0 +1,88 @@
import { DebugLogger } from '@affine/debug';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { Menu } from '@toeverything/components/menu';
import { getWorkspace } from '@toeverything/infra/__internal__/workspace';
import { getCurrentStore } from '@toeverything/infra/atom';
import { lazy } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import { redirect } from 'react-router-dom';
import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list';
const AllWorkspaceModals = lazy(() =>
import('../providers/modal-provider').then(({ AllWorkspaceModals }) => ({
default: AllWorkspaceModals,
}))
);
const logger = new DebugLogger('index-page');
export const loader: LoaderFunction = async () => {
const rootStore = getCurrentStore();
const { createFirstAppData } = await import('../bootstrap/setup');
createFirstAppData(rootStore);
const meta = await rootStore.get(rootWorkspacesMetadataAtom);
const lastId = localStorage.getItem('last_workspace_id');
const lastPageId = localStorage.getItem('last_page_id');
const target = (lastId && meta.find(({ id }) => id === lastId)) || meta.at(0);
if (target) {
const targetWorkspace = getWorkspace(target.id);
const nonTrashPages = targetWorkspace.meta.pageMetas.filter(
({ trash }) => !trash
);
const helloWorldPage = nonTrashPages.find(({ jumpOnce }) => jumpOnce)?.id;
const pageId =
nonTrashPages.find(({ id }) => id === lastPageId)?.id ??
nonTrashPages.at(0)?.id;
if (helloWorldPage) {
logger.debug(
'Found target workspace. Jump to hello world page',
helloWorldPage
);
return redirect(`/workspace/${targetWorkspace.id}/${helloWorldPage}`);
} else if (pageId) {
logger.debug('Found target workspace. Jump to page', pageId);
return redirect(`/workspace/${targetWorkspace.id}/${pageId}`);
} else {
logger.debug('Found target workspace. Jump to all page');
return redirect(`/workspace/${targetWorkspace.id}/all`);
}
}
return null;
};
export const Component = () => {
// TODO: We need a no workspace page
return (
<>
<div
style={{
position: 'fixed',
left: '50%',
top: '50%',
}}
>
<Menu
rootOptions={{
open: true,
}}
items={<UserWithWorkspaceList />}
contentOptions={{
style: {
width: 300,
transform: 'translate(-50%, -50%)',
borderRadius: '8px',
boxShadow: 'var(--affine-shadow-2)',
backgroundColor: 'var(--affine-background-overlay-panel-color)',
padding: '16px 12px',
},
}}
>
<div></div>
</Menu>
</div>
<AllWorkspaceModals />
</>
);
};

View File

@@ -0,0 +1,101 @@
import { AcceptInvitePage } from '@affine/component/member-components';
import { WorkspaceSubPath } from '@affine/env/workspace';
import {
acceptInviteByInviteIdMutation,
type GetInviteInfoQuery,
getInviteInfoQuery,
} from '@affine/graphql';
import { fetcher } from '@affine/workspace/affine/gql';
import { useSetAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
import { type LoaderFunction, redirect, useLoaderData } from 'react-router-dom';
import { authAtom } from '../atoms';
import { setOnceSignedInEventAtom } from '../atoms/event';
import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
import { useAppHelper } from '../hooks/use-workspaces';
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 loginStatus = useCurrentLoginStatus();
const { jumpToSignIn } = useNavigateHelper();
const { addCloudWorkspace } = useAppHelper();
const { jumpToSubPath } = useNavigateHelper();
const setOnceSignedInEvent = useSetAtom(setOnceSignedInEventAtom);
const setAuthAtom = useSetAtom(authAtom);
const { inviteInfo } = useLoaderData() as {
inviteId: string;
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
};
const openWorkspace = useCallback(() => {
addCloudWorkspace(inviteInfo.workspace.id);
jumpToSubPath(
inviteInfo.workspace.id,
WorkspaceSubPath.ALL,
RouteLogic.REPLACE
);
}, [addCloudWorkspace, inviteInfo.workspace.id, jumpToSubPath]);
useEffect(() => {
if (loginStatus === 'unauthenticated') {
// We can not pass function to navigate state, so we need to save it in atom
setOnceSignedInEvent(openWorkspace);
jumpToSignIn(RouteLogic.REPLACE, {
state: {
callbackURL: `/workspace/${inviteInfo.workspace.id}/all`,
},
});
}
}, [
inviteInfo.workspace.id,
jumpToSignIn,
loginStatus,
openWorkspace,
setAuthAtom,
setOnceSignedInEvent,
]);
if (loginStatus === 'authenticated') {
return (
<AcceptInvitePage
inviteInfo={inviteInfo}
onOpenWorkspace={openWorkspace}
/>
);
}
return null;
};

View File

@@ -0,0 +1,58 @@
import { style } from '@vanilla-extract/css';
export const root = style({
height: '100vh',
width: '100vw',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: 'var(--affine-font-base)',
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: 'var(--affine-text-primary-color)',
fontSize: 'var(--affine-font-sm)',
fontWeight: 500,
textDecoration: 'none',
padding: '4px 18px',
});
export const tryAgainLink = style({
color: 'var(--affine-link-color)',
fontWeight: 500,
textDecoration: 'none',
fontSize: 'var(--affine-font-sm)',
});
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,204 @@
import { type GetCurrentUserQuery, getCurrentUserQuery } from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { fetcher } from '@affine/workspace/affine/gql';
import { Logo1Icon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { useCallback, useMemo } from 'react';
import {
type LoaderFunction,
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>;
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>;
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 = useAFFiNEI18N();
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;
open(urlToOpen, '_blank');
}
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 = useMemo(() => params.get('url'), [params]);
const channel = useMemo(() => {
const urlObj = new URL(urlToOpen || '');
const maybeSchema = appSchemas.safeParse(urlObj.protocol.replace(':', ''));
return schemaToChanel[maybeSchema.success ? maybeSchema.data : 'affine'];
}, [urlToOpen]);
return <OpenAppImpl urlToOpen={urlToOpen} channel={channel} />;
};
const OpenOAuthJwt = () => {
const { currentUser } = useLoaderData() as LoaderData;
const [params] = useSearchParams();
const schema = useMemo(() => {
const maybeSchema = appSchemas.safeParse(params.get('schema'));
return maybeSchema.success ? maybeSchema.data : 'affine';
}, [params]);
const channel = schemaToChanel[schema as Schema];
if (!currentUser || !currentUser?.token?.sessionToken) {
return null;
}
const urlToOpen = `${schema}://signin-redirect?token=${currentUser.token.sessionToken}`;
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,90 @@
import { MainContainer } from '@affine/component/workspace';
import { DebugLogger } from '@affine/debug';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { getOrCreateWorkspace } from '@affine/workspace/manager';
import { downloadBinaryFromCloud } from '@affine/workspace/providers';
import { assertExists } from '@blocksuite/global/utils';
import type { Page } from '@blocksuite/store';
import { noop } from 'foxact/noop';
import type { ReactElement } from 'react';
import { useCallback } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import {
isRouteErrorResponse,
redirect,
useLoaderData,
useRouteError,
} from 'react-router-dom';
import { applyUpdate } from 'yjs';
import { PageDetailEditor } from '../../adapters/shared';
import { AppContainer } from '../../components/affine/app-container';
import { SharePageNotFoundError } from '../../components/share-page-not-found-error';
function assertArrayBuffer(value: unknown): asserts value is ArrayBuffer {
if (!(value instanceof ArrayBuffer)) {
throw new Error('value is not ArrayBuffer');
}
}
const logger = new DebugLogger('public:share-page');
export const loader: LoaderFunction = async ({ params }) => {
const workspaceId = params?.workspaceId;
const pageId = params?.pageId;
if (!workspaceId || !pageId) {
return redirect('/404');
}
const workspace = getOrCreateWorkspace(
workspaceId,
WorkspaceFlavour.AFFINE_PUBLIC
);
// download root workspace
{
const buffer = await downloadBinaryFromCloud(workspaceId, workspaceId);
assertArrayBuffer(buffer);
applyUpdate(workspace.doc, new Uint8Array(buffer));
}
const page = workspace.getPage(pageId);
assertExists(page, 'cannot find page');
// download page
{
const buffer = await downloadBinaryFromCloud(
workspaceId,
page.spaceDoc.guid
);
assertArrayBuffer(buffer);
applyUpdate(page.spaceDoc, new Uint8Array(buffer));
}
logger.info('workspace', workspace);
workspace.awarenessStore.setReadonly(page, true);
return page;
};
export const Component = (): ReactElement => {
const page = useLoaderData() as Page;
return (
<AppContainer>
<MainContainer>
<PageDetailEditor
isPublic
workspace={page.workspace}
pageId={page.id}
onInit={noop}
onLoad={useCallback(() => noop, [])}
/>
</MainContainer>
</AppContainer>
);
};
export function ErrorBoundary() {
const error = useRouteError();
return isRouteErrorResponse(error) ? (
<h1>
{error.status} {error.statusText}
</h1>
) : (
<SharePageNotFoundError />
);
}

View File

@@ -0,0 +1,70 @@
import { SignInPageContainer } from '@affine/component/auth-components';
import { useAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useLocation, useNavigate } from 'react-router-dom';
import { authAtom } from '../atoms';
import { AuthPanel } from '../components/affine/auth';
import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
interface LocationState {
state?: {
callbackURL?: string;
};
}
export const Component = () => {
const [{ state, email = '', emailType = 'changePassword' }, setAuthAtom] =
useAtom(authAtom);
const loginStatus = useCurrentLoginStatus();
const location = useLocation() as LocationState;
const navigate = useNavigate();
const { jumpToIndex } = useNavigateHelper();
useEffect(() => {
if (loginStatus === 'authenticated') {
if (location.state?.callbackURL) {
navigate(location.state.callbackURL, {
replace: true,
});
} else {
jumpToIndex(RouteLogic.REPLACE);
}
}
}, [
jumpToIndex,
location.state?.callbackURL,
loginStatus,
navigate,
setAuthAtom,
]);
return (
<SignInPageContainer>
<AuthPanel
state={state}
email={email}
emailType={emailType}
setEmailType={useCallback(
emailType => {
setAuthAtom(prev => ({ ...prev, emailType }));
},
[setAuthAtom]
)}
setAuthState={useCallback(
state => {
setAuthAtom(prev => ({ ...prev, state }));
},
[setAuthAtom]
)}
setAuthEmail={useCallback(
email => {
setAuthAtom(prev => ({ ...prev, email }));
},
[setAuthAtom]
)}
/>
</SignInPageContainer>
);
};

View File

@@ -0,0 +1,68 @@
import { useCollectionManager } from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { assertExists } from '@blocksuite/global/utils';
import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace';
import { getCurrentStore } from '@toeverything/infra/atom';
import { useCallback } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import { redirect } from 'react-router-dom';
import { getUIAdapter } from '../../adapters/workspace';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { currentCollectionsAtom } from '../../utils/user-setting';
export const loader: LoaderFunction = async args => {
const rootStore = getCurrentStore();
const workspaceId = args.params.workspaceId;
assertExists(workspaceId);
const [workspaceAtom] = getBlockSuiteWorkspaceAtom(workspaceId);
const workspace = await rootStore.get(workspaceAtom);
for (const pageId of workspace.pages.keys()) {
const page = workspace.getPage(pageId);
if (page && page.meta.jumpOnce) {
workspace.meta.setPageMeta(page.id, {
jumpOnce: false,
});
return redirect(`/workspace/${workspace.id}/${page.id}`);
}
}
return null;
};
export const AllPage = () => {
const { jumpToPage } = useNavigateHelper();
const [currentWorkspace] = useCurrentWorkspace();
const setting = useCollectionManager(currentCollectionsAtom);
const onClickPage = useCallback(
(pageId: string, newTab?: boolean) => {
assertExists(currentWorkspace);
if (newTab) {
window.open(`/workspace/${currentWorkspace?.id}/${pageId}`, '_blank');
} else {
jumpToPage(currentWorkspace.id, pageId);
}
},
[currentWorkspace, jumpToPage]
);
const { PageList, Header } = getUIAdapter(currentWorkspace.flavour);
return (
<>
<Header
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
subPath: WorkspaceSubPath.ALL,
}}
/>
<PageList
collection={setting.currentCollection}
onOpenPage={onClickPage}
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
/>
</>
);
};
export const Component = () => {
return <AllPage />;
};

View File

@@ -0,0 +1,149 @@
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import {
createTagFilter,
useCollectionManager,
} from '@affine/component/page-list';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { globalBlockSuiteSchema } from '@affine/workspace/manager';
import type { EditorContainer } from '@blocksuite/editor';
import { assertExists } from '@blocksuite/global/utils';
import type { Page } from '@blocksuite/store';
import {
contentLayoutAtom,
currentPageIdAtom,
currentWorkspaceAtom,
currentWorkspaceIdAtom,
getCurrentStore,
} from '@toeverything/infra/atom';
import { useAtomValue, useSetAtom } from 'jotai';
import { type ReactElement, useCallback } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import { redirect } from 'react-router-dom';
import type { Map as YMap } from 'yjs';
import { getUIAdapter } from '../../adapters/workspace';
import { setPageModeAtom } from '../../atoms';
import { currentModeAtom } from '../../atoms/mode';
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/affine/use-register-blocksuite-editor-commands';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { currentCollectionsAtom } from '../../utils/user-setting';
const DetailPageImpl = (): ReactElement => {
const { openPage, jumpToSubPath } = useNavigateHelper();
const currentPageId = useAtomValue(currentPageIdAtom);
const [currentWorkspace] = useCurrentWorkspace();
assertExists(currentWorkspace);
assertExists(currentPageId);
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
const collectionManager = useCollectionManager(currentCollectionsAtom);
const mode = useAtomValue(currentModeAtom);
const setPageMode = useSetAtom(setPageModeAtom);
useRegisterBlocksuiteEditorCommands(blockSuiteWorkspace, currentPageId, mode);
const onLoad = useCallback(
(page: Page, editor: EditorContainer) => {
try {
const surfaceBlock = page.getBlockByFlavour('affine:surface')[0];
// hotfix for old page
if (
surfaceBlock &&
(surfaceBlock.yBlock.get('prop:elements') as YMap<any>).get(
'type'
) !== '$blocksuite:internal:native$'
) {
globalBlockSuiteSchema.upgradePage(
0,
{
'affine:surface': 3,
},
page.spaceDoc
);
}
} catch {}
setPageMode(currentPageId, mode);
const dispose = editor.slots.pageLinkClicked.on(({ pageId }) => {
return openPage(blockSuiteWorkspace.id, pageId);
});
const disposeTagClick = editor.slots.tagClicked.on(async ({ tagId }) => {
jumpToSubPath(currentWorkspace.id, WorkspaceSubPath.ALL);
collectionManager.backToAll();
collectionManager.setTemporaryFilter([createTagFilter(tagId)]);
});
return () => {
dispose.dispose();
disposeTagClick.dispose();
};
},
[
blockSuiteWorkspace.id,
collectionManager,
currentPageId,
currentWorkspace.id,
jumpToSubPath,
mode,
openPage,
setPageMode,
]
);
const { PageDetail, Header } = getUIAdapter(currentWorkspace.flavour);
return (
<>
<Header
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
pageId: currentPageId,
}}
/>
<PageDetail
currentWorkspaceId={currentWorkspace.id}
currentPageId={currentPageId}
onLoadEditor={onLoad}
/>
</>
);
};
export const DetailPage = (): ReactElement => {
const [currentWorkspace] = useCurrentWorkspace();
const currentPageId = useAtomValue(currentPageIdAtom);
const page = currentPageId
? currentWorkspace.blockSuiteWorkspace.getPage(currentPageId)
: null;
if (!currentPageId || !page) {
return <PageDetailSkeleton key="current-page-is-null" />;
}
return <DetailPageImpl />;
};
export const loader: LoaderFunction = async args => {
const rootStore = getCurrentStore();
rootStore.set(contentLayoutAtom, 'editor');
if (args.params.workspaceId) {
localStorage.setItem('last_workspace_id', args.params.workspaceId);
rootStore.set(currentWorkspaceIdAtom, args.params.workspaceId);
}
const currentWorkspace = await rootStore.get(currentWorkspaceAtom);
if (args.params.pageId) {
const pageId = args.params.pageId;
localStorage.setItem('last_page_id', pageId);
const page = currentWorkspace.getPage(pageId);
if (!page) {
return redirect('/404');
}
if (page.meta.jumpOnce) {
currentWorkspace.setPageMeta(page.id, {
jumpOnce: false,
});
}
rootStore.set(currentPageIdAtom, pageId);
} else {
return redirect('/404');
}
return null;
};
export const Component = () => {
return <DetailPage />;
};

View File

@@ -0,0 +1,59 @@
import { WorkspaceFlavour } from '@affine/env/workspace';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace';
import {
currentPageIdAtom,
currentWorkspaceIdAtom,
getCurrentStore,
} from '@toeverything/infra/atom';
import type { ReactElement } from 'react';
import {
type LoaderFunction,
Outlet,
redirect,
useLoaderData,
} from 'react-router-dom';
import { WorkspaceLayout } from '../../layouts/workspace-layout';
export const loader: LoaderFunction = async args => {
const rootStore = getCurrentStore();
const meta = await rootStore.get(rootWorkspacesMetadataAtom);
const currentMetadata = meta.find(({ id }) => id === args.params.workspaceId);
if (!currentMetadata) {
return redirect('/404');
}
if (args.params.workspaceId) {
localStorage.setItem('last_workspace_id', args.params.workspaceId);
rootStore.set(currentWorkspaceIdAtom, args.params.workspaceId);
}
if (!args.params.pageId) {
rootStore.set(currentPageIdAtom, null);
}
if (currentMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD) {
const [workspaceAtom] = getBlockSuiteWorkspaceAtom(currentMetadata.id);
const workspace = await rootStore.get(workspaceAtom);
return (() => {
const blockVersions = workspace.meta.blockVersions;
if (!blockVersions) {
return true;
}
for (const [flavour, schema] of workspace.schema.flavourSchemaMap) {
if (blockVersions[flavour] !== schema.version) {
return true;
}
}
return false;
})();
}
return null;
};
export const Component = (): ReactElement => {
const incompatible = useLoaderData();
return (
<WorkspaceLayout incompatible={!!incompatible}>
<Outlet />
</WorkspaceLayout>
);
};

View File

@@ -0,0 +1,47 @@
import { WorkspaceSubPath } from '@affine/env/workspace';
import { assertExists } from '@blocksuite/global/utils';
import { useCallback } from 'react';
import { getUIAdapter } from '../../adapters/workspace';
import { BlockSuitePageList } from '../../components/blocksuite/block-suite-page-list';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
export const TrashPage = () => {
const { jumpToPage } = useNavigateHelper();
const [currentWorkspace] = useCurrentWorkspace();
const onClickPage = useCallback(
(pageId: string, newTab?: boolean) => {
assertExists(currentWorkspace);
if (newTab) {
window.open(`/workspace/${currentWorkspace?.id}/${pageId}`, '_blank');
} else {
jumpToPage(currentWorkspace.id, pageId);
}
},
[currentWorkspace, jumpToPage]
);
// todo(himself65): refactor to plugin
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
assertExists(blockSuiteWorkspace);
const { Header } = getUIAdapter(currentWorkspace.flavour);
return (
<>
<Header
currentWorkspaceId={currentWorkspace.id}
currentEntry={{
subPath: WorkspaceSubPath.TRASH,
}}
/>
<BlockSuitePageList
blockSuiteWorkspace={blockSuiteWorkspace}
onOpenPage={onClickPage}
listType="trash"
/>
</>
);
};
export const Component = () => {
return <TrashPage />;
};