mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
feat(electron): backup panel (#9738)
fix PD-2071, PD-2059, PD-2069, PD-2068
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
IconButton,
|
||||
Loading,
|
||||
Menu,
|
||||
MenuItem,
|
||||
notify,
|
||||
Skeleton,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { Pagination } from '@affine/component/member-components';
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { Avatar } from '@affine/component/ui/avatar';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
|
||||
import { BackupService } from '@affine/core/modules/backup/services';
|
||||
import { i18nTime, useI18n } from '@affine/i18n';
|
||||
import {
|
||||
DeleteIcon,
|
||||
LocalWorkspaceIcon,
|
||||
MoreVerticalIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import bytes from 'bytes';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const Empty = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
{t['com.affine.settings.workspace.backup.empty']()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BlobAvatar = ({
|
||||
blob,
|
||||
name,
|
||||
}: {
|
||||
blob: Uint8Array | null;
|
||||
name: string;
|
||||
}) => {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(new Blob([blob]));
|
||||
setUrl(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [blob]);
|
||||
return (
|
||||
<Avatar colorfulFallback name={name} rounded={4} size={32} url={url} />
|
||||
);
|
||||
};
|
||||
|
||||
type BackupWorkspaceItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
fileSize: number;
|
||||
updatedAt: Date;
|
||||
avatar: Uint8Array | null;
|
||||
dbPath: string;
|
||||
};
|
||||
|
||||
const BackupWorkspaceItem = ({ item }: { item: BackupWorkspaceItem }) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const backupService = useService(BackupService);
|
||||
const t = useI18n();
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const { jumpToPage } = useNavigateHelper();
|
||||
|
||||
const handleImport = useAsyncCallback(async () => {
|
||||
setImporting(true);
|
||||
const workspaceId = await backupService.recoverBackupWorkspace(item.dbPath);
|
||||
if (!workspaceId) {
|
||||
setImporting(false);
|
||||
return;
|
||||
}
|
||||
notify.success({
|
||||
title: t['com.affine.settings.workspace.backup.import.success'](),
|
||||
action: {
|
||||
label:
|
||||
t['com.affine.settings.workspace.backup.import.success.action'](),
|
||||
onClick: () => {
|
||||
jumpToPage(workspaceId, 'all');
|
||||
},
|
||||
autoClose: false,
|
||||
},
|
||||
});
|
||||
setMenuOpen(false);
|
||||
setImporting(false);
|
||||
}, [backupService, item.dbPath, jumpToPage, t]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(backupWorkspaceId: string) => {
|
||||
openConfirmModal({
|
||||
title: t['com.affine.workspaceDelete.title'](),
|
||||
children: t['com.affine.settings.workspace.backup.delete.warning'](),
|
||||
onConfirm: async () => {
|
||||
await backupService.deleteBackupWorkspace(backupWorkspaceId);
|
||||
notify.success({
|
||||
title: t['com.affine.settings.workspace.backup.delete.success'](),
|
||||
});
|
||||
},
|
||||
confirmText: t['Confirm'](),
|
||||
cancelText: t['Cancel'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'error',
|
||||
},
|
||||
});
|
||||
},
|
||||
[backupService, openConfirmModal, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="backup-workspace-item"
|
||||
className={styles.listItem}
|
||||
key={item.id}
|
||||
onClick={() => setMenuOpen(v => !v)}
|
||||
>
|
||||
<BlobAvatar blob={item.avatar} name={item.name} />
|
||||
<div className={styles.listItemLeftLabel}>
|
||||
<div className={styles.listItemLeftLabelTitle}>{item.name}</div>
|
||||
<div className={styles.listItemLeftLabelDesc}>
|
||||
{bytes(item.fileSize)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.listItemRightLabel}>
|
||||
{t['com.affine.settings.workspace.backup.delete-at']({
|
||||
date: i18nTime(item.updatedAt, {
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
},
|
||||
}),
|
||||
time: i18nTime(item.updatedAt, {
|
||||
absolute: {
|
||||
accuracy: 'minute',
|
||||
noDate: true,
|
||||
noYear: true,
|
||||
},
|
||||
}),
|
||||
})}
|
||||
<Menu
|
||||
rootOptions={{
|
||||
open: menuOpen && !importing,
|
||||
onOpenChange: setMenuOpen,
|
||||
modal: true,
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
prefixIcon={<LocalWorkspaceIcon />}
|
||||
onClick={handleImport}
|
||||
>
|
||||
{t['com.affine.settings.workspace.backup.import']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
prefixIcon={<DeleteIcon />}
|
||||
onClick={() => handleDelete(item.id)}
|
||||
type="danger"
|
||||
>
|
||||
{t['Delete']()}
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
contentOptions={{ align: 'end' }}
|
||||
>
|
||||
<IconButton disabled={importing} size="20">
|
||||
{importing ? <Loading /> : <MoreVerticalIcon />}
|
||||
</IconButton>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
export const BackupSettingPanel = () => {
|
||||
const t = useI18n();
|
||||
const backupService = useService(BackupService);
|
||||
|
||||
const handlePageChange = useCallback(() => {
|
||||
backupService.revalidate();
|
||||
}, [backupService]);
|
||||
|
||||
useEffect(() => {
|
||||
backupService.revalidate();
|
||||
}, [backupService, handlePageChange]);
|
||||
|
||||
const isLoading = useLiveData(backupService.isLoading$);
|
||||
const backupWorkspaces = useLiveData(backupService.pageBackupWorkspaces$);
|
||||
|
||||
const [pageNum, setPageNum] = useState(0);
|
||||
|
||||
const innerElement = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Skeleton
|
||||
style={{ margin: '2px', width: 'calc(100% - 4px)' }}
|
||||
height={60}
|
||||
animation="wave"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (backupWorkspaces?.items.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={styles.list}>
|
||||
{backupWorkspaces?.items
|
||||
.slice(pageNum * PAGE_SIZE, (pageNum + 1) * PAGE_SIZE)
|
||||
.map(item => <BackupWorkspaceItem key={item.id} item={item} />)}
|
||||
</div>
|
||||
<div className={styles.pagination}>
|
||||
<Pagination
|
||||
totalCount={backupWorkspaces?.items.length ?? 0}
|
||||
countPerPage={PAGE_SIZE}
|
||||
pageNum={pageNum}
|
||||
onPageChange={(_, pageNum) => {
|
||||
setPageNum(pageNum);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}, [isLoading, backupWorkspaces, pageNum]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
title={t['com.affine.settings.workspace.backup']()}
|
||||
subtitle={t['com.affine.settings.workspace.backup.subtitle']()}
|
||||
data-testid="backup-title"
|
||||
/>
|
||||
<div className={styles.listContainer}>{innerElement}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const listContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
borderRadius: '8px',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const list = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const listItem = style({
|
||||
display: 'flex',
|
||||
padding: '8px 8px 8px 16px',
|
||||
height: '60px',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
':hover': {
|
||||
background: cssVarV2('layer/background/hoverOverlay'),
|
||||
},
|
||||
});
|
||||
|
||||
export const listItemLeftLabel = style({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
});
|
||||
|
||||
export const listItemLeftLabelTitle = style({
|
||||
flex: 1,
|
||||
maxWidth: '250px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: cssVar('fontSm'),
|
||||
});
|
||||
|
||||
export const listItemLeftLabelDesc = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const listItemRightLabel = style({
|
||||
flex: 1,
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
gap: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const empty = style({
|
||||
padding: '8px 16px',
|
||||
});
|
||||
|
||||
export const pagination = style({
|
||||
paddingBottom: '4px',
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
AppearanceIcon,
|
||||
ExperimentIcon,
|
||||
FolderIcon,
|
||||
InformationIcon,
|
||||
KeyboardIcon,
|
||||
PenIcon,
|
||||
@@ -16,6 +17,7 @@ import { AuthService, ServerService } from '../../../../modules/cloud';
|
||||
import type { SettingSidebarItem, SettingState } from '../types';
|
||||
import { AboutAffine } from './about';
|
||||
import { AppearanceSettings } from './appearance';
|
||||
import { BackupSettingPanel } from './backup';
|
||||
import { BillingSettings } from './billing';
|
||||
import { EditorSettings } from './editor';
|
||||
import { ExperimentalFeatures } from './experimental-features';
|
||||
@@ -93,6 +95,15 @@ export const useGeneralSettingList = (): GeneralSettingList => {
|
||||
}
|
||||
}
|
||||
|
||||
if (BUILD_CONFIG.isElectron) {
|
||||
settings.push({
|
||||
key: 'backup',
|
||||
title: t['com.affine.settings.workspace.backup'](),
|
||||
icon: <FolderIcon />,
|
||||
testId: 'backup-panel-trigger',
|
||||
});
|
||||
}
|
||||
|
||||
settings.push({
|
||||
key: 'experimental-features',
|
||||
title: t['com.affine.settings.workspace.experimental-features'](),
|
||||
@@ -129,6 +140,8 @@ export const GeneralSetting = ({
|
||||
return <BillingSettings onChangeSettingState={onChangeSettingState} />;
|
||||
case 'experimental-features':
|
||||
return <ExperimentalFeatures />;
|
||||
case 'backup':
|
||||
return <BackupSettingPanel />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
9
packages/frontend/core/src/modules/backup/index.ts
Normal file
9
packages/frontend/core/src/modules/backup/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { DesktopApiService } from '../desktop-api';
|
||||
import { WorkspacesService } from '../workspace';
|
||||
import { BackupService } from './services';
|
||||
|
||||
export function configureDesktopBackupModule(framework: Framework) {
|
||||
framework.service(BackupService, [DesktopApiService, WorkspacesService]);
|
||||
}
|
||||
71
packages/frontend/core/src/modules/backup/services/index.ts
Normal file
71
packages/frontend/core/src/modules/backup/services/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
catchErrorInto,
|
||||
effect,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
Service,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, mergeMap, switchMap } from 'rxjs';
|
||||
|
||||
import type { DesktopApiService } from '../../desktop-api';
|
||||
import type { WorkspacesService } from '../../workspace';
|
||||
import { _addLocalWorkspace } from '../../workspace-engine';
|
||||
|
||||
type BackupWorkspaceResult = Awaited<
|
||||
ReturnType<DesktopApiService['handler']['workspace']['getBackupWorkspaces']>
|
||||
>;
|
||||
|
||||
export class BackupService extends Service {
|
||||
constructor(
|
||||
private readonly desktopApiService: DesktopApiService,
|
||||
private readonly workspacesService: WorkspacesService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
isLoading$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
|
||||
pageBackupWorkspaces$ = new LiveData<BackupWorkspaceResult | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
readonly revalidate = effect(
|
||||
switchMap(() =>
|
||||
fromPromise(async () => {
|
||||
return this.desktopApiService.handler.workspace.getBackupWorkspaces();
|
||||
}).pipe(
|
||||
mergeMap(data => {
|
||||
this.pageBackupWorkspaces$.setValue(data);
|
||||
return EMPTY;
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => this.isLoading$.setValue(true)),
|
||||
onComplete(() => this.isLoading$.setValue(false))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
async recoverBackupWorkspace(dbPath: string) {
|
||||
const result =
|
||||
await this.desktopApiService.handler.dialog.loadDBFile(dbPath);
|
||||
if (result.workspaceId) {
|
||||
_addLocalWorkspace(result.workspaceId);
|
||||
this.workspacesService.list.revalidate();
|
||||
}
|
||||
return result.workspaceId;
|
||||
}
|
||||
|
||||
async deleteBackupWorkspace(backupWorkspaceId: string) {
|
||||
await this.desktopApiService.handler.workspace.deleteBackupWorkspace(
|
||||
backupWorkspaceId
|
||||
);
|
||||
this.revalidate();
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.revalidate.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type SettingTab =
|
||||
| 'about'
|
||||
| 'plans'
|
||||
| 'billing'
|
||||
| 'backup' // electron only
|
||||
| 'experimental-features'
|
||||
| 'editor'
|
||||
| 'account'
|
||||
|
||||
Reference in New Issue
Block a user