mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 22:37:04 +08:00
refactor(core): move mobile components to core (#8258)
This commit is contained in:
75
packages/frontend/core/src/desktop/pages/404.tsx
Normal file
75
packages/frontend/core/src/desktop/pages/404.tsx
Normal 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 />;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AIUpgradeSuccess } from '../../components/affine/subscription-landing';
|
||||
|
||||
export const Component = () => {
|
||||
return <AIUpgradeSuccess />;
|
||||
};
|
||||
206
packages/frontend/core/src/desktop/pages/auth/auth.tsx
Normal file
206
packages/frontend/core/src/desktop/pages/auth/auth.tsx
Normal 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;
|
||||
};
|
||||
9
packages/frontend/core/src/desktop/pages/auth/common.ts
Normal file
9
packages/frontend/core/src/desktop/pages/auth/common.ts
Normal 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'] : []),
|
||||
]);
|
||||
75
packages/frontend/core/src/desktop/pages/auth/magic-link.tsx
Normal file
75
packages/frontend/core/src/desktop/pages/auth/magic-link.tsx
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
56
packages/frontend/core/src/desktop/pages/auth/sign-in.tsx
Normal file
56
packages/frontend/core/src/desktop/pages/auth/sign-in.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
packages/frontend/core/src/desktop/pages/expired.tsx
Normal file
28
packages/frontend/core/src/desktop/pages/expired.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
packages/frontend/core/src/desktop/pages/import-template.tsx
Normal file
22
packages/frontend/core/src/desktop/pages/import-template.tsx
Normal 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;
|
||||
};
|
||||
166
packages/frontend/core/src/desktop/pages/index.tsx
Normal file
166
packages/frontend/core/src/desktop/pages/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
100
packages/frontend/core/src/desktop/pages/invite.tsx
Normal file
100
packages/frontend/core/src/desktop/pages/invite.tsx
Normal 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;
|
||||
};
|
||||
42
packages/frontend/core/src/desktop/pages/onboarding.tsx
Normal file
42
packages/frontend/core/src/desktop/pages/onboarding.tsx
Normal 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} />;
|
||||
};
|
||||
51
packages/frontend/core/src/desktop/pages/open-app.css.ts
Normal file
51
packages/frontend/core/src/desktop/pages/open-app.css.ts
Normal 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,
|
||||
});
|
||||
210
packages/frontend/core/src/desktop/pages/open-app.tsx
Normal file
210
packages/frontend/core/src/desktop/pages/open-app.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
55
packages/frontend/core/src/desktop/pages/redirect.tsx
Normal file
55
packages/frontend/core/src/desktop/pages/redirect.tsx
Normal 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" />;
|
||||
};
|
||||
12
packages/frontend/core/src/desktop/pages/root.tsx
Normal file
12
packages/frontend/core/src/desktop/pages/root.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { AllWorkspaceModals } from '../../components/providers/modal-provider';
|
||||
|
||||
export const RootWrapper = () => {
|
||||
return (
|
||||
<>
|
||||
<AllWorkspaceModals />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
};
|
||||
13
packages/frontend/core/src/desktop/pages/subscribe.css.ts
Normal file
13
packages/frontend/core/src/desktop/pages/subscribe.css.ts
Normal 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'),
|
||||
});
|
||||
171
packages/frontend/core/src/desktop/pages/subscribe.tsx
Normal file
171
packages/frontend/core/src/desktop/pages/subscribe.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ThemeEditor } from '../../modules/theme-editor';
|
||||
|
||||
export const Component = () => {
|
||||
return <ThemeEditor />;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CloudUpgradeSuccess } from '../../components/affine/subscription-landing';
|
||||
|
||||
export const Component = () => {
|
||||
return <CloudUpgradeSuccess />;
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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'} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const body = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
@@ -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 />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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%',
|
||||
});
|
||||
@@ -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 />;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const body = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
@@ -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'} />} />;
|
||||
};
|
||||
@@ -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 />;
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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'} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './detail-page';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
@@ -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} />;
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
225
packages/frontend/core/src/desktop/pages/workspace/index.tsx
Normal file
225
packages/frontend/core/src/desktop/pages/workspace/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const body = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
101
packages/frontend/core/src/desktop/pages/workspace/tag/index.tsx
Normal file
101
packages/frontend/core/src/desktop/pages/workspace/tag/index.tsx
Normal 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} />;
|
||||
};
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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 />;
|
||||
};
|
||||
@@ -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]} />;
|
||||
};
|
||||
177
packages/frontend/core/src/desktop/router.tsx
Normal file
177
packages/frontend/core/src/desktop/router.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
36
packages/frontend/core/src/desktop/workbench-router.ts
Normal file
36
packages/frontend/core/src/desktop/workbench-router.ts
Normal 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[];
|
||||
Reference in New Issue
Block a user