mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-03 02:20:19 +08:00
feat(core): import template (#8000)
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
|
||||
import type { CreateWorkspaceCallbackPayload } from '../types';
|
||||
|
||||
export class CreateWorkspaceDialog extends Entity {
|
||||
readonly mode$ = new LiveData<'new' | 'add'>('new');
|
||||
readonly isOpen$ = new LiveData(false);
|
||||
readonly callback$ = new LiveData<
|
||||
(data: CreateWorkspaceCallbackPayload | undefined) => void
|
||||
>(() => {});
|
||||
|
||||
open(
|
||||
mode: 'new' | 'add',
|
||||
callback?: (data: CreateWorkspaceCallbackPayload | undefined) => void
|
||||
) {
|
||||
this.callback(undefined);
|
||||
this.mode$.next(mode);
|
||||
this.isOpen$.next(true);
|
||||
if (callback) {
|
||||
this.callback$.next(callback);
|
||||
}
|
||||
}
|
||||
|
||||
callback(payload: CreateWorkspaceCallbackPayload | undefined) {
|
||||
this.callback$.value(payload);
|
||||
this.callback$.next(() => {});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen$.next(false);
|
||||
this.callback(undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
|
||||
import { CreateWorkspaceDialog } from './entities/dialog';
|
||||
import { CreateWorkspaceDialogService } from './services/dialog';
|
||||
|
||||
export { CreateWorkspaceDialogService } from './services/dialog';
|
||||
export type { CreateWorkspaceCallbackPayload } from './types';
|
||||
export { CreateWorkspaceDialogProvider } from './views/dialog';
|
||||
|
||||
export function configureCreateWorkspaceModule(framework: Framework) {
|
||||
framework.service(CreateWorkspaceDialogService).entity(CreateWorkspaceDialog);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { CreateWorkspaceDialog } from '../entities/dialog';
|
||||
|
||||
export class CreateWorkspaceDialogService extends Service {
|
||||
dialog = this.framework.createEntity(CreateWorkspaceDialog);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { WorkspaceMetadata } from '@toeverything/infra';
|
||||
|
||||
export type CreateWorkspaceMode = 'add' | 'new';
|
||||
export type CreateWorkspaceCallbackPayload = {
|
||||
meta: WorkspaceMetadata;
|
||||
defaultDocId?: string;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const header = style({
|
||||
position: 'relative',
|
||||
marginTop: '44px',
|
||||
});
|
||||
|
||||
export const subTitle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const avatarWrapper = style({
|
||||
display: 'flex',
|
||||
margin: '10px 0',
|
||||
});
|
||||
|
||||
export const workspaceNameWrapper = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '12px 0',
|
||||
});
|
||||
export const affineCloudWrapper = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
paddingTop: '10px',
|
||||
});
|
||||
|
||||
export const card = style({
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: cssVar('backgroundSecondaryColor'),
|
||||
minHeight: '114px',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const cardText = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const cardTitle = style({
|
||||
fontSize: cssVar('fontBase'),
|
||||
color: cssVar('textPrimaryColor'),
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
export const cardDescription = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVar('textSecondaryColor'),
|
||||
maxWidth: '288px',
|
||||
});
|
||||
|
||||
export const cloudTips = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVar('textSecondaryColor'),
|
||||
});
|
||||
|
||||
export const cloudSvgContainer = style({
|
||||
width: '146px',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
right: '0',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { Avatar, ConfirmModal, Input, Switch, toast } from '@affine/component';
|
||||
import type { ConfirmModalProps } from '@affine/component/ui/modal';
|
||||
import { authAtom } from '@affine/core/atoms';
|
||||
import { CloudSvg } from '@affine/core/components/affine/share-page-modal/cloud-svg';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { track } from '@affine/core/mixpanel';
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { apis } from '@affine/electron-api';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
initEmptyPage,
|
||||
useLiveData,
|
||||
useService,
|
||||
WorkspacesService,
|
||||
} from '@toeverything/infra';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useCallback, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { buildShowcaseWorkspace } from '../../../bootstrap/first-app-data';
|
||||
import { AuthService } from '../../../modules/cloud';
|
||||
import { _addLocalWorkspace } from '../../../modules/workspace-engine';
|
||||
import { CreateWorkspaceDialogService } from '../services/dialog';
|
||||
import * as styles from './dialog.css';
|
||||
|
||||
const logger = new DebugLogger('CreateWorkspaceModal');
|
||||
|
||||
interface NameWorkspaceContentProps extends ConfirmModalProps {
|
||||
loading: boolean;
|
||||
onConfirmName: (
|
||||
name: string,
|
||||
workspaceFlavour: WorkspaceFlavour,
|
||||
avatar?: File
|
||||
) => void;
|
||||
}
|
||||
|
||||
const shouldEnableCloud = !runtimeConfig.allowLocalWorkspace;
|
||||
|
||||
const NameWorkspaceContent = ({
|
||||
loading,
|
||||
onConfirmName,
|
||||
...props
|
||||
}: NameWorkspaceContentProps) => {
|
||||
const t = useI18n();
|
||||
const [workspaceName, setWorkspaceName] = useState('');
|
||||
const [enable, setEnable] = useState(shouldEnableCloud);
|
||||
const session = useService(AuthService).session;
|
||||
const loginStatus = useLiveData(session.status$);
|
||||
|
||||
const setOpenSignIn = useSetAtom(authAtom);
|
||||
|
||||
const openSignInModal = useCallback(() => {
|
||||
setOpenSignIn(state => ({
|
||||
...state,
|
||||
openModal: true,
|
||||
}));
|
||||
}, [setOpenSignIn]);
|
||||
|
||||
const onSwitchChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (loginStatus !== 'authenticated') {
|
||||
return openSignInModal();
|
||||
}
|
||||
return setEnable(checked);
|
||||
},
|
||||
[loginStatus, openSignInModal]
|
||||
);
|
||||
|
||||
const handleCreateWorkspace = useCallback(() => {
|
||||
onConfirmName(
|
||||
workspaceName,
|
||||
enable ? WorkspaceFlavour.AFFINE_CLOUD : WorkspaceFlavour.LOCAL
|
||||
);
|
||||
}, [enable, onConfirmName, workspaceName]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && workspaceName) {
|
||||
handleCreateWorkspace();
|
||||
}
|
||||
},
|
||||
[handleCreateWorkspace, workspaceName]
|
||||
);
|
||||
|
||||
// Currently, when we create a new workspace and upload an avatar at the same time,
|
||||
// an error occurs after the creation is successful: get blob 404 not found
|
||||
return (
|
||||
<ConfirmModal
|
||||
defaultOpen={true}
|
||||
title={t['com.affine.nameWorkspace.title']()}
|
||||
description={t['com.affine.nameWorkspace.description']()}
|
||||
cancelText={t['com.affine.nameWorkspace.button.cancel']()}
|
||||
confirmText={t['com.affine.nameWorkspace.button.create']()}
|
||||
confirmButtonOptions={{
|
||||
variant: 'primary',
|
||||
loading,
|
||||
disabled: !workspaceName,
|
||||
['data-testid' as string]: 'create-workspace-create-button',
|
||||
}}
|
||||
closeButtonOptions={{
|
||||
['data-testid' as string]: 'create-workspace-close-button',
|
||||
}}
|
||||
onConfirm={handleCreateWorkspace}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.avatarWrapper}>
|
||||
<Avatar size={56} name={workspaceName} colorfulFallback />
|
||||
</div>
|
||||
|
||||
<div className={styles.workspaceNameWrapper}>
|
||||
<div className={styles.subTitle}>
|
||||
{t['com.affine.nameWorkspace.subtitle.workspace-name']()}
|
||||
</div>
|
||||
<Input
|
||||
autoFocus
|
||||
data-testid="create-workspace-input"
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t['com.affine.nameWorkspace.placeholder']()}
|
||||
maxLength={64}
|
||||
minLength={0}
|
||||
onChange={setWorkspaceName}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.affineCloudWrapper}>
|
||||
<div className={styles.subTitle}>{t['AFFiNE Cloud']()}</div>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardText}>
|
||||
<div className={styles.cardTitle}>
|
||||
<span>{t['com.affine.nameWorkspace.affine-cloud.title']()}</span>
|
||||
<Switch
|
||||
checked={enable}
|
||||
onChange={onSwitchChange}
|
||||
disabled={shouldEnableCloud}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.cardDescription}>
|
||||
{t['com.affine.nameWorkspace.affine-cloud.description']()}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cloudSvgContainer}>
|
||||
<CloudSvg />
|
||||
</div>
|
||||
</div>
|
||||
{shouldEnableCloud ? (
|
||||
<a
|
||||
className={styles.cloudTips}
|
||||
href={runtimeConfig.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t['com.affine.nameWorkspace.affine-cloud.web-tips']()}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateWorkspaceDialog = () => {
|
||||
const createWorkspaceDialogService = useService(CreateWorkspaceDialogService);
|
||||
const mode = useLiveData(createWorkspaceDialogService.dialog.mode$);
|
||||
const t = useI18n();
|
||||
const workspacesService = useService(WorkspacesService);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// TODO(@Peng): maybe refactor using xstate?
|
||||
useLayoutEffect(() => {
|
||||
let canceled = false;
|
||||
// if mode changed, reset step
|
||||
if (mode === 'add') {
|
||||
// a hack for now
|
||||
// when adding a workspace, we will immediately let user select a db file
|
||||
// after it is done, it will effectively add a new workspace to app-data folder
|
||||
// so after that, we will be able to load it via importLocalWorkspace
|
||||
(async () => {
|
||||
if (!apis) {
|
||||
return;
|
||||
}
|
||||
logger.info('load db file');
|
||||
const result = await apis.dialog.loadDBFile();
|
||||
if (result.workspaceId && !canceled) {
|
||||
_addLocalWorkspace(result.workspaceId);
|
||||
workspacesService.list.revalidate();
|
||||
createWorkspaceDialogService.dialog.callback({
|
||||
meta: {
|
||||
flavour: WorkspaceFlavour.LOCAL,
|
||||
id: result.workspaceId,
|
||||
},
|
||||
});
|
||||
} else if (result.error || result.canceled) {
|
||||
if (result.error) {
|
||||
toast(t[result.error]());
|
||||
}
|
||||
createWorkspaceDialogService.dialog.callback(undefined);
|
||||
createWorkspaceDialogService.dialog.close();
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [createWorkspaceDialogService, mode, t, workspacesService]);
|
||||
|
||||
const onConfirmName = useAsyncCallback(
|
||||
async (name: string, workspaceFlavour: WorkspaceFlavour) => {
|
||||
track.$.$.$.createWorkspace({ flavour: workspaceFlavour });
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
|
||||
// this will be the last step for web for now
|
||||
// fix me later
|
||||
if (runtimeConfig.enablePreloading) {
|
||||
const { meta, defaultDocId } = await buildShowcaseWorkspace(
|
||||
workspacesService,
|
||||
workspaceFlavour,
|
||||
name
|
||||
);
|
||||
createWorkspaceDialogService.dialog.callback({ meta, defaultDocId });
|
||||
} else {
|
||||
let defaultDocId: string | undefined = undefined;
|
||||
const meta = await workspacesService.create(
|
||||
workspaceFlavour,
|
||||
async workspace => {
|
||||
workspace.meta.initialize();
|
||||
workspace.meta.setName(name);
|
||||
const page = workspace.createDoc();
|
||||
defaultDocId = page.id;
|
||||
initEmptyPage(page);
|
||||
}
|
||||
);
|
||||
createWorkspaceDialogService.dialog.callback({ meta, defaultDocId });
|
||||
}
|
||||
|
||||
createWorkspaceDialogService.dialog.close();
|
||||
setLoading(false);
|
||||
},
|
||||
[createWorkspaceDialogService.dialog, loading, workspacesService]
|
||||
);
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
createWorkspaceDialogService.dialog.close();
|
||||
}
|
||||
},
|
||||
[createWorkspaceDialogService]
|
||||
);
|
||||
|
||||
if (mode === 'new') {
|
||||
return (
|
||||
<NameWorkspaceContent
|
||||
loading={loading}
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
onConfirmName={onConfirmName}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const CreateWorkspaceDialogProvider = () => {
|
||||
const createWorkspaceDialogService = useService(CreateWorkspaceDialogService);
|
||||
const isOpen = useLiveData(createWorkspaceDialogService.dialog.isOpen$);
|
||||
|
||||
return isOpen ? <CreateWorkspaceDialog /> : null;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
|
||||
export class ImportTemplateDialog extends Entity {
|
||||
readonly isOpen$ = new LiveData(false);
|
||||
readonly template$ = new LiveData<{
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
templateName: string;
|
||||
} | null>(null);
|
||||
|
||||
open(workspaceId: string, docId: string, templateName: string) {
|
||||
this.template$.next({ workspaceId, docId, templateName });
|
||||
this.isOpen$.next(true);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen$.next(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, mergeMap, switchMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { TemplateDownloaderStore } from '../store/downloader';
|
||||
|
||||
export class TemplateDownloader extends Entity {
|
||||
constructor(private readonly store: TemplateDownloaderStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
readonly isDownloading$ = new LiveData<boolean>(false);
|
||||
readonly data$ = new LiveData<Uint8Array | null>(null);
|
||||
readonly error$ = new LiveData<any | null>(null);
|
||||
|
||||
readonly download = effect(
|
||||
switchMap(
|
||||
({ workspaceId, docId }: { workspaceId: string; docId: string }) => {
|
||||
return fromPromise(() => this.store.download(workspaceId, docId)).pipe(
|
||||
mergeMap(({ data }) => {
|
||||
this.data$.next(data);
|
||||
return EMPTY;
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => {
|
||||
this.isDownloading$.next(true);
|
||||
this.data$.next(null);
|
||||
this.error$.next(null);
|
||||
}),
|
||||
onComplete(() => this.isDownloading$.next(false))
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { type Framework, WorkspacesService } from '@toeverything/infra';
|
||||
|
||||
import { FetchService } from '../cloud';
|
||||
import { ImportTemplateDialog } from './entities/dialog';
|
||||
import { TemplateDownloader } from './entities/downloader';
|
||||
import { ImportTemplateDialogService } from './services/dialog';
|
||||
import { TemplateDownloaderService } from './services/downloader';
|
||||
import { ImportTemplateService } from './services/import';
|
||||
import { TemplateDownloaderStore } from './store/downloader';
|
||||
|
||||
export { ImportTemplateDialogService } from './services/dialog';
|
||||
export { ImportTemplateDialogProvider } from './views/dialog';
|
||||
|
||||
export function configureImportTemplateModule(framework: Framework) {
|
||||
framework
|
||||
.service(ImportTemplateDialogService)
|
||||
.entity(ImportTemplateDialog)
|
||||
.service(TemplateDownloaderService)
|
||||
.entity(TemplateDownloader, [TemplateDownloaderStore])
|
||||
.store(TemplateDownloaderStore, [FetchService])
|
||||
.service(ImportTemplateService, [WorkspacesService]);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { ImportTemplateDialog } from '../entities/dialog';
|
||||
|
||||
export class ImportTemplateDialogService extends Service {
|
||||
dialog = this.framework.createEntity(ImportTemplateDialog);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import { TemplateDownloader } from '../entities/downloader';
|
||||
|
||||
export class TemplateDownloaderService extends Service {
|
||||
downloader = this.framework.createEntity(TemplateDownloader);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import type { WorkspaceMetadata, WorkspacesService } from '@toeverything/infra';
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
export class ImportTemplateService extends Service {
|
||||
constructor(private readonly workspacesService: WorkspacesService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async importToWorkspace(
|
||||
workspaceMetadata: WorkspaceMetadata,
|
||||
docBinary: Uint8Array
|
||||
) {
|
||||
const { workspace, dispose: disposeWorkspace } =
|
||||
this.workspacesService.open({
|
||||
metadata: workspaceMetadata,
|
||||
});
|
||||
await workspace.engine.waitForRootDocReady();
|
||||
const newDoc = workspace.docCollection.createDoc({});
|
||||
await workspace.engine.doc.storage.behavior.doc.set(
|
||||
newDoc.spaceDoc.guid,
|
||||
docBinary
|
||||
);
|
||||
disposeWorkspace();
|
||||
return newDoc.id;
|
||||
}
|
||||
|
||||
async importToNewWorkspace(
|
||||
flavour: WorkspaceFlavour,
|
||||
workspaceName: string,
|
||||
docBinary: Uint8Array
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
let docId: string = null!;
|
||||
const { id: workspaceId } = await this.workspacesService.create(
|
||||
flavour,
|
||||
async (docCollection, _, docStorage) => {
|
||||
docCollection.meta.initialize();
|
||||
docCollection.meta.setName(workspaceName);
|
||||
const doc = docCollection.createDoc();
|
||||
docId = doc.id;
|
||||
await docStorage.doc.set(doc.spaceDoc.guid, docBinary);
|
||||
}
|
||||
);
|
||||
return { workspaceId, docId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { FetchService } from '../../cloud';
|
||||
|
||||
export class TemplateDownloaderStore extends Store {
|
||||
constructor(private readonly fetchService: FetchService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async download(workspaceId: string, docId: string) {
|
||||
const response = await this.fetchService.fetch(
|
||||
`/api/workspaces/${workspaceId}/docs/${docId}`,
|
||||
{
|
||||
priority: 'high',
|
||||
} as any
|
||||
);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
return { data: new Uint8Array(arrayBuffer) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const dialogContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
color: cssVarV2('text/primary'),
|
||||
padding: '16px',
|
||||
});
|
||||
|
||||
export const mainIcon = style({
|
||||
width: 36,
|
||||
height: 36,
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const mainTitle = style({
|
||||
fontSize: '18px',
|
||||
lineHeight: '26px',
|
||||
textAlign: 'center',
|
||||
marginTop: '16px',
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const desc = style({
|
||||
textAlign: 'center',
|
||||
color: cssVarV2('text/secondary'),
|
||||
marginBottom: '20px',
|
||||
});
|
||||
|
||||
export const mainButton = style({
|
||||
width: '100%',
|
||||
fontSize: '14px',
|
||||
height: '42px',
|
||||
});
|
||||
|
||||
export const modal = style({
|
||||
maxWidth: '400px',
|
||||
});
|
||||
|
||||
export const workspaceSelector = style({
|
||||
margin: '0 -16px',
|
||||
width: 'calc(100% + 32px)',
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
padding: '0 16px',
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Button, Modal } from '@affine/component';
|
||||
import { WorkspaceSelector } from '@affine/core/components/workspace-selector';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||
import { useWorkspaceName } from '@affine/core/hooks/use-workspace-info';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { AllDocsIcon } from '@blocksuite/icons/rc';
|
||||
import {
|
||||
useLiveData,
|
||||
useService,
|
||||
type WorkspaceMetadata,
|
||||
WorkspacesService,
|
||||
} from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { AuthService } from '../../cloud';
|
||||
import type { CreateWorkspaceCallbackPayload } from '../../create-workspace';
|
||||
import { ImportTemplateDialogService } from '../services/dialog';
|
||||
import { TemplateDownloaderService } from '../services/downloader';
|
||||
import { ImportTemplateService } from '../services/import';
|
||||
import * as styles from './dialog.css';
|
||||
|
||||
const Dialog = ({
|
||||
workspaceId,
|
||||
docId,
|
||||
templateName,
|
||||
onClose,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
templateName: string;
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const session = useService(AuthService).session;
|
||||
const notLogin = useLiveData(session.status$) === 'unauthenticated';
|
||||
const isSessionRevalidating = useLiveData(session.isRevalidating$);
|
||||
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importingError, setImportingError] = useState<any>(null);
|
||||
const workspacesService = useService(WorkspacesService);
|
||||
const templateDownloaderService = useService(TemplateDownloaderService);
|
||||
const importTemplateService = useService(ImportTemplateService);
|
||||
const templateDownloader = templateDownloaderService.downloader;
|
||||
const isDownloading = useLiveData(templateDownloader.isDownloading$);
|
||||
const downloadError = useLiveData(templateDownloader.error$);
|
||||
const workspaces = useLiveData(workspacesService.list.workspaces$);
|
||||
const [rawSelectedWorkspace, setSelectedWorkspace] =
|
||||
useState<WorkspaceMetadata | null>(null);
|
||||
const selectedWorkspace =
|
||||
rawSelectedWorkspace ??
|
||||
workspaces.find(w => w.flavour === WorkspaceFlavour.AFFINE_CLOUD) ??
|
||||
workspaces.at(0);
|
||||
const selectedWorkspaceName = useWorkspaceName(selectedWorkspace);
|
||||
const { openPage, jumpToSignIn } = useNavigateHelper();
|
||||
|
||||
const noWorkspace = workspaces.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
workspacesService.list.revalidate();
|
||||
}, [workspacesService]);
|
||||
|
||||
useEffect(() => {
|
||||
session.revalidate();
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSessionRevalidating && notLogin) {
|
||||
jumpToSignIn(
|
||||
'/template/import?workspaceId=' +
|
||||
workspaceId +
|
||||
'&docId=' +
|
||||
docId +
|
||||
'&name=' +
|
||||
templateName
|
||||
);
|
||||
onClose?.();
|
||||
}
|
||||
}, [
|
||||
docId,
|
||||
isSessionRevalidating,
|
||||
jumpToSignIn,
|
||||
notLogin,
|
||||
onClose,
|
||||
templateName,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
templateDownloader.download({ workspaceId, docId });
|
||||
}, [docId, templateDownloader, workspaceId]);
|
||||
|
||||
const handleSelectedWorkspace = useCallback(
|
||||
(workspaceMetadata: WorkspaceMetadata) => {
|
||||
return setSelectedWorkspace(workspaceMetadata);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCreatedWorkspace = useCallback(
|
||||
(payload: CreateWorkspaceCallbackPayload) => {
|
||||
return setSelectedWorkspace(payload.meta);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleImportToSelectedWorkspace = useAsyncCallback(async () => {
|
||||
if (templateDownloader.data$.value && selectedWorkspace) {
|
||||
setImporting(true);
|
||||
try {
|
||||
const docId = await importTemplateService.importToWorkspace(
|
||||
selectedWorkspace,
|
||||
templateDownloader.data$.value
|
||||
);
|
||||
openPage(selectedWorkspace.id, docId);
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
setImportingError(err);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
importTemplateService,
|
||||
onClose,
|
||||
openPage,
|
||||
selectedWorkspace,
|
||||
templateDownloader.data$.value,
|
||||
]);
|
||||
|
||||
const handleImportToNewWorkspace = useAsyncCallback(async () => {
|
||||
if (!templateDownloader.data$.value) {
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
try {
|
||||
const { workspaceId, docId } =
|
||||
await importTemplateService.importToNewWorkspace(
|
||||
WorkspaceFlavour.AFFINE_CLOUD,
|
||||
'Workspace',
|
||||
templateDownloader.data$.value
|
||||
);
|
||||
openPage(workspaceId, docId);
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
setImportingError(err);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, [
|
||||
importTemplateService,
|
||||
onClose,
|
||||
openPage,
|
||||
templateDownloader.data$.value,
|
||||
]);
|
||||
|
||||
const disabled = isDownloading || importing || notLogin;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.dialogContainer}>
|
||||
<AllDocsIcon className={styles.mainIcon} />
|
||||
<h6 className={styles.mainTitle}>
|
||||
{t['com.affine.import-template.dialog.createDocWithTemplate']({
|
||||
templateName,
|
||||
})}
|
||||
</h6>
|
||||
{noWorkspace ? (
|
||||
<p className={styles.desc}>A new workspace will be created.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.desc}>Choose a workspace.</p>
|
||||
<WorkspaceSelector
|
||||
workspaceMetadata={selectedWorkspace}
|
||||
onSelectWorkspace={handleSelectedWorkspace}
|
||||
onCreatedWorkspace={handleCreatedWorkspace}
|
||||
className={styles.workspaceSelector}
|
||||
showArrowDownIcon
|
||||
disable={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{importingError && (
|
||||
<span style={{ color: cssVar('warningColor') }}>
|
||||
{t['com.affine.import-template.dialog.errorImport']()}
|
||||
</span>
|
||||
)}
|
||||
{downloadError ? (
|
||||
<span style={{ color: cssVar('warningColor') }}>
|
||||
{t['com.affine.import-template.dialog.errorLoad']()}
|
||||
</span>
|
||||
) : selectedWorkspace ? (
|
||||
<Button
|
||||
className={styles.mainButton}
|
||||
variant={disabled ? 'secondary' : 'primary'}
|
||||
loading={disabled}
|
||||
disabled={disabled}
|
||||
onClick={handleImportToSelectedWorkspace}
|
||||
>
|
||||
{selectedWorkspaceName &&
|
||||
t['com.affine.import-template.dialog.createDocToWorkspace']({
|
||||
workspace: selectedWorkspaceName,
|
||||
})}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={styles.mainButton}
|
||||
variant="primary"
|
||||
loading={disabled}
|
||||
disabled={disabled}
|
||||
onClick={handleImportToNewWorkspace}
|
||||
>
|
||||
{t['com.affine.import-template.dialog.createDocToNewWorkspace']()}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImportTemplateDialogProvider = () => {
|
||||
const importTemplateDialogService = useService(ImportTemplateDialogService);
|
||||
const isOpen = useLiveData(importTemplateDialogService.dialog.isOpen$);
|
||||
const template = useLiveData(importTemplateDialogService.dialog.template$);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
modal={true}
|
||||
persistent
|
||||
withoutCloseButton
|
||||
contentOptions={{
|
||||
className: styles.modal,
|
||||
}}
|
||||
onOpenChange={() => importTemplateDialogService.dialog.close()}
|
||||
>
|
||||
{template && (
|
||||
<Dialog
|
||||
docId={template.docId}
|
||||
templateName={template.templateName}
|
||||
workspaceId={template.workspaceId}
|
||||
onClose={() => importTemplateDialogService.dialog.close()}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { configureInfraModules, type Framework } from '@toeverything/infra';
|
||||
|
||||
import { configureCloudModule } from './cloud';
|
||||
import { configureCollectionModule } from './collection';
|
||||
import { configureCreateWorkspaceModule } from './create-workspace';
|
||||
import { configureDocLinksModule } from './doc-link';
|
||||
import { configureDocsSearchModule } from './docs-search';
|
||||
import { configureEditorModule } from './editor';
|
||||
@@ -10,6 +11,7 @@ import { configureEditorSettingModule } from './editor-settting';
|
||||
import { configureExplorerModule } from './explorer';
|
||||
import { configureFavoriteModule } from './favorite';
|
||||
import { configureFindInPageModule } from './find-in-page';
|
||||
import { configureImportTemplateModule } from './import-template';
|
||||
import { configureNavigationModule } from './navigation';
|
||||
import { configureOrganizeModule } from './organize';
|
||||
import { configurePeekViewModule } from './peek-view';
|
||||
@@ -45,4 +47,6 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureEditorModule(framework);
|
||||
configureSystemFontFamilyModule(framework);
|
||||
configureEditorSettingModule(framework);
|
||||
configureImportTemplateModule(framework);
|
||||
configureCreateWorkspaceModule(framework);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ export const useCustomTheme = (target: HTMLElement) => {
|
||||
const valueMap = themeObj[mode];
|
||||
|
||||
// remove previous style
|
||||
// TOOD(@CatsJuice): find better way to remove previous style
|
||||
target.style.cssText = '';
|
||||
// recover color scheme set by next-themes
|
||||
target.style.colorScheme = mode;
|
||||
|
||||
Object.entries(valueMap).forEach(([key, value]) => {
|
||||
value && target.style.setProperty(key, value);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RightSidebarIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Suspense, useCallback } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
|
||||
import { appSidebarOpenAtom } from '../../../components/app-sidebar/index.jotai';
|
||||
@@ -40,7 +41,7 @@ const ToggleButton = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const RouteContainer = ({ route }: Props) => {
|
||||
export const RouteContainer = () => {
|
||||
const viewPosition = useViewPosition();
|
||||
const leftSidebarOpen = useAtomValue(appSidebarOpenAtom);
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
@@ -74,7 +75,7 @@ export const RouteContainer = ({ route }: Props) => {
|
||||
|
||||
<AffineErrorBoundary>
|
||||
<Suspense>
|
||||
<route.Component />
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</AffineErrorBoundary>
|
||||
<ViewBodyTarget viewId={view.id} className={styles.viewBodyContainer} />
|
||||
|
||||
@@ -8,15 +8,8 @@ import {
|
||||
useService,
|
||||
} from '@toeverything/infra';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import {
|
||||
lazy as reactLazy,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { type RouteObject, useLocation } from 'react-router-dom';
|
||||
|
||||
import type { View } from '../entities/view';
|
||||
import { WorkbenchService } from '../services/workbench';
|
||||
@@ -33,24 +26,6 @@ const useAdapter = environment.isDesktop
|
||||
? useBindWorkbenchToDesktopRouter
|
||||
: useBindWorkbenchToBrowserRouter;
|
||||
|
||||
const warpedRoutes = viewRoutes.map(({ path, lazy }) => {
|
||||
const Component = reactLazy(() =>
|
||||
lazy().then(m => ({
|
||||
default: m.Component as React.ComponentType,
|
||||
}))
|
||||
);
|
||||
const route = {
|
||||
Component,
|
||||
};
|
||||
|
||||
return {
|
||||
path,
|
||||
Component: () => {
|
||||
return <RouteContainer route={route} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const WorkbenchRoot = memo(() => {
|
||||
const workbench = useService(WorkbenchService).workbench;
|
||||
|
||||
@@ -118,9 +93,18 @@ const WorkbenchView = ({ view, index }: { view: View; index: number }) => {
|
||||
return;
|
||||
}, [handleOnFocus]);
|
||||
|
||||
const routes: RouteObject[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
element: <RouteContainer />,
|
||||
children: viewRoutes,
|
||||
},
|
||||
] satisfies RouteObject[];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.workbenchViewContainer} ref={containerRef}>
|
||||
<ViewRoot routes={warpedRoutes} key={view.id} view={view} />
|
||||
<ViewRoot routes={routes} key={view.id} view={view} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user