mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-08 12:45:55 +08:00
feat: page view storage with cloud support (#4238)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
import type { SessionContextValue } from 'next-auth/react';
|
||||||
|
|
||||||
|
export const sessionAtom = atom<SessionContextValue<true> | null>(null);
|
||||||
@@ -19,6 +19,7 @@ import { useGetPageInfoById } from '../../../hooks/use-get-page-info';
|
|||||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
import type { BlockSuiteWorkspace } from '../../../shared';
|
||||||
import { toast } from '../../../utils';
|
import { toast } from '../../../utils';
|
||||||
import { filterPage } from '../../../utils/filter';
|
import { filterPage } from '../../../utils/filter';
|
||||||
|
import { currentCollectionsAtom } from '../../../utils/user-setting';
|
||||||
import { emptyDescButton, emptyDescKbd, pageListEmptyStyle } from './index.css';
|
import { emptyDescButton, emptyDescKbd, pageListEmptyStyle } from './index.css';
|
||||||
import { usePageHelper } from './utils';
|
import { usePageHelper } from './utils';
|
||||||
|
|
||||||
@@ -277,7 +278,7 @@ export const BlockSuitePageList = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageList
|
<PageList
|
||||||
workspaceId={blockSuiteWorkspace.id}
|
collectionsAtom={currentCollectionsAtom}
|
||||||
propertiesMeta={blockSuiteWorkspace.meta.properties}
|
propertiesMeta={blockSuiteWorkspace.meta.properties}
|
||||||
getPageInfo={getPageInfo}
|
getPageInfo={getPageInfo}
|
||||||
onCreateNewPage={createPage}
|
onCreateNewPage={createPage}
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@ import { IconButton } from '@toeverything/components/button';
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
|
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
|
||||||
|
import { currentCollectionsAtom } from '../../../../utils/user-setting';
|
||||||
|
|
||||||
type AddCollectionButtonProps = {
|
type AddCollectionButtonProps = {
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
@@ -20,7 +21,7 @@ export const AddCollectionButton = ({
|
|||||||
workspace,
|
workspace,
|
||||||
}: AddCollectionButtonProps) => {
|
}: AddCollectionButtonProps) => {
|
||||||
const getPageInfo = useGetPageInfoById(workspace);
|
const getPageInfo = useGetPageInfoById(workspace);
|
||||||
const setting = useCollectionManager(workspace.id);
|
const setting = useCollectionManager(currentCollectionsAtom);
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [show, showUpdateCollection] = useState(false);
|
const [show, showUpdateCollection] = useState(false);
|
||||||
const [defaultCollection, setDefaultCollection] = useState<Collection>();
|
const [defaultCollection, setDefaultCollection] = useState<Collection>();
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ import {
|
|||||||
} from '@toeverything/components/menu';
|
} from '@toeverything/components/menu';
|
||||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
|
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
|
||||||
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
|
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
|
||||||
import { filterPage } from '../../../../utils/filter';
|
import { filterPage } from '../../../../utils/filter';
|
||||||
|
import { currentCollectionsAtom } from '../../../../utils/user-setting';
|
||||||
import type { CollectionsListProps } from '../index';
|
import type { CollectionsListProps } from '../index';
|
||||||
import { Page } from './page';
|
import { Page } from './page';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
@@ -148,8 +149,8 @@ const CollectionRenderer = ({
|
|||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
getPageInfo: GetPageInfoById;
|
getPageInfo: GetPageInfoById;
|
||||||
}) => {
|
}) => {
|
||||||
const [collapsed, setCollapsed] = React.useState(true);
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
const setting = useCollectionManager(workspace.id);
|
const setting = useCollectionManager(currentCollectionsAtom);
|
||||||
const { jumpToSubPath } = useNavigateHelper();
|
const { jumpToSubPath } = useNavigateHelper();
|
||||||
const clickCollection = useCallback(() => {
|
const clickCollection = useCallback(() => {
|
||||||
jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
|
jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
|
||||||
@@ -272,7 +273,7 @@ const CollectionRenderer = ({
|
|||||||
};
|
};
|
||||||
export const CollectionsList = ({ workspace }: CollectionsListProps) => {
|
export const CollectionsList = ({ workspace }: CollectionsListProps) => {
|
||||||
const metas = useBlockSuitePageMeta(workspace);
|
const metas = useBlockSuitePageMeta(workspace);
|
||||||
const { savedCollections } = useSavedCollections(workspace.id);
|
const { savedCollections } = useSavedCollections(currentCollectionsAtom);
|
||||||
const getPageInfo = useGetPageInfoById(workspace);
|
const getPageInfo = useGetPageInfoById(workspace);
|
||||||
const pinedCollections = useMemo(
|
const pinedCollections = useMemo(
|
||||||
() => savedCollections.filter(v => v.pinned),
|
() => savedCollections.filter(v => v.pinned),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import React, { useCallback, useEffect, useMemo } from 'react';
|
|||||||
import { useHistoryAtom } from '../../atoms/history';
|
import { useHistoryAtom } from '../../atoms/history';
|
||||||
import { useAppSetting } from '../../atoms/settings';
|
import { useAppSetting } from '../../atoms/settings';
|
||||||
import type { AllWorkspace } from '../../shared';
|
import type { AllWorkspace } from '../../shared';
|
||||||
|
import { currentCollectionsAtom } from '../../utils/user-setting';
|
||||||
import { CollectionsList } from '../pure/workspace-slider-bar/collections';
|
import { CollectionsList } from '../pure/workspace-slider-bar/collections';
|
||||||
import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button';
|
import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button';
|
||||||
import { AddFavouriteButton } from '../pure/workspace-slider-bar/favorite/add-favourite-button';
|
import { AddFavouriteButton } from '../pure/workspace-slider-bar/favorite/add-favourite-button';
|
||||||
@@ -98,7 +99,7 @@ export const RootAppSidebar = ({
|
|||||||
}: RootAppSidebarProps): ReactElement => {
|
}: RootAppSidebarProps): ReactElement => {
|
||||||
const currentWorkspaceId = currentWorkspace.id;
|
const currentWorkspaceId = currentWorkspace.id;
|
||||||
const [appSettings] = useAppSetting();
|
const [appSettings] = useAppSetting();
|
||||||
const { backToAll } = useCollectionManager(currentWorkspace.id);
|
const { backToAll } = useCollectionManager(currentCollectionsAtom);
|
||||||
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
|
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const onClickNewPage = useCallback(async () => {
|
const onClickNewPage = useCallback(async () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useCallback } from 'react';
|
|||||||
import { appHeaderAtom, mainContainerAtom } from '../atoms/element';
|
import { appHeaderAtom, mainContainerAtom } from '../atoms/element';
|
||||||
import { useGetPageInfoById } from '../hooks/use-get-page-info';
|
import { useGetPageInfoById } from '../hooks/use-get-page-info';
|
||||||
import { useWorkspace } from '../hooks/use-workspace';
|
import { useWorkspace } from '../hooks/use-workspace';
|
||||||
|
import { currentCollectionsAtom } from '../utils/user-setting';
|
||||||
import { SharePageModal } from './affine/share-page-modal';
|
import { SharePageModal } from './affine/share-page-modal';
|
||||||
import { BlockSuiteHeaderTitle } from './blocksuite/block-suite-header-title';
|
import { BlockSuiteHeaderTitle } from './blocksuite/block-suite-header-title';
|
||||||
import { filterContainerStyle } from './filter-container.css';
|
import { filterContainerStyle } from './filter-container.css';
|
||||||
@@ -27,7 +28,7 @@ import { WorkspaceModeFilterTab } from './pure/workspace-mode-filter-tab';
|
|||||||
|
|
||||||
const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
|
const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
|
||||||
const currentWorkspace = useWorkspace(workspaceId);
|
const currentWorkspace = useWorkspace(workspaceId);
|
||||||
const setting = useCollectionManager(workspaceId);
|
const setting = useCollectionManager(currentCollectionsAtom);
|
||||||
const saveToCollection = useCallback(
|
const saveToCollection = useCallback(
|
||||||
async (collection: Collection) => {
|
async (collection: Collection) => {
|
||||||
await setting.saveCollection(collection);
|
await setting.saveCollection(collection);
|
||||||
@@ -78,10 +79,10 @@ export function WorkspaceHeader({
|
|||||||
currentWorkspaceId,
|
currentWorkspaceId,
|
||||||
currentEntry,
|
currentEntry,
|
||||||
}: WorkspaceHeaderProps<WorkspaceFlavour>) {
|
}: WorkspaceHeaderProps<WorkspaceFlavour>) {
|
||||||
const setting = useCollectionManager(currentWorkspaceId);
|
|
||||||
const setAppHeader = useSetAtom(appHeaderAtom);
|
const setAppHeader = useSetAtom(appHeaderAtom);
|
||||||
|
|
||||||
const currentWorkspace = useWorkspace(currentWorkspaceId);
|
const currentWorkspace = useWorkspace(currentWorkspaceId);
|
||||||
|
const setting = useCollectionManager(currentCollectionsAtom);
|
||||||
const getPageInfoById = useGetPageInfoById(
|
const getPageInfoById = useGetPageInfoById(
|
||||||
currentWorkspace.blockSuiteWorkspace
|
currentWorkspace.blockSuiteWorkspace
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { redirect } from 'react-router-dom';
|
|||||||
import { getUIAdapter } from '../../adapters/workspace';
|
import { getUIAdapter } from '../../adapters/workspace';
|
||||||
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
|
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
|
||||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||||
|
import { currentCollectionsAtom } from '../../utils/user-setting';
|
||||||
|
|
||||||
export const loader: LoaderFunction = async args => {
|
export const loader: LoaderFunction = async args => {
|
||||||
const rootStore = getCurrentStore();
|
const rootStore = getCurrentStore();
|
||||||
@@ -35,7 +36,7 @@ export const loader: LoaderFunction = async args => {
|
|||||||
export const AllPage = () => {
|
export const AllPage = () => {
|
||||||
const { jumpToPage } = useNavigateHelper();
|
const { jumpToPage } = useNavigateHelper();
|
||||||
const [currentWorkspace] = useCurrentWorkspace();
|
const [currentWorkspace] = useCurrentWorkspace();
|
||||||
const setting = useCollectionManager(currentWorkspace.id);
|
const setting = useCollectionManager(currentCollectionsAtom);
|
||||||
const onClickPage = useCallback(
|
const onClickPage = useCallback(
|
||||||
(pageId: string, newTab?: boolean) => {
|
(pageId: string, newTab?: boolean) => {
|
||||||
assertExists(currentWorkspace);
|
assertExists(currentWorkspace);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { setPageModeAtom } from '../../atoms';
|
|||||||
import { currentModeAtom } from '../../atoms/mode';
|
import { currentModeAtom } from '../../atoms/mode';
|
||||||
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
|
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
|
||||||
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||||
|
import { currentCollectionsAtom } from '../../utils/user-setting';
|
||||||
|
|
||||||
const DetailPageImpl = (): ReactElement => {
|
const DetailPageImpl = (): ReactElement => {
|
||||||
const { openPage, jumpToSubPath } = useNavigateHelper();
|
const { openPage, jumpToSubPath } = useNavigateHelper();
|
||||||
@@ -34,7 +35,7 @@ const DetailPageImpl = (): ReactElement => {
|
|||||||
assertExists(currentWorkspace);
|
assertExists(currentWorkspace);
|
||||||
assertExists(currentPageId);
|
assertExists(currentPageId);
|
||||||
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
|
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
|
||||||
const collectionManager = useCollectionManager(currentWorkspace.id);
|
const collectionManager = useCollectionManager(currentCollectionsAtom);
|
||||||
const mode = useAtomValue(currentModeAtom);
|
const mode = useAtomValue(currentModeAtom);
|
||||||
const setPageMode = useSetAtom(setPageModeAtom);
|
const setPageMode = useSetAtom(setPageModeAtom);
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,27 @@ import { pushNotificationAtom } from '@affine/component/notification-center';
|
|||||||
import { isDesktop } from '@affine/env/constant';
|
import { isDesktop } from '@affine/env/constant';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { refreshRootMetadataAtom } from '@affine/workspace/atom';
|
import { refreshRootMetadataAtom } from '@affine/workspace/atom';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||||
import { SessionProvider, useSession } from 'next-auth/react';
|
import { SessionProvider, useSession } from 'next-auth/react';
|
||||||
import { type PropsWithChildren, startTransition, useRef } from 'react';
|
import { type PropsWithChildren, startTransition, useRef } from 'react';
|
||||||
|
|
||||||
|
import { sessionAtom } from '../atoms/cloud-user';
|
||||||
import { useOnceSignedInEvents } from '../atoms/event';
|
import { useOnceSignedInEvents } from '../atoms/event';
|
||||||
|
|
||||||
const SessionReporter = () => {
|
const SessionDefence = (props: PropsWithChildren) => {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const prevSession = useRef<ReturnType<typeof useSession>>();
|
const prevSession = useRef<ReturnType<typeof useSession>>();
|
||||||
|
const [sessionInAtom, setSession] = useAtom(sessionAtom);
|
||||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||||
const refreshMetadata = useSetAtom(refreshRootMetadataAtom);
|
const refreshMetadata = useSetAtom(refreshRootMetadataAtom);
|
||||||
const onceSignedInEvents = useOnceSignedInEvents();
|
const onceSignedInEvents = useOnceSignedInEvents();
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
|
|
||||||
|
if (sessionInAtom !== session && session.status === 'authenticated') {
|
||||||
|
setSession(session);
|
||||||
|
}
|
||||||
|
|
||||||
if (prevSession.current !== session && session.status !== 'loading') {
|
if (prevSession.current !== session && session.status !== 'loading') {
|
||||||
// unauthenticated -> authenticated
|
// unauthenticated -> authenticated
|
||||||
if (
|
if (
|
||||||
@@ -42,14 +48,13 @@ const SessionReporter = () => {
|
|||||||
}
|
}
|
||||||
prevSession.current = session;
|
prevSession.current = session;
|
||||||
}
|
}
|
||||||
return null;
|
return props.children;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CloudSessionProvider = ({ children }: PropsWithChildren) => {
|
export const CloudSessionProvider = ({ children }: PropsWithChildren) => {
|
||||||
return (
|
return (
|
||||||
<SessionProvider refetchOnWindowFocus>
|
<SessionProvider refetchOnWindowFocus>
|
||||||
<SessionReporter />
|
<SessionDefence>{children}</SessionDefence>
|
||||||
{children}
|
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type { CollectionsAtom } from '@affine/component/page-list';
|
||||||
|
import type { Collection } from '@affine/env/filter';
|
||||||
|
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||||
|
import { currentWorkspaceAtom } from '@toeverything/infra/atom';
|
||||||
|
import { type DBSchema, openDB } from 'idb';
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
import { atomWithObservable } from 'jotai/utils';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import type { Map as YMap } from 'yjs';
|
||||||
|
import { Doc as YDoc } from 'yjs';
|
||||||
|
|
||||||
|
import { sessionAtom } from '../atoms/cloud-user';
|
||||||
|
|
||||||
|
export interface PageCollectionDBV1 extends DBSchema {
|
||||||
|
view: {
|
||||||
|
key: Collection['id'];
|
||||||
|
value: Collection;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageCRUD<Value> {
|
||||||
|
get: (key: string) => Promise<Value | null>;
|
||||||
|
set: (key: string, value: Value) => Promise<string>;
|
||||||
|
delete: (key: string) => Promise<void>;
|
||||||
|
list: () => Promise<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subscribe = () => void;
|
||||||
|
|
||||||
|
const collectionDBAtom = atom(
|
||||||
|
openDB<PageCollectionDBV1>('page-view', 1, {
|
||||||
|
upgrade(database) {
|
||||||
|
database.createObjectStore('view', {
|
||||||
|
keyPath: 'id',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const callbackSet = new Set<Subscribe>();
|
||||||
|
|
||||||
|
const localCollectionCRUDAtom = atom(get => ({
|
||||||
|
get: async (key: string) => {
|
||||||
|
const db = await get(collectionDBAtom);
|
||||||
|
const t = db.transaction('view').objectStore('view');
|
||||||
|
return (await t.get(key)) ?? null;
|
||||||
|
},
|
||||||
|
set: async (key: string, value: Collection) => {
|
||||||
|
const db = await get(collectionDBAtom);
|
||||||
|
const t = db.transaction('view', 'readwrite').objectStore('view');
|
||||||
|
await t.put(value);
|
||||||
|
callbackSet.forEach(cb => cb());
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
delete: async (key: string) => {
|
||||||
|
const db = await get(collectionDBAtom);
|
||||||
|
const t = db.transaction('view', 'readwrite').objectStore('view');
|
||||||
|
callbackSet.forEach(cb => cb());
|
||||||
|
await t.delete(key);
|
||||||
|
},
|
||||||
|
list: async () => {
|
||||||
|
const db = await get(collectionDBAtom);
|
||||||
|
const t = db.transaction('view').objectStore('view');
|
||||||
|
return t.getAllKeys();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getCollections = async (
|
||||||
|
storage: StorageCRUD<Collection>
|
||||||
|
): Promise<Collection[]> => {
|
||||||
|
return storage
|
||||||
|
.list()
|
||||||
|
.then(async keys => {
|
||||||
|
return await Promise.all(keys.map(key => storage.get(key))).then(v =>
|
||||||
|
v.filter((v): v is Collection => v !== null)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Failed to load collections', error);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageCollectionBaseAtom = atomWithObservable<Collection[]>(get => {
|
||||||
|
const currentWorkspacePromise = get(currentWorkspaceAtom);
|
||||||
|
const session = get(sessionAtom);
|
||||||
|
const localCRUD = get(localCollectionCRUDAtom);
|
||||||
|
const userId = session?.data?.user.id ?? null;
|
||||||
|
|
||||||
|
const useLocalStorage = userId === null;
|
||||||
|
|
||||||
|
return new Observable<Collection[]>(subscriber => {
|
||||||
|
// initial value
|
||||||
|
subscriber.next([]);
|
||||||
|
if (useLocalStorage) {
|
||||||
|
getCollections(localCRUD).then(collections => {
|
||||||
|
subscriber.next(collections);
|
||||||
|
});
|
||||||
|
const fn = () => {
|
||||||
|
getCollections(localCRUD).then(collections => {
|
||||||
|
subscriber.next(collections);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
callbackSet.add(fn);
|
||||||
|
return () => {
|
||||||
|
callbackSet.delete(fn);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const group = new DisposableGroup();
|
||||||
|
currentWorkspacePromise.then(async currentWorkspace => {
|
||||||
|
const collectionsFromLocal = await getCollections(localCRUD);
|
||||||
|
const rootDoc = currentWorkspace.doc;
|
||||||
|
const settingMap = rootDoc.getMap('settings') as YMap<YDoc>;
|
||||||
|
if (!settingMap.has(userId)) {
|
||||||
|
settingMap.set(
|
||||||
|
userId,
|
||||||
|
new YDoc({
|
||||||
|
guid: `${rootDoc.guid}:settings:${userId}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const settingDoc = settingMap.get(userId) as YDoc;
|
||||||
|
if (!settingDoc.isLoaded) {
|
||||||
|
settingDoc.load();
|
||||||
|
await settingDoc.whenLoaded;
|
||||||
|
}
|
||||||
|
const viewMap = settingDoc.getMap('view') as YMap<Collection>;
|
||||||
|
// sync local storage to doc
|
||||||
|
collectionsFromLocal.map(v => viewMap.set(v.id, v));
|
||||||
|
// delete from indexeddb
|
||||||
|
Promise.all(
|
||||||
|
collectionsFromLocal.map(async v => {
|
||||||
|
await localCRUD.delete(v.id);
|
||||||
|
})
|
||||||
|
).catch(error => {
|
||||||
|
console.error('Failed to delete collections from indexeddb', error);
|
||||||
|
});
|
||||||
|
const collectionsFromDoc: Collection[] = Array.from(viewMap.keys())
|
||||||
|
.map(key => viewMap.get(key))
|
||||||
|
.filter((v): v is Collection => !!v);
|
||||||
|
const collections = [...collectionsFromDoc];
|
||||||
|
subscriber.next(collections);
|
||||||
|
if (group.disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fn = () => {
|
||||||
|
const collectionsFromDoc: Collection[] = Array.from(viewMap.keys())
|
||||||
|
.map(key => viewMap.get(key))
|
||||||
|
.filter((v): v is Collection => !!v);
|
||||||
|
const collections = [...collectionsFromLocal, ...collectionsFromDoc];
|
||||||
|
subscriber.next(collections);
|
||||||
|
};
|
||||||
|
viewMap.observe(fn);
|
||||||
|
group.add(() => {
|
||||||
|
viewMap.unobserve(fn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
group.dispose();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const currentCollectionsAtom: CollectionsAtom = atom(
|
||||||
|
get => get(pageCollectionBaseAtom),
|
||||||
|
async (get, set, apply) => {
|
||||||
|
const collections = await get(pageCollectionBaseAtom);
|
||||||
|
let newCollections: Collection[];
|
||||||
|
if (typeof apply === 'function') {
|
||||||
|
newCollections = apply(collections);
|
||||||
|
} else {
|
||||||
|
newCollections = apply;
|
||||||
|
}
|
||||||
|
const session = get(sessionAtom);
|
||||||
|
const userId = session?.data?.user.id ?? null;
|
||||||
|
const useLocalStorage = userId === null;
|
||||||
|
const added = newCollections.filter(v => !collections.includes(v));
|
||||||
|
const removed = collections.filter(v => !newCollections.includes(v));
|
||||||
|
if (useLocalStorage) {
|
||||||
|
const localCRUD = get(localCollectionCRUDAtom);
|
||||||
|
await Promise.all([
|
||||||
|
...added.map(async v => {
|
||||||
|
await localCRUD.set(v.id, v);
|
||||||
|
}),
|
||||||
|
...removed.map(async v => {
|
||||||
|
await localCRUD.delete(v.id);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const currentWorkspace = await get(currentWorkspaceAtom);
|
||||||
|
const rootDoc = currentWorkspace.doc;
|
||||||
|
const settingMap = rootDoc.getMap('settings') as YMap<YDoc>;
|
||||||
|
const settingDoc = settingMap.get(userId) as YDoc;
|
||||||
|
const viewMap = settingDoc.getMap('view') as YMap<Collection>;
|
||||||
|
await Promise.all([
|
||||||
|
...added.map(async v => {
|
||||||
|
viewMap.set(v.id, v);
|
||||||
|
}),
|
||||||
|
...removed.map(async v => {
|
||||||
|
viewMap.delete(v.id);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -3,16 +3,30 @@
|
|||||||
*/
|
*/
|
||||||
import 'fake-indexeddb/auto';
|
import 'fake-indexeddb/auto';
|
||||||
|
|
||||||
|
import type { Collection } from '@affine/env/filter';
|
||||||
import { renderHook } from '@testing-library/react';
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { atom } from 'jotai';
|
||||||
import { expect, test } from 'vitest';
|
import { expect, test } from 'vitest';
|
||||||
|
|
||||||
import { createDefaultFilter, vars } from '../filter/vars';
|
import { createDefaultFilter, vars } from '../filter/vars';
|
||||||
import { useCollectionManager } from '../use-collection-manager';
|
import {
|
||||||
|
type CollectionsAtom,
|
||||||
|
useCollectionManager,
|
||||||
|
} from '../use-collection-manager';
|
||||||
|
|
||||||
const defaultMeta = { tags: { options: [] } };
|
const defaultMeta = { tags: { options: [] } };
|
||||||
|
|
||||||
|
const baseAtom = atom<Collection[]>([]);
|
||||||
|
|
||||||
|
const mockAtom: CollectionsAtom = atom(
|
||||||
|
get => get(baseAtom),
|
||||||
|
async (get, set, update) => {
|
||||||
|
set(baseAtom, update);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
test('useAllPageSetting', async () => {
|
test('useAllPageSetting', async () => {
|
||||||
const settingHook = renderHook(() => useCollectionManager('test'));
|
const settingHook = renderHook(() => useCollectionManager(mockAtom));
|
||||||
const prevCollection = settingHook.result.current.currentCollection;
|
const prevCollection = settingHook.result.current.currentCollection;
|
||||||
expect(settingHook.result.current.savedCollections).toEqual([]);
|
expect(settingHook.result.current.savedCollections).toEqual([]);
|
||||||
await settingHook.result.current.updateCollection({
|
await settingHook.result.current.updateCollection({
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { CollectionBar } from '@affine/component/page-list';
|
import {
|
||||||
|
CollectionBar,
|
||||||
|
type CollectionsAtom,
|
||||||
|
} from '@affine/component/page-list';
|
||||||
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
||||||
import type { PropertiesMeta } from '@affine/env/filter';
|
import type { PropertiesMeta } from '@affine/env/filter';
|
||||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||||
@@ -36,7 +39,7 @@ interface AllPagesHeadProps {
|
|||||||
importFile: () => void;
|
importFile: () => void;
|
||||||
getPageInfo: GetPageInfoById;
|
getPageInfo: GetPageInfoById;
|
||||||
propertiesMeta: PropertiesMeta;
|
propertiesMeta: PropertiesMeta;
|
||||||
workspaceId: string;
|
collectionsAtom: CollectionsAtom;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AllPagesHead = ({
|
const AllPagesHead = ({
|
||||||
@@ -47,7 +50,7 @@ const AllPagesHead = ({
|
|||||||
importFile,
|
importFile,
|
||||||
getPageInfo,
|
getPageInfo,
|
||||||
propertiesMeta,
|
propertiesMeta,
|
||||||
workspaceId,
|
collectionsAtom,
|
||||||
}: AllPagesHeadProps) => {
|
}: AllPagesHeadProps) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const titleList = useMemo(
|
const titleList = useMemo(
|
||||||
@@ -147,10 +150,10 @@ const AllPagesHead = ({
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
<TableHeadRow>{tableItem}</TableHeadRow>
|
<TableHeadRow>{tableItem}</TableHeadRow>
|
||||||
<CollectionBar
|
<CollectionBar
|
||||||
workspaceId={workspaceId}
|
|
||||||
columnsCount={titleList.length}
|
columnsCount={titleList.length}
|
||||||
getPageInfo={getPageInfo}
|
getPageInfo={getPageInfo}
|
||||||
propertiesMeta={propertiesMeta}
|
propertiesMeta={propertiesMeta}
|
||||||
|
collectionsAtom={collectionsAtom}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
);
|
);
|
||||||
@@ -158,7 +161,7 @@ const AllPagesHead = ({
|
|||||||
|
|
||||||
export const PageList = ({
|
export const PageList = ({
|
||||||
isPublicWorkspace = false,
|
isPublicWorkspace = false,
|
||||||
workspaceId,
|
collectionsAtom,
|
||||||
list,
|
list,
|
||||||
onCreateNewPage,
|
onCreateNewPage,
|
||||||
onCreateNewEdgeless,
|
onCreateNewEdgeless,
|
||||||
@@ -203,7 +206,7 @@ export const PageList = ({
|
|||||||
<StyledTableContainer ref={ref}>
|
<StyledTableContainer ref={ref}>
|
||||||
<Table showBorder={hasScrollTop} style={{ maxHeight: '100%' }}>
|
<Table showBorder={hasScrollTop} style={{ maxHeight: '100%' }}>
|
||||||
<AllPagesHead
|
<AllPagesHead
|
||||||
workspaceId={workspaceId}
|
collectionsAtom={collectionsAtom}
|
||||||
propertiesMeta={propertiesMeta}
|
propertiesMeta={propertiesMeta}
|
||||||
isPublicWorkspace={isPublicWorkspace}
|
isPublicWorkspace={isPublicWorkspace}
|
||||||
sorter={sorter}
|
sorter={sorter}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import type { CollectionsAtom } from '@affine/component/page-list/use-collection-manager';
|
||||||
import type { Tag } from '@affine/env/filter';
|
import type { Tag } from '@affine/env/filter';
|
||||||
import type { PropertiesMeta } from '@affine/env/filter';
|
import type { PropertiesMeta } from '@affine/env/filter';
|
||||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||||
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the keys of an object type whose values are of a given type
|
* Get the keys of an object type whose values are of a given type
|
||||||
@@ -15,7 +17,7 @@ export type ListData = {
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
icon: JSX.Element;
|
icon: JSX.Element;
|
||||||
title: string;
|
title: string;
|
||||||
preview?: React.ReactNode;
|
preview?: ReactNode;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
createDate: Date;
|
createDate: Date;
|
||||||
@@ -34,7 +36,7 @@ export type TrashListData = {
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
icon: JSX.Element;
|
icon: JSX.Element;
|
||||||
title: string;
|
title: string;
|
||||||
preview?: React.ReactNode;
|
preview?: ReactNode;
|
||||||
createDate: Date;
|
createDate: Date;
|
||||||
// TODO remove optional after assert that trashDate is always set
|
// TODO remove optional after assert that trashDate is always set
|
||||||
trashDate?: Date;
|
trashDate?: Date;
|
||||||
@@ -45,9 +47,9 @@ export type TrashListData = {
|
|||||||
|
|
||||||
export type PageListProps = {
|
export type PageListProps = {
|
||||||
isPublicWorkspace?: boolean;
|
isPublicWorkspace?: boolean;
|
||||||
workspaceId: string;
|
collectionsAtom: CollectionsAtom;
|
||||||
list: ListData[];
|
list: ListData[];
|
||||||
fallback?: React.ReactNode;
|
fallback?: ReactNode;
|
||||||
onCreateNewPage: () => void;
|
onCreateNewPage: () => void;
|
||||||
onCreateNewEdgeless: () => void;
|
onCreateNewEdgeless: () => void;
|
||||||
onImportFile: () => void;
|
onImportFile: () => void;
|
||||||
@@ -59,5 +61,5 @@ export type DraggableTitleCellData = {
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
pageTitle: string;
|
pageTitle: string;
|
||||||
pagePreview?: string;
|
pagePreview?: string;
|
||||||
icon: React.ReactElement;
|
icon: ReactElement;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,42 +1,19 @@
|
|||||||
import type { Collection, Filter, VariableMap } from '@affine/env/filter';
|
import type { Collection, Filter, VariableMap } from '@affine/env/filter';
|
||||||
import type { DBSchema } from 'idb';
|
|
||||||
import type { IDBPDatabase } from 'idb';
|
|
||||||
import { openDB } from 'idb';
|
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { atomWithReset, RESET } from 'jotai/utils';
|
import { atomWithReset, RESET } from 'jotai/utils';
|
||||||
|
import type { WritableAtom } from 'jotai/vanilla';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import useSWRImmutable from 'swr/immutable';
|
|
||||||
import { NIL } from 'uuid';
|
import { NIL } from 'uuid';
|
||||||
|
|
||||||
import { evalFilterList } from './filter';
|
import { evalFilterList } from './filter';
|
||||||
|
|
||||||
type PersistenceCollection = Collection;
|
|
||||||
|
|
||||||
export interface PageCollectionDBV1 extends DBSchema {
|
|
||||||
view: {
|
|
||||||
key: PersistenceCollection['id'];
|
|
||||||
value: PersistenceCollection;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageCollectionDBPromise: Promise<IDBPDatabase<PageCollectionDBV1>> =
|
|
||||||
typeof window === 'undefined'
|
|
||||||
? // never resolve in SSR
|
|
||||||
new Promise<any>(() => {})
|
|
||||||
: openDB<PageCollectionDBV1>('page-view', 1, {
|
|
||||||
upgrade(database) {
|
|
||||||
database.createObjectStore('view', {
|
|
||||||
keyPath: 'id',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultCollection = {
|
const defaultCollection = {
|
||||||
id: NIL,
|
id: NIL,
|
||||||
name: 'All',
|
name: 'All',
|
||||||
filterList: [],
|
filterList: [],
|
||||||
workspaceId: 'temporary',
|
workspaceId: 'temporary',
|
||||||
};
|
};
|
||||||
|
|
||||||
const collectionAtom = atomWithReset<{
|
const collectionAtom = atomWithReset<{
|
||||||
currentId: string;
|
currentId: string;
|
||||||
defaultCollection: Collection;
|
defaultCollection: Collection;
|
||||||
@@ -45,69 +22,62 @@ const collectionAtom = atomWithReset<{
|
|||||||
defaultCollection: defaultCollection,
|
defaultCollection: defaultCollection,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useSavedCollections = (workspaceId: string) => {
|
export type CollectionsAtom = WritableAtom<
|
||||||
const { data: savedCollections, mutate } = useSWRImmutable<Collection[]>(
|
Collection[] | Promise<Collection[]>,
|
||||||
['affine', 'page-collection', workspaceId],
|
[Collection[] | ((collection: Collection[]) => Collection[])],
|
||||||
{
|
Promise<void>
|
||||||
fetcher: async () => {
|
>;
|
||||||
const db = await pageCollectionDBPromise;
|
|
||||||
const t = db.transaction('view').objectStore('view');
|
export const useSavedCollections = (collectionAtom: CollectionsAtom) => {
|
||||||
const all = await t.getAll();
|
const [savedCollections, setCollections] = useAtom(collectionAtom);
|
||||||
return all.filter(v => v.workspaceId === workspaceId);
|
|
||||||
},
|
|
||||||
suspense: true,
|
|
||||||
fallbackData: [],
|
|
||||||
revalidateOnMount: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const saveCollection = useCallback(
|
const saveCollection = useCallback(
|
||||||
async (collection: Collection) => {
|
async (collection: Collection) => {
|
||||||
if (collection.id === NIL) {
|
if (collection.id === NIL) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const db = await pageCollectionDBPromise;
|
await setCollections(old => [...old, collection]);
|
||||||
const t = db.transaction('view', 'readwrite').objectStore('view');
|
|
||||||
await t.put(collection);
|
|
||||||
await mutate();
|
|
||||||
},
|
},
|
||||||
[mutate]
|
[setCollections]
|
||||||
);
|
);
|
||||||
const deleteCollection = useCallback(
|
const deleteCollection = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
if (id === NIL) {
|
if (id === NIL) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const db = await pageCollectionDBPromise;
|
await setCollections(old => old.filter(v => v.id !== id));
|
||||||
const t = db.transaction('view', 'readwrite').objectStore('view');
|
|
||||||
await t.delete(id);
|
|
||||||
await mutate();
|
|
||||||
},
|
},
|
||||||
[mutate]
|
[setCollections]
|
||||||
);
|
);
|
||||||
const addPage = useCallback(
|
const addPage = useCallback(
|
||||||
async (collectionId: string, pageId: string) => {
|
async (collectionId: string, pageId: string) => {
|
||||||
const collection = savedCollections?.find(v => v.id === collectionId);
|
await setCollections(old => {
|
||||||
if (!collection) {
|
const collection = old.find(v => v.id === collectionId);
|
||||||
return;
|
if (!collection) {
|
||||||
}
|
return old;
|
||||||
await saveCollection({
|
}
|
||||||
...collection,
|
return [
|
||||||
allowList: [pageId, ...(collection.allowList ?? [])],
|
...old.filter(v => v.id !== collectionId),
|
||||||
|
{
|
||||||
|
...collection,
|
||||||
|
allowList: [pageId, ...(collection.allowList ?? [])],
|
||||||
|
},
|
||||||
|
];
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[saveCollection, savedCollections]
|
[setCollections]
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
savedCollections: savedCollections ?? [],
|
savedCollections,
|
||||||
saveCollection,
|
saveCollection,
|
||||||
deleteCollection,
|
deleteCollection,
|
||||||
addPage,
|
addPage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCollectionManager = (workspaceId: string) => {
|
export const useCollectionManager = (collectionsAtom: CollectionsAtom) => {
|
||||||
const { savedCollections, saveCollection, deleteCollection, addPage } =
|
const { savedCollections, saveCollection, deleteCollection, addPage } =
|
||||||
useSavedCollections(workspaceId);
|
useSavedCollections(collectionsAtom);
|
||||||
const [collectionData, setCollectionData] = useAtom(collectionAtom);
|
const [collectionData, setCollectionData] = useAtom(collectionAtom);
|
||||||
|
|
||||||
const updateCollection = useCallback(
|
const updateCollection = useCallback(
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { EditCollectionModel } from '@affine/component/page-list';
|
import {
|
||||||
|
type CollectionsAtom,
|
||||||
|
EditCollectionModel,
|
||||||
|
} from '@affine/component/page-list';
|
||||||
import type { PropertiesMeta } from '@affine/env/filter';
|
import type { PropertiesMeta } from '@affine/env/filter';
|
||||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
@@ -15,14 +18,14 @@ import { useActions } from './use-action';
|
|||||||
interface CollectionBarProps {
|
interface CollectionBarProps {
|
||||||
getPageInfo: GetPageInfoById;
|
getPageInfo: GetPageInfoById;
|
||||||
propertiesMeta: PropertiesMeta;
|
propertiesMeta: PropertiesMeta;
|
||||||
|
collectionsAtom: CollectionsAtom;
|
||||||
columnsCount: number;
|
columnsCount: number;
|
||||||
workspaceId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CollectionBar = (props: CollectionBarProps) => {
|
export const CollectionBar = (props: CollectionBarProps) => {
|
||||||
const { getPageInfo, propertiesMeta, columnsCount, workspaceId } = props;
|
const { getPageInfo, propertiesMeta, columnsCount, collectionsAtom } = props;
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const setting = useCollectionManager(workspaceId);
|
const setting = useCollectionManager(collectionsAtom);
|
||||||
const collection = setting.currentCollection;
|
const collection = setting.currentCollection;
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const actions = useActions({
|
const actions = useActions({
|
||||||
|
|||||||
@@ -139,6 +139,36 @@ test.describe('collaboration', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('can sync collections between different browser', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
await page.reload();
|
||||||
|
await waitForEditorLoad(page);
|
||||||
|
await createLocalWorkspace(
|
||||||
|
{
|
||||||
|
name: 'test',
|
||||||
|
},
|
||||||
|
page
|
||||||
|
);
|
||||||
|
await enableCloudWorkspace(page);
|
||||||
|
await page.getByTestId('slider-bar-add-collection-button').click();
|
||||||
|
const title = page.getByTestId('input-collection-title');
|
||||||
|
await title.isVisible();
|
||||||
|
await title.fill('test collection');
|
||||||
|
await page.getByTestId('save-collection').click();
|
||||||
|
|
||||||
|
{
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page2 = await context.newPage();
|
||||||
|
await loginUser(page2, user.email);
|
||||||
|
await page2.goto(page.url());
|
||||||
|
waitForEditorLoad(page2);
|
||||||
|
const collections = page2.getByTestId('collections');
|
||||||
|
await expect(collections.getByText('test collection')).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('exit successfully and re-login', async ({ page }) => {
|
test('exit successfully and re-login', async ({ page }) => {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await clickSideBarAllPageButton(page);
|
await clickSideBarAllPageButton(page);
|
||||||
|
|||||||
Reference in New Issue
Block a user