feat: add import modal (#8599)

[BS-1471](https://linear.app/affine-design/issue/BS-1471/新的-import-dialog-ui)
This commit is contained in:
donteatfriedrice
2024-10-31 04:09:43 +00:00
parent c08b02caba
commit afcf595626
9 changed files with 569 additions and 23 deletions

View File

@@ -0,0 +1,359 @@
import { Button, IconButton, Modal } from '@affine/component';
import { UrlService } from '@affine/core/modules/url';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import {
MarkdownTransformer,
NotionHtmlTransformer,
openFileOrFiles,
} from '@blocksuite/affine/blocks';
import type { DocCollection } from '@blocksuite/affine/store';
import {
ExportToMarkdownIcon,
HelpIcon,
NotionIcon,
} from '@blocksuite/icons/rc';
import { useService, WorkspaceService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useSetAtom } from 'jotai';
import { type ReactElement, useCallback, useState } from 'react';
import { openImportModalAtom } from '../../atoms';
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
import * as style from './style.css';
type ImportType = 'markdown' | 'markdownZip' | 'notion';
type AcceptType = 'Markdown' | 'Zip';
type Status = 'idle' | 'importing' | 'success' | 'error';
type ImportConfig = {
fileOptions: { acceptType: AcceptType; multiple: boolean };
importFunction: (
docCollection: DocCollection,
file: File | File[]
) => Promise<string[]>;
};
const DISCORD_URL = 'https://discord.gg/whd5mjYqVw';
const importOptions = [
{
label: 'com.affine.import.markdown-files',
prefixIcon: (
<ExportToMarkdownIcon
color={cssVarV2('icon/primary')}
width={20}
height={20}
/>
),
testId: 'editor-option-menu-import-markdown-files',
type: 'markdown' as ImportType,
},
{
label: 'com.affine.import.markdown-with-media-files',
prefixIcon: (
<ExportToMarkdownIcon
color={cssVarV2('icon/primary')}
width={20}
height={20}
/>
),
testId: 'editor-option-menu-import-markdown-with-media',
type: 'markdownZip' as ImportType,
},
{
label: 'com.affine.import.notion',
prefixIcon: <NotionIcon color={cssVar('black')} width={20} height={20} />,
suffixIcon: (
<HelpIcon color={cssVarV2('icon/primary')} width={20} height={20} />
),
suffixTooltip: 'com.affine.import.notion.tooltip',
testId: 'editor-option-menu-import-notion',
type: 'notion' as ImportType,
},
];
const importConfigs: Record<ImportType, ImportConfig> = {
markdown: {
fileOptions: { acceptType: 'Markdown', multiple: true },
importFunction: async (docCollection, files) => {
if (!Array.isArray(files)) {
throw new Error('Expected an array of files for markdown files import');
}
const pageIds: string[] = [];
for (const file of files) {
const text = await file.text();
const fileName = file.name.split('.').slice(0, -1).join('.');
const pageId = await MarkdownTransformer.importMarkdownToDoc({
collection: docCollection,
markdown: text,
fileName,
});
if (pageId) pageIds.push(pageId);
}
return pageIds;
},
},
markdownZip: {
fileOptions: { acceptType: 'Zip', multiple: false },
importFunction: async (docCollection, file) => {
if (Array.isArray(file)) {
throw new Error('Expected a single zip file for markdownZip import');
}
return MarkdownTransformer.importMarkdownZip({
collection: docCollection,
imported: file,
});
},
},
notion: {
fileOptions: { acceptType: 'Zip', multiple: false },
importFunction: async (docCollection, file) => {
if (Array.isArray(file)) {
throw new Error('Expected a single zip file for notion import');
}
const { pageIds } = await NotionHtmlTransformer.importNotionZip({
collection: docCollection,
imported: file,
});
return pageIds;
},
},
};
const ImportOptionItem = ({
label,
prefixIcon,
suffixIcon,
suffixTooltip,
type,
onImport,
}: {
label: string;
prefixIcon: ReactElement;
suffixIcon?: ReactElement;
suffixTooltip?: string;
type: ImportType;
onImport: (type: ImportType) => void;
}) => {
const t = useI18n();
return (
<div className={style.importItem} onClick={() => onImport(type)}>
{prefixIcon}
<div className={style.importItemLabel}>{t[label]()}</div>
{suffixIcon && (
<IconButton
className={style.importItemSuffix}
icon={suffixIcon}
tooltip={suffixTooltip ? t[suffixTooltip]() : undefined}
/>
)}
</div>
);
};
const ImportOptions = ({
onImport,
}: {
onImport: (type: ImportType) => void;
}) => {
const t = useI18n();
return (
<>
<div className={style.importModalTitle}>{t['Import']()}</div>
<div className={style.importModalContent}>
{importOptions.map(
({ label, prefixIcon, suffixIcon, suffixTooltip, testId, type }) => (
<ImportOptionItem
key={testId}
prefixIcon={prefixIcon}
suffixIcon={suffixIcon}
suffixTooltip={suffixTooltip}
label={label}
data-testid={testId}
type={type}
onImport={onImport}
/>
)
)}
</div>
<div className={style.importModalTip}>
{t['com.affine.import.modal.tip']()}{' '}
<a
className={style.link}
href="https://discord.gg/whd5mjYqVw"
target="_blank"
rel="noreferrer"
>
Discord
</a>{' '}
.
</div>
</>
);
};
const ImportingStatus = () => {
const t = useI18n();
return (
<>
<div className={style.importModalTitle}>
{t['com.affine.import.status.importing.title']()}
</div>
<p className={style.importStatusContent}>
{t['com.affine.import.status.importing.message']()}
</p>
</>
);
};
const SuccessStatus = ({ onComplete }: { onComplete: () => void }) => {
const t = useI18n();
return (
<>
<div className={style.importModalTitle}>
{t['com.affine.import.status.success.title']()}
</div>
<p className={style.importStatusContent}>
{t['com.affine.import.status.success.message']()}{' '}
<a
className={style.link}
href={DISCORD_URL}
target="_blank"
rel="noreferrer"
>
Discord
</a>
.
</p>
<div className={style.importModalButtonContainer}>
<Button onClick={onComplete} variant="primary">
{t['Complete']()}
</Button>
</div>
</>
);
};
const ErrorStatus = ({
error,
onRetry,
}: {
error: string | null;
onRetry: () => void;
}) => {
const t = useI18n();
const urlService = useService(UrlService);
return (
<>
<div className={style.importModalTitle}>
{t['com.affine.import.status.failed.title']()}
</div>
<p className={style.importStatusContent}>
{error || 'Unknown error occurred'}
</p>
<div className={style.importModalButtonContainer}>
<Button
onClick={() => {
urlService.openPopupWindow(DISCORD_URL);
}}
variant="secondary"
>
{t['Feedback']()}
</Button>
<Button onClick={onRetry} variant="primary">
{t['Retry']()}
</Button>
</div>
</>
);
};
export const ImportModal = ({ ...modalProps }) => {
const t = useI18n();
const [status, setStatus] = useState<Status>('idle');
const [importError, setImportError] = useState<string | null>(null);
const [pageIds, setPageIds] = useState<string[]>([]);
const setOpenImportModalAtom = useSetAtom(openImportModalAtom);
const workspace = useService(WorkspaceService).workspace;
const workbench = useService(WorkbenchService).workbench;
const docCollection = workspace.docCollection;
const handleImport = useAsyncCallback(
async (type: ImportType) => {
setImportError(null);
try {
const importConfig = importConfigs[type];
const file = await openFileOrFiles(importConfig.fileOptions);
if (!file || (Array.isArray(file) && file.length === 0)) {
throw new Error(
t['com.affine.import.status.failed.message.no-file-selected']()
);
}
setStatus('importing');
const pageIds = await importConfig.importFunction(docCollection, file);
setPageIds(pageIds);
setStatus('success');
} catch (error) {
setImportError(
error instanceof Error ? error.message : 'Unknown error occurred'
);
setStatus('error');
}
},
[docCollection, t]
);
const handleComplete = useCallback(() => {
if (pageIds.length > 1) {
workbench.openAll();
} else if (pageIds.length === 1) {
workbench.openDoc(pageIds[0]);
}
setOpenImportModalAtom(false);
}, [pageIds, workbench, setOpenImportModalAtom]);
const handleRetry = () => {
setStatus('idle');
};
const statusComponents = {
idle: <ImportOptions onImport={handleImport} />,
importing: <ImportingStatus />,
success: <SuccessStatus onComplete={handleComplete} />,
error: <ErrorStatus error={importError} onRetry={handleRetry} />,
};
return (
<Modal
width={480}
contentOptions={{
['data-testid' as string]: 'import-modal',
style: {
maxHeight: '85vh',
maxWidth: '70vw',
minHeight: '126px',
padding: 0,
overflow: 'hidden',
display: 'flex',
background: cssVarV2('layer/background/primary'),
},
}}
closeButtonOptions={{
className: style.closeButton,
}}
withoutCloseButton={status === 'importing'}
persistent={status === 'importing'}
{...modalProps}
>
<div className={style.importModalContainer}>
{statusComponents[status]}
</div>
</Modal>
);
};

View File

@@ -0,0 +1,110 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const importModalContainer = style({
width: '100%',
height: '100%',
display: 'flex',
boxSizing: 'border-box',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '20px 24px',
gap: '12px',
});
export const importModalTitle = style({
width: '100%',
height: 'auto',
fontSize: cssVar('fontH6'),
fontWeight: '600',
lineHeight: cssVar('lineHeight'),
});
export const importModalContent = style({
width: '100%',
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '12px',
});
export const closeButton = style({
top: '24px',
right: '24px',
});
export const importModalTip = style({
width: '100%',
height: 'auto',
fontSize: cssVar('fontSm'),
lineHeight: cssVar('lineHeight'),
fontWeight: '400',
color: cssVar('textSecondaryColor'),
});
export const link = style({
color: cssVar('linkColor'),
cursor: 'pointer',
});
export const importStatusContent = style({
width: '100%',
fontSize: cssVar('fontBase'),
lineHeight: cssVar('lineHeight'),
fontWeight: '400',
color: cssVar('textPrimaryColor'),
});
export const importModalButtonContainer = style({
width: '100%',
display: 'flex',
flexDirection: 'row',
gap: '20px',
justifyContent: 'end',
marginTop: '20px',
});
export const importItem = style({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
height: 'auto',
gap: '4px',
padding: '8px 12px',
borderRadius: '8px',
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
background: cssVarV2('button/secondary'),
selectors: {
'&:hover': {
background: cssVarV2('layer/background/hoverOverlay'),
cursor: 'pointer',
transition: 'background .30s',
},
},
});
export const importItemLabel = style({
display: 'flex',
alignItems: 'center',
padding: '0 4px',
textAlign: 'left',
flex: 1,
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontBase'),
lineHeight: cssVar('lineHeight'),
fontWeight: '500',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
export const importItemPrefix = style({
marginRight: 'auto',
});
export const importItemSuffix = style({
marginLeft: 'auto',
});

View File

@@ -35,6 +35,8 @@ export const openSettingModalAtom = atom<SettingAtom>({
open: false,
});
export const openImportModalAtom = atom(false);
export type AuthAtomData =
| { state: 'signIn' }
| {

View File

@@ -7,12 +7,14 @@ import {
} from '@affine/component/ui/menu';
import { PageHistoryModal } from '@affine/core/components/affine/page-history-modal';
import { ShareMenuContent } from '@affine/core/components/affine/share-page-modal/share-menu';
import { openHistoryTipsModalAtom } from '@affine/core/components/atoms';
import {
openHistoryTipsModalAtom,
openImportModalAtom,
} from '@affine/core/components/atoms';
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
import { useExportPage } from '@affine/core/components/hooks/affine/use-export-page';
import { useTrashModalHelper } from '@affine/core/components/hooks/affine/use-trash-modal-helper';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { Export, MoveToTrash } from '@affine/core/components/page-list';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
@@ -47,7 +49,6 @@ import { useSetAtom } from 'jotai';
import { useCallback, useState } from 'react';
import { HeaderDropDownButton } from '../../../pure/header-drop-down-button';
import { usePageHelper } from '../../block-suite-page-list/utils';
import { useFavorite } from '../favorite';
type PageMenuProps = {
@@ -69,7 +70,6 @@ export const PageHeaderMenuButton = ({
const confirmEnableCloud = useEnableCloud();
const workspace = useService(WorkspaceService).workspace;
const docCollection = workspace.docCollection;
const editorService = useService(EditorService);
const isInTrash = useLiveData(
@@ -83,7 +83,6 @@ export const PageHeaderMenuButton = ({
const { favorite, toggleFavorite } = useFavorite(pageId);
const { duplicate } = useBlockSuiteMetaHelper();
const { importFile } = usePageHelper(docCollection);
const { setTrashModal } = useTrashModalHelper();
const [isEditing, setEditing] = useState(!page.readonly);
@@ -109,6 +108,7 @@ export const PageHeaderMenuButton = ({
const [historyModalOpen, setHistoryModalOpen] = useState(false);
const setOpenHistoryTipsModal = useSetAtom(openHistoryTipsModalAtom);
const setOpenImportModalAtom = useSetAtom(openImportModalAtom);
const openHistoryModal = useCallback(() => {
track.$.header.history.open();
@@ -184,19 +184,10 @@ export const PageHeaderMenuButton = ({
});
}, [duplicate, pageId]);
const onImportFile = useAsyncCallback(async () => {
const options = await importFile();
const handleOpenImportModal = useCallback(() => {
track.$.header.docOptions.import();
if (options.isWorkspaceFile) {
track.$.header.actions.createWorkspace({
control: 'import',
});
} else {
track.$.header.actions.createDoc({
control: 'import',
});
}
}, [importFile]);
setOpenImportModalAtom(true);
}, [setOpenImportModalAtom]);
const handleShareMenuOpenChange = useCallback((open: boolean) => {
if (open) {
@@ -361,7 +352,7 @@ export const PageHeaderMenuButton = ({
<MenuItem
prefixIcon={<ImportIcon />}
data-testid="editor-option-menu-import"
onSelect={onImportFile}
onSelect={handleOpenImportModal}
>
{t['Import']()}
</MenuItem>

View File

@@ -21,6 +21,7 @@ import { PeekViewManagerModal } from '../../modules/peek-view';
import { AuthModal } from '../affine/auth';
import { AiLoginRequiredModal } from '../affine/auth/ai-login-required';
import { HistoryTipsModal } from '../affine/history-tips-modal';
import { ImportModal } from '../affine/import-modal';
import { IssueFeedbackModal } from '../affine/issue-feedback-modal';
import {
CloudQuotaModal,
@@ -30,7 +31,11 @@ import { SettingModal } from '../affine/setting-modal';
import { SignOutModal } from '../affine/sign-out-modal';
import { StarAFFiNEModal } from '../affine/star-affine-modal';
import type { SettingAtom } from '../atoms';
import { openSettingModalAtom, openSignOutModalAtom } from '../atoms';
import {
openImportModalAtom,
openSettingModalAtom,
openSignOutModalAtom,
} from '../atoms';
import { InfoModal } from '../doc-properties/info-modal/info-modal';
import { useTrashModalHelper } from '../hooks/affine/use-trash-modal-helper';
import { useAsyncCallback } from '../hooks/affine-async-hooks';
@@ -128,6 +133,7 @@ export function CurrentWorkspaceModals() {
titles={deletePageTitles}
/>
{currentWorkspace ? <InfoModal /> : null}
<Import />
</>
);
}
@@ -193,3 +199,20 @@ export const AllWorkspaceModals = (): ReactElement => {
</>
);
};
export const Import = () => {
const [open, setOpenImportModalAtom] = useAtom(openImportModalAtom);
const onOpenChange = useCallback(
(open: boolean) => {
setOpenImportModalAtom(open);
},
[setOpenImportModalAtom]
);
if (!open) {
return null;
}
return <ImportModal open={open} onOpenChange={onOpenChange} />;
};

View File

@@ -1,4 +1,7 @@
import { openSettingModalAtom } from '@affine/core/components/atoms';
import {
openImportModalAtom,
openSettingModalAtom,
} from '@affine/core/components/atoms';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import {
AddPageButton,
@@ -27,6 +30,7 @@ import type { Doc } from '@blocksuite/affine/store';
import {
AllDocsIcon,
GithubIcon,
ImportIcon,
JournalIcon,
SettingsIcon,
} from '@blocksuite/icons/rc';
@@ -43,7 +47,6 @@ import { useCallback } from 'react';
import { WorkbenchService } from '../../modules/workbench';
import { usePageHelper } from '../blocksuite/block-suite-page-list/utils';
import { WorkspaceNavigator } from '../workspace-selector';
import ImportPage from './import-page';
import {
quickSearch,
quickSearchAndNewPage,
@@ -82,7 +85,6 @@ export const RootAppSidebar = (): ReactElement => {
CMDKQuickSearchService,
});
const currentWorkspace = workspaceService.workspace;
const docCollection = currentWorkspace.docCollection;
const t = useI18n();
const workbench = workbenchService.workbench;
const currentPath = useLiveData(
@@ -105,6 +107,7 @@ export const RootAppSidebar = (): ReactElement => {
);
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
const setOpenImportModalAtom = useSetAtom(openImportModalAtom);
const onOpenSettingModal = useCallback(() => {
setOpenSettingModalAtom({
@@ -114,6 +117,10 @@ export const RootAppSidebar = (): ReactElement => {
track.$.navigationPanel.$.openSettings();
}, [setOpenSettingModalAtom]);
const onOpenImportModal = useCallback(() => {
setOpenImportModalAtom(true);
}, [setOpenImportModalAtom]);
return (
<AppSidebar>
<SidebarContainer>
@@ -163,7 +170,13 @@ export const RootAppSidebar = (): ReactElement => {
<CategoryDivider label={t['com.affine.rootAppSidebar.others']()} />
<div style={{ padding: '0 8px' }}>
<TrashButton />
<ImportPage docCollection={docCollection} />
<MenuItem
data-testid="slider-bar-import-button"
icon={<ImportIcon />}
onClick={onOpenImportModal}
>
<span data-testid="import-modal-trigger">{t['Import']()}</span>
</MenuItem>
<ExternalMenuLinkItem
href="https://affine.pro/blog?tag=Release+Note"
icon={<JournalIcon />}